@intentius/chant 0.110.0 → 0.112.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.
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/op/activities/factory.d.ts.map +1 -1
- package/dist/op/steward.d.ts +1 -1
- package/dist/op/steward.d.ts.map +1 -1
- package/dist/workspace/box-cli.d.ts +3 -2
- package/dist/workspace/box-cli.d.ts.map +1 -1
- package/dist/workspace/box-factory-write.d.ts +71 -0
- package/dist/workspace/box-factory-write.d.ts.map +1 -0
- package/dist/workspace/box-intent.d.ts +6 -0
- package/dist/workspace/box-intent.d.ts.map +1 -1
- package/dist/workspace/declaration-write-cli.d.ts +11 -0
- package/dist/workspace/declaration-write-cli.d.ts.map +1 -0
- package/dist/workspace/declaration-write.d.ts +78 -0
- package/dist/workspace/declaration-write.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +8 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/json-edit.d.ts +11 -0
- package/dist/workspace/json-edit.d.ts.map +1 -1
- package/dist/workspace/ls.d.ts +8 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +4 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +4 -1
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/steward-index.d.ts +89 -0
- package/dist/workspace/steward-index.d.ts.map +1 -0
- package/dist/workspace/write-scope.d.ts +10 -0
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/main.test.ts +2 -0
- package/src/cli/main.ts +18 -0
- package/src/op/activities/factory.ts +3 -1
- package/src/op/factory.e2e.test.ts +3 -1
- package/src/op/steward.ts +1 -1
- package/src/workspace/box-cli.ts +35 -4
- package/src/workspace/box-factory-write.schema.json +342 -0
- package/src/workspace/box-factory-write.test.ts +108 -0
- package/src/workspace/box-factory-write.ts +226 -0
- package/src/workspace/box-intent.test.ts +14 -4
- package/src/workspace/box-intent.ts +9 -1
- package/src/workspace/declaration-extensions.test.ts +79 -0
- package/src/workspace/declaration-write-cli.ts +82 -0
- package/src/workspace/declaration-write.test.ts +155 -0
- package/src/workspace/declaration-write.ts +275 -0
- package/src/workspace/declaration.ts +19 -1
- package/src/workspace/json-edit.ts +71 -0
- package/src/workspace/ls-contract.test.ts +53 -0
- package/src/workspace/ls.schema.json +11 -0
- package/src/workspace/ls.ts +18 -0
- package/src/workspace/member-write.schema.json +193 -0
- package/src/workspace/reason-codes.test.ts +4 -0
- package/src/workspace/reason-codes.ts +6 -0
- package/src/workspace/status-stewards.ts +23 -35
- package/src/workspace/status.schema.json +10 -1
- package/src/workspace/status.ts +25 -6
- package/src/workspace/steward-index.test.ts +171 -0
- package/src/workspace/steward-index.ts +226 -0
- package/src/workspace/write-scope.ts +17 -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
|
+
});
|