@intentius/chant 0.109.0 → 0.111.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/discovery/fold-import.d.ts.map +1 -1
  3. package/dist/discovery/param-deps.d.ts +30 -2
  4. package/dist/discovery/param-deps.d.ts.map +1 -1
  5. package/dist/fold/subset.d.ts +9 -2
  6. package/dist/fold/subset.d.ts.map +1 -1
  7. package/dist/fold-provenance.d.ts +14 -2
  8. package/dist/fold-provenance.d.ts.map +1 -1
  9. package/dist/import/ir-utils.d.ts.map +1 -1
  10. package/dist/import/parser.d.ts +8 -0
  11. package/dist/import/parser.d.ts.map +1 -1
  12. package/dist/op/activities/factory.d.ts.map +1 -1
  13. package/dist/provenance.d.ts +11 -0
  14. package/dist/provenance.d.ts.map +1 -1
  15. package/dist/serializer-walker.d.ts.map +1 -1
  16. package/dist/stack-output.d.ts +5 -0
  17. package/dist/stack-output.d.ts.map +1 -1
  18. package/dist/workspace/box-cli.d.ts +3 -2
  19. package/dist/workspace/box-cli.d.ts.map +1 -1
  20. package/dist/workspace/box-factory-write.d.ts +71 -0
  21. package/dist/workspace/box-factory-write.d.ts.map +1 -0
  22. package/dist/workspace/box-intent.d.ts +6 -0
  23. package/dist/workspace/box-intent.d.ts.map +1 -1
  24. package/dist/workspace/declaration-write-cli.d.ts +11 -0
  25. package/dist/workspace/declaration-write-cli.d.ts.map +1 -0
  26. package/dist/workspace/declaration-write.d.ts +78 -0
  27. package/dist/workspace/declaration-write.d.ts.map +1 -0
  28. package/dist/workspace/declaration.d.ts +8 -0
  29. package/dist/workspace/declaration.d.ts.map +1 -1
  30. package/dist/workspace/json-edit.d.ts +11 -0
  31. package/dist/workspace/json-edit.d.ts.map +1 -1
  32. package/dist/workspace/ls.d.ts +8 -0
  33. package/dist/workspace/ls.d.ts.map +1 -1
  34. package/dist/workspace/reason-codes.d.ts +4 -0
  35. package/dist/workspace/reason-codes.d.ts.map +1 -1
  36. package/dist/workspace/status.d.ts.map +1 -1
  37. package/dist/workspace/write-scope.d.ts +10 -0
  38. package/dist/workspace/write-scope.d.ts.map +1 -1
  39. package/dist/yaml.d.ts.map +1 -1
  40. package/package.json +1 -1
  41. package/src/cli/commands/import.test.ts +5 -4
  42. package/src/cli/main.test.ts +2 -0
  43. package/src/cli/main.ts +18 -0
  44. package/src/discovery/fold-composite.test.ts +22 -0
  45. package/src/discovery/fold-host-composite.test.ts +23 -0
  46. package/src/discovery/fold-import.ts +73 -21
  47. package/src/discovery/fold-project-provenance.test.ts +52 -0
  48. package/src/discovery/param-deps.test.ts +43 -2
  49. package/src/discovery/param-deps.ts +101 -2
  50. package/src/fold/subset.ts +9 -2
  51. package/src/fold-provenance.test.ts +11 -0
  52. package/src/fold-provenance.ts +22 -2
  53. package/src/import/ir-utils.ts +6 -1
  54. package/src/import/parser.ts +8 -0
  55. package/src/op/activities/factory.ts +3 -1
  56. package/src/op/factory.e2e.test.ts +3 -1
  57. package/src/provenance.ts +13 -1
  58. package/src/serializer-walker.test.ts +10 -0
  59. package/src/serializer-walker.ts +17 -1
  60. package/src/stack-output.ts +6 -1
  61. package/src/workspace/box-cli.ts +35 -4
  62. package/src/workspace/box-factory-write.schema.json +342 -0
  63. package/src/workspace/box-factory-write.test.ts +108 -0
  64. package/src/workspace/box-factory-write.ts +226 -0
  65. package/src/workspace/box-intent.test.ts +14 -4
  66. package/src/workspace/box-intent.ts +9 -1
  67. package/src/workspace/declaration-extensions.test.ts +79 -0
  68. package/src/workspace/declaration-write-cli.ts +82 -0
  69. package/src/workspace/declaration-write.test.ts +155 -0
  70. package/src/workspace/declaration-write.ts +275 -0
  71. package/src/workspace/declaration.ts +19 -1
  72. package/src/workspace/json-edit.ts +71 -0
  73. package/src/workspace/ls-contract.test.ts +53 -0
  74. package/src/workspace/ls.schema.json +11 -0
  75. package/src/workspace/ls.ts +18 -0
  76. package/src/workspace/member-write.schema.json +193 -0
  77. package/src/workspace/reason-codes.test.ts +4 -0
  78. package/src/workspace/reason-codes.ts +6 -0
  79. package/src/workspace/status.schema.json +10 -1
  80. package/src/workspace/status.ts +25 -6
  81. package/src/workspace/write-scope.ts +17 -0
  82. package/src/yaml.test.ts +6 -0
  83. package/src/yaml.ts +4 -0
@@ -4,7 +4,7 @@
4
4
  * `graph --intent` shows it for the box's files.
5
5
  */
6
6
 
7
- import { readFileSync } from "node:fs";
7
+ import { readFileSync, writeFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { afterAll, describe, expect, test } from "vitest";
10
10
  import { cleanScratch, commitAll, contract, REPO, repo } from "./__fixtures__/contract-repo";
@@ -134,13 +134,13 @@ describe("the box block's intent", () => {
134
134
  describe("a box planted as a question", () => {
135
135
  test("a proposed intent reports its question and no answer, and passes the checks", async () => {
136
136
  const root = workspace({ intent: "box-001" }, { "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["member:app"]) });
137
- expect((await boxOf(root))?.intent).toEqual({ id: "box-001", state: "proposed", question: QUESTION, choice: null, answer: null, decided_by: null, decided_on: null });
137
+ expect((await boxOf(root))?.intent).toEqual({ id: "box-001", state: "proposed", question: QUESTION, choice: null, answer: null, decided_by: null, decided_on: null, approved: false });
138
138
  expect(await intentFindings(root)).toEqual([]);
139
139
  });
140
140
 
141
141
  test("a decided intent reports the answer and who gave it, and graph --intent shows it for the box's files", async () => {
142
142
  const root = workspace({ intent: "box-001" }, { "decisions/box-001-what-app-is-for.md": decision("box-001", "decided", ["member:app"]) });
143
- expect((await boxOf(root))?.intent).toEqual({ id: "box-001", state: "decided", question: QUESTION, choice: CHOICE, answer: ANSWER, decided_by: "alex", decided_on: "2026-09-26" });
143
+ expect((await boxOf(root))?.intent).toEqual({ id: "box-001", state: "decided", question: QUESTION, choice: CHOICE, answer: ANSWER, decided_by: "alex", decided_on: "2026-09-26", approved: true });
144
144
  expect(await intentFindings(root)).toEqual([]);
145
145
  commitAll(root, "plant the box");
146
146
  const { doc } = await intentGraph({ cwd: root, region: "app" });
@@ -148,6 +148,16 @@ describe("a box planted as a question", () => {
148
148
  expect(doc.nodes.filter((n) => n.kind === "decision").map((n) => (n.kind === "decision" ? [n.record, n.state, n.decided_by] : []))).toEqual([["box-001", "decided", "alex"]]);
149
149
  });
150
150
 
151
+ test("approved follows the decision kind's approval ranks, not the state's name (#3609)", async () => {
152
+ const records = { "decisions/box-001-what-app-is-for.md": decision("box-001", "decided", ["member:app"]) };
153
+ const root = workspace({ intent: "box-001" }, records);
154
+ const kind = join(root, "decisions", "decision.kind.mjs");
155
+ const text = readFileSync(kind, "utf-8");
156
+ expect(text).toContain("decided: 1,");
157
+ writeFileSync(kind, text.replace("decided: 1,", "decided: 0,"));
158
+ expect((await boxOf(root))?.intent).toMatchObject({ state: "decided", approved: false });
159
+ });
160
+
151
161
  test("a decided intent whose choice names an option not in options[] reports a null answer", async () => {
152
162
  const data = { ...BASE, id: "box-001", title: "Intent box-001", state: "decided", question: QUESTION, choice: { option: "z", reason: "no such option" }, rejected: [], evidence: [], decided_by: "alex", decided_on: "2026-09-26", constrains: ["member:app"] };
153
163
  const record = `---\n${JSON.stringify(data, null, 2)}\n---\n\n# box-001\n`;
@@ -157,7 +167,7 @@ describe("a box planted as a question", () => {
157
167
 
158
168
  test("an intent no decision record has fails WSP126, and status reports only its id", async () => {
159
169
  const root = workspace({ intent: "box-009" }, { "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["member:app"]) });
160
- expect((await boxOf(root))?.intent).toEqual({ id: "box-009", state: null, question: null, choice: null, answer: null, decided_by: null, decided_on: null });
170
+ expect((await boxOf(root))?.intent).toEqual({ id: "box-009", state: null, question: null, choice: null, answer: null, decided_by: null, decided_on: null, approved: false });
161
171
  expect(await intentFindings(root)).toEqual([["WSP126", "error", "box-intent-unknown", "app"]]);
162
172
  const [d] = (await runDeclarationChecks(root)).diagnostics.filter((x) => x.ruleId === "WSP126");
163
173
  expect(d.message).toContain("no record of decisions/decision.kind.mjs has that id");
@@ -37,6 +37,12 @@ export interface BoxIntent {
37
37
  answer: string | null;
38
38
  decided_by: string | null;
39
39
  decided_on: string | null;
40
+ /**
41
+ * Whether the record's state counts as approved (#3609): the decision
42
+ * kind's `approval` rank for it is above 0, the rule the factory's pick
43
+ * holds dispatch with. False with no record, and for a kind with no ranks.
44
+ */
45
+ approved: boolean;
40
46
  }
41
47
 
42
48
  /** The decision record a box's intent names, as the checks read it. */
@@ -71,7 +77,7 @@ function answerOf(choice: unknown, options: unknown): string | null {
71
77
 
72
78
  /** The intent of `id` with no record: every field but the id null. */
73
79
  export function unresolvedIntent(id: string): BoxIntent {
74
- return { id, state: null, question: null, choice: null, answer: null, decided_by: null, decided_on: null };
80
+ return { id, state: null, question: null, choice: null, answer: null, decided_by: null, decided_on: null, approved: false };
75
81
  }
76
82
 
77
83
  /**
@@ -96,6 +102,7 @@ export async function resolveBoxIntents(declaration: Declaration, root: string,
96
102
  try {
97
103
  const read = await readRecordsFor({ kind: k.file, cwd: root, workGaps: false });
98
104
  const field = read.loaded.kind.constrains?.field ?? "constrains";
105
+ const approval = read.loaded.kind.approval ?? {};
99
106
  for (const r of read.result.records) {
100
107
  if (r.id === null || found.has(r.id)) continue;
101
108
  const data = r.data ?? {};
@@ -112,6 +119,7 @@ export async function resolveBoxIntents(declaration: Declaration, root: string,
112
119
  answer: answerOf(data.choice, data.options),
113
120
  decided_by: str(data.decided_by),
114
121
  decided_on: str(data.decided_on),
122
+ approved: r.state !== null && (approval[r.state] ?? 0) > 0,
115
123
  },
116
124
  });
117
125
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * #3595: the x- keys a declaration holds come back from `status --json` and
3
+ * `ls --json` on the object that holds them, so a reader never parses the
4
+ * declaration to get back what it wrote through chant.
5
+ */
6
+
7
+ import { afterAll, describe, expect, test } from "vitest";
8
+ import { cleanScratch, contract, repo } from "./__fixtures__/contract-repo";
9
+ import { extensionsAt, parseDeclaration } from "./declaration";
10
+ import { listWorkspace } from "./ls";
11
+ import lsSchema from "./ls.schema.json";
12
+ import { workspaceStatus } from "./status";
13
+ import statusSchema from "./status.schema.json";
14
+
15
+ afterAll(cleanScratch);
16
+
17
+ const APP = { owner: "alex", createdAt: "2026-10-01T00:00:00.000Z", repo: "acme/fern", branch: "main" };
18
+
19
+ const DECLARATION = {
20
+ name: "fern",
21
+ schema: 1,
22
+ "x-lobby": { slot: 3 },
23
+ members: [
24
+ { name: "app", dir: "app", kind: "other", because: "the app", "x-member": 1 },
25
+ {
26
+ name: "steward",
27
+ dir: "steward",
28
+ kind: "other",
29
+ because: "the box",
30
+ box: {
31
+ "x-factory": { intent: "box-001" },
32
+ capabilities: [{ name: "inference", scope: ["chat"], "x-cap": true }],
33
+ services: [{ name: "app", cmd: "node app.mjs", "x-svc": "a" }],
34
+ factory: { builds: ["app"], check: { run: "npm test", "x-check": 2 }, publish: { repo: "acme/fern", "x-pub": "p" }, "x-fac": "f" },
35
+ listing: { title: "Fern", line: "A review queue", "x-studio-app": APP },
36
+ ship: { op: "ship", "x-ship": "s" },
37
+ },
38
+ },
39
+ ],
40
+ };
41
+
42
+ describe("x- keys in the read contract (#3595)", () => {
43
+ const root = repo({ "chant.workspace.json": `${JSON.stringify(DECLARATION, null, 2)}\n`, "app/a.txt": "", "steward/s.txt": "" }, true);
44
+
45
+ test("extensionsAt reads the x- keys at a pointer, and nothing where there is no object", () => {
46
+ const decl = parseDeclaration(JSON.stringify(DECLARATION), "chant.workspace.json");
47
+ expect(extensionsAt(decl, "")).toEqual({ "x-lobby": { slot: 3 } });
48
+ expect(extensionsAt(decl, "/members/0")).toEqual({ "x-member": 1 });
49
+ expect(extensionsAt(decl, "/members/1/box/listing")).toEqual({ "x-studio-app": APP });
50
+ expect(extensionsAt(decl, "/members/0/box")).toEqual({});
51
+ expect(extensionsAt(decl, "/members/0/name")).toEqual({});
52
+ expect(extensionsAt({ ...decl }, "")).toEqual({});
53
+ });
54
+
55
+ test("status --json keeps them on the member, the box and the box's parts", async () => {
56
+ const out = await workspaceStatus({ cwd: root, env: "box" });
57
+ contract(statusSchema).expectValid(out);
58
+ if ("error" in out) throw new Error(out.error.message);
59
+ const [app, steward] = out.members;
60
+ expect(app).toMatchObject({ name: "app", "x-member": 1 });
61
+ const box = steward.box!;
62
+ expect(box).toMatchObject({ "x-factory": { intent: "box-001" } });
63
+ expect(box.capabilities[0]).toMatchObject({ name: "inference", "x-cap": true });
64
+ expect(box.services[0]).toMatchObject({ name: "app", "x-svc": "a" });
65
+ expect(box.factory).toMatchObject({ "x-fac": "f", check: { run: "npm test", "x-check": 2 }, publish: { repo: "acme/fern", "x-pub": "p" } });
66
+ expect(box.listing).toMatchObject({ title: "Fern", "x-studio-app": APP });
67
+ expect(box.ship).toMatchObject({ op: "ship", "x-ship": "s" });
68
+ expect(Object.keys(steward).filter((k) => k.startsWith("x-"))).toEqual([]);
69
+ });
70
+
71
+ test("ls --json keeps them on each member and on the workspace", () => {
72
+ const out = listWorkspace({ cwd: root });
73
+ contract(lsSchema).expectValid(out);
74
+ if ("error" in out) throw new Error(out.error.message);
75
+ expect(out.workspace).toMatchObject({ name: "fern", "x-lobby": { slot: 3 } });
76
+ expect(out.members[0]).toMatchObject({ name: "app", "x-member": 1 });
77
+ expect(Object.keys(out.members[1]).filter((k) => k.startsWith("x-"))).toEqual([]);
78
+ });
79
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `chant workspace member add <name> --from <file|->`, `chant workspace
3
+ * member remove <name>` and `chant workspace host set <name> --from
4
+ * <file|->` (#3596): the command lines of `declaration-write.ts`.
5
+ */
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import type { CommandContext } from "../cli/registry";
10
+ import { readerVersion } from "./declaration";
11
+ import {
12
+ DECLARATION_WRITE_CONTRACT_VERSION,
13
+ DECLARATION_WRITE_SCHEMA_ID,
14
+ declarationWrite,
15
+ type DeclarationWriteAction,
16
+ type DeclarationWriteDocument,
17
+ } from "./declaration-write";
18
+ import { AGENT_ENV } from "./write-scope";
19
+
20
+ const USAGE: Record<DeclarationWriteAction, string> = {
21
+ "member add": "chant workspace member add <name> --from <file|-> [--by <principal>] [--dry-run] [--json]",
22
+ "member remove": "chant workspace member remove <name> [--by <principal>] [--dry-run] [--json]",
23
+ "host set": "chant workspace host set <name> --from <file|-> [--by <principal>] [--dry-run] [--json]",
24
+ };
25
+
26
+ const print = (doc: DeclarationWriteDocument): number => {
27
+ console.log(JSON.stringify(doc, null, 2));
28
+ return "error" in doc ? 1 : 0;
29
+ };
30
+
31
+ /** `chant workspace member add|remove <name>`. */
32
+ export function runWorkspaceMember(ctx: CommandContext): number {
33
+ const [, verb, ...rest] = ctx.args.positionals ?? [];
34
+ const action = verb === "add" ? "member add" : verb === "remove" ? "member remove" : null;
35
+ if (action === null) return usage("member add", rest[0], `chant workspace member takes add <name> or remove <name>${verb === undefined ? "" : `, not ${verb}`}`);
36
+ return run(ctx, action, rest);
37
+ }
38
+
39
+ /** `chant workspace host set <name>`. */
40
+ export function runWorkspaceHost(ctx: CommandContext): number {
41
+ const [, verb, ...rest] = ctx.args.positionals ?? [];
42
+ if (verb !== "set") return usage("host set", rest[0], `chant workspace host takes set <name>${verb === undefined ? "" : `, not ${verb}`}`);
43
+ return run(ctx, "host set", rest);
44
+ }
45
+
46
+ function usage(action: DeclarationWriteAction, name: string | undefined, message: string): number {
47
+ return print({
48
+ $schema: DECLARATION_WRITE_SCHEMA_ID,
49
+ contract: DECLARATION_WRITE_CONTRACT_VERSION,
50
+ chant: readerVersion(),
51
+ action,
52
+ name: name ?? null,
53
+ error: { code: "write-usage-invalid", message: `${message}; ${USAGE[action]}` },
54
+ });
55
+ }
56
+
57
+ function run(ctx: CommandContext, action: DeclarationWriteAction, rest: string[]): number {
58
+ const { args } = ctx;
59
+ const cwd = process.cwd();
60
+ const [name, ...extra] = rest;
61
+ if (name === undefined) return usage(action, undefined, `${action} needs a name`);
62
+ if (extra.length > 0) return usage(action, name, `${action} takes one name, and was also given ${extra.join(" ")}`);
63
+ for (const [flag, v] of [["--set", args.set], ["--kind", args.kind], ["--at", args.at], ["--cover", args.cover]] as const) {
64
+ if (v !== undefined) return usage(action, name, `${action} takes no ${flag}`);
65
+ }
66
+ let entry: string | undefined;
67
+ if (args.migrateFrom !== undefined) {
68
+ try {
69
+ entry = readFileSync(args.migrateFrom === "-" ? 0 : resolve(cwd, args.migrateFrom), "utf-8");
70
+ } catch (err) {
71
+ return print({
72
+ $schema: DECLARATION_WRITE_SCHEMA_ID,
73
+ contract: DECLARATION_WRITE_CONTRACT_VERSION,
74
+ chant: readerVersion(),
75
+ action,
76
+ name,
77
+ error: { code: "write-input-invalid", message: `--from ${args.migrateFrom} could not be read: ${err instanceof Error ? err.message : String(err)}` },
78
+ });
79
+ }
80
+ }
81
+ return print(declarationWrite({ cwd, action, name, entry, by: args.by, agent: process.env[AGENT_ENV] || undefined, dryRun: args.dryRun === true }));
82
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * #3596: a declaration's members and hosts written through chant, the way a
3
+ * local lobby plants, retires and starts boxes, without editing the file.
4
+ */
5
+
6
+ import { readFileSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { afterAll, describe, expect, test } from "vitest";
9
+ import { cleanScratch, contract, git, repo } from "./__fixtures__/contract-repo";
10
+ import { readDeclaration } from "./declaration";
11
+ import { declarationWrite, type DeclarationWriteDocument, type DeclarationWriteRequest } from "./declaration-write";
12
+ import schema from "./member-write.schema.json";
13
+ import { appendElement, removeElement, setElement } from "./json-edit";
14
+ import { parseJsonText } from "./jsonc";
15
+ import { workingTree } from "./tree";
16
+
17
+ afterAll(cleanScratch);
18
+
19
+ const { expectValid } = contract(schema);
20
+
21
+ const HOST = { name: "local", ports: { from: 18100, to: 18199, perBox: 10 }, stateRoot: "${STUDIO_LOBBY_HOME}/boxes" };
22
+ const box = (name: string, slot: number) => ({
23
+ name,
24
+ dir: `boxes/${name}`,
25
+ kind: "other",
26
+ because: "a box the lobby planted",
27
+ box: { host: "local", slot, ports: { door: 0, site: 1 }, state: { record: "box.json" }, cookies: ["hud_session"] },
28
+ });
29
+
30
+ const JSONC = `{
31
+ // the lobby
32
+ "name": "studio-lobby",
33
+ "schema": 1,
34
+ "minReader": "0.93.0",
35
+ "members": [
36
+ // planted boxes
37
+ ],
38
+ }
39
+ `;
40
+
41
+ function lobby(text = JSONC, file = "chant.workspace.jsonc"): string {
42
+ return repo({ [file]: text });
43
+ }
44
+
45
+ function run(root: string, req: Omit<DeclarationWriteRequest, "cwd" | "entry"> & { entry?: unknown }): DeclarationWriteDocument {
46
+ const doc = declarationWrite({ ...req, cwd: root, entry: req.entry === undefined ? undefined : JSON.stringify(req.entry) });
47
+ expectValid(doc);
48
+ return doc;
49
+ }
50
+
51
+ const code = (doc: DeclarationWriteDocument) => ("error" in doc ? doc.error.code : null);
52
+ const declOf = (root: string) => readDeclaration(workingTree(root));
53
+
54
+ describe("host set and member add|remove on a lobby's declaration (#3596)", () => {
55
+ test("the first start writes the host, plants two boxes and retires one, keeping the file's comments", () => {
56
+ const root = lobby();
57
+ const host = run(root, { action: "host set", name: "local", entry: HOST });
58
+ expect(host).toMatchObject({ action: "host set", name: "local", changed: true, previous: null, entry: HOST, paths: ["chant.workspace.jsonc"] });
59
+
60
+ const fern = run(root, { action: "member add", name: "fern", entry: box("fern", 0) });
61
+ expect(fern).toMatchObject({ changed: true, previous: null, entry: box("fern", 0) });
62
+ expect(run(root, { action: "member add", name: "moss", entry: { ...box("moss", 1), name: undefined } })).toMatchObject({ changed: true });
63
+ expect(declOf(root).members.map((m) => [m.name, m.box?.isolation?.slot])).toEqual([
64
+ ["fern", 0],
65
+ ["moss", 1],
66
+ ]);
67
+
68
+ const text = readFileSync(join(root, "chant.workspace.jsonc"), "utf-8");
69
+ expect(text).toContain("// the lobby");
70
+ expect(text).toContain("// planted boxes");
71
+ expect(declarationWrite({ cwd: root, action: "host set", name: "local", entry: JSON.stringify(HOST) })).toMatchObject({ changed: false, previous: HOST, paths: [] });
72
+
73
+ const retire = run(root, { action: "member remove", name: "fern" });
74
+ expect(retire).toMatchObject({ changed: true, previous: box("fern", 0), entry: null });
75
+ expect(declOf(root).members.map((m) => m.name)).toEqual(["moss"]);
76
+ expect(readFileSync(join(root, "chant.workspace.jsonc"), "utf-8")).toContain("// planted boxes");
77
+ });
78
+
79
+ test("host set replaces a host in place, and a dry run writes nothing", () => {
80
+ const root = lobby(`${JSON.stringify({ name: "l", schema: 1, members: [], hosts: [HOST] }, null, 2)}\n`, "chant.workspace.json");
81
+ const before = readFileSync(join(root, "chant.workspace.json"), "utf-8");
82
+ const wider = { ...HOST, ports: { from: 18100, to: 18299, perBox: 10 } };
83
+ const dry = run(root, { action: "host set", name: "local", entry: wider, dryRun: true });
84
+ expect(dry).toMatchObject({ changed: true, dryRun: true, previous: HOST, entry: wider });
85
+ expect(readFileSync(join(root, "chant.workspace.json"), "utf-8")).toBe(before);
86
+ run(root, { action: "host set", name: "local", entry: wider });
87
+ expect(declOf(root).hosts.map((h) => h.ports.to)).toEqual([18299]);
88
+ });
89
+
90
+ test("refuses a name taken, a member not there, an entry that doesn't read, and two boxes in one slot", () => {
91
+ const root = lobby();
92
+ run(root, { action: "host set", name: "local", entry: HOST });
93
+ run(root, { action: "member add", name: "fern", entry: box("fern", 0) });
94
+ const before = readFileSync(join(root, "chant.workspace.jsonc"), "utf-8");
95
+
96
+ expect(run(root, { action: "member add", name: "fern", entry: box("fern", 0) })).toMatchObject({ changed: false });
97
+ expect(code(run(root, { action: "member add", name: "fern", entry: box("fern", 2) }))).toBe("member-exists");
98
+ expect(code(run(root, { action: "member remove", name: "ghost" }))).toBe("member-unknown");
99
+ const invalid = run(root, { action: "member add", name: "moss", entry: { ...box("moss", 1), because: undefined } });
100
+ expect(code(invalid)).toBe("write-input-invalid");
101
+ expect("error" in invalid && invalid.error.message).toMatch(/needs "because"/);
102
+ const shared = run(root, { action: "member add", name: "moss", entry: box("moss", 0) });
103
+ expect(code(shared)).toBe("box-isolation-collision");
104
+ expect("error" in shared && shared.error.message).toMatch(/fern\.door and moss\.door/);
105
+ expect(code(run(root, { action: "member add", name: "moss", entry: { ...box("moss", 1), name: "fern" } }))).toBe("write-input-invalid");
106
+ expect(code(run(root, { action: "host set", name: "local", entry: { ...HOST, stateRoot: "/var/boxes" } }))).toBe("box-isolation-literal");
107
+ expect(code(run(root, { action: "member remove", name: "fern", entry: {} }))).toBe("write-usage-invalid");
108
+ expect(code(run(root, { action: "host set", name: "local" }))).toBe("write-usage-invalid");
109
+ expect(readFileSync(join(root, "chant.workspace.jsonc"), "utf-8")).toBe(before);
110
+ });
111
+
112
+ test("a writer the write scope at base keeps off the declaration is refused, unless except allows members and hosts", () => {
113
+ const decl = (except?: string[]) => ({
114
+ name: "l",
115
+ schema: 1,
116
+ members: [{ name: "root", dir: ".", kind: "other", because: "the lobby" }],
117
+ writeScope: { human: { protected: [except ? { path: "chant.workspace.json", except } : "chant.workspace.json"] } },
118
+ });
119
+ const root = repo({ "chant.workspace.json": `${JSON.stringify(decl(), null, 2)}\n` }, true);
120
+ git(root, "branch", "-M", "main");
121
+ expect(code(run(root, { action: "host set", name: "local", entry: HOST }))).toBe("write-scope-protected");
122
+ writeFileSync(join(root, "chant.workspace.json"), `${JSON.stringify(decl(["members", "hosts"]), null, 2)}\n`);
123
+ git(root, "commit", "-q", "-am", "allow the lobby's writes");
124
+ expect(run(root, { action: "host set", name: "local", entry: HOST })).toMatchObject({ changed: true });
125
+ expect(run(root, { action: "member add", name: "fern", entry: box("fern", 0) })).toMatchObject({ changed: true });
126
+ });
127
+ });
128
+
129
+ describe("array edits in place (#3596)", () => {
130
+ const read = (text: string) => {
131
+ const r = parseJsonText(text, { jsonc: true });
132
+ if (!r.ok) throw new Error(r.message);
133
+ return r.value;
134
+ };
135
+
136
+ test("append to an empty, a multi-line and a one-line array", () => {
137
+ expect(read(appendElement('{ "a": [] }', false, "/a", { x: 1 }))).toEqual({ a: [{ x: 1 }] });
138
+ const multi = '{\n "a": [\n 1, // one\n 2\n ]\n}\n';
139
+ const out = appendElement(multi, true, "/a", 3);
140
+ expect(out).toBe('{\n "a": [\n 1, // one\n 2,\n 3\n ]\n}\n');
141
+ expect(appendElement('{ "a": [1, 2] }', false, "/a", 3)).toBe('{ "a": [1, 2, 3] }');
142
+ expect(read(appendElement('{\n "a": [\n 1,\n ],\n}', true, "/a", 2))).toEqual({ a: [1, 2] });
143
+ });
144
+
145
+ test("remove the first, a middle, the last and the only element, and replace one", () => {
146
+ const text = '{\n "a": [\n 1,\n 2,\n 3\n ]\n}\n';
147
+ expect(removeElement(text, false, "/a", 0)).toBe('{\n "a": [\n 2,\n 3\n ]\n}\n');
148
+ expect(removeElement(text, false, "/a", 1)).toBe('{\n "a": [\n 1,\n 3\n ]\n}\n');
149
+ expect(removeElement(text, false, "/a", 2)).toBe('{\n "a": [\n 1,\n 2\n ]\n}\n');
150
+ expect(removeElement('{ "a": [1] }', false, "/a", 0)).toBe('{ "a": [] }');
151
+ expect(removeElement('{\n "a": [\n // kept\n 1\n ]\n}', true, "/a", 0)).toBe('{\n "a": [\n // kept\n ]\n}');
152
+ expect(read(setElement(text, false, "/a", 1, { b: true }))).toEqual({ a: [1, { b: true }, 3] });
153
+ expect(() => removeElement(text, false, "/a", 3)).toThrow(/no element 3/);
154
+ });
155
+ });