Implement reusable interface conformance rules

This commit is contained in:
Timothy J. Aveni
2026-09-23 00:17:13 -07:00
parent 20e9f4b056
commit 8455599c3c
14 changed files with 2387 additions and 1638 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -57,6 +57,7 @@ import { StoragePolicyContext } from "./QuixosCapabilityParser.js";
import { EdgeDeclContext } from "./QuixosCapabilityParser.js";
import { EdgeEndpointContext } from "./QuixosCapabilityParser.js";
import { ConformanceDeclContext } from "./QuixosCapabilityParser.js";
import { ConformanceSourceArgumentsContext } from "./QuixosCapabilityParser.js";
import { ConformanceItemContext } from "./QuixosCapabilityParser.js";
import { ModuleBindingDeclContext } from "./QuixosCapabilityParser.js";
import { InitializationDeclContext } from "./QuixosCapabilityParser.js";
@@ -424,6 +425,12 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
* @return the visitor result
*/
visitConformanceDecl?: (ctx: ConformanceDeclContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.conformanceSourceArguments`.
* @param ctx the parse tree
* @return the visitor result
*/
visitConformanceSourceArguments?: (ctx: ConformanceSourceArgumentsContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.conformanceItem`.
* @param ctx the parse tree
+2 -1
View File
@@ -34,6 +34,7 @@ export class GenericSourceTypes {
readonly applications = new Map<string, InterfaceRevision>();
readonly aliases = new Map<string, ValueAliasDefinition>();
parameters = new Map<string, TypeParameter>();
bindings = new Map<string, ClosedTypeArgument>();
self?: AtomId;
private active: string[] = [];
private applicationCount = 0;
@@ -42,7 +43,7 @@ export class GenericSourceTypes {
environment(): GenericTypeEnvironment {
return {
arguments: new Map(),
arguments: this.bindings,
aliases: this.aliases,
self: this.self,
applyInterface: (id, args) => this.apply(id, args).revisionId,
+215 -10
View File
@@ -46,6 +46,8 @@ import {
type GenericDependencyPort,
specializePackageExport,
} from "../capability-model/index.js";
import { expandConformanceRules, type ConformanceRule } from "../capability-model/conformance-rules.js";
import type { ClosedTypeArgument, TypeParameter } from "../capability-model/generics.js";
import { GenericSourceTypes } from "./generic-types.js";
import {
defaultQueryBudgets,
@@ -1992,14 +1994,140 @@ const lowerRelationshipMaterialization = (
: undefined;
};
const lowerConformance = (
const withConformanceTypes = <T>(
state: LoweringState,
atom: AtomId | undefined,
parameters: TypeParameter[],
bindings: Map<string, ClosedTypeArgument>,
run: () => T,
): T => {
const previous = { self: state.types.self, parameters: state.types.parameters, bindings: state.types.bindings };
state.types.self = atom;
state.types.parameters = new Map(parameters.map((p) => [p.name, p]));
state.types.bindings = bindings;
try {
return run();
} finally {
Object.assign(state.types, previous);
}
};
const lowerConformanceRule = (state: LoweringState, context: ConformanceDeclContext): ConformanceRule | undefined => {
if (!context.stringLiteral()) {
loweringIssue(state, context, "missing-rule-id", "Interface conformance rules require a stable ID");
return undefined;
}
return withConformanceTypes(state, undefined, [], new Map(), () => {
const id = stringValue(context.stringLiteral()!);
const parameters = state.types.declareParameters(
context.typeParameters(),
JSON.stringify(["conformance-rule", id]),
);
const source = state.types.interfaceByName(
identifier(context.identifier(0)),
context.conformanceSourceArguments()?.typeArguments() ?? null,
);
const target = state.types.interfaceByName(identifier(context.identifier(1)), context.typeArguments());
const targetDefinition = state.types.definitions.get(target.definitionId)!;
const applications = [source, target];
const members = targetDefinition.template?.members ?? targetDefinition.members;
for (const item of context.conformanceItem()) {
if (
item.attachmentDecl() ||
item.initializationDecl() ||
item.relationshipMaterializationDecl() ||
item.stateFieldBindingDecl()
) {
loweringIssue(
state,
item,
"unsupported-rule-storage",
"Rules support operation and module bindings, not private storage, initialization or materialization",
);
continue;
}
const module = item.moduleBindingDecl();
if (module) {
const pkg = requireSymbol(state, state.packages, identifier(module.identifier(1)), module, "package");
const member = members.find((m) => m.displayName === identifier(module.identifier(0)));
if (
member?.kind !== "module" ||
!pkg?.definition?.modules?.some((m) => m.displayName === identifier(module.identifier(2)))
)
loweringIssue(state, module, "invalid-module-binding", "Expected a module member and imported module export");
}
const binding = item.operationBindingDecl();
if (!binding) continue;
const ref = binding.memberOperationRef();
const member = members.find((m) => m.displayName === identifier(ref.identifier()));
if (!member?.operations.some((op) => op.displayName === text(ref.operationName())))
loweringIssue(state, ref, "unknown-operation", "Unknown target interface member operation");
const provider = binding.operationProvider();
if (provider.INTERFACE()) {
const application = state.types.interfaceByName(identifier(provider.identifier(0)), provider.typeArguments());
applications.push(application);
const definition = state.types.definitions.get(application.definitionId)!;
const targetRef = provider.memberOperationRef()!;
if (
!(definition.template?.members ?? definition.members)
.find((m) => m.displayName === identifier(targetRef.identifier()))
?.operations.some((op) => op.displayName === text(targetRef.operationName()))
)
loweringIssue(state, provider, "unknown-operation", "Unknown forwarding member operation");
} else if (provider.PACKAGE()) {
const pkg = requireSymbol(state, state.packages, identifier(provider.identifier(0)), provider, "package");
const name = identifier(provider.identifier(1));
if (pkg && !pkg.exports.has(name))
loweringIssue(state, provider, "unknown-export", `Unknown package export ${name}`);
const arguments_ = state.types.arguments(provider.typeArguments());
const exportDefinition = pkg?.definition?.genericExports?.find((entry) => entry.displayName === name);
const expected = exportDefinition?.parameters ?? [];
if (expected.length !== arguments_.length || expected.some((p, i) => p.kind !== arguments_[i]?.kind))
loweringIssue(state, provider, "parameter-kind", "Package export argument kinds or arity do not match");
for (const block of [
provider.dependencyBindingBlock(),
provider.acceptanceBinding()?.dependencyBindingBlock(),
]) {
for (const dependency of block?.dependencyBinding() ?? []) {
if (dependency.INTERFACE())
applications.push(
state.types.interfaceByName(identifier(dependency.identifier(1)), dependency.typeArguments()),
);
}
}
} else {
loweringIssue(
state,
provider,
"unsupported-rule-provider",
"Rule operations forward an interface or bind a package export",
);
}
}
state.types.validateTemplate({
...targetDefinition,
template: {
parameters,
members: [],
requires: applications,
usesSelf: false,
aliases: [...state.types.aliases.values()],
},
});
return { id, parameters, source, target };
});
};
const lowerConformanceBody = (
parent: LoweringState,
context: ConformanceDeclContext,
privateAttachments: PersistentAttachment[],
implementingAtom?: AtomId,
): WorkspaceRevision["conformances"][number] | undefined => {
const state = { ...parent, interfaces: new Map(parent.interfaces) };
const atomName = identifier(context.identifier(0));
const interfaceName = identifier(context.identifier(1));
const atomId = requireSymbol(state, state.atoms, atomName, context, "atom");
const atomId = implementingAtom ?? requireSymbol(state, state.atoms, atomName, context, "atom");
let interfaceSymbol = requireSymbol(state, state.interfaces, interfaceName, context, "interface");
if (!atomId || !interfaceSymbol) {
return undefined;
@@ -2461,7 +2589,7 @@ const lowerWorkspace = (
}
}
const conformance = item.conformanceDecl();
if (conformance) {
if (conformance && !state.interfaces.has(identifier(conformance.identifier(0)))) {
state.types.self = state.atoms.get(identifier(conformance.identifier(0)));
const attachments = conformance.conformanceItem().flatMap((entry) => {
const declaration = entry.attachmentDecl();
@@ -2480,16 +2608,93 @@ const lowerWorkspace = (
}
}
const conformances = items.flatMap((item) => {
const context = item.conformanceDecl();
if (!context) {
return [];
const contexts = items.flatMap((item) => item.conformanceDecl() ?? []);
const rules = new Map<ConformanceRule, ConformanceDeclContext>();
const direct: ConformanceDeclContext[] = [];
for (const declaration of contexts) {
const name = identifier(declaration.identifier(0));
if (state.interfaces.has(name) && state.atoms.has(name)) {
loweringIssue(state, declaration, "ambiguous-conformance-source", name + " names both an atom and an interface");
continue;
}
const conformance = atGenericSource(context, () =>
lowerConformance(state, context, privateAttachments.get(context) ?? []),
if (state.interfaces.has(name)) {
const rule = atGenericSource(declaration, () => lowerConformanceRule(state, declaration));
if (rule) rules.set(rule, declaration);
} else {
if (declaration.typeParameters() || declaration.conformanceSourceArguments())
loweringIssue(state, declaration, "invalid-conformance-source", "Only interface rules take source parameters");
direct.push(declaration);
}
}
const origins = new Map<WorkspaceRevision["conformances"][number], ConformanceDeclContext>();
const conformances = direct.flatMap((declaration) => {
const atom = state.atoms.get(identifier(declaration.identifier(0)));
const result = atGenericSource(declaration, () =>
withConformanceTypes(state, atom, [], new Map(), () =>
lowerConformanceBody(state, declaration, privateAttachments.get(declaration) ?? []),
),
);
return conformance ? [conformance] : [];
if (result) origins.set(result, declaration);
return result ? [result] : [];
});
if (!state.diagnostics.length) {
const applications = expandConformanceRules(
[...rules.keys()],
conformances,
state.types.definitions,
(atom, bindings) => ({
...state.types.environment(),
self: atom,
arguments: bindings,
applyInterface: (id, args) =>
withConformanceTypes(state, atom, [], bindings, () => state.types.apply(id, args).revisionId),
}),
);
for (const application of applications) {
const declaration = rules.get(application.rule)!;
const result = atGenericSource(declaration, () =>
withConformanceTypes(state, application.atomId, application.rule.parameters, application.bindings, () =>
lowerConformanceBody(state, declaration, [], application.atomId),
),
);
if (result) {
const generated = {
...result,
id: application.id,
ruleOrigin: {
ruleId: application.rule.id,
sourceInterfaceRevisionId: application.sourceId,
line: declaration.start?.line ?? 0,
column: declaration.start?.column ?? 0,
},
};
conformances.push(generated);
origins.set(generated, declaration);
}
}
}
const candidates = new Map<string, WorkspaceRevision["conformances"][number]>();
for (const conformance of conformances) {
const key = JSON.stringify([conformance.atomId, conformance.interfaceRevisionId]);
const previous = candidates.get(key);
if (previous) {
const describe = (c: typeof conformance) =>
c.ruleOrigin
? "rule " + c.ruleOrigin.ruleId + " from " + c.ruleOrigin.sourceInterfaceRevisionId
: "conformance " + (c.id ?? "(unnamed)");
const message =
"Duplicate conformance for " +
conformance.atomId +
" as " +
conformance.interfaceRevisionId +
": " +
describe(previous) +
" conflicts with " +
describe(conformance);
for (const candidate of [previous, conformance])
loweringIssue(state, origins.get(candidate)!, "duplicate-conformance", message);
} else candidates.set(key, conformance);
}
const constructors = items.flatMap((item) => {
const constructor = item.constructorBindingDecl();
+18
View File
@@ -86,3 +86,21 @@ export const resolveQxSources = async (read: (name: string) => Promise<string>,
};
export const loadQxSources = (root: string) => resolveQxSources((name) => readQxSource(root, name));
/** The same validated closure used by compilation, for installation and pinning. */
export const qxSourceManifest = async (root: string) => {
const resolved = await loadQxSources(root);
const syntax = parseQx(resolved.source);
const imports = [...walkSyntax(syntax.root)]
.filter((node) => node.kind === "resourceImportDecl")
.map((node) => {
const name = node.children.find((child) => child.kind === "identifier")!;
const kind = syntax.tokens.some(
(token) => token.start >= node.start && token.end <= node.end && token.kind === "INTERFACE",
)
? ("interface" as const)
: ("package" as const);
return { kind, binding: resolved.source.slice(name.start, name.end) };
});
return { sourceFiles: resolved.sourceFiles, imports };
};
+6 -1
View File
@@ -2,7 +2,7 @@
import { readFile, writeFile, mkdtemp, rm, realpath } from "node:fs/promises";
import { parseQx, formatQx, lintQx } from "./source.js";
import { scaffoldAtom } from "./scaffold.js";
import { loadQxSources } from "./source-loader.js";
import { loadQxSources, qxSourceManifest } from "./source-loader.js";
import { createGitCapabilityResolver } from "./git-resolver.js";
import { planEvolution } from "../capability-model/index.js";
import { snapshotRepository } from "./candidate-check.js";
@@ -54,6 +54,11 @@ const planSummary = (plan: Awaited<ReturnType<typeof planStructure>>) => ({
const main = async () => {
const [command, ...args] = process.argv.slice(2);
if (command === "source-manifest") {
if (args.length !== 1) throw new Error("source-manifest WORKSPACE_ROOT");
process.stdout.write(JSON.stringify(await qxSourceManifest(args[0])) + "\n");
return;
}
if (command === "scaffold-validate-qx") {
if (args.length !== 1) throw new Error("scaffold-validate-qx SOURCES_JSON");
const sources = JSON.parse(args[0]);
+208
View File
@@ -0,0 +1,208 @@
import { createHash } from "node:crypto";
import {
capabilityId,
type AtomId,
type Conformance,
type InterfaceRevision,
type InterfaceRevisionId,
} from "./types.js";
import {
GenericTypeError,
TypeSubstitution,
bindTypeParameters,
canonicalTypeArgument,
type ClosedTypeArgument,
type GenericTypeEnvironment,
type InterfaceApplicationExpression,
type TypeParameter,
} from "./generics.js";
/** Compiler input only. Runtime plans contain ordinary closed conformances. */
export interface ConformanceRule {
id: string;
parameters: TypeParameter[];
source: InterfaceApplicationExpression;
target: InterfaceApplicationExpression;
}
export interface RuleApplication {
rule: ConformanceRule;
atomId: AtomId;
sourceId: InterfaceRevisionId;
targetId: InterfaceRevisionId;
bindings: Map<string, ClosedTypeArgument>;
id: Conformance["id"];
}
const parameterId = (argument: ConformanceRule["source"]["arguments"][number]) => {
const value = argument.kind === "value" ? argument.type : argument.target;
return value.kind === "parameter" ? value.parameterId : undefined;
};
const hasParameter = (node: unknown): boolean =>
!!node &&
typeof node === "object" &&
(("kind" in node && node.kind === "parameter") || Object.values(node).some(hasParameter));
export function validateConformanceRules(
rules: readonly ConformanceRule[],
definitions: ReadonlyMap<string, InterfaceRevision>,
) {
const ids = new Set<string>();
const graph = new Map<string, ConformanceRule[]>();
for (const rule of rules) {
if (ids.has(rule.id)) throw new GenericTypeError("duplicate-rule-id", rule.id, `Duplicate rule ID ${rule.id}`);
ids.add(rule.id);
for (const application of [
rule.source,
rule.target,
...rule.parameters.flatMap((p) => (p.kind === "object" ? p.implements : [])),
]) {
const definition = definitions.get(application.definitionId);
if (!definition)
throw new GenericTypeError("unknown-interface", rule.id, `Unknown interface ${application.definitionId}`);
const parameters = definition.template?.parameters ?? [];
if (parameters.length !== application.arguments.length)
throw new GenericTypeError("type-arity", rule.id, `Wrong arity for ${definition.displayName}`);
parameters.forEach((p, i) => {
if (p.kind !== application.arguments[i].kind)
throw new GenericTypeError(
"parameter-kind",
rule.id,
`Wrong argument kind for ${definition.displayName}.${p.name}`,
);
});
}
const captured = new Set<string>();
for (const argument of rule.source.arguments) {
const id = parameterId(argument);
if (id) captured.add(id);
else if (hasParameter(argument))
throw new GenericTypeError(
"unsupported-rule-pattern",
rule.id,
"Capture whole source arguments, not nested type patterns",
);
}
for (const p of rule.parameters)
if (!captured.has(p.id))
throw new GenericTypeError(
"uncaptured-rule-parameter",
rule.id,
`Parameter ${p.name} must occur in the source application`,
);
graph.set(rule.source.definitionId, [...(graph.get(rule.source.definitionId) ?? []), rule]);
}
const visited = new Set<string>();
const active = new Set<string>();
const visit = (id: string, trail: string[]) => {
if (active.has(id))
throw new GenericTypeError(
"recursive-conformance-rule",
trail.at(-1) ?? id,
`Recursive conformance rules: ${trail.join(" -> ")}`,
);
if (visited.has(id)) return;
active.add(id);
for (const rule of graph.get(id) ?? []) visit(rule.target.definitionId, [...trail, rule.id]);
active.delete(id);
visited.add(id);
};
for (const id of graph.keys()) visit(id, []);
}
/** Monotone closure: bounds only enable applications, never invent evidence. */
export function expandConformanceRules(
rules: readonly ConformanceRule[],
initial: readonly Pick<Conformance, "atomId" | "interfaceRevisionId">[],
definitions: ReadonlyMap<string, InterfaceRevision>,
environment: (atom: AtomId, bindings: Map<string, ClosedTypeArgument>) => GenericTypeEnvironment,
): RuleApplication[] {
validateConformanceRules(rules, definitions);
const key = (atom: string, iface: string) => JSON.stringify([atom, iface]);
const facts = new Map(initial.map((f) => [key(f.atomId, f.interfaceRevisionId), f]));
const applications = new Map<string, RuleApplication>();
const implies = (actual: InterfaceRevisionId, required: InterfaceRevisionId, seen = new Set<string>()): boolean => {
if (actual === required) return true;
if (seen.has(actual)) return false;
seen.add(actual);
return (definitions.get(actual)?.requiredInterfaces ?? []).some((id) => implies(id, required, seen));
};
const bySource = new Map<string, ConformanceRule[]>();
for (const rule of [...rules].sort((a, b) => a.id.localeCompare(b.id)))
bySource.set(rule.source.definitionId, [...(bySource.get(rule.source.definitionId) ?? []), rule]);
let changed = true;
while (changed) {
changed = false;
for (const fact of [...facts.values()].sort((a, b) =>
key(a.atomId, a.interfaceRevisionId).localeCompare(key(b.atomId, b.interfaceRevisionId)),
)) {
const definition = definitions.get(fact.interfaceRevisionId);
if (!definition) continue;
for (const rule of bySource.get(definition.application?.definitionId ?? definition.revisionId) ?? []) {
const arguments_ = definition.application?.arguments ?? [];
const bindings = new Map<string, ClosedTypeArgument>();
const env = environment(fact.atomId, bindings);
const substitution = new TypeSubstitution(env);
if (
!rule.source.arguments.every((pattern, i) => {
const actual = arguments_[i];
if (!actual || actual.kind !== pattern.kind) return false;
const id = parameterId(pattern);
if (!id) return canonicalTypeArgument(substitution.argument(pattern)) === canonicalTypeArgument(actual);
const previous = bindings.get(id);
if (previous && canonicalTypeArgument(previous) !== canonicalTypeArgument(actual)) return false;
bindings.set(id, actual);
return true;
})
)
continue;
try {
bindTypeParameters(
rule.parameters,
rule.parameters.map((p) => bindings.get(p.id)!),
{
...env,
implementsInterface: (target, required) =>
target.kind === "atom"
? facts.has(key(target.atomId, required))
: implies(target.interfaceRevisionId, required),
},
rule.id,
);
} catch (error) {
if (error instanceof GenericTypeError && ["unsatisfied-bound", "non-storable-argument"].includes(error.code))
continue;
throw error;
}
const applicationKey = JSON.stringify([rule.id, fact.atomId, fact.interfaceRevisionId]);
if (applications.has(applicationKey)) continue;
if (applications.size >= 10000)
throw new GenericTypeError(
"rule-expansion-limit",
rule.id,
"Conformance rule expansion exceeds 10000 applications",
);
const targetId = substitution.application(rule.target);
const id = capabilityId.conformance(
`conformance-rule:sha256:${createHash("sha256")
.update(
JSON.stringify(["quixos-conformance-rule-v1", rule.id, fact.atomId, fact.interfaceRevisionId, targetId]),
)
.digest("hex")}`,
);
applications.set(applicationKey, {
rule,
atomId: fact.atomId,
sourceId: fact.interfaceRevisionId,
targetId,
bindings,
id,
});
const targetKey = key(fact.atomId, targetId);
if (!facts.has(targetKey)) {
facts.set(targetKey, { atomId: fact.atomId, interfaceRevisionId: targetId });
changed = true;
}
}
}
}
return [...applications.values()];
}
+2
View File
@@ -445,6 +445,8 @@ export interface InitializationField {
}
export interface Conformance {
/** Source provenance of a compiler-expanded rule, never runtime dispatch input. */
ruleOrigin?: { ruleId: string; sourceInterfaceRevisionId: InterfaceRevisionId; line: number; column: number };
moduleBindings?: ModuleBinding[];
initialization?: InitializationField[];
/** Absent only for legacy assemblies awaiting explicit ownership enrollment. */
+8 -1
View File
@@ -708,7 +708,14 @@ const collectIdentityIndexes = (
issues,
"duplicate-conformance",
path,
`Duplicate conformance for ${conformance.atomId} as ${conformance.interfaceRevisionId}`,
`Duplicate conformance for ${conformance.atomId} as ${conformance.interfaceRevisionId}: ` +
[conformances.get(key)!, conformance]
.map((candidate) =>
candidate.ruleOrigin
? `rule ${candidate.ruleOrigin.ruleId} from ${candidate.ruleOrigin.sourceInterfaceRevisionId} at line ${candidate.ruleOrigin.line}`
: `conformance ${candidate.id ?? "(unnamed)"}`,
)
.join(" conflicts with "),
);
} else {
conformances.set(key, conformance);