Implement reusable interface conformance rules
This commit is contained in:
@@ -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)}" {
|
||||
import interface Comment;
|
||||
atom Comment id "comment";
|
||||
conform Comment as Comment id "owner:comment" {
|
||||
private state Content id "content" on Comment : ${content} policy ${policy};
|
||||
atom CommentAtom id "comment";
|
||||
conform CommentAtom as Comment id "owner:comment" {
|
||||
private state Content id "content" on CommentAtom : ${content} policy ${policy};
|
||||
initialize { content to state Content; };
|
||||
bind content.get to state Content.read;
|
||||
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 test from "node:test";
|
||||
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.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