git mv packages/business -> packages/organization (@mosaic/organization). The old package stays as one-line re-export shims of each moved module, with src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs forwarding to the moved executable. BusinessError is OrganizationConfigError (one constructor, name kept BusinessError); OrganizationError stays distinct. The bus adapter gains organization-context.mjs (busOrganization), with business.mjs (busBusiness) as the same function. Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure walker (organization-imports.mjs, both roots required) move to the new path. v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage A contract tests stay where they were and pass as before.
143 lines
8.5 KiB
JavaScript
143 lines
8.5 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { BusinessError, LAYERS, REGISTRY, checkVars, mergeVars } from "../src/index.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;
|
|
});
|
|
}
|
|
|
|
test("every key names known layers and a merge rule", () => {
|
|
for (const [key, entry] of Object.entries(REGISTRY)) {
|
|
assert.ok(entry.layers.length > 0, key);
|
|
for (const layer of entry.layers) assert.ok(LAYERS.includes(layer), `${key}: ${layer}`);
|
|
assert.ok(["replace", "intersect"].includes(entry.merge), key);
|
|
if (entry.merge === "intersect") assert.ok(key.startsWith("limits."), key);
|
|
}
|
|
});
|
|
|
|
test("unknown keys and wrong layers refuse", () => {
|
|
refuses(() => checkVars({ colour: "blue" }, "business", "b.vars"), /b\.vars: unknown variable "colour"/);
|
|
refuses(() => checkVars({ harness: "pi" }, "business", "b.vars"), /can't be set at the business layer \(allowed: agent\)/);
|
|
refuses(() => checkVars({ "tracker.baseUrl": "http://x" }, "project", "p.vars"), /project layer/);
|
|
refuses(() => checkVars({ "tracker.project": 3 }, "agent", "a.vars"), /agent layer/);
|
|
refuses(() => checkVars({ dataRoot: "/x" }, "business", "b.vars"), /business layer/);
|
|
refuses(() => checkVars(["harness"], "agent", "a.vars"), /JSON object/);
|
|
refuses(() => checkVars({ constructor: 1 }, "agent", "a.vars"), /unknown variable "constructor"/);
|
|
assert.throws(() => checkVars({}, "fleet", "x"), /unknown layer/);
|
|
});
|
|
|
|
test("types", () => {
|
|
const ok = (vars, layer) => assert.deepEqual({ ...checkVars(vars, layer, "v") }, vars);
|
|
ok({ "tracker.baseUrl": "https://tasks.example:3456", "tracker.reconcileMinutes": 1, "tracker.pollSeconds": 10, "human.discordUserId": "123456789012345678", "bus.tester": true }, "business");
|
|
ok({ "tracker.project": 3, "git.workingBranch": "refactor", "git.protectedBranches": ["main", "next"], suites: ["scripts/test-task.sh"], "issues.repo": "mosaicstack/stack", "tracker.descendants": true }, "project");
|
|
ok({ harness: "claude-code", model: "claude-opus-5-5", thinking: "high", "limits.tools": [], "limits.network": "none", "limits.authority": ["message.send"] }, "agent");
|
|
// Row 65: any module name passes here; whether it is switched on is a launch and write check.
|
|
ok({ harness: "fixture-harness", model: "m", thinking: "off", "fallback.harness": "pi", "fallback.model": "m", "fallback.thinking": "low" }, "agent");
|
|
|
|
const bad = [
|
|
[{ "tracker.baseUrl": "ftp://x" }, "business", /http or https/],
|
|
[{ "tracker.baseUrl": "https://x/" }, "business", /trailing slash/],
|
|
[{ "tracker.baseUrl": "https://u:p@x" }, "business", /no credentials/],
|
|
[{ "tracker.baseUrl": "https://x?a=1" }, "business", /query/],
|
|
[{ "tracker.baseUrl": "not a url" }, "business", /http or https URL/],
|
|
[{ "tracker.kind": "jira" }, "business", /one of: vikunja/],
|
|
[{ "tracker.pollSeconds": 9 }, "business", /at least 10/],
|
|
[{ "tracker.pollSeconds": "30" }, "project", /at least 10/],
|
|
[{ "tracker.reconcileMinutes": 0 }, "business", /at least 1/],
|
|
[{ "human.discordUserId": 123456789012345678 }, "business", /as a string/],
|
|
[{ "human.discordUserId": "0123456789012345678" }, "business", /Discord user id/],
|
|
[{ "tracker.project": 0 }, "project", /positive integer/],
|
|
[{ "tracker.descendants": "true" }, "project", /true or false/],
|
|
[{ "tracker.descendants": 1 }, "project", /true or false/],
|
|
[{ "tracker.descendants": true }, "business", /business layer \(allowed: project\)/],
|
|
[{ "bus.tester": "true" }, "business", /true or false/],
|
|
[{ "bus.tester": 1 }, "business", /true or false/],
|
|
[{ "bus.tester": true }, "project", /project layer \(allowed: business\)/],
|
|
[{ "bus.tester": true }, "agent", /agent layer \(allowed: business\)/],
|
|
[{ "bus.tester": true }, "system", /system layer \(allowed: business\)/],
|
|
[{ "git.workingBranch": "-x" }, "project", /git branch name/],
|
|
[{ "git.workingBranch": "a..b" }, "project", /git branch name/],
|
|
[{ "git.workingBranch": "x.lock" }, "project", /git branch name/],
|
|
[{ "git.workingBranch": "feat/" }, "project", /git branch name/],
|
|
[{ "git.protectedBranches": [] }, "project", /must not be empty/],
|
|
[{ "git.protectedBranches": ["main", "main"] }, "project", /duplicate/],
|
|
[{ suites: "scripts/test-task.sh" }, "project", /must be an array/],
|
|
[{ "issues.repo": "stack" }, "project", /owner\/name/],
|
|
[{ harness: "Codex" }, "agent", /harness module name/],
|
|
[{ harness: "../pi" }, "agent", /harness module name/],
|
|
[{ "fallback.harness": "pi" }, "agent", /a fallback sets all of fallback\.harness, fallback\.model, fallback\.thinking or none \(missing fallback\.model, fallback\.thinking\)/],
|
|
[{ "fallback.harness": "pi", "fallback.model": "m" }, "agent", /missing fallback\.thinking/],
|
|
[{ "fallback.harness": "pi", "fallback.model": "m", "fallback.thinking": "huge" }, "agent", /one of/],
|
|
[{ thinking: "huge" }, "agent", /one of/],
|
|
[{ model: "" }, "agent", /non-empty string/],
|
|
[{ "limits.tools": ["render3d"] }, "agent", /unsupported tool/],
|
|
[{ "web.searxng": "https://search.example.org/" }, "business", /base URL/],
|
|
[{ "web.searxng": "https://search.example.org" }, "agent", /web\.searxng/],
|
|
[{ "web.searxng": "http://search.example.org" }, "business", /https, or http on loopback/],
|
|
[{ "web.searxng": "http://10.0.0.5:8888" }, "business", /https, or http on loopback/],
|
|
[{ "limits.network": "lan" }, "agent", /one of: none, api-only, open/],
|
|
[{ "limits.authority": ["deploy.prod"] }, "agent", /unknown action/],
|
|
];
|
|
for (const [vars, layer, pattern] of bad) refuses(() => checkVars(vars, layer, "v"), pattern);
|
|
});
|
|
|
|
test("merge: defaults, then the most specific layer wins", () => {
|
|
const { vars, provenance } = mergeVars([
|
|
{ layer: "business", source: "business", vars: checkVars({ "tracker.pollSeconds": 60 }, "business", "b") },
|
|
{ layer: "project", source: "project", vars: checkVars({ "tracker.pollSeconds": 20 }, "project", "p") },
|
|
]);
|
|
assert.equal(vars["tracker.pollSeconds"], 20);
|
|
assert.equal(provenance["tracker.pollSeconds"], "project");
|
|
assert.equal(vars["tracker.reconcileMinutes"], 60);
|
|
assert.equal(provenance["tracker.reconcileMinutes"], "default");
|
|
assert.equal(vars["tracker.kind"], "vikunja");
|
|
assert.equal(vars["tracker.descendants"], false);
|
|
assert.equal(provenance["tracker.descendants"], "default");
|
|
assert.equal(vars["bus.tester"], false);
|
|
assert.equal(provenance["bus.tester"], "default");
|
|
assert.equal(vars.harness, undefined);
|
|
assert.equal(provenance.harness, undefined);
|
|
});
|
|
|
|
test("merge: limits only narrow, and provenance lists each source", () => {
|
|
const layer = (source, layerName, v) => ({ layer: layerName, source, vars: checkVars(v, layerName, source) });
|
|
const { vars, provenance } = mergeVars([
|
|
layer("business", "business", { "limits.network": "none", "limits.tools": ["read", "grep", "bash"] }),
|
|
layer("project", "project", { "limits.network": "open", "limits.tools": ["read", "bash", "write"] }),
|
|
layer("agent", "agent", { "limits.tools": ["bash", "read", "edit"] }),
|
|
]);
|
|
assert.equal(vars["limits.network"], "none");
|
|
assert.deepEqual(provenance["limits.network"], ["business", "project"]);
|
|
assert.deepEqual(vars["limits.tools"], ["read", "bash"]);
|
|
assert.deepEqual(provenance["limits.tools"], ["business", "project", "agent"]);
|
|
|
|
const widen = mergeVars([
|
|
layer("business", "business", { "limits.authority": ["message.send"] }),
|
|
layer("agent", "agent", { "limits.authority": ["message.send", "task.close"] }),
|
|
]);
|
|
assert.deepEqual(widen.vars["limits.authority"], ["message.send"]);
|
|
const empty = mergeVars([layer("agent", "agent", { "limits.tools": [] })]);
|
|
assert.deepEqual(empty.vars["limits.tools"], []);
|
|
});
|
|
|
|
test("merge doesn't change its inputs", () => {
|
|
const business = checkVars({ "limits.tools": ["read", "bash"] }, "business", "b");
|
|
const snapshot = JSON.stringify(business);
|
|
mergeVars([
|
|
{ layer: "business", source: "business", vars: business },
|
|
{ layer: "agent", source: "agent", vars: checkVars({ "limits.tools": ["read"] }, "agent", "a") },
|
|
]);
|
|
assert.equal(JSON.stringify(business), snapshot);
|
|
});
|
|
|
|
test("web.searxng takes https anywhere and http on loopback, so Jason's 127.0.0.1:8888 passes", () => {
|
|
for (const v of ["https://search.example.org", "http://127.0.0.1:8888", "http://localhost:8888", "http://[::1]:8888"]) {
|
|
assert.deepEqual(checkVars({ "web.searxng": v }, "business", "v"), { "web.searxng": v }, v);
|
|
}
|
|
});
|