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]);