Build workspace agent, capability graph, and versioned cutovers
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
compileCapabilityResourceRepository,
|
||||
compileWorkspaceRepository,
|
||||
} from "../src/capability-language/index.js";
|
||||
|
||||
const quixosCommit = "1".repeat(40);
|
||||
const namedCommit = "2".repeat(40);
|
||||
const packageCommit = "3".repeat(40);
|
||||
|
||||
const lock = (resources = "") => `quixos-lock version 1 {
|
||||
quixos source {
|
||||
repository "https://example.test/quixos.git";
|
||||
commit "${quixosCommit}";
|
||||
}
|
||||
${resources}
|
||||
}
|
||||
`;
|
||||
|
||||
test("workspace assembly resolves resource-owned dependencies recursively", async (context) => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "quixos-assembly-"));
|
||||
context.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const root = path.join(directory, "root");
|
||||
const named = path.join(directory, "named");
|
||||
const runtime = path.join(directory, "runtime");
|
||||
await Promise.all([mkdir(root), mkdir(named), mkdir(runtime)]);
|
||||
await writeFile(path.join(root, "quixos.lock"), lock(`package Runtime source {
|
||||
repository "https://example.test/package-runtime.git";
|
||||
commit "${packageCommit}";
|
||||
}`));
|
||||
await writeFile(path.join(root, "workspace.qx"), `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"4".repeat(40)}" {
|
||||
atom Subject id "atom:subject";
|
||||
import package Runtime;
|
||||
}
|
||||
`);
|
||||
await writeFile(path.join(named, "quixos.lock"), lock());
|
||||
await writeFile(path.join(named, "interface.qx"), `interface Named id "interface:named" revision "interface:named@1" {
|
||||
value name id "member:named:name" : string {
|
||||
get id "operation:named:name:get";
|
||||
}
|
||||
}
|
||||
`);
|
||||
await writeFile(path.join(runtime, "quixos.lock"), lock(`interface Named source {
|
||||
repository "https://example.test/interface-named.git";
|
||||
commit "${namedCommit}";
|
||||
}`));
|
||||
await writeFile(path.join(runtime, "package.qx"), `import interface Named;
|
||||
package Runtime id "package:runtime" revision "package:runtime@1" {
|
||||
function describe id "export:runtime:describe" : interface-ref<Named> -> string;
|
||||
}
|
||||
`);
|
||||
|
||||
const directories = new Map([
|
||||
[`interface\0https://example.test/interface-named.git\0${namedCommit}`, named],
|
||||
[`package\0https://example.test/package-runtime.git\0${packageCommit}`, runtime],
|
||||
]);
|
||||
const result = await compileWorkspaceRepository({
|
||||
rootDirectory: root,
|
||||
resolveResource: async (source, kind) => {
|
||||
const resolved = directories.get(`${kind}\0${source.repository}\0${source.commit}`);
|
||||
if (!resolved) throw new Error(`Unexpected source ${source.repository}`);
|
||||
return { directory: resolved };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.resources.length, 2);
|
||||
assert.deepEqual(
|
||||
result.workspace.interfaceImports.map((entry) => entry.revisionId),
|
||||
["interface:named@1"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.workspace.packageImports.map((entry) => entry.revisionId),
|
||||
["package:runtime@1"],
|
||||
);
|
||||
assert.equal(result.directResources.size, 1);
|
||||
|
||||
const resource = await compileCapabilityResourceRepository({
|
||||
rootDirectory: runtime,
|
||||
kind: "package",
|
||||
source: {
|
||||
resolver: "git",
|
||||
repository: "https://example.test/package-runtime.git",
|
||||
commit: packageCommit,
|
||||
},
|
||||
resolveResource: async (source, kind) => {
|
||||
const resolved = directories.get(`${kind}\0${source.repository}\0${source.commit}`);
|
||||
if (!resolved) throw new Error(`Unexpected source ${source.repository}`);
|
||||
return { directory: resolved };
|
||||
},
|
||||
});
|
||||
assert.equal(resource.resource.kind, "package");
|
||||
assert.equal(resource.resources.length, 2);
|
||||
assert.equal(resource.directResources.size, 1);
|
||||
});
|
||||
|
||||
test("resource repositories cannot own workspace Quixos selection policy", async (context) => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "quixos-resource-policy-"));
|
||||
context.after(() => rm(directory, { recursive: true, force: true }));
|
||||
await writeFile(path.join(directory, "quixos.lock"), `quixos-lock version 1 {
|
||||
quixos source {
|
||||
repository "https://example.test/quixos.git";
|
||||
policy track-development;
|
||||
ref "dev/alice/main";
|
||||
commit "${quixosCommit}";
|
||||
}
|
||||
}`);
|
||||
await writeFile(path.join(directory, "package.qx"), `package Runtime id "package:runtime" revision "package:runtime@1" { }\n`);
|
||||
|
||||
await assert.rejects(
|
||||
compileCapabilityResourceRepository({
|
||||
rootDirectory: directory,
|
||||
kind: "package",
|
||||
source: {
|
||||
resolver: "git",
|
||||
repository: "https://example.test/package-runtime.git",
|
||||
commit: packageCommit,
|
||||
},
|
||||
resolveResource: async () => ({ directory }),
|
||||
}),
|
||||
/resource locks may only declare their exact authored-against commit/,
|
||||
);
|
||||
});
|
||||
|
||||
test("resource manifests cannot hide lock dependencies", async (context) => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "quixos-assembly-mismatch-"));
|
||||
context.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const root = path.join(directory, "root");
|
||||
const runtime = path.join(directory, "runtime");
|
||||
await Promise.all([mkdir(root), mkdir(runtime)]);
|
||||
await writeFile(path.join(root, "quixos.lock"), lock(`package Runtime source {
|
||||
repository "https://example.test/package-runtime.git";
|
||||
commit "${packageCommit}";
|
||||
}`));
|
||||
await writeFile(path.join(root, "workspace.qx"), `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"4".repeat(40)}" {
|
||||
atom Subject id "atom:subject";
|
||||
import package Runtime;
|
||||
}
|
||||
`);
|
||||
await writeFile(path.join(runtime, "quixos.lock"), lock());
|
||||
await writeFile(path.join(runtime, "package.qx"), `import interface Hidden;
|
||||
package Runtime id "package:runtime" revision "package:runtime@1" { }
|
||||
`);
|
||||
|
||||
await assert.rejects(
|
||||
compileWorkspaceRepository({
|
||||
rootDirectory: root,
|
||||
resolveResource: async () => ({ directory: runtime }),
|
||||
}),
|
||||
/No resolved interface is available for lock binding Hidden/,
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace assembly must satisfy nominal external interfaces", async (context) => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "quixos-assembly-external-"));
|
||||
context.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const root = path.join(directory, "root");
|
||||
const runtime = path.join(directory, "runtime");
|
||||
await Promise.all([mkdir(root), mkdir(runtime)]);
|
||||
await writeFile(path.join(root, "quixos.lock"), lock(`package Runtime source {
|
||||
repository "https://example.test/package-runtime.git";
|
||||
commit "${packageCommit}";
|
||||
}`));
|
||||
await writeFile(path.join(root, "workspace.qx"), `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"4".repeat(40)}" {
|
||||
atom Subject id "atom:subject";
|
||||
import package Runtime;
|
||||
}
|
||||
`);
|
||||
await writeFile(path.join(runtime, "quixos.lock"), lock());
|
||||
await writeFile(path.join(runtime, "package.qx"), `external interface Named revision "interface:named@1";
|
||||
package Runtime id "package:runtime" revision "package:runtime@1" { }
|
||||
`);
|
||||
|
||||
await assert.rejects(
|
||||
compileWorkspaceRepository({
|
||||
rootDirectory: root,
|
||||
resolveResource: async () => ({ directory: runtime }),
|
||||
}),
|
||||
/requires external interface Named \(interface:named@1\)/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import { test } from "node:test";
|
||||
|
||||
const cli = resolve(process.cwd(), "dist/src/capability-language/cli.js");
|
||||
|
||||
const runResourceCheck = (source: string) => spawnSync(
|
||||
process.execPath,
|
||||
[cli, "--resource", "--check", "-"],
|
||||
{ input: source, encoding: "utf8" },
|
||||
);
|
||||
|
||||
test("capability CLI checks a standalone interface resource", () => {
|
||||
const result = runResourceCheck(`interface WeatherBase id "interface:weather-base" revision "interface:weather-base@1" {
|
||||
value temperature id "member:weather:temperature" : double {
|
||||
get id "operation:weather:temperature:get";
|
||||
}
|
||||
}\n`);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, "");
|
||||
});
|
||||
|
||||
test("capability CLI reports invalid standalone interface members", () => {
|
||||
const result = runResourceCheck(`interface WeatherBase id "interface:weather-base" revision "interface:weather-base@1" {
|
||||
value temperature : definitely-not-a-type;
|
||||
}\n`);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /syntax-error/);
|
||||
});
|
||||
@@ -2,18 +2,96 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { compileCapabilitySource } from "../src/capability-language/index.js";
|
||||
import {
|
||||
capabilityFixturePath,
|
||||
compileCapabilityResourceSource,
|
||||
compileCapabilitySource,
|
||||
} from "../src/capability-language/index.js";
|
||||
import {
|
||||
capabilityFixtureSource,
|
||||
capabilityResourceSources,
|
||||
compileCapabilityFixture,
|
||||
fixtureId,
|
||||
} from "./fixtures/capability-model.js";
|
||||
|
||||
test("ANTLR parses and validates a complete capability workspace", () => {
|
||||
const result = compileCapabilitySource(
|
||||
capabilityFixtureSource,
|
||||
capabilityFixturePath,
|
||||
const compileWebStudioFixture = (packageTransform = (source: string) => source) => {
|
||||
const fixture = (name: string) => readFileSync(
|
||||
resolve(process.cwd(), "test/fixtures", name),
|
||||
"utf8",
|
||||
);
|
||||
const react = compileCapabilityResourceSource(
|
||||
fixture("react-component.interface.qx"),
|
||||
{
|
||||
source: {
|
||||
repository: "https://repos.quixos.org/org-quixos-web-studio/interface-react-component.git",
|
||||
commit: "2".repeat(40),
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!react.ok || react.resource.kind !== "interface") {
|
||||
throw new Error("ReactComponent fixture did not compile");
|
||||
}
|
||||
const named = compileCapabilityResourceSource(
|
||||
fixture("named.interface.qx"),
|
||||
{
|
||||
source: {
|
||||
repository: "https://repos.quixos.org/quixos-test/interface-named.git",
|
||||
commit: "5".repeat(40),
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!named.ok || named.resource.kind !== "interface") {
|
||||
throw new Error("Named fixture did not compile");
|
||||
}
|
||||
const has = compileCapabilityResourceSource(
|
||||
fixture("has-react-component.interface.qx"),
|
||||
{
|
||||
source: {
|
||||
repository: "https://repos.quixos.org/org-quixos-web-studio/interface-has-react-component.git",
|
||||
commit: "3".repeat(40),
|
||||
},
|
||||
environment: {
|
||||
interfaces: new Map([["ReactComponent", react.resource.revision]]),
|
||||
interfaceClosure: [react.resource.revision],
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!has.ok || has.resource.kind !== "interface") {
|
||||
throw new Error("HasReactComponent fixture did not compile");
|
||||
}
|
||||
const component = compileCapabilityResourceSource(
|
||||
packageTransform(fixture("component-runtime.package.qx")),
|
||||
{
|
||||
source: {
|
||||
repository: "https://repos.quixos.org/quixos-test/package-component-runtime.git",
|
||||
commit: "4".repeat(40),
|
||||
},
|
||||
environment: {
|
||||
interfaces: new Map([["Named", named.resource.revision]]),
|
||||
interfaceClosure: [named.resource.revision],
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!component.ok || component.resource.kind !== "package") {
|
||||
throw new Error("ComponentRuntime fixture did not compile");
|
||||
}
|
||||
return compileCapabilitySource(
|
||||
fixture("web-studio.capabilities.qx"),
|
||||
"web-studio.capabilities.qx",
|
||||
{
|
||||
interfaces: new Map([
|
||||
["Named", named.resource.revision],
|
||||
["ReactComponent", react.resource.revision],
|
||||
["HasReactComponent", has.resource.revision],
|
||||
]),
|
||||
packages: new Map([["ComponentRuntime", component.resource.revision]]),
|
||||
interfaceClosure: [named.resource.revision, react.resource.revision, has.resource.revision],
|
||||
packageClosure: [component.resource.revision],
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
test("ANTLR parses and validates a complete capability workspace", () => {
|
||||
const result = compileCapabilityFixture();
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) return;
|
||||
assert.equal(result.workspace.workspaceId, fixtureId.workspace);
|
||||
@@ -24,26 +102,24 @@ test("ANTLR parses and validates a complete capability workspace", () => {
|
||||
});
|
||||
|
||||
test("conformances do not accept authored IDs", () => {
|
||||
const result = compileCapabilitySource(
|
||||
capabilityFixtureSource.replace(
|
||||
const result = compileCapabilityFixture({
|
||||
workspace: capabilityFixtureSource.replace(
|
||||
"conform Project as Named {",
|
||||
'conform Project as Named id "conformance:project:named" {',
|
||||
),
|
||||
"conformance-id.qx",
|
||||
);
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.ok(result.diagnostics.some((entry) => entry.phase === "syntax"));
|
||||
});
|
||||
|
||||
test("the v1 language has no implicit relationship materialization rule", () => {
|
||||
const result = compileCapabilitySource(
|
||||
capabilityFixtureSource.replace(
|
||||
const result = compileCapabilityFixture({
|
||||
workspace: capabilityFixtureSource.replace(
|
||||
/\n}\s*$/,
|
||||
'\n materialize ProjectOwner.owner if absent with Person;\n}\n',
|
||||
),
|
||||
"materialize.qx",
|
||||
);
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.ok(result.diagnostics.some((entry) => entry.phase === "syntax"));
|
||||
@@ -57,21 +133,23 @@ test("forward declarations make source order irrelevant", () => {
|
||||
/\n}\s*$/,
|
||||
'\n atom Project id "atom:project";\n atom Person id "atom:person";\n}\n',
|
||||
);
|
||||
assert.equal(compileCapabilitySource(source, "reordered.qx").ok, true);
|
||||
assert.equal(compileCapabilityFixture({ workspace: source }).ok, true);
|
||||
});
|
||||
|
||||
test("interfaces can declare ordinary call operations", () => {
|
||||
const source = capabilityFixtureSource.replace(
|
||||
" value summary id \"member:summary:summary\" : string {\n get id \"operation:summary:summary:get\";\n }",
|
||||
" operation summarize id \"member:summary:summarize\" : string -> string {\n call id \"operation:summary:summarize\";\n }",
|
||||
).replace(
|
||||
const workspace = capabilityFixtureSource.replace(
|
||||
"bind summary.get to package TodoRuntime.summaryGet",
|
||||
"bind summarize.call to package TodoRuntime.summaryGet",
|
||||
).replace(
|
||||
);
|
||||
const summary = capabilityResourceSources.summary.replace(
|
||||
" value summary id \"member:summary:summary\" : string {\n get id \"operation:summary:summary:get\";\n }",
|
||||
" operation summarize id \"member:summary:summarize\" : string -> string {\n call id \"operation:summary:summarize\";\n }",
|
||||
);
|
||||
const todo = capabilityResourceSources.todo.replace(
|
||||
"operation summaryGet id \"export:todo-runtime:summary-get\" : unit -> string",
|
||||
"operation summaryGet id \"export:todo-runtime:summary-get\" : string -> string",
|
||||
);
|
||||
const result = compileCapabilitySource(source, "operation-member.qx");
|
||||
const result = compileCapabilityFixture({ workspace, summary, todo });
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) return;
|
||||
const member = result.workspace.interfaceImports
|
||||
@@ -86,7 +164,7 @@ test("state defaults accept recursive JSON values", () => {
|
||||
shared state ProjectMetadata id "slot:project:metadata" on Project : message "example.Metadata"
|
||||
policy optimistic-register default {"labels":["compiler","runtime"],"score":1.5,"enabled":true,"extra":null};`,
|
||||
);
|
||||
const result = compileCapabilitySource(source, "json-default.qx");
|
||||
const result = compileCapabilityFixture({ workspace: source });
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) return;
|
||||
const metadata = result.workspace.sharedAttachments.find(
|
||||
@@ -103,25 +181,23 @@ test("state defaults accept recursive JSON values", () => {
|
||||
});
|
||||
|
||||
test("syntax errors retain source locations", () => {
|
||||
const result = compileCapabilitySource(
|
||||
capabilityFixtureSource.replace("atom Project", "atom Project ???"),
|
||||
"broken.qx",
|
||||
);
|
||||
const result = compileCapabilityFixture({
|
||||
workspace: capabilityFixtureSource.replace("atom Project", "atom Project ???"),
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.ok(result.diagnostics.some((entry) =>
|
||||
entry.phase === "syntax" && entry.fileName === "broken.qx" && entry.line > 0
|
||||
entry.phase === "syntax" && entry.line > 0
|
||||
));
|
||||
});
|
||||
|
||||
test("unknown authoring names are lowering errors", () => {
|
||||
const result = compileCapabilitySource(
|
||||
capabilityFixtureSource.replace(
|
||||
const result = compileCapabilityFixture({
|
||||
workspace: capabilityFixtureSource.replace(
|
||||
"to state ProjectTitle.read",
|
||||
"to state NotAState.read",
|
||||
),
|
||||
"unknown-name.qx",
|
||||
);
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.ok(result.diagnostics.some((entry) =>
|
||||
@@ -132,13 +208,12 @@ test("unknown authoring names are lowering errors", () => {
|
||||
});
|
||||
|
||||
test("well-formed but invalid programs report semantic paths", () => {
|
||||
const result = compileCapabilitySource(
|
||||
capabilityFixtureSource.replace(
|
||||
const result = compileCapabilityFixture({
|
||||
workspace: capabilityFixtureSource.replace(
|
||||
"bind name.get to state ProjectTitle.read",
|
||||
"bind name.get to state ProjectTitle.write",
|
||||
),
|
||||
"invalid-binding.qx",
|
||||
);
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
const diagnostic = result.diagnostics.find(
|
||||
@@ -150,11 +225,7 @@ test("well-formed but invalid programs report semantic paths", () => {
|
||||
});
|
||||
|
||||
test("Web Studio sidecars declare lazy materialization and checked cross-object ports", () => {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), "test/fixtures/web-studio.capabilities.qx"),
|
||||
"utf8",
|
||||
);
|
||||
const result = compileCapabilitySource(source, "web-studio.capabilities.qx");
|
||||
const result = compileWebStudioFixture();
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) return;
|
||||
|
||||
@@ -175,9 +246,9 @@ test("Web Studio sidecars declare lazy materialization and checked cross-object
|
||||
const binding = component?.operationBindings[0]?.binding;
|
||||
assert.equal(binding?.kind, "package");
|
||||
if (binding?.kind !== "package") return;
|
||||
assert.deepEqual(binding.dependencies[1]?.binding, {
|
||||
kind: "state",
|
||||
slotId: "slot:project:name",
|
||||
assert.deepEqual(binding.dependencies[0]?.binding, {
|
||||
kind: "interface",
|
||||
interfaceRevisionId: "interface:named@1",
|
||||
via: {
|
||||
edgeTypeId: "edge:project:component",
|
||||
projectionId: "projection:component:subject",
|
||||
@@ -186,14 +257,10 @@ test("Web Studio sidecars declare lazy materialization and checked cross-object
|
||||
});
|
||||
|
||||
test("relationship materializers require a constructor from the host atom", () => {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), "test/fixtures/web-studio.capabilities.qx"),
|
||||
"utf8",
|
||||
).replace(
|
||||
const result = compileWebStudioFixture((source) => source.replace(
|
||||
"constructs ProjectComponent : atom-ref<Project>;",
|
||||
"constructs ProjectComponent : unit;",
|
||||
);
|
||||
const result = compileCapabilitySource(source, "invalid-materializer.qx");
|
||||
));
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.ok(result.diagnostics.some((entry) =>
|
||||
@@ -201,3 +268,24 @@ test("relationship materializers require a constructor from the host atom", () =
|
||||
entry.message.includes("must accept")
|
||||
));
|
||||
});
|
||||
|
||||
test("callable interface ports require a full source dependency, not a nominal reference", () => {
|
||||
const result = compileCapabilityResourceSource(`
|
||||
external interface Named revision "interface:named@1";
|
||||
package Runtime id "package:runtime" revision "package:runtime@1" {
|
||||
function readName id "export:runtime:read-name" : unit -> string requires {
|
||||
interface named id "port:runtime:named" : Named;
|
||||
};
|
||||
}
|
||||
`, {
|
||||
source: {
|
||||
repository: "https://repos.quixos.org/quixos-test/package-runtime.git",
|
||||
commit: "6".repeat(40),
|
||||
},
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.ok(result.diagnostics.some((entry) =>
|
||||
entry.code === "nominal-interface-port" && entry.message.includes("import interface Named")
|
||||
));
|
||||
});
|
||||
|
||||
@@ -92,7 +92,7 @@ test("the representative v1 workspace compiles to native and package plans", ()
|
||||
summary.dependencies.map((entry) => [entry.port.id, entry.binding.kind]),
|
||||
[
|
||||
[fixtureId.titlePort, "state"],
|
||||
[fixtureId.namedPort, "receiver-interface"],
|
||||
[fixtureId.namedPort, "interface"],
|
||||
[fixtureId.personPort, "constructor"],
|
||||
],
|
||||
);
|
||||
@@ -164,6 +164,25 @@ test("private attachments are visible only to their owning conformance", () => {
|
||||
expectIssue(workspace, "private-attachment-access");
|
||||
});
|
||||
|
||||
test("related-object dependency views cannot traverse another conformance's private edge", () => {
|
||||
const workspace = makeValidCapabilityWorkspace();
|
||||
const packageBinding = conformance(
|
||||
workspace,
|
||||
fixtureId.projectSummaryConformance,
|
||||
).operationBindings[0]!.binding;
|
||||
assert.equal(packageBinding.kind, "package");
|
||||
if (packageBinding.kind !== "package") return;
|
||||
const named = packageBinding.dependencies.find(
|
||||
(dependency) => dependency.portId === fixtureId.namedPort,
|
||||
);
|
||||
assert.ok(named && named.binding.kind === "interface");
|
||||
named.binding.via = {
|
||||
edgeTypeId: fixtureId.projectOwner,
|
||||
projectionId: fixtureId.projectOwnerProjection,
|
||||
};
|
||||
expectIssue(workspace, "private-attachment-access");
|
||||
});
|
||||
|
||||
test("native state and edge providers must match operation shape", () => {
|
||||
const stateWorkspace = makeValidCapabilityWorkspace();
|
||||
const state = conformance(
|
||||
|
||||
Vendored
+121
-5
@@ -4,7 +4,15 @@ import {
|
||||
capabilityId,
|
||||
type WorkspaceRevision,
|
||||
} from "../../src/capability-model/index.js";
|
||||
import { compileCapabilitySource } from "../../src/capability-language/index.js";
|
||||
import {
|
||||
compileCapabilityResourceSource,
|
||||
compileCapabilitySource,
|
||||
type CapabilityImportEnvironment,
|
||||
} from "../../src/capability-language/index.js";
|
||||
import type {
|
||||
InterfaceRevision,
|
||||
PackageRevision,
|
||||
} from "../../src/capability-model/index.js";
|
||||
|
||||
export const fixtureId = {
|
||||
workspace: capabilityId.workspace("workspace:todo"),
|
||||
@@ -62,11 +70,119 @@ export const capabilityFixtureSource = readFileSync(
|
||||
"utf8",
|
||||
);
|
||||
|
||||
export const makeValidCapabilityWorkspace = (): WorkspaceRevision => {
|
||||
const result = compileCapabilitySource(
|
||||
capabilityFixtureSource,
|
||||
capabilityFixturePath,
|
||||
const resourceSource = (name: string) => readFileSync(
|
||||
resolve(process.cwd(), "test/fixtures", name),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
export const capabilityResourceSources = {
|
||||
named: resourceSource("named.interface.qx"),
|
||||
owned: resourceSource("owned.interface.qx"),
|
||||
summary: resourceSource("summary.interface.qx"),
|
||||
todo: resourceSource("todo.package.qx"),
|
||||
} as const;
|
||||
|
||||
const source = (repository: string, commit: string) => ({ repository, commit });
|
||||
|
||||
const interfaceRevision = (
|
||||
resource: { kind: "interface"; revision: InterfaceRevision } |
|
||||
{ kind: "package"; revision: PackageRevision },
|
||||
) => {
|
||||
if (resource.kind !== "interface") throw new Error("Expected interface fixture");
|
||||
return resource.revision;
|
||||
};
|
||||
|
||||
const packageRevision = (
|
||||
resource: { kind: "interface"; revision: InterfaceRevision } |
|
||||
{ kind: "package"; revision: PackageRevision },
|
||||
) => {
|
||||
if (resource.kind !== "package") throw new Error("Expected package fixture");
|
||||
return resource.revision;
|
||||
};
|
||||
|
||||
export const compileCapabilityFixture = (overrides: Partial<{
|
||||
workspace: string;
|
||||
named: string;
|
||||
owned: string;
|
||||
summary: string;
|
||||
todo: string;
|
||||
}> = {}) => {
|
||||
const named = compileCapabilityResourceSource(
|
||||
overrides.named ?? capabilityResourceSources.named,
|
||||
{
|
||||
source: source(
|
||||
"https://repos.quixos.org/quixos-todo/interface-named.git",
|
||||
"2".repeat(40),
|
||||
),
|
||||
fileName: "named.interface.qx",
|
||||
},
|
||||
);
|
||||
if (!named.ok) return named;
|
||||
const namedRevision = interfaceRevision(named.resource);
|
||||
const namedEnvironment: CapabilityImportEnvironment = {
|
||||
interfaces: new Map([["Named", namedRevision]]),
|
||||
interfaceClosure: [namedRevision],
|
||||
};
|
||||
const owned = compileCapabilityResourceSource(
|
||||
overrides.owned ?? capabilityResourceSources.owned,
|
||||
{
|
||||
source: source(
|
||||
"https://repos.quixos.org/quixos-todo/interface-owned.git",
|
||||
"3".repeat(40),
|
||||
),
|
||||
fileName: "owned.interface.qx",
|
||||
environment: namedEnvironment,
|
||||
},
|
||||
);
|
||||
if (!owned.ok) return owned;
|
||||
const ownedRevision = interfaceRevision(owned.resource);
|
||||
const summary = compileCapabilityResourceSource(
|
||||
overrides.summary ?? capabilityResourceSources.summary,
|
||||
{
|
||||
source: source(
|
||||
"https://repos.quixos.org/quixos-todo/interface-summary.git",
|
||||
"4".repeat(40),
|
||||
),
|
||||
fileName: "summary.interface.qx",
|
||||
},
|
||||
);
|
||||
if (!summary.ok) return summary;
|
||||
const summaryRevision = interfaceRevision(summary.resource);
|
||||
const todo = compileCapabilityResourceSource(
|
||||
overrides.todo ?? capabilityResourceSources.todo,
|
||||
{
|
||||
source: source(
|
||||
"https://repos.quixos.org/quixos-todo/package-todo-runtime.git",
|
||||
"5".repeat(40),
|
||||
),
|
||||
fileName: "todo.package.qx",
|
||||
environment: namedEnvironment,
|
||||
},
|
||||
);
|
||||
if (!todo.ok) return todo;
|
||||
const todoRevision = packageRevision(todo.resource);
|
||||
return compileCapabilitySource(
|
||||
overrides.workspace ?? capabilityFixtureSource,
|
||||
capabilityFixturePath,
|
||||
{
|
||||
interfaces: new Map([
|
||||
["Named", namedRevision],
|
||||
["Owned", ownedRevision],
|
||||
["Summary", summaryRevision],
|
||||
]),
|
||||
packages: new Map([["TodoRuntime", todoRevision]]),
|
||||
interfaceClosure: [
|
||||
namedRevision,
|
||||
ownedRevision,
|
||||
summaryRevision,
|
||||
],
|
||||
packageClosure: [todoRevision],
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const makeValidCapabilityWorkspace = (): WorkspaceRevision => {
|
||||
const result = compileCapabilityFixture();
|
||||
if (!result.ok) {
|
||||
throw new Error(
|
||||
result.diagnostics
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import interface Named;
|
||||
external atom Project id "atom:project";
|
||||
external atom ProjectComponent id "atom:project-component";
|
||||
|
||||
package ComponentRuntime id "package:component-runtime" revision "package:component-runtime@1" {
|
||||
constructor createComponent id "export:component-runtime:create-component" constructs ProjectComponent : atom-ref<Project>;
|
||||
operation propsGet id "export:component-runtime:props-get" : unit -> string mode call receiver atom ProjectComponent requires {
|
||||
interface project id "port:component:project" : Named;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import interface ReactComponent;
|
||||
|
||||
interface HasReactComponent id "interface:org.quixos.web-studio.has-react-component" revision "interface:org.quixos.web-studio.has-react-component@1" {
|
||||
relation component id "member:org.quixos.web-studio.has-react-component:component" : optional-one interface ReactComponent {
|
||||
resolve id "operation:org.quixos.web-studio.has-react-component:component:resolve";
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
interface Named id "interface:named" revision "interface:named@1" {
|
||||
value name id "member:named:name" : string {
|
||||
get id "operation:named:name:get";
|
||||
set id "operation:named:name:set";
|
||||
watch start id "operation:named:name:watch-start" stop id "operation:named:name:watch-stop";
|
||||
}
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
import interface Named;
|
||||
|
||||
interface Owned id "interface:owned" revision "interface:owned@1" {
|
||||
relation owner id "member:owned:owner" : exactly-one interface Named {
|
||||
resolve id "operation:owned:owner:resolve";
|
||||
connect id "operation:owned:owner:connect";
|
||||
disconnect id "operation:owned:owner:disconnect";
|
||||
watch start id "operation:owned:owner:watch-start" stop id "operation:owned:owner:watch-stop";
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
interface ReactComponent id "interface:org.quixos.web-studio.react-component" revision "interface:org.quixos.web-studio.react-component@1" {
|
||||
value props id "member:org.quixos.web-studio.react-component:props" : string {
|
||||
get id "operation:org.quixos.web-studio.react-component:props:get";
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
interface Summary id "interface:summary" revision "interface:summary@1" {
|
||||
value summary id "member:summary:summary" : string {
|
||||
get id "operation:summary:summary:get";
|
||||
}
|
||||
}
|
||||
Vendored
+4
-44
@@ -2,50 +2,10 @@ workspace Todo id "workspace:todo" revision "workspace:todo@1" commit "111111111
|
||||
atom Project id "atom:project" doc "A project containing work.";
|
||||
atom Person id "atom:person";
|
||||
|
||||
interface Named id "interface:named" revision "interface:named@1" source {
|
||||
repository "https://repos.quixos.org/quixos-todo/interface-named.git";
|
||||
commit "2222222222222222222222222222222222222222";
|
||||
} {
|
||||
value name id "member:named:name" : string {
|
||||
get id "operation:named:name:get";
|
||||
set id "operation:named:name:set";
|
||||
watch start id "operation:named:name:watch-start" stop id "operation:named:name:watch-stop";
|
||||
}
|
||||
}
|
||||
|
||||
interface Owned id "interface:owned" revision "interface:owned@1" source {
|
||||
repository "https://repos.quixos.org/quixos-todo/interface-owned.git";
|
||||
commit "3333333333333333333333333333333333333333";
|
||||
} {
|
||||
relation owner id "member:owned:owner" : exactly-one interface Named {
|
||||
resolve id "operation:owned:owner:resolve";
|
||||
connect id "operation:owned:owner:connect";
|
||||
disconnect id "operation:owned:owner:disconnect";
|
||||
watch start id "operation:owned:owner:watch-start" stop id "operation:owned:owner:watch-stop";
|
||||
}
|
||||
}
|
||||
|
||||
interface Summary id "interface:summary" revision "interface:summary@1" source {
|
||||
repository "https://repos.quixos.org/quixos-todo/interface-summary.git";
|
||||
commit "4444444444444444444444444444444444444444";
|
||||
} {
|
||||
value summary id "member:summary:summary" : string {
|
||||
get id "operation:summary:summary:get";
|
||||
}
|
||||
}
|
||||
|
||||
package TodoRuntime id "package:todo-runtime" revision "package:todo-runtime@1" source {
|
||||
repository "https://repos.quixos.org/quixos-todo/package-todo-runtime.git";
|
||||
commit "5555555555555555555555555555555555555555";
|
||||
} {
|
||||
operation summaryGet id "export:todo-runtime:summary-get" : unit -> string
|
||||
mode call receiver interfaces [Named] requires {
|
||||
state title id "port:summary:title" : string [read];
|
||||
interface named id "port:summary:named" : Named;
|
||||
constructor person id "port:summary:person" : Person;
|
||||
};
|
||||
constructor createPerson id "export:todo-runtime:create-person" constructs Person : unit;
|
||||
}
|
||||
import interface Named;
|
||||
import interface Owned;
|
||||
import interface Summary;
|
||||
import package TodoRuntime;
|
||||
|
||||
shared state ProjectTitle id "slot:project:title" on Project : string
|
||||
policy optimistic-register default "Untitled project";
|
||||
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
import interface Named;
|
||||
external atom Person id "atom:person";
|
||||
|
||||
package TodoRuntime id "package:todo-runtime" revision "package:todo-runtime@1" {
|
||||
operation summaryGet id "export:todo-runtime:summary-get" : unit -> string
|
||||
mode call receiver interfaces [Named] requires {
|
||||
state title id "port:summary:title" : string [read];
|
||||
interface named id "port:summary:named" : Named;
|
||||
constructor person id "port:summary:person" : Person;
|
||||
};
|
||||
constructor createPerson id "export:todo-runtime:create-person" constructs Person : unit;
|
||||
}
|
||||
+12
-30
@@ -2,34 +2,10 @@ workspace WebStudioFixture id "workspace:web-studio-fixture" revision "workspace
|
||||
atom Project id "atom:project";
|
||||
atom ProjectComponent id "atom:project-component";
|
||||
|
||||
interface ReactComponent id "interface:org.quixos.web-studio.react-component" revision "interface:org.quixos.web-studio.react-component@1" source {
|
||||
repository "https://repos.quixos.org/org-quixos-web-studio/interface-react-component.git";
|
||||
commit "2222222222222222222222222222222222222222";
|
||||
} {
|
||||
value props id "member:org.quixos.web-studio.react-component:props" : string {
|
||||
get id "operation:org.quixos.web-studio.react-component:props:get";
|
||||
}
|
||||
}
|
||||
|
||||
interface HasReactComponent id "interface:org.quixos.web-studio.has-react-component" revision "interface:org.quixos.web-studio.has-react-component@1" source {
|
||||
repository "https://repos.quixos.org/org-quixos-web-studio/interface-has-react-component.git";
|
||||
commit "3333333333333333333333333333333333333333";
|
||||
} {
|
||||
relation component id "member:org.quixos.web-studio.has-react-component:component" : optional-one interface ReactComponent {
|
||||
resolve id "operation:org.quixos.web-studio.has-react-component:component:resolve";
|
||||
}
|
||||
}
|
||||
|
||||
package ComponentRuntime id "package:component-runtime" revision "package:component-runtime@1" source {
|
||||
repository "https://repos.quixos.org/quixos-test/package-component-runtime.git";
|
||||
commit "4444444444444444444444444444444444444444";
|
||||
} {
|
||||
constructor createComponent id "export:component-runtime:create-component" constructs ProjectComponent : atom-ref<Project>;
|
||||
operation propsGet id "export:component-runtime:props-get" : unit -> string mode call receiver atom ProjectComponent requires {
|
||||
edge subject id "port:component:subject" : exactly-one atom Project [resolve];
|
||||
state projectName id "port:component:project-name" : string [read];
|
||||
};
|
||||
}
|
||||
import interface Named;
|
||||
import interface ReactComponent;
|
||||
import interface HasReactComponent;
|
||||
import package ComponentRuntime;
|
||||
|
||||
shared state ProjectName id "slot:project:name" on Project : string
|
||||
policy optimistic-register default "Untitled project";
|
||||
@@ -38,14 +14,20 @@ workspace WebStudioFixture id "workspace:web-studio-fixture" revision "workspace
|
||||
atom Project projection component id "projection:project:component" optional-one;
|
||||
}
|
||||
|
||||
conform Project as Named {
|
||||
bind name.get to state ProjectName.read;
|
||||
bind name.set to state ProjectName.write;
|
||||
bind name.watch-start to state ProjectName.watch-start;
|
||||
bind name.watch-stop to state ProjectName.watch-stop;
|
||||
}
|
||||
|
||||
conform Project as HasReactComponent {
|
||||
bind component.resolve to edge ProjectComponentEdge.component.resolve;
|
||||
materialize component if absent using constructor ProjectComponent via edge ProjectComponentEdge.subject;
|
||||
}
|
||||
conform ProjectComponent as ReactComponent {
|
||||
bind props.get to package ComponentRuntime.propsGet with {
|
||||
subject to edge ProjectComponentEdge.subject;
|
||||
projectName to state ProjectName via edge ProjectComponentEdge.subject;
|
||||
project to interface Named via edge ProjectComponentEdge.subject;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
formatQuixosLock,
|
||||
formatQuixosLockDocument,
|
||||
loadQuixosLock,
|
||||
nixGitInput,
|
||||
parseQuixosLock,
|
||||
parseQuixosLockDocument,
|
||||
resolveQuixosLock,
|
||||
retentionTagForCommit,
|
||||
} from "../src/resource-lock/index.js";
|
||||
|
||||
@@ -13,6 +20,8 @@ const packageCommit = "3".repeat(64);
|
||||
const fixture = `quixos-lock version 1 {
|
||||
quixos source {
|
||||
repository "https://gitea.example/quixos/quixos.git";
|
||||
policy track-development;
|
||||
ref "dev/alice/main";
|
||||
commit "${quixosCommit}";
|
||||
}
|
||||
|
||||
@@ -34,6 +43,8 @@ test("parses and canonically formats a Git-only repository lock", () => {
|
||||
if (!parsed.ok) return;
|
||||
assert.equal(parsed.lock.formatVersion, 1);
|
||||
assert.equal(parsed.lock.quixos.commit, quixosCommit);
|
||||
assert.equal(parsed.lock.quixos.policy, "track-development");
|
||||
assert.equal(parsed.lock.quixos.ref, "dev/alice/main");
|
||||
assert.deepEqual(
|
||||
parsed.lock.resources.map(({ kind, binding }) => ({ kind, binding })),
|
||||
[
|
||||
@@ -48,6 +59,29 @@ test("parses and canonically formats a Git-only repository lock", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("validates pinned Quixos selections without imposing policy on resource locks", () => {
|
||||
const mismatch = parseQuixosLockDocument(`quixos-lock version 1 {
|
||||
quixos source {
|
||||
repository "https://gitea.example/quixos/quixos.git";
|
||||
policy pinned;
|
||||
ref "${namedCommit}";
|
||||
commit "${quixosCommit}";
|
||||
}
|
||||
}`);
|
||||
assert.equal(mismatch.ok, false);
|
||||
if (!mismatch.ok) {
|
||||
assert.equal(mismatch.diagnostics[0]?.code, "pinned-quixos-commit-mismatch");
|
||||
}
|
||||
|
||||
const resourceBaseline = parseQuixosLockDocument(`quixos-lock version 1 {
|
||||
quixos source {
|
||||
repository "https://gitea.example/quixos/quixos.git";
|
||||
commit "${quixosCommit}";
|
||||
}
|
||||
}`);
|
||||
assert.equal(resourceBaseline.ok, true);
|
||||
});
|
||||
|
||||
test("derives immutable retention and Nix inputs without storing extra identity", () => {
|
||||
const source = {
|
||||
resolver: "git" as const,
|
||||
@@ -92,3 +126,152 @@ test("rejects mutable revisions, embedded credentials, and duplicate bindings",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("resolves root-relative lock fragments into one deterministic resource closure", async () => {
|
||||
const root = `quixos-lock version 1 {
|
||||
quixos source {
|
||||
repository "https://gitea.example/quixos/quixos.git";
|
||||
commit "${quixosCommit}";
|
||||
}
|
||||
import "locks/web-studio.lock";
|
||||
import "locks/domain.lock";
|
||||
package RootRuntime source {
|
||||
repository "https://repos.example/alice/package-root.git";
|
||||
commit "${packageCommit}";
|
||||
}
|
||||
}`;
|
||||
const sources = new Map([
|
||||
["locks/web-studio.lock", `quixos-lock fragment version 1 {
|
||||
import "locks/shared.lock";
|
||||
interface Placeable source {
|
||||
repository "https://repos.example/alice/interface-placeable.git";
|
||||
commit "${namedCommit}";
|
||||
}
|
||||
}`],
|
||||
["locks/shared.lock", `quixos-lock fragment version 1 {
|
||||
interface Named source {
|
||||
repository "https://repos.example/alice/interface-named.git";
|
||||
commit "${namedCommit}";
|
||||
}
|
||||
}`],
|
||||
["locks/domain.lock", `quixos-lock fragment version 1 {
|
||||
package TodoRuntime source {
|
||||
repository "https://repos.example/alice/package-todo.git";
|
||||
commit "${packageCommit}";
|
||||
}
|
||||
}`],
|
||||
]);
|
||||
const result = await resolveQuixosLock(root, async (relativePath) => {
|
||||
const source = sources.get(relativePath);
|
||||
if (!source) throw new Error("missing fixture");
|
||||
return source;
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) return;
|
||||
assert.deepEqual(result.lock.sourceFiles, [
|
||||
"quixos.lock",
|
||||
"locks/web-studio.lock",
|
||||
"locks/shared.lock",
|
||||
"locks/domain.lock",
|
||||
]);
|
||||
assert.deepEqual(result.lock.resources.map(({ kind, binding }) => ({ kind, binding })), [
|
||||
{ kind: "package", binding: "RootRuntime" },
|
||||
{ kind: "interface", binding: "Placeable" },
|
||||
{ kind: "interface", binding: "Named" },
|
||||
{ kind: "package", binding: "TodoRuntime" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("lock fragments format canonically and cannot redeclare the Quixos source", () => {
|
||||
const document = {
|
||||
kind: "fragment" as const,
|
||||
formatVersion: 1 as const,
|
||||
imports: ["locks/shared.lock"],
|
||||
resources: [{
|
||||
kind: "interface" as const,
|
||||
binding: "Named",
|
||||
source: {
|
||||
resolver: "git" as const,
|
||||
repository: "https://repos.example/alice/interface-named.git",
|
||||
commit: namedCommit,
|
||||
},
|
||||
}],
|
||||
};
|
||||
assert.deepEqual(parseQuixosLockDocument(formatQuixosLockDocument(document)), {
|
||||
ok: true,
|
||||
document,
|
||||
diagnostics: [],
|
||||
});
|
||||
const invalid = parseQuixosLockDocument(`quixos-lock fragment version 1 {
|
||||
quixos source {
|
||||
repository "https://gitea.example/quixos/quixos.git";
|
||||
commit "${quixosCommit}";
|
||||
}
|
||||
}`, "bad.lock");
|
||||
assert.equal(invalid.ok, false);
|
||||
if (!invalid.ok) assert.equal(invalid.diagnostics[0]?.code, "fragment-has-quixos-source");
|
||||
});
|
||||
|
||||
test("lock imports reject traversal, cycles, root documents, and cross-file binding collisions", async () => {
|
||||
const invalidPath = parseQuixosLockDocument(`quixos-lock fragment version 1 {
|
||||
import "../outside.lock";
|
||||
}`, "bad-path.lock");
|
||||
assert.equal(invalidPath.ok, false);
|
||||
if (!invalidPath.ok) assert.equal(invalidPath.diagnostics[0]?.code, "invalid-import-path");
|
||||
|
||||
const root = `quixos-lock version 1 {
|
||||
quixos source {
|
||||
repository "https://gitea.example/quixos/quixos.git";
|
||||
commit "${quixosCommit}";
|
||||
}
|
||||
import "a.lock";
|
||||
import "root-again.lock";
|
||||
interface Named source {
|
||||
repository "https://repos.example/alice/interface-named.git";
|
||||
commit "${namedCommit}";
|
||||
}
|
||||
}`;
|
||||
const sources = new Map([
|
||||
["a.lock", `quixos-lock fragment version 1 {
|
||||
import "b.lock";
|
||||
interface Named source {
|
||||
repository "https://repos.example/alice/interface-named-copy.git";
|
||||
commit "${namedCommit}";
|
||||
}
|
||||
}`],
|
||||
["b.lock", `quixos-lock fragment version 1 { import "a.lock"; }`],
|
||||
["root-again.lock", root],
|
||||
]);
|
||||
const result = await resolveQuixosLock(root, async (relativePath) => sources.get(relativePath) ?? "");
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) {
|
||||
assert.deepEqual(new Set(result.diagnostics.map(({ code }) => code)), new Set([
|
||||
"duplicate-resource-binding",
|
||||
"import-cycle",
|
||||
"imported-root-lock",
|
||||
]));
|
||||
}
|
||||
});
|
||||
|
||||
test("file loading rejects a symlink in any import path component", async (context) => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "quixos-lock-test-"));
|
||||
context.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const outside = path.join(directory, "outside");
|
||||
await mkdir(outside);
|
||||
await writeFile(path.join(outside, "fragment.lock"), "quixos-lock fragment version 1 {}\n");
|
||||
await symlink(outside, path.join(directory, "linked"), "dir");
|
||||
await writeFile(path.join(directory, "quixos.lock"), `quixos-lock version 1 {
|
||||
quixos source {
|
||||
repository "https://gitea.example/quixos/quixos.git";
|
||||
commit "${quixosCommit}";
|
||||
}
|
||||
import "linked/fragment.lock";
|
||||
}`);
|
||||
|
||||
const result = await loadQuixosLock(path.join(directory, "quixos.lock"));
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) {
|
||||
assert.equal(result.diagnostics[0]?.code, "import-read-failed");
|
||||
assert.match(result.diagnostics[0]?.message ?? "", /symbolic links/);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user