348 lines
17 KiB
TypeScript
348 lines
17 KiB
TypeScript
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 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 { content to state Missing; }; }', "invalid-initialization"],
|
|
['conform Person as HasName id "r" { bind name to state Missing; }', "invalid-state-field-binding"],
|
|
[
|
|
'conform Person as HasName id "r" {private state Bad id "bad" on Self : Missing policy register;}',
|
|
"unknown-type",
|
|
],
|
|
['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));
|
|
}
|
|
});
|
|
|
|
test("rule storage is scoped per application, including repeated specializations on one atom", async () => {
|
|
const compile = fixture([
|
|
'interface Source<value T : storable> id "source" revision "source@1" {}',
|
|
'interface Stored<value T : storable> id "stored" revision "stored@1" {value content id "value" : optional<T> {get id "get";set id "set";}}',
|
|
]);
|
|
const rule = `conform<value T : storable> Source<T> as Stored<T> id "store-rule" {
|
|
private state Value id "stored:value" on Self : optional<T> policy register default null;
|
|
bind content to state Value;
|
|
}`;
|
|
const atoms = `atom A id "a"; atom B id "b";
|
|
conform A as Source<string> id "a-string" {}
|
|
conform A as Source<int64> id "a-number" {}
|
|
conform B as Source<string> id "b-string" {}`;
|
|
const first = ok(compile(rule + atoms)).workspace;
|
|
const second = ok(compile(atoms + rule)).workspace;
|
|
const storage = (workspace: typeof first) =>
|
|
workspace.conformances
|
|
.filter((c) => c.ruleOrigin)
|
|
.map((c) => [c.id, c.privateAttachments, c.operationBindings])
|
|
.sort((a, b) => String(a[0]).localeCompare(String(b[0])));
|
|
assert.deepEqual(storage(first), storage(second));
|
|
const owners = first.conformances.filter((c) => c.ruleOrigin);
|
|
assert.equal(owners.length, 3);
|
|
assert.equal(new Set(owners.map((c) => c.privateAttachments[0]!.id)).size, 3);
|
|
for (const owner of owners) {
|
|
const state = owner.privateAttachments[0]!;
|
|
assert.equal(state.kind, "state");
|
|
if (state.kind !== "state") throw Error("state expected");
|
|
assert.equal(state.attachedTo, owner.atomId);
|
|
assert(owner.operationBindings.every((b) => b.binding.kind === "state" && b.binding.slotId === state.id));
|
|
}
|
|
const { storageContracts, storageChangeRequiresMigration } = await import("../src/capability-model/evolution.js");
|
|
const before = storageContracts(first);
|
|
const changed = ok(
|
|
compile(
|
|
atoms.replace('conform A as Source<int64> id "a-number" {}', "") +
|
|
rule.replace("default null", 'default "new default"'),
|
|
),
|
|
).workspace;
|
|
// Use the string specialization for default evolution; all nominal identities
|
|
// remain stable, and changing a default must not rewrite existing state.
|
|
const oldSlot = before.find((s) => s.id === owners.find((c) => c.atomId === "b")!.privateAttachments[0]!.id)!;
|
|
const newSlot = storageContracts(changed).find((s) => s.id === oldSlot.id)!;
|
|
assert.equal(storageChangeRequiresMigration(oldSlot, newSlot, new Set(["a", "b"])), false);
|
|
assert.equal(storageChangeRequiresMigration(oldSlot, undefined, new Set(["a", "b"])), true);
|
|
});
|
|
|
|
test("rule attachments shadow outside names without leaking into another owner", () => {
|
|
const rule = `conform Person as HasName id "r" {
|
|
private state NameLiving id "rule:name" on Self : string policy register default "rule";
|
|
bind name to state NameLiving;
|
|
}`;
|
|
const workspace = ok(fixture(nameResources)(rule + person("Living"))).workspace;
|
|
const original = workspace.conformances.find((c) => c.interfaceRevisionId === "person@1")!;
|
|
const derived = workspace.conformances.find((c) => c.interfaceRevisionId === "name@1")!;
|
|
assert.notEqual(original.privateAttachments[0]!.id, derived.privateAttachments[0]!.id);
|
|
assert(
|
|
derived.operationBindings.every(
|
|
(b) => b.binding.kind === "state" && b.binding.slotId === derived.privateAttachments[0]!.id,
|
|
),
|
|
);
|
|
assert(
|
|
original.operationBindings.every(
|
|
(b) => b.binding.kind === "state" && b.binding.slotId === original.privateAttachments[0]!.id,
|
|
),
|
|
);
|
|
});
|
|
|
|
test("rule private edges substitute Self and captured endpoints with stable projection IDs", () => {
|
|
const compile = fixture([
|
|
'interface Linkable<object T> id "linkable" revision "linkable@1" {}',
|
|
'interface Links<object T> id "links" revision "links@1" {relation items id "items" : many object T {resolve id "resolve";}}',
|
|
]);
|
|
const source = `atom A id "a"; atom B id "b"; atom Target id "target";
|
|
conform A as Linkable<atom Target> id "a-link" {} conform B as Linkable<atom Target> id "b-link" {}
|
|
conform<object T> Linkable<T> as Links<T> id "links" {
|
|
private edge Items id "items" {
|
|
object Self projection items id "forward" many;
|
|
object T projection owners id "inverse" many;
|
|
}
|
|
bind items.resolve to edge Items.items.resolve;
|
|
}`;
|
|
const workspace = ok(compile(source)).workspace;
|
|
const edges = workspace.conformances.filter((c) => c.ruleOrigin).map((c) => c.privateAttachments[0]!);
|
|
assert.equal(edges.length, 2);
|
|
assert.notEqual(edges[0]!.id, edges[1]!.id);
|
|
const projections = edges.flatMap((edge) => {
|
|
assert.equal(edge.kind, "edge");
|
|
if (edge.kind !== "edge") throw Error("edge expected");
|
|
assert.deepEqual(edge.endpoints[1].constraint, { kind: "atom", atomId: "target" });
|
|
return edge.endpoints.map((e) => e.projectionId);
|
|
});
|
|
assert.equal(new Set(projections).size, 4);
|
|
});
|
|
|
|
test("rule creation ports close captured atom parameters and retain initialization checking", () => {
|
|
const compile = fixture([
|
|
'interface Factory<object T> id "factory" revision "factory@1" {operation create id "create" : unit -> ref<T> {call id "call" execution transaction targets [browser,server];}}',
|
|
'interface Creates<object T> id "creates" revision "creates@1" {}',
|
|
'package Runtime id "runtime" revision "runtime@1" {operation create<object T> id "create" : unit -> ref<T> mode call execution transaction targets [browser,server] receiver any requires {creation result id "result" : T input unit;};}',
|
|
]);
|
|
const rule = `conform<object T> Creates<T> as Factory<T> id "factory-rule" {
|
|
bind create.call to package Runtime.create<T> with {result to creation T with {};};
|
|
}`;
|
|
const result = ok(
|
|
compile(`atom Subject id "subject"; atom Product id "product";
|
|
conform Subject as Creates<atom Product> id "creates-product" {} ${rule}`),
|
|
);
|
|
const owner = result.workspace.conformances.find((c) => c.ruleOrigin)!;
|
|
const binding = owner.operationBindings[0]!.binding;
|
|
assert.equal(binding.kind, "package");
|
|
if (binding.kind !== "package") throw Error("package expected");
|
|
assert.deepEqual(binding.dependencies[0]!.binding, { kind: "creation", atomId: "product", owners: [] });
|
|
const invalid = compile(`atom Subject id "subject";
|
|
conform Subject as Creates<interface Creates<atom Subject>> id "creates-interface" {} ${rule}`);
|
|
assert.equal(invalid.ok, false);
|
|
assert.match(JSON.stringify(invalid.diagnostics), /concrete atom/);
|
|
});
|
|
|
|
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/);
|
|
});
|
|
|
|
test("rule inverse reads retain the concrete conformance ownership checks", () => {
|
|
const compile = fixture([
|
|
'interface Parent id "parent" revision "parent@1" {}',
|
|
'interface Child id "child" revision "child@1" {}',
|
|
'import interface Parent; interface Owned id "owned" revision "owned@1" {queryable relation parent id "parent-ref" : optional-one interface Parent {resolve id "parent-read" execution snapshot targets [browser,server];}}',
|
|
]);
|
|
const source = `
|
|
atom P id "p"; atom C id "c";
|
|
conform P as Parent id "parent-impl" {
|
|
private edge Membership id "membership" {
|
|
atom P projection children id "children" many-unique;
|
|
interface Child projection parent id "parent" optional-one public-traversal;
|
|
}
|
|
}
|
|
conform C as Child id "child-impl" {}
|
|
conform Child as Owned id "owned-rule" {bind parent.resolve to edge Membership.parent.resolve;}
|
|
`;
|
|
ok(compile(source));
|
|
const hidden = compile(source.replace(" public-traversal", ""));
|
|
assert.equal(hidden.ok, false);
|
|
assert.match(JSON.stringify(hidden.diagnostics), /private|public|own/i);
|
|
const missing = compile(source.replace("Membership.parent.resolve", "Missing.parent.resolve"));
|
|
assert.equal(missing.ok, false);
|
|
assert.match(JSON.stringify(missing.diagnostics), /Unknown attachment/);
|
|
});
|