@intentius/chant 0.110.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.
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/op/activities/factory.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.d.ts.map +1 -1
- 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/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.schema.json +10 -1
- package/src/workspace/status.ts +25 -6
- package/src/workspace/write-scope.ts +17 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant workspace box factory set <member>` (#3600): change a box's
|
|
3
|
+
* factory, `members[i].box.factory` in the declaration (#3146, ws-077),
|
|
4
|
+
* through chant.
|
|
5
|
+
*
|
|
6
|
+
* A box planted from a shared template can't carry where it publishes:
|
|
7
|
+
* `factory.publish.repo` must be a real owner/name, so the template leaves
|
|
8
|
+
* it out and whoever plants the box, which knows the box's repo, sets it
|
|
9
|
+
* here. The write is the factory's sibling of `box listing set` (#3308): it
|
|
10
|
+
* changes only the factory's own top-level properties, in place, so the file
|
|
11
|
+
* keeps its formatting, key order and comments; it validates the declaration
|
|
12
|
+
* it would write, judges the declaration by the write scope at base (so a
|
|
13
|
+
* protected declaration takes it when its `except` names
|
|
14
|
+
* `/members/*\/box/factory/publish`), refuses a bare `--by` name under
|
|
15
|
+
* `identity.attribution: "identified"`, holds the working tree's write lock,
|
|
16
|
+
* prints one JSON document (`box-factory-write.schema.json`) and never
|
|
17
|
+
* commits.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
23
|
+
import { isDeepStrictEqual } from "node:util";
|
|
24
|
+
import { factoryView, type FactoryView } from "./box-factory";
|
|
25
|
+
import { parseDeclaration, readDeclaration, readerVersion, WORKSPACE_ERROR_CODES, WorkspaceReadError, type Declaration } from "./declaration";
|
|
26
|
+
import { IDENTITY_CODES, IdentityError, refuseUnidentified } from "./identity";
|
|
27
|
+
import { removeProperty, setProperty, valueAt } from "./json-edit";
|
|
28
|
+
import { parseJsonText } from "./jsonc";
|
|
29
|
+
import type { ReasonCode } from "./reason-codes";
|
|
30
|
+
import { locateWorkspace } from "./which-chant";
|
|
31
|
+
import { judgePath, resolveWriter, scopeSource, unknownClassVerdict, WriteScopeError } from "./write-scope";
|
|
32
|
+
import { withWriteLockSync, WriteLockError, WRITE_LOCK_CODES } from "./write-lock";
|
|
33
|
+
|
|
34
|
+
/** The version of the document this write prints. */
|
|
35
|
+
export const BOX_FACTORY_CONTRACT_VERSION = 1;
|
|
36
|
+
|
|
37
|
+
/** `$id` of the JSON Schema for what `box factory set` prints, shipped beside this file. */
|
|
38
|
+
export const BOX_FACTORY_WRITE_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/box-factory-write/v1/box-factory-write.schema.json";
|
|
39
|
+
|
|
40
|
+
/** Why a factory write wrote nothing. Closed. */
|
|
41
|
+
export const BOX_FACTORY_ERROR_CODES = [
|
|
42
|
+
...WORKSPACE_ERROR_CODES,
|
|
43
|
+
"write-usage-invalid",
|
|
44
|
+
"write-input-invalid",
|
|
45
|
+
"factory-member-unknown",
|
|
46
|
+
"factory-box-missing",
|
|
47
|
+
"write-scope-member",
|
|
48
|
+
"write-scope-protected",
|
|
49
|
+
"write-scope-class-unknown",
|
|
50
|
+
"agent-unknown",
|
|
51
|
+
...IDENTITY_CODES,
|
|
52
|
+
...WRITE_LOCK_CODES,
|
|
53
|
+
] as const satisfies readonly ReasonCode[];
|
|
54
|
+
export type BoxFactoryErrorCode = (typeof BOX_FACTORY_ERROR_CODES)[number];
|
|
55
|
+
|
|
56
|
+
/** The factory properties a write may set. Any `x-` key is passed through as well. */
|
|
57
|
+
export const FACTORY_FIELDS = ["builds", "check", "checks", "builders", "tiers", "publish"] as const;
|
|
58
|
+
|
|
59
|
+
type Head = { $schema: string; contract: number; chant: string };
|
|
60
|
+
|
|
61
|
+
/** What `box factory set` prints. */
|
|
62
|
+
export type BoxFactoryWriteDocument =
|
|
63
|
+
| (Head & {
|
|
64
|
+
member: string;
|
|
65
|
+
/** The declaration file, from the repository root, and the sha256 of its bytes after the write (or as it would be, with dryRun). */
|
|
66
|
+
declaration: { path: string; sha256: string };
|
|
67
|
+
/** The declaration, from the repository root, when the write changed it; else empty. */
|
|
68
|
+
paths: string[];
|
|
69
|
+
changed: boolean;
|
|
70
|
+
dryRun: boolean;
|
|
71
|
+
/** The factory before the write, as status --json prints it, or null when the box declared none. */
|
|
72
|
+
previous: FactoryView | null;
|
|
73
|
+
/** The factory after the write, as status --json prints it, or null when the write took every field out. */
|
|
74
|
+
factory: FactoryView | null;
|
|
75
|
+
})
|
|
76
|
+
| (Head & { member: string | null; error: { code: BoxFactoryErrorCode; message: string } });
|
|
77
|
+
|
|
78
|
+
export interface BoxFactoryWriteRequest {
|
|
79
|
+
cwd: string;
|
|
80
|
+
member: string;
|
|
81
|
+
/** The factory fields as JSON text (`--from`). */
|
|
82
|
+
fields?: string;
|
|
83
|
+
/** Who is writing (`--by`). */
|
|
84
|
+
by?: string;
|
|
85
|
+
/** The agent session (`CHANT_AGENT`). */
|
|
86
|
+
agent?: string;
|
|
87
|
+
dryRun?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
class FactoryWriteError extends Error {
|
|
91
|
+
constructor(
|
|
92
|
+
readonly code: BoxFactoryErrorCode,
|
|
93
|
+
message: string,
|
|
94
|
+
) {
|
|
95
|
+
super(message);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const toPosix = (p: string) => (sep === "/" ? p : p.split(sep).join("/"));
|
|
100
|
+
const sha256 = (b: string) => createHash("sha256").update(b).digest("hex");
|
|
101
|
+
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
102
|
+
|
|
103
|
+
/** The factory fields given with --from: a JSON object of the factory's fields and x- keys; null for a key takes it out. */
|
|
104
|
+
function readFields(text: string | undefined): Record<string, unknown> {
|
|
105
|
+
if (text === undefined || text.trim() === "") throw new FactoryWriteError("write-usage-invalid", "box factory set needs the factory fields: --from <file|->, such as {\"publish\": {\"repo\": \"owner/name\"}}");
|
|
106
|
+
let value: unknown;
|
|
107
|
+
try {
|
|
108
|
+
value = JSON.parse(text);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
throw new FactoryWriteError("write-input-invalid", `the factory fields are not JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
111
|
+
}
|
|
112
|
+
if (!isObject(value)) throw new FactoryWriteError("write-input-invalid", "the factory fields are a JSON object, such as {\"publish\": {\"repo\": \"owner/name\"}}");
|
|
113
|
+
for (const k of Object.keys(value)) {
|
|
114
|
+
if (k.startsWith("x-") || (FACTORY_FIELDS as readonly string[]).includes(k)) continue;
|
|
115
|
+
throw new FactoryWriteError("write-input-invalid", `${k} is not a factory field; a factory takes ${FACTORY_FIELDS.join(", ")} and x- keys`);
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Write `text` to `file` through a sibling temporary file, so a reader never sees half of it. */
|
|
121
|
+
function writeAtomically(file: string, text: string): void {
|
|
122
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
123
|
+
const tmp = `${file}.chant-${process.pid}.tmp`;
|
|
124
|
+
writeFileSync(tmp, text);
|
|
125
|
+
renameSync(tmp, file);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Run the write and build the document it prints. Never throws for a refusal. */
|
|
129
|
+
export function boxFactorySet(req: BoxFactoryWriteRequest): BoxFactoryWriteDocument {
|
|
130
|
+
const head: Head = { $schema: BOX_FACTORY_WRITE_SCHEMA_ID, contract: BOX_FACTORY_CONTRACT_VERSION, chant: readerVersion() };
|
|
131
|
+
try {
|
|
132
|
+
return withWriteLockSync(req.cwd, { verb: "box factory set", by: req.by ?? null, agent: req.agent || null }, req.dryRun, () => write(req, head));
|
|
133
|
+
} catch (err) {
|
|
134
|
+
const fail = (code: BoxFactoryErrorCode, message: string): BoxFactoryWriteDocument => ({ ...head, member: req.member || null, error: { code, message } });
|
|
135
|
+
if (err instanceof FactoryWriteError) return fail(err.code, err.message);
|
|
136
|
+
if (err instanceof WriteScopeError) return fail(err.code as BoxFactoryErrorCode, err.message);
|
|
137
|
+
if (err instanceof IdentityError) return fail(err.code, err.message);
|
|
138
|
+
if (err instanceof WriteLockError) return fail(err.code, err.message);
|
|
139
|
+
if (err instanceof WorkspaceReadError) return fail(err.code as BoxFactoryErrorCode, err.describe());
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function write(req: BoxFactoryWriteRequest, head: Head): BoxFactoryWriteDocument {
|
|
145
|
+
if (!req.member) throw new FactoryWriteError("write-usage-invalid", "box factory set needs the member whose box builds: box factory set <member>");
|
|
146
|
+
const fields = readFields(req.fields);
|
|
147
|
+
|
|
148
|
+
const located = locateWorkspace(req.cwd);
|
|
149
|
+
const declaration = readDeclaration(located.tree);
|
|
150
|
+
const member = declaration.members.find((m) => m.name === req.member);
|
|
151
|
+
if (!member) {
|
|
152
|
+
throw new FactoryWriteError("factory-member-unknown", `no member is named ${req.member}; the declaration's members are ${declaration.members.map((m) => m.name).join(", ") || "none"}`);
|
|
153
|
+
}
|
|
154
|
+
if (!member.box) throw new FactoryWriteError("factory-box-missing", `member ${member.name} declares no box block, so it has no factory; a factory is what a box builds (#3146)`);
|
|
155
|
+
|
|
156
|
+
const repoBase = located.top ?? located.rootOnDisk;
|
|
157
|
+
const declPath = toPosix(relative(repoBase, join(located.rootOnDisk, declaration.file)));
|
|
158
|
+
const declFile = join(located.rootOnDisk, declaration.file);
|
|
159
|
+
const before = readFileSync(declFile, "utf-8");
|
|
160
|
+
const jsonc = declaration.file.endsWith(".jsonc");
|
|
161
|
+
|
|
162
|
+
// The declaration, with only the factory's own properties changed.
|
|
163
|
+
const boxPointer = member.box.pointer;
|
|
164
|
+
const factoryPointer = `${boxPointer}/factory`;
|
|
165
|
+
const parsed = parseJsonText(before, { jsonc });
|
|
166
|
+
if (!parsed.ok) throw new WorkspaceReadError("declaration-unparseable", parsed.message);
|
|
167
|
+
const raw = valueAt(parsed.value, factoryPointer);
|
|
168
|
+
let after = before;
|
|
169
|
+
const expected = structuredClone(parsed.value) as Record<string, unknown>;
|
|
170
|
+
const expectedBox = valueAt(expected, boxPointer) as Record<string, unknown>;
|
|
171
|
+
if (raw === undefined) {
|
|
172
|
+
const value = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== null));
|
|
173
|
+
if (Object.keys(value).length > 0) {
|
|
174
|
+
after = setProperty(after, jsonc, boxPointer, "factory", value);
|
|
175
|
+
expectedBox.factory = value;
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
const expectedFactory = expectedBox.factory as Record<string, unknown>;
|
|
179
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
180
|
+
if (v === null) {
|
|
181
|
+
after = removeProperty(after, jsonc, factoryPointer, k);
|
|
182
|
+
delete expectedFactory[k];
|
|
183
|
+
} else if (!isDeepStrictEqual(expectedFactory[k], v)) {
|
|
184
|
+
after = setProperty(after, jsonc, factoryPointer, k, v);
|
|
185
|
+
expectedFactory[k] = v;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const reread = parseJsonText(after, { jsonc });
|
|
190
|
+
if (!reread.ok || !isDeepStrictEqual(reread.value, expected)) {
|
|
191
|
+
// An edit that does not read back as the value meant is never written.
|
|
192
|
+
throw new Error(`box factory set: editing ${declaration.file} in place did not give the declaration intended; nothing was written`);
|
|
193
|
+
}
|
|
194
|
+
let next: Declaration;
|
|
195
|
+
try {
|
|
196
|
+
next = parseDeclaration(after, declaration.file);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (err instanceof WorkspaceReadError) throw new FactoryWriteError("write-input-invalid", `the factory would make the declaration invalid: ${err.describe()}`);
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
const nextMember = next.members.find((m) => m.name === member.name)!;
|
|
202
|
+
|
|
203
|
+
// The write scope at base, and who --by names.
|
|
204
|
+
const source = scopeSource(req.cwd);
|
|
205
|
+
if (req.by !== undefined) refuseUnidentified(source, [req.by], "--by", { agent: req.agent });
|
|
206
|
+
if (after !== before && (source.declaration !== null || (req.agent !== undefined && req.agent !== ""))) {
|
|
207
|
+
const writer = resolveWriter(source.declaration, source.policy, { agent: req.agent || null, principal: req.by ?? null }, source.classes);
|
|
208
|
+
const unknown = unknownClassVerdict(source.declaration, writer, source.classes);
|
|
209
|
+
if (!unknown.ok) throw new WriteScopeError(unknown.code, unknown.message);
|
|
210
|
+
const verdict = judgePath(source.declaration, writer, declaration.file, { before: () => before, after: () => after });
|
|
211
|
+
if (!verdict.ok) throw new FactoryWriteError(verdict.code as BoxFactoryErrorCode, verdict.message);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const changed = after !== before;
|
|
215
|
+
if (changed && !req.dryRun) writeAtomically(declFile, after);
|
|
216
|
+
return {
|
|
217
|
+
...head,
|
|
218
|
+
member: member.name,
|
|
219
|
+
declaration: { path: declPath, sha256: sha256(after) },
|
|
220
|
+
paths: changed ? [declPath] : [],
|
|
221
|
+
changed,
|
|
222
|
+
dryRun: req.dryRun === true,
|
|
223
|
+
previous: factoryView(member.box.factory, declaration.members),
|
|
224
|
+
factory: factoryView(nextMember.box!.factory, next.members),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -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
|
+
});
|