Implement reusable interface conformance rules
This commit is contained in:
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user