import { test } from "node:test"; import assert from "node:assert/strict"; import { join } from "node:path"; import { BusinessError, classify, projectFilePath, resolveInstance, systemVars, validateBusinessDocument, validateProjectDocument, } from "../src/index.mjs"; import { businessDoc, REPO_ROLES, systemFor, tmp } from "./helpers.mjs"; function refuses(fn, pattern) { assert.throws(fn, (error) => { assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`); assert.equal(error.exitCode, 2); assert.match(error.message, pattern); return true; }); } function setup(mutate, projectDoc) { const root = tmp(); const doc = businessDoc(root); mutate?.(doc); const business = validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES }); const project = projectDoc ? validateProjectDocument({ projectVersion: 1, id: "stack", ...projectDoc }, projectFilePath(doc.projects.stack.root)) : null; return { root, business, project, system: systemFor(root) }; } const resolve = (s, instance) => resolveInstance({ system: s.system, business: s.business, project: s.project, instance }); test("systemVars flattens the validated config", () => { const vars = systemVars({ environment: "development", dataRoot: "/d", execution: { backend: "docker", provider: "zai", model: "m", adapter: "pi" } }); assert.deepEqual({ ...vars }, { environment: "development", dataRoot: "/d", "execution.backend": "docker", "execution.provider": "zai", "execution.model": "m", "execution.adapter": "pi", }); refuses(() => systemVars({ environment: "development", dataRoot: "relative", execution: { backend: "b", provider: "p", model: "m", adapter: "a" } }), /dataRoot must be an absolute path/); }); test("precedence: system, business, project, project role, agent", () => { const s = setup((d) => { d.roles.coder.vars.model = "agent-model"; }, { vars: { "tracker.pollSeconds": 20, "tracker.project": 3 }, roles: { coder: { vars: { "tracker.pollSeconds": 15 } } }, }); const coder = resolve(s, "coder"); assert.equal(coder.project, "stack"); assert.equal(coder.vars["tracker.pollSeconds"], 15); assert.equal(coder.provenance["tracker.pollSeconds"], "project:stack:roles.coder"); assert.equal(coder.vars["tracker.project"], 3); assert.equal(coder.provenance["tracker.project"], "project:stack"); assert.equal(coder.vars["tracker.baseUrl"], "http://127.0.0.1:3456"); assert.equal(coder.provenance["tracker.baseUrl"], "business:acme"); assert.equal(coder.vars.model, "agent-model"); assert.equal(coder.provenance.model, "business:acme:roles.coder"); assert.equal(coder.vars["execution.adapter"], "mock"); assert.equal(coder.provenance["execution.adapter"], "system"); assert.equal(coder.provenance["tracker.reconcileMinutes"], "default"); const pm = resolve(s, "pm"); assert.equal(pm.vars["tracker.pollSeconds"], 20); assert.equal(pm.provenance["tracker.pollSeconds"], "project:stack"); const noProject = resolveInstance({ system: s.system, business: s.business, instance: "pm" }); assert.equal(noProject.project, null); assert.equal(noProject.vars["tracker.pollSeconds"], 60); }); test("limits narrow the definition and never widen it", () => { const s = setup((d) => { d.vars["limits.network"] = "open"; d.roles.cto.vars["limits.tools"] = ["read", "grep", "bash", "write"]; d.roles.reviewer.vars = { "limits.tools": ["read", "write"], "limits.authority": ["review.verdict", "task.close"] }; }, { roles: { cto: { vars: { "limits.tools": ["read", "bash", "write", "edit"] } } } }); const coder = resolve(s, "coder"); assert.equal(coder.limits.network, "none"); assert.deepEqual(coder.provenance["limits.network"], ["business:acme", "business:acme:roles.coder"]); assert.equal(resolve(s, "pm").limits.network, "api-only"); const cto = resolve(s, "cto"); assert.deepEqual(cto.limits.tools, ["read", "write", "bash"]); assert.deepEqual(cto.provenance["limits.tools"], ["project:stack:roles.cto", "business:acme:roles.cto"]); const reviewer = resolve(s, "reviewer"); assert.deepEqual(reviewer.limits.tools, ["read"]); assert.deepEqual(reviewer.limits.authority, { withinRole: ["review.verdict"], crossRole: [] }); assert.equal(classify(reviewer, "message.send"), "gated"); assert.equal(classify(reviewer, "task.close"), "gated"); }); test("role.launch stays within-role only for the instance the launch block names", () => { const pm = resolve(setup(), "pm"); assert.equal(classify(pm, "role.launch"), "within"); assert.deepEqual(pm.launch.instances, ["coder", "reviewer"]); assert.equal(resolve(setup(), "cto").launch, null); const noLaunch = resolve(setup((d) => { delete d.launch; }), "pm"); assert.equal(classify(noLaunch, "role.launch"), "gated"); assert.equal(noLaunch.launch, null); // A second pm instance is the launcher, so the first one may not launch. const two = setup((d) => { d.roles.pm2 = { ...d.roles.pm, holder: undefined, tracker: { bot: "bot-acme-pm2", botId: 7 } }; d.launch.by = "pm2"; }); assert.equal(classify(resolve(two, "pm"), "role.launch"), "gated"); assert.equal(resolve(two, "pm").launch, null); assert.equal(classify(resolve(two, "pm2"), "role.launch"), "within"); assert.equal(resolve(two, "pm2").launch.by, "pm2"); }); test("limits.authority without role.launch leaves the launcher with no launch block", () => { for (const layer of ["agent", "project"]) { const authority = ["task.create", "task.assign", "message.send"]; const s = layer === "agent" ? setup((d) => { d.roles.pm.vars["limits.authority"] = authority; }) : setup(undefined, { roles: { pm: { vars: { "limits.authority": authority } } } }); const pm = resolve(s, "pm"); assert.equal(classify(pm, "role.launch"), "gated", layer); assert.equal(pm.launch, null, layer); } }); test("limits.authority narrows cross-role actions too", () => { const s = setup((d) => { d.roles.coder.vars["limits.authority"] = ["task.update.assigned", "git.push.working", "task.scope.change"]; }); const coder = resolve(s, "coder"); assert.deepEqual(coder.limits.authority, { withinRole: ["task.update.assigned", "git.push.working"], crossRole: ["task.scope.change"] }); assert.equal(classify(coder, "task.reassign"), "gated"); assert.equal(classify(coder, "task.scope.change"), "cross"); assert.equal(classify(coder, "review.request"), "gated"); }); test("classify", () => { const s = setup(); const coder = resolve(s, "coder"); assert.equal(classify(coder, "git.push.working"), "within"); assert.equal(classify(coder, "task.reassign"), "cross"); assert.equal(classify(coder, "task.close"), "gated"); assert.equal(classify(coder, "deploy"), "gated"); assert.equal(classify(resolve(s, "pm"), "task.priority.change"), "cross"); assert.equal(classify(resolve(s, "cto"), "decision.resolve.technical"), "within"); refuses(() => classify(coder, "task.delete"), /unknown action/); }); test("the record carries what the broker and launcher need", () => { const s = setup(); const pm = resolve(s, "pm"); assert.deepEqual(Object.keys(pm).sort(), [ "business", "contract", "credentials", "definition", "digest", "holder", "instance", "launch", "limits", "project", "provenance", "tracker", "vars", ]); assert.equal(pm.contract, join(REPO_ROLES, "pm.md")); assert.equal(pm.holder, "sage"); assert.deepEqual(pm.tracker, { bot: "bot-acme-pm", botId: 3 }); assert.deepEqual(Object.keys(pm.credentials).sort(), ["gitea", "vikunja"]); assert.match(pm.credentials.gitea.file, /pm-gitea\.token$/); assert.ok(Object.isFrozen(pm.limits.authority.withinRole)); }); test("digest: key order doesn't matter, any value change does", () => { const root = tmp(); const file = join(root, "businesses", "acme.json"); const load = (mutate) => { const doc = businessDoc(root); mutate?.(doc); return validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }); }; const system = systemFor(root); const digest = (business, instance = "coder", sys = system) => resolveInstance({ system: sys, business, instance }).digest; const base = digest(load()); assert.match(base, /^[0-9a-f]{64}$/); assert.equal(digest(load()), base); assert.equal(digest(load((d) => { d.roles.coder.vars = { "limits.network": "none", harness: "pi" }; })), base); assert.equal(digest(load((d) => { d.vars = Object.fromEntries(Object.entries(d.vars).reverse()); })), base); assert.notEqual(digest(load((d) => { d.roles.coder.vars.harness = "claude-code"; })), base); assert.notEqual(digest(load((d) => { d.roles.coder.holder = "rocko"; })), base); assert.notEqual(digest(load((d) => { d.roles.coder.credentials.gitea.rotateBy = "2098-01-01"; })), base); assert.notEqual(digest(load(), "coder", { ...system, "execution.model": "other" }), base); assert.notEqual(digest(load(), "reviewer"), base); }); test("refusals", () => { const s = setup(); refuses(() => resolveInstance({ system: s.system, business: s.business, instance: "ghost" }), /declares no role instance "ghost"/); refuses(() => resolveInstance({ system: s.system, business: s.business, instance: "constructor" }), /declares no role instance/); const misnamed = setup(undefined, {}); const wrongId = validateProjectDocument({ projectVersion: 1, id: "other" }, misnamed.project.file); refuses(() => resolveInstance({ system: misnamed.system, business: misnamed.business, project: wrongId, instance: "pm" }), /isn't declared under that id/); const elsewhere = validateProjectDocument({ projectVersion: 1, id: "stack" }, "/elsewhere/.mosaic/project.json"); refuses(() => resolveInstance({ system: s.system, business: s.business, project: elsewhere, instance: "pm" }), /isn't declared under that id/); const extraRole = setup(undefined, { roles: { ghost: { vars: {} } } }); refuses(() => resolve(extraRole, "pm"), /sets vars for role instance ghost/); });