Implement reusable interface conformance rules
This commit is contained in:
@@ -296,10 +296,14 @@ edgeEndpoint
|
|||||||
;
|
;
|
||||||
|
|
||||||
conformanceDecl
|
conformanceDecl
|
||||||
: CONFORM identifier AS identifier typeArguments? (ID stringLiteral)? (SEMANTIC_MAJOR INTEGER)?
|
: CONFORM typeParameters? identifier conformanceSourceArguments? AS identifier typeArguments? (ID stringLiteral)? (SEMANTIC_MAJOR INTEGER)?
|
||||||
LBRACE conformanceItem* RBRACE
|
LBRACE conformanceItem* RBRACE
|
||||||
;
|
;
|
||||||
|
|
||||||
|
conformanceSourceArguments
|
||||||
|
: typeArguments
|
||||||
|
;
|
||||||
|
|
||||||
conformanceItem
|
conformanceItem
|
||||||
: PRIVATE attachmentDecl
|
: PRIVATE attachmentDecl
|
||||||
| operationBindingDecl
|
| operationBindingDecl
|
||||||
|
|||||||
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 { EdgeDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { EdgeEndpointContext } from "./QuixosCapabilityParser.js";
|
import { EdgeEndpointContext } from "./QuixosCapabilityParser.js";
|
||||||
import { ConformanceDeclContext } from "./QuixosCapabilityParser.js";
|
import { ConformanceDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { ConformanceSourceArgumentsContext } from "./QuixosCapabilityParser.js";
|
||||||
import { ConformanceItemContext } from "./QuixosCapabilityParser.js";
|
import { ConformanceItemContext } from "./QuixosCapabilityParser.js";
|
||||||
import { ModuleBindingDeclContext } from "./QuixosCapabilityParser.js";
|
import { ModuleBindingDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { InitializationDeclContext } from "./QuixosCapabilityParser.js";
|
import { InitializationDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
@@ -424,6 +425,12 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
|
|||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitConformanceDecl?: (ctx: ConformanceDeclContext) => 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`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.conformanceItem`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export class GenericSourceTypes {
|
|||||||
readonly applications = new Map<string, InterfaceRevision>();
|
readonly applications = new Map<string, InterfaceRevision>();
|
||||||
readonly aliases = new Map<string, ValueAliasDefinition>();
|
readonly aliases = new Map<string, ValueAliasDefinition>();
|
||||||
parameters = new Map<string, TypeParameter>();
|
parameters = new Map<string, TypeParameter>();
|
||||||
|
bindings = new Map<string, ClosedTypeArgument>();
|
||||||
self?: AtomId;
|
self?: AtomId;
|
||||||
private active: string[] = [];
|
private active: string[] = [];
|
||||||
private applicationCount = 0;
|
private applicationCount = 0;
|
||||||
@@ -42,7 +43,7 @@ export class GenericSourceTypes {
|
|||||||
|
|
||||||
environment(): GenericTypeEnvironment {
|
environment(): GenericTypeEnvironment {
|
||||||
return {
|
return {
|
||||||
arguments: new Map(),
|
arguments: this.bindings,
|
||||||
aliases: this.aliases,
|
aliases: this.aliases,
|
||||||
self: this.self,
|
self: this.self,
|
||||||
applyInterface: (id, args) => this.apply(id, args).revisionId,
|
applyInterface: (id, args) => this.apply(id, args).revisionId,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ import {
|
|||||||
type GenericDependencyPort,
|
type GenericDependencyPort,
|
||||||
specializePackageExport,
|
specializePackageExport,
|
||||||
} from "../capability-model/index.js";
|
} 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 { GenericSourceTypes } from "./generic-types.js";
|
||||||
import {
|
import {
|
||||||
defaultQueryBudgets,
|
defaultQueryBudgets,
|
||||||
@@ -1992,14 +1994,140 @@ const lowerRelationshipMaterialization = (
|
|||||||
: undefined;
|
: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const lowerConformance = (
|
const withConformanceTypes = <T>(
|
||||||
state: LoweringState,
|
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,
|
context: ConformanceDeclContext,
|
||||||
privateAttachments: PersistentAttachment[],
|
privateAttachments: PersistentAttachment[],
|
||||||
|
implementingAtom?: AtomId,
|
||||||
): WorkspaceRevision["conformances"][number] | undefined => {
|
): WorkspaceRevision["conformances"][number] | undefined => {
|
||||||
|
const state = { ...parent, interfaces: new Map(parent.interfaces) };
|
||||||
const atomName = identifier(context.identifier(0));
|
const atomName = identifier(context.identifier(0));
|
||||||
const interfaceName = identifier(context.identifier(1));
|
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");
|
let interfaceSymbol = requireSymbol(state, state.interfaces, interfaceName, context, "interface");
|
||||||
if (!atomId || !interfaceSymbol) {
|
if (!atomId || !interfaceSymbol) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -2461,7 +2589,7 @@ const lowerWorkspace = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const conformance = item.conformanceDecl();
|
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)));
|
state.types.self = state.atoms.get(identifier(conformance.identifier(0)));
|
||||||
const attachments = conformance.conformanceItem().flatMap((entry) => {
|
const attachments = conformance.conformanceItem().flatMap((entry) => {
|
||||||
const declaration = entry.attachmentDecl();
|
const declaration = entry.attachmentDecl();
|
||||||
@@ -2480,16 +2608,93 @@ const lowerWorkspace = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const conformances = items.flatMap((item) => {
|
const contexts = items.flatMap((item) => item.conformanceDecl() ?? []);
|
||||||
const context = item.conformanceDecl();
|
const rules = new Map<ConformanceRule, ConformanceDeclContext>();
|
||||||
if (!context) {
|
const direct: ConformanceDeclContext[] = [];
|
||||||
return [];
|
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, () =>
|
if (state.interfaces.has(name)) {
|
||||||
lowerConformance(state, context, privateAttachments.get(context) ?? []),
|
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 constructors = items.flatMap((item) => {
|
||||||
const constructor = item.constructorBindingDecl();
|
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));
|
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 { readFile, writeFile, mkdtemp, rm, realpath } from "node:fs/promises";
|
||||||
import { parseQx, formatQx, lintQx } from "./source.js";
|
import { parseQx, formatQx, lintQx } from "./source.js";
|
||||||
import { scaffoldAtom } from "./scaffold.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 { createGitCapabilityResolver } from "./git-resolver.js";
|
||||||
import { planEvolution } from "../capability-model/index.js";
|
import { planEvolution } from "../capability-model/index.js";
|
||||||
import { snapshotRepository } from "./candidate-check.js";
|
import { snapshotRepository } from "./candidate-check.js";
|
||||||
@@ -54,6 +54,11 @@ const planSummary = (plan: Awaited<ReturnType<typeof planStructure>>) => ({
|
|||||||
|
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
const [command, ...args] = process.argv.slice(2);
|
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 (command === "scaffold-validate-qx") {
|
||||||
if (args.length !== 1) throw new Error("scaffold-validate-qx SOURCES_JSON");
|
if (args.length !== 1) throw new Error("scaffold-validate-qx SOURCES_JSON");
|
||||||
const sources = JSON.parse(args[0]);
|
const sources = JSON.parse(args[0]);
|
||||||
|
|||||||
@@ -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 {
|
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[];
|
moduleBindings?: ModuleBinding[];
|
||||||
initialization?: InitializationField[];
|
initialization?: InitializationField[];
|
||||||
/** Absent only for legacy assemblies awaiting explicit ownership enrollment. */
|
/** Absent only for legacy assemblies awaiting explicit ownership enrollment. */
|
||||||
|
|||||||
@@ -708,7 +708,14 @@ const collectIdentityIndexes = (
|
|||||||
issues,
|
issues,
|
||||||
"duplicate-conformance",
|
"duplicate-conformance",
|
||||||
path,
|
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 {
|
} else {
|
||||||
conformances.set(key, conformance);
|
conformances.set(key, conformance);
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
compileCapabilityResourceSource,
|
||||||
|
compileCapabilitySource,
|
||||||
|
type CapabilityImportEnvironment,
|
||||||
|
} from "../src/capability-language/parser.js";
|
||||||
|
import type { InterfaceRevision, PackageRevision } from "../src/capability-model/types.js";
|
||||||
|
|
||||||
|
function fixture(declarations: string[]) {
|
||||||
|
const interfaces = new Map<string, InterfaceRevision>();
|
||||||
|
const packages = new Map<string, PackageRevision>();
|
||||||
|
const environment: CapabilityImportEnvironment = { interfaces, packages };
|
||||||
|
for (const sourceText of declarations) {
|
||||||
|
const result = compileCapabilityResourceSource(sourceText, {
|
||||||
|
source: { repository: "https://example.test/rules.git", commit: "a".repeat(40) },
|
||||||
|
environment,
|
||||||
|
});
|
||||||
|
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||||
|
if (result.resource.kind === "interface")
|
||||||
|
interfaces.set(result.resource.revision.displayName, result.resource.revision);
|
||||||
|
else packages.set(result.resource.revision.displayName, result.resource.revision);
|
||||||
|
}
|
||||||
|
return (body: string) =>
|
||||||
|
compileCapabilitySource(
|
||||||
|
`workspace W id "w" revision "w@1" commit "${"a".repeat(40)}" {
|
||||||
|
${[...interfaces.keys()].map((name) => `import interface ${name};`).join("\n")}
|
||||||
|
${[...packages.keys()].map((name) => `import package ${name};`).join("\n")}
|
||||||
|
${body}
|
||||||
|
}`,
|
||||||
|
"rules.qx",
|
||||||
|
environment,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ok = (result: ReturnType<ReturnType<typeof fixture>>) => {
|
||||||
|
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const nameResources = [
|
||||||
|
'interface Person id "person" revision "person@1" {value name id "p:name" : string {get id "p:get";set id "p:set";}}',
|
||||||
|
'interface HasName id "name" revision "name@1" {value name id "n:name" : string {get id "n:get";set id "n:set";}}',
|
||||||
|
];
|
||||||
|
const person = (name: string) => `atom ${name} id "${name}"; conform ${name} as Person id "${name}:person" {
|
||||||
|
private state Name${name} id "${name}:name" on ${name} : string policy register default "hello";
|
||||||
|
bind name.get to state Name${name}.read; bind name.set to state Name${name}.write;
|
||||||
|
}`;
|
||||||
|
const namedRule =
|
||||||
|
'conform Person as HasName id "person-name" {bind name.get to interface Person.name.get;bind name.set to interface Person.name.set;}';
|
||||||
|
|
||||||
|
test("interface rules are order independent and preserve each original storage owner", () => {
|
||||||
|
const compile = fixture(nameResources);
|
||||||
|
const first = ok(compile(namedRule + person("Living") + person("Avatar")));
|
||||||
|
const second = ok(compile(person("Avatar") + person("Living") + namedRule));
|
||||||
|
const generated = (result: typeof first) =>
|
||||||
|
result.workspace.conformances
|
||||||
|
.filter((c) => c.ruleOrigin)
|
||||||
|
.map((c) => [c.atomId, c.id])
|
||||||
|
.sort();
|
||||||
|
assert.deepEqual(generated(first), generated(second));
|
||||||
|
for (const atom of ["Living", "Avatar"]) {
|
||||||
|
const c = first.workspace.conformances.find((c) => c.atomId === atom && c.interfaceRevisionId === "name@1")!;
|
||||||
|
assert(c);
|
||||||
|
assert.equal(c.privateAttachments.length, 0);
|
||||||
|
assert(
|
||||||
|
c.operationBindings.every((b) => b.binding.kind === "interface" && b.binding.interfaceRevisionId === "person@1"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rule conflicts identify competing implementations", () => {
|
||||||
|
const result = fixture(nameResources)(
|
||||||
|
person("Living") + namedRule + namedRule.replace('id "person-name"', 'id "other-rule"'),
|
||||||
|
);
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(JSON.stringify(result.diagnostics), /duplicate-conformance/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rules validate unused bodies and reject storage and ambiguous names", () => {
|
||||||
|
const compile = fixture(nameResources);
|
||||||
|
for (const [body, code] of [
|
||||||
|
["conform Person as HasName { }", "missing-rule-id"],
|
||||||
|
['conform Person as HasName id "r" {bind name.get to interface Person.missing.get;}', "unknown-operation"],
|
||||||
|
['conform Person as HasName id "r" { initialize {}; }', "unsupported-rule-storage"],
|
||||||
|
['atom Person id "p";' + namedRule, "ambiguous-conformance-source"],
|
||||||
|
[namedRule + namedRule, "duplicate-rule-id"],
|
||||||
|
]) {
|
||||||
|
const result = compile(body);
|
||||||
|
assert.equal(result.ok, false, body);
|
||||||
|
assert.match(JSON.stringify(result.diagnostics), new RegExp(code));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const genericResources = [
|
||||||
|
'interface Placeable id "placeable" revision "placeable@1" {}',
|
||||||
|
'interface Eligible id "eligible" revision "eligible@1" {}',
|
||||||
|
'interface Factory<value Input, object Result> id "factory" revision "factory@1" {}',
|
||||||
|
'import interface Placeable; interface CanvasFactory<value Input, object Result implements Placeable> id "canvas-factory" revision "canvas-factory@1" {}',
|
||||||
|
];
|
||||||
|
const factoryRule =
|
||||||
|
'conform<value I, object R implements Placeable> Factory<I,R> as CanvasFactory<I,R> id "factory-canvas" {}';
|
||||||
|
test("generic bounds filter factories and can be supplied by later rules", () => {
|
||||||
|
const result = ok(
|
||||||
|
fixture(genericResources)(`
|
||||||
|
${factoryRule}
|
||||||
|
atom Owner id "owner"; atom Product id "product"; atom Other id "other";
|
||||||
|
conform Owner as Factory<unit,atom Product> id "unit-factory" {}
|
||||||
|
conform Owner as Factory<record { title:string; },atom Product> id "record-factory" {}
|
||||||
|
conform Owner as Factory<string,atom Other> id "ineligible" {}
|
||||||
|
conform Product as Eligible id "eligible-product" {}
|
||||||
|
conform Eligible as Placeable id "eligible-placeable" {}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
const generated = result.workspace.conformances.filter((c) => c.ruleOrigin?.ruleId === "factory-canvas");
|
||||||
|
assert.equal(generated.length, 2);
|
||||||
|
assert(generated.every((c) => c.atomId === "owner"));
|
||||||
|
for (const c of generated) {
|
||||||
|
const iface = result.workspace.interfaceImports.find((i) => i.revisionId === c.interfaceRevisionId)!;
|
||||||
|
assert.deepEqual(iface.application?.arguments[1], { kind: "object", target: { kind: "atom", atomId: "product" } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rules reject recursive graphs, nested capture, wrong kinds and unbound parameters", () => {
|
||||||
|
const compile = fixture([
|
||||||
|
'interface A<value T> id "a" revision "a@1" {}',
|
||||||
|
'interface B<value T> id "b" revision "b@1" {}',
|
||||||
|
]);
|
||||||
|
for (const [body, code] of [
|
||||||
|
['conform<value T> A<T> as A<list<T>> id "grow" {}', "recursive-conformance-rule"],
|
||||||
|
['conform<value T> A<T> as B<T> id "ab" {} conform<value T> B<T> as A<T> id "ba" {}', "recursive-conformance-rule"],
|
||||||
|
['conform<value T> A<list<T>> as B<T> id "nested" {}', "unsupported-rule-pattern"],
|
||||||
|
['conform<value T> A<string> as B<T> id "uncaptured" {}', "uncaptured-rule-parameter"],
|
||||||
|
['conform<object T> A<T> as B<string> id "kind" {}', "parameter-kind"],
|
||||||
|
]) {
|
||||||
|
const result = compile(body);
|
||||||
|
assert.equal(result.ok, false, body);
|
||||||
|
assert.match(JSON.stringify(result.diagnostics), new RegExp(code));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("package-backed rules retain declared interface dependencies", () => {
|
||||||
|
const result = ok(
|
||||||
|
fixture([
|
||||||
|
...nameResources,
|
||||||
|
'import interface Person; package Names id "names" revision "names@1" {operation displayName id "read" : unit -> string mode call receiver any requires {interface person id "person-port" : Person using [name.get];};}',
|
||||||
|
])(
|
||||||
|
person("Living") +
|
||||||
|
`conform Person as HasName id "formatted" {
|
||||||
|
bind name.get to package Names.displayName with {person to interface Person;};
|
||||||
|
bind name.set to interface Person.name.set;
|
||||||
|
}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const generated = result.workspace.conformances.find((c) => c.ruleOrigin)!;
|
||||||
|
const binding = generated.operationBindings[0].binding;
|
||||||
|
assert.equal(binding.kind, "package");
|
||||||
|
if (binding.kind === "package")
|
||||||
|
assert.deepEqual(binding.dependencies[0].binding, { kind: "interface", interfaceRevisionId: "person@1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repeated arguments, closed patterns, Self and seedless bounds remain exact", () => {
|
||||||
|
const compile = fixture([
|
||||||
|
'interface Pair<value A, value B> id "pair" revision "pair@1" {}',
|
||||||
|
'interface Same<value T, object Owner> id "same" revision "same@1" {}',
|
||||||
|
...genericResources,
|
||||||
|
]);
|
||||||
|
const result = ok(
|
||||||
|
compile(`
|
||||||
|
atom A id "atom-a";
|
||||||
|
conform A as Pair<string,string> id "equal" {}
|
||||||
|
conform A as Pair<string,int32> id "unequal" {}
|
||||||
|
conform<value T> Pair<T,T> as Same<T,Self> id "same-rule" {}
|
||||||
|
conform A as Factory<unit,atom A> id "factory" {}
|
||||||
|
${factoryRule}
|
||||||
|
conform<object R implements Eligible> Factory<unit,R> as Placeable id "seedless" {}
|
||||||
|
conform<object R implements Placeable> Factory<unit,R> as Eligible id "seedless-other" {}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
const generated = result.workspace.conformances.filter((c) => c.ruleOrigin);
|
||||||
|
assert.equal(generated.length, 1);
|
||||||
|
const target = result.workspace.interfaceImports.find((i) => i.revisionId === generated[0].interfaceRevisionId)!;
|
||||||
|
assert.deepEqual(target.application?.arguments[1], { kind: "object", target: { kind: "atom", atomId: "atom-a" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chains do not duplicate an application and explicit targets conflict", () => {
|
||||||
|
const compile = fixture([
|
||||||
|
'interface A id "a" revision "a@1" {}',
|
||||||
|
'interface B id "b" revision "b@1" {}',
|
||||||
|
'interface C id "c" revision "c@1" {}',
|
||||||
|
]);
|
||||||
|
const source = `atom Item id "item"; conform Item as A id "a-impl" {}
|
||||||
|
conform B as C id "bc" {} conform A as B id "ab" {}`;
|
||||||
|
assert.equal(ok(compile(source)).workspace.conformances.length, 3);
|
||||||
|
const conflict = compile(source + 'conform Item as C id "explicit-c" {}');
|
||||||
|
assert.equal(conflict.ok, false);
|
||||||
|
assert.match(JSON.stringify(conflict.diagnostics), /explicit-c/);
|
||||||
|
assert.match(JSON.stringify(conflict.diagnostics), /rule bc/);
|
||||||
|
});
|
||||||
@@ -19,9 +19,9 @@ const compile = (policy = `crdt(${content})`, binding = "edit") =>
|
|||||||
`
|
`
|
||||||
workspace Comments id "w" revision "w@1" commit "${"a".repeat(40)}" {
|
workspace Comments id "w" revision "w@1" commit "${"a".repeat(40)}" {
|
||||||
import interface Comment;
|
import interface Comment;
|
||||||
atom Comment id "comment";
|
atom CommentAtom id "comment";
|
||||||
conform Comment as Comment id "owner:comment" {
|
conform CommentAtom as Comment id "owner:comment" {
|
||||||
private state Content id "content" on Comment : ${content} policy ${policy};
|
private state Content id "content" on CommentAtom : ${content} policy ${policy};
|
||||||
initialize { content to state Content; };
|
initialize { content to state Content; };
|
||||||
bind content.get to state Content.read;
|
bind content.get to state Content.read;
|
||||||
bind content.edit to state Content.${binding};
|
bind content.edit to state Content.${binding};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { qxSourceManifest } from "../src/capability-language/source-loader.js";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { mkdtemp, writeFile, readFile, rm, symlink } from "node:fs/promises";
|
import { mkdtemp, writeFile, readFile, rm, symlink } from "node:fs/promises";
|
||||||
@@ -119,3 +120,21 @@ test("lowering diagnostics map to the imported file", async () => {
|
|||||||
assert.equal(position.fileName, "bad.qx");
|
assert.equal(position.fileName, "bad.qx");
|
||||||
assert.equal(position.line, 2);
|
assert.equal(position.line, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("source manifests include resource imports in deduplicated workspace fragments", async (t) => {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "qx-source-manifest-"));
|
||||||
|
t.after(() => rm(root, { recursive: true, force: true }));
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, "workspace.qx"),
|
||||||
|
addWorkspaceImport(addWorkspaceImport(workspace, "left.qx"), "right.qx"),
|
||||||
|
);
|
||||||
|
await writeFile(path.join(root, "left.qx"), 'fragment {import "rules.qx";}');
|
||||||
|
await writeFile(path.join(root, "right.qx"), 'fragment {import "rules.qx";}');
|
||||||
|
await writeFile(path.join(root, "rules.qx"), "fragment {import interface Person; import package Runtime;}");
|
||||||
|
const manifest = await qxSourceManifest(root);
|
||||||
|
assert.deepEqual(manifest.imports, [
|
||||||
|
{ kind: "interface", binding: "Person" },
|
||||||
|
{ kind: "package", binding: "Runtime" },
|
||||||
|
]);
|
||||||
|
assert.deepEqual([...manifest.sourceFiles].sort(), ["left.qx", "right.qx", "rules.qx", "workspace.qx"]);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user