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