@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
@@ -0,0 +1,275 @@
1
+ /**
2
+ * `chant workspace member add|remove` and `chant workspace host set` (#3596):
3
+ * a declaration's members and hosts, written through chant.
4
+ *
5
+ * ws-074 lets a tool write the repo only through chant. A local lobby keeps
6
+ * one member per box it runs, each with a box block whose isolation chant
7
+ * resolves, and the host those boxes run on; before these writes it edited
8
+ * `chant.workspace.json` itself to plant, retire and start a box. Each write
9
+ * changes one entry of `members` or `hosts` in place, so the file keeps its
10
+ * formatting, key order and, in a `.jsonc` file, its comments, as `box
11
+ * listing set` does (#3308).
12
+ *
13
+ * A write is refused, and nothing written, when the declaration it would
14
+ * write does not read (`write-input-invalid`, with the read's own message),
15
+ * when it adds a collision between two boxes on one host or a literal
16
+ * machine path (`box-isolation-collision`, `box-isolation-literal`, the codes
17
+ * `chant workspace check` reports as WSP123 and WSP124), and when the write
18
+ * scope at base keeps the writer off the declaration (`judgePath`, with the
19
+ * change, so a protected entry's `except` can allow `members` or `hosts`).
20
+ * Each holds the working tree's write lock (#3173, ws-089), prints one JSON
21
+ * document (`member-write.schema.json`) and never commits.
22
+ */
23
+
24
+ import { createHash } from "node:crypto";
25
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
26
+ import { dirname, join, relative, sep } from "node:path";
27
+ import { isDeepStrictEqual } from "node:util";
28
+ import { boxCollisions, boxLiterals } from "./box-isolation";
29
+ import { parseDeclaration, readDeclaration, readerVersion, WORKSPACE_ERROR_CODES, WorkspaceReadError, type Declaration } from "./declaration";
30
+ import { IDENTITY_CODES, IdentityError, refuseUnidentified } from "./identity";
31
+ import { appendElement, removeElement, setElement, setProperty, valueAt } from "./json-edit";
32
+ import { parseJsonText } from "./jsonc";
33
+ import type { ReasonCode } from "./reason-codes";
34
+ import { locateWorkspace } from "./which-chant";
35
+ import { judgePath, resolveWriter, scopeSource, unknownClassVerdict, WriteScopeError } from "./write-scope";
36
+ import { withWriteLockSync, WriteLockError, WRITE_LOCK_CODES } from "./write-lock";
37
+
38
+ /** The version of the document these writes print. */
39
+ export const DECLARATION_WRITE_CONTRACT_VERSION = 1;
40
+
41
+ /** `$id` of the JSON Schema for what the member and host writes print, shipped beside this file. */
42
+ export const DECLARATION_WRITE_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/member-write/v1/member-write.schema.json";
43
+
44
+ /** The writes. */
45
+ export const DECLARATION_WRITE_ACTIONS = ["member add", "member remove", "host set"] as const;
46
+ export type DeclarationWriteAction = (typeof DECLARATION_WRITE_ACTIONS)[number];
47
+
48
+ /** Why a member or host write wrote nothing. Closed. */
49
+ export const DECLARATION_WRITE_ERROR_CODES = [
50
+ ...WORKSPACE_ERROR_CODES,
51
+ "write-usage-invalid",
52
+ "write-input-invalid",
53
+ "member-exists",
54
+ "member-unknown",
55
+ "box-isolation-collision",
56
+ "box-isolation-literal",
57
+ "write-scope-member",
58
+ "write-scope-protected",
59
+ "write-scope-class-unknown",
60
+ "agent-unknown",
61
+ ...IDENTITY_CODES,
62
+ ...WRITE_LOCK_CODES,
63
+ ] as const satisfies readonly ReasonCode[];
64
+ export type DeclarationWriteErrorCode = (typeof DECLARATION_WRITE_ERROR_CODES)[number];
65
+
66
+ type Head = { $schema: string; contract: number; chant: string; action: DeclarationWriteAction; name: string | null };
67
+
68
+ /** What `member add`, `member remove` and `host set` print. */
69
+ export type DeclarationWriteDocument =
70
+ | (Head & {
71
+ name: string;
72
+ /** The declaration file, from the repository root, and the sha256 of its bytes after the write (or as it would be, with dryRun). */
73
+ declaration: { path: string; sha256: string };
74
+ /** The declaration, from the repository root, when the write changed it; else empty. */
75
+ paths: string[];
76
+ changed: boolean;
77
+ dryRun: boolean;
78
+ /** The entry before the write, as the file held it, or null when there was none. */
79
+ previous: Record<string, unknown> | null;
80
+ /** The entry after the write, as the file holds it, or null after a remove. */
81
+ entry: Record<string, unknown> | null;
82
+ })
83
+ | (Head & { error: { code: DeclarationWriteErrorCode; message: string } });
84
+
85
+ export interface DeclarationWriteRequest {
86
+ cwd: string;
87
+ action: DeclarationWriteAction;
88
+ /** The member's or host's name. */
89
+ name: string;
90
+ /** The entry as JSON text (`--from`), for member add and host set. */
91
+ entry?: string;
92
+ /** Who is writing (`--by`). */
93
+ by?: string;
94
+ /** The agent session (`CHANT_AGENT`). */
95
+ agent?: string;
96
+ dryRun?: boolean;
97
+ }
98
+
99
+ class DeclarationWriteError extends Error {
100
+ constructor(
101
+ readonly code: DeclarationWriteErrorCode,
102
+ message: string,
103
+ ) {
104
+ super(message);
105
+ }
106
+ }
107
+
108
+ const toPosix = (p: string) => (sep === "/" ? p : p.split(sep).join("/"));
109
+ const sha256 = (b: string) => createHash("sha256").update(b).digest("hex");
110
+ const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
111
+
112
+ /** The entry given with --from: a JSON object whose name, if it has one, is the one the command names. The name goes first. */
113
+ function readEntry(text: string | undefined, req: DeclarationWriteRequest): Record<string, unknown> {
114
+ const what = req.action === "host set" ? "host" : "member";
115
+ if (text === undefined || text.trim() === "") throw new DeclarationWriteError("write-usage-invalid", `${req.action} needs the ${what}'s entry: --from <file|->`);
116
+ let value: unknown;
117
+ try {
118
+ value = JSON.parse(text);
119
+ } catch (err) {
120
+ throw new DeclarationWriteError("write-input-invalid", `the ${what} entry is not JSON: ${err instanceof Error ? err.message : String(err)}`);
121
+ }
122
+ if (!isObject(value)) throw new DeclarationWriteError("write-input-invalid", `the ${what} entry is a JSON object, as the declaration's ${what}s hold it`);
123
+ if ("name" in value && value.name !== req.name) {
124
+ throw new DeclarationWriteError("write-input-invalid", `the ${what} entry is named ${JSON.stringify(value.name)}, and the command names ${req.name}; leave name out or make them the same`);
125
+ }
126
+ const { name: _name, ...rest } = value;
127
+ return { name: req.name, ...rest };
128
+ }
129
+
130
+ /** Write `text` to `file` through a sibling temporary file, so a reader never sees half of it. */
131
+ function writeAtomically(file: string, text: string): void {
132
+ mkdirSync(dirname(file), { recursive: true });
133
+ const tmp = `${file}.chant-${process.pid}.tmp`;
134
+ writeFileSync(tmp, text);
135
+ renameSync(tmp, file);
136
+ }
137
+
138
+ const collisionKeys = (d: Declaration) =>
139
+ new Map(boxCollisions(d).map((c) => [`${c.host} ${c.what} ${c.value} ${c.holders.map((h) => `${h.box}.${h.name}`).join(",")}`, c]));
140
+
141
+ /** Run the write and build the document it prints. Never throws for a refusal. */
142
+ export function declarationWrite(req: DeclarationWriteRequest): DeclarationWriteDocument {
143
+ const head: Head = { $schema: DECLARATION_WRITE_SCHEMA_ID, contract: DECLARATION_WRITE_CONTRACT_VERSION, chant: readerVersion(), action: req.action, name: req.name || null };
144
+ try {
145
+ return withWriteLockSync(req.cwd, { verb: req.action, by: req.by ?? null, agent: req.agent || null }, req.dryRun, () => write(req, head));
146
+ } catch (err) {
147
+ const fail = (code: DeclarationWriteErrorCode, message: string): DeclarationWriteDocument => ({ ...head, error: { code, message } });
148
+ if (err instanceof DeclarationWriteError) return fail(err.code, err.message);
149
+ if (err instanceof WriteScopeError) return fail(err.code as DeclarationWriteErrorCode, err.message);
150
+ if (err instanceof IdentityError) return fail(err.code, err.message);
151
+ if (err instanceof WriteLockError) return fail(err.code, err.message);
152
+ if (err instanceof WorkspaceReadError) return fail(err.code as DeclarationWriteErrorCode, err.describe());
153
+ throw err;
154
+ }
155
+ }
156
+
157
+ function write(req: DeclarationWriteRequest, head: Head): DeclarationWriteDocument {
158
+ if (!req.name) throw new DeclarationWriteError("write-usage-invalid", `${req.action} needs the name of the ${req.action === "host set" ? "host" : "member"}`);
159
+ if (req.action === "member remove" && req.entry !== undefined) throw new DeclarationWriteError("write-usage-invalid", "member remove takes no --from: it removes the member's whole entry");
160
+ const given = req.action === "member remove" ? null : readEntry(req.entry, req);
161
+
162
+ const located = locateWorkspace(req.cwd);
163
+ const declaration = readDeclaration(located.tree);
164
+ const repoBase = located.top ?? located.rootOnDisk;
165
+ const declPath = toPosix(relative(repoBase, join(located.rootOnDisk, declaration.file)));
166
+ const declFile = join(located.rootOnDisk, declaration.file);
167
+ const before = readFileSync(declFile, "utf-8");
168
+ const jsonc = declaration.file.endsWith(".jsonc");
169
+ const parsed = parseJsonText(before, { jsonc });
170
+ if (!parsed.ok) throw new WorkspaceReadError("declaration-unparseable", parsed.message);
171
+ const expected = structuredClone(parsed.value) as Record<string, unknown>;
172
+
173
+ // The edit, made twice: in place in the text, and on the parsed value it must read back as.
174
+ let after = before;
175
+ let previous: Record<string, unknown> | null = null;
176
+ if (req.action === "host set") {
177
+ const hosts = valueAt(parsed.value, "/hosts");
178
+ const index = Array.isArray(hosts) ? hosts.findIndex((h) => isObject(h) && h.name === req.name) : -1;
179
+ if (index >= 0) {
180
+ previous = (hosts as Record<string, unknown>[])[index];
181
+ if (!isDeepStrictEqual(previous, given)) {
182
+ after = setElement(after, jsonc, "/hosts", index, given);
183
+ (expected.hosts as unknown[])[index] = given;
184
+ }
185
+ } else if (Array.isArray(hosts)) {
186
+ after = appendElement(after, jsonc, "/hosts", given);
187
+ (expected.hosts as unknown[]).push(given);
188
+ } else {
189
+ after = setProperty(after, jsonc, "", "hosts", [given]);
190
+ expected.hosts = [given];
191
+ }
192
+ } else {
193
+ const entries = valueAt(parsed.value, "/members");
194
+ const list = Array.isArray(entries) ? (entries as unknown[]) : [];
195
+ const index = list.findIndex((e) => isObject(e) && e.name === req.name);
196
+ if (req.action === "member add") {
197
+ if (index >= 0) {
198
+ previous = list[index] as Record<string, unknown>;
199
+ if (!isDeepStrictEqual(previous, given)) {
200
+ throw new DeclarationWriteError("member-exists", `the declaration already has an entry named ${req.name} (/members/${index}); member add writes a new member, so remove that one first or give its entry as it is`);
201
+ }
202
+ } else if (Array.isArray(entries)) {
203
+ after = appendElement(after, jsonc, "/members", given);
204
+ (expected.members as unknown[]).push(given);
205
+ } else {
206
+ after = setProperty(after, jsonc, "", "members", [given]);
207
+ expected.members = [given];
208
+ }
209
+ } else {
210
+ const member = declaration.members.find((m) => m.name === req.name);
211
+ if (!member || index < 0) {
212
+ const isGroup = declaration.groups.some((g) => g.name === req.name);
213
+ throw new DeclarationWriteError(
214
+ "member-unknown",
215
+ isGroup
216
+ ? `${req.name} is an example group, not a member; member remove removes members`
217
+ : `no member is named ${req.name}; the declaration's members are ${declaration.members.map((m) => m.name).join(", ") || "none"}`,
218
+ );
219
+ }
220
+ previous = list[index] as Record<string, unknown>;
221
+ after = removeElement(after, jsonc, "/members", index);
222
+ (expected.members as unknown[]).splice(index, 1);
223
+ }
224
+ }
225
+ const reread = parseJsonText(after, { jsonc });
226
+ if (!reread.ok || !isDeepStrictEqual(reread.value, expected)) {
227
+ // An edit that does not read back as the value meant is never written.
228
+ throw new Error(`${req.action}: editing ${declaration.file} in place did not give the declaration intended; nothing was written`);
229
+ }
230
+
231
+ // The declaration it would write reads, and adds no collision or literal path.
232
+ let next: Declaration;
233
+ try {
234
+ next = parseDeclaration(after, declaration.file);
235
+ } catch (err) {
236
+ if (err instanceof WorkspaceReadError) throw new DeclarationWriteError("write-input-invalid", `the ${req.action} would make the declaration invalid: ${err.describe()}`);
237
+ throw err;
238
+ }
239
+ const had = collisionKeys(declaration);
240
+ const added = [...collisionKeys(next)].filter(([k]) => !had.has(k)).map(([, c]) => c);
241
+ if (added.length > 0) {
242
+ const c = added[0];
243
+ throw new DeclarationWriteError(
244
+ "box-isolation-collision",
245
+ `the ${req.action} would give ${c.holders.map((h) => `${h.box}.${h.name}`).join(" and ")} the same ${c.what} ${c.value} on host ${c.host}; give the box a slot no other box holds`,
246
+ );
247
+ }
248
+ const literalsBefore = new Set(boxLiterals(declaration).map((l) => l.message));
249
+ const literal = boxLiterals(next).find((l) => !literalsBefore.has(l.message));
250
+ if (literal) throw new DeclarationWriteError("box-isolation-literal", `the ${req.action} would add a literal machine path: ${literal.message}`);
251
+
252
+ // The write scope at base, and who --by names.
253
+ const source = scopeSource(req.cwd);
254
+ if (req.by !== undefined) refuseUnidentified(source, [req.by], "--by", { agent: req.agent });
255
+ if (after !== before && (source.declaration !== null || (req.agent !== undefined && req.agent !== ""))) {
256
+ const writer = resolveWriter(source.declaration, source.policy, { agent: req.agent || null, principal: req.by ?? null }, source.classes);
257
+ const unknown = unknownClassVerdict(source.declaration, writer, source.classes);
258
+ if (!unknown.ok) throw new WriteScopeError(unknown.code, unknown.message);
259
+ const verdict = judgePath(source.declaration, writer, declaration.file, { before: () => before, after: () => after });
260
+ if (!verdict.ok) throw new DeclarationWriteError(verdict.code as DeclarationWriteErrorCode, verdict.message);
261
+ }
262
+
263
+ const changed = after !== before;
264
+ if (changed && !req.dryRun) writeAtomically(declFile, after);
265
+ return {
266
+ ...head,
267
+ name: req.name,
268
+ declaration: { path: declPath, sha256: sha256(after) },
269
+ paths: changed ? [declPath] : [],
270
+ changed,
271
+ dryRun: req.dryRun === true,
272
+ previous,
273
+ entry: given,
274
+ };
275
+ }
@@ -945,7 +945,7 @@ export function parseDeclaration(text: string, file: string, reader: string = re
945
945
  integrity: p.integrity ?? null,
946
946
  }));
947
947
 
948
- return {
948
+ const declaration: Declaration = {
949
949
  name: obj.name as string,
950
950
  schema: obj.schema as number,
951
951
  minReader: (obj.minReader as string | undefined) ?? null,
@@ -965,6 +965,24 @@ export function parseDeclaration(text: string, file: string, reader: string = re
965
965
  ci,
966
966
  file,
967
967
  };
968
+ written.set(declaration, raw);
969
+ return declaration;
970
+ }
971
+
972
+ /** Each parsed declaration's JSON as written, for its x- keys (#3595). */
973
+ const written = new WeakMap<Declaration, unknown>();
974
+
975
+ /**
976
+ * The `x-` keys of the object at `pointer` in the declaration as written
977
+ * (#3595), in file order: what a reader of `status --json` or `ls --json`
978
+ * gets back on the object the declaration holds them on. Empty when the
979
+ * pointer names no object, and for a declaration not read through
980
+ * {@link parseDeclaration}.
981
+ */
982
+ export function extensionsAt(declaration: Declaration, pointer: string): Record<string, unknown> {
983
+ const value = valueAt(written.get(declaration), pointer);
984
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return {};
985
+ return Object.fromEntries(Object.entries(value).filter(([k]) => k.startsWith("x-")));
968
986
  }
969
987
 
970
988
  /**
@@ -140,3 +140,74 @@ export function removeProperty(text: string, jsonc: boolean, objectPointer: stri
140
140
  const comma = skipTrivia(text, prev.end);
141
141
  return text.slice(0, comma) + text.slice(child.end);
142
142
  }
143
+
144
+ /**
145
+ * Add `value` to the end of the array at `arrayPointer` (#3596), indented
146
+ * like the element before it, or on its own line in an empty array. An array
147
+ * on one line stays on one line. Throws a {@link JsonEditError} when there is
148
+ * no array at the pointer.
149
+ */
150
+ export function appendElement(text: string, jsonc: boolean, arrayPointer: string, value: unknown): string {
151
+ const r = parse(text, jsonc);
152
+ const arr = valueAt(r.value, arrayPointer);
153
+ if (!Array.isArray(arr)) throw new JsonEditError(`there is no array at ${arrayPointer || "the root"}`);
154
+ const { eol, unit } = style(text);
155
+ const span = r.span(arrayPointer)!;
156
+ const parentIndent = lineIndent(text, span.key ?? span.start);
157
+ if (arr.length === 0) {
158
+ // Inside the brackets, after anything already there, such as a comment.
159
+ const indent = parentIndent + unit;
160
+ const inside = text.slice(span.start + 1, span.end - 1).replace(/\s+$/, "");
161
+ return `${text.slice(0, span.start)}[${inside}${eol}${indent}${render(value, indent, unit, eol)}${eol}${parentIndent}]${text.slice(span.end)}`;
162
+ }
163
+ const last = r.span(`${arrayPointer}/${arr.length - 1}`)!;
164
+ const next = skipTrivia(text, last.end);
165
+ const trailingComma = text[next] === ",";
166
+ if (lineStart(text, last.start) === lineStart(text, span.start)) {
167
+ const entry = JSON.stringify(value);
168
+ return trailingComma ? `${text.slice(0, next + 1)} ${entry},${text.slice(next + 1)}` : `${text.slice(0, last.end)}, ${entry}${text.slice(last.end)}`;
169
+ }
170
+ const indent = lineIndent(text, last.start);
171
+ const after = trailingComma ? next + 1 : last.end;
172
+ const eolAt = text.indexOf("\n", after);
173
+ const lineEnd = eolAt < 0 ? text.length : text[eolAt - 1] === "\r" ? eolAt - 1 : eolAt;
174
+ const at = /^[ \t]*(\/\/.*)?$/.test(text.slice(after, lineEnd)) ? lineEnd : after;
175
+ const entry = `${eol}${indent}${render(value, indent, unit, eol)}`;
176
+ if (trailingComma) return `${text.slice(0, at)}${entry},${text.slice(at)}`;
177
+ return `${text.slice(0, last.end)},${text.slice(last.end, at)}${entry}${text.slice(at)}`;
178
+ }
179
+
180
+ /** Replace the element at `index` of the array at `arrayPointer` with `value` (#3596). Throws a {@link JsonEditError} when there is none. */
181
+ export function setElement(text: string, jsonc: boolean, arrayPointer: string, index: number, value: unknown): string {
182
+ const r = parse(text, jsonc);
183
+ const arr = valueAt(r.value, arrayPointer);
184
+ if (!Array.isArray(arr) || index < 0 || index >= arr.length) throw new JsonEditError(`there is no element ${index} in an array at ${arrayPointer || "the root"}`);
185
+ const { eol, unit } = style(text);
186
+ const span = r.span(arrayPointer)!;
187
+ const child = r.span(`${arrayPointer}/${index}`)!;
188
+ const value_ = lineStart(text, child.start) === lineStart(text, span.start) ? JSON.stringify(value) : render(value, lineIndent(text, child.start), unit, eol);
189
+ return text.slice(0, child.start) + value_ + text.slice(child.end);
190
+ }
191
+
192
+ /** Remove the element at `index` of the array at `arrayPointer`, with the comma that went with it (#3596). Throws a {@link JsonEditError} when there is none. */
193
+ export function removeElement(text: string, jsonc: boolean, arrayPointer: string, index: number): string {
194
+ const r = parse(text, jsonc);
195
+ const arr = valueAt(r.value, arrayPointer);
196
+ if (!Array.isArray(arr) || index < 0 || index >= arr.length) throw new JsonEditError(`there is no element ${index} in an array at ${arrayPointer || "the root"}`);
197
+ const child = r.span(`${arrayPointer}/${index}`)!;
198
+ const next = skipTrivia(text, child.end);
199
+ if (text[next] === "," || arr.length === 1) {
200
+ const start = lineStart(text, child.start);
201
+ const ownLine = /^[ \t]*$/.test(text.slice(start, child.start));
202
+ // The only element may have no comma after it.
203
+ let to = text[next] === "," ? next + 1 : child.end;
204
+ const eolAt = text.indexOf("\n", to);
205
+ if (ownLine && /^[ \t\r]*$/.test(text.slice(to, eolAt < 0 ? text.length : eolAt))) to = eolAt < 0 ? text.length : eolAt + 1;
206
+ else while (text[to] === " ") to++;
207
+ return text.slice(0, ownLine ? start : child.start) + text.slice(to);
208
+ }
209
+ // The last element, with no trailing comma: remove from the comma after the one before it.
210
+ const prev = r.span(`${arrayPointer}/${index - 1}`)!;
211
+ const comma = skipTrivia(text, prev.end);
212
+ return text.slice(0, comma) + text.slice(child.end);
213
+ }
@@ -352,3 +352,56 @@ describe("chant workspace ls: generated files and job names (#3050)", () => {
352
352
  expect(doc.members[0].generated).toEqual([{ path: ".github/workflows/x.yml", command: "c", env: null, jobs: null }]);
353
353
  });
354
354
  });
355
+
356
+ describe("chant workspace ls: each member's agent sessions (#3615)", () => {
357
+ const jsonc = (agents: unknown[]) =>
358
+ [
359
+ "// the lobby's declaration",
360
+ "{",
361
+ ' "name": "acme", "schema": 1,',
362
+ ' "members": [{ "name": "app", "dir": "app", "kind": "other", "because": "b" }, { "name": "design", "dir": "design", "kind": "other", "because": "b" }, { "name": "docs", "dir": "docs", "kind": "other", "because": "b" },],',
363
+ ` "agents": ${JSON.stringify(agents)},`,
364
+ "}",
365
+ ].join("\n");
366
+ const sessions = (doc: LsDocument) => Object.fromEntries(result(doc).members.map((m) => [m.name, m.agents]));
367
+
368
+ test("reads a .jsonc declaration in the working tree when there is no base", () => {
369
+ const root = repo({
370
+ "chant.workspace.jsonc": jsonc([
371
+ { name: "app-agent", member: "app" },
372
+ { name: "factory", members: ["app", "design"] },
373
+ ]),
374
+ "app/a.txt": "",
375
+ "design/d.txt": "",
376
+ "docs/d.txt": "",
377
+ });
378
+ const doc = listWorkspace({ cwd: root });
379
+ expectValid(doc);
380
+ expect(sessions(doc)).toEqual({ app: ["app-agent", "factory"], design: ["factory"], docs: [] });
381
+ expect(result(doc).workspace.agentsFrom).toBe("working-tree");
382
+ });
383
+
384
+ test("reads them at base, as workspace agent does, and at the revision under --at", () => {
385
+ const root = repo({
386
+ "chant.workspace.jsonc": jsonc([{ name: "app-agent", member: "app" }]),
387
+ "app/a.txt": "",
388
+ "design/d.txt": "",
389
+ "docs/d.txt": "",
390
+ });
391
+ git(root, "add", "-A");
392
+ git(root, "commit", "-q", "-m", "one");
393
+ git(root, "branch", "-M", "main");
394
+ // A session added in the working tree binds nothing until it reaches base.
395
+ writeFileSync(join(root, "chant.workspace.jsonc"), jsonc([{ name: "app-agent", member: "app" }, { name: "docs-agent", member: "docs" }]));
396
+ const doc = listWorkspace({ cwd: join(root, "docs") });
397
+ expectValid(doc);
398
+ expect(sessions(doc)).toEqual({ app: ["app-agent"], design: [], docs: [] });
399
+ expect(result(doc).workspace.agentsFrom).toBe("base");
400
+ git(root, "commit", "-q", "-am", "two");
401
+ const at = listWorkspace({ cwd: root, at: "HEAD~1" });
402
+ expectValid(at);
403
+ expect(sessions(at)).toEqual({ app: ["app-agent"], design: [], docs: [] });
404
+ expect(result(at).workspace.agentsFrom).toBe("at");
405
+ expect(sessions(listWorkspace({ cwd: root }))).toEqual({ app: ["app-agent"], design: [], docs: ["docs-agent"] });
406
+ });
407
+ });
@@ -44,6 +44,7 @@
44
44
  "error": false,
45
45
  "workspace": {
46
46
  "type": "object",
47
+ "patternProperties": { "^x-": { "description": "Added in contract 1 by #3595. An x- key the declaration holds at the top level, with its value as written." } },
47
48
  "required": ["name", "root", "file", "schema", "minReader", "pins"],
48
49
  "properties": {
49
50
  "name": { "type": "string", "description": "The declared name. With the revision, it identifies the workspace." },
@@ -71,6 +72,10 @@
71
72
  "description": "Added in contract 1 by #2680. The workspace's own record kinds, from the declaration's top-level records, in file order.",
72
73
  "type": "array",
73
74
  "items": { "$ref": "#/$defs/recordKind" }
75
+ },
76
+ "agentsFrom": {
77
+ "description": "Added in contract 1 by #3615. Where the members' agents were read: base, the declaration at the base revision (origin/HEAD, main or master), as chant workspace agent and the write paths read it; working-tree, when there is no base or no declaration at it; at, the declaration at the --at revision.",
78
+ "enum": ["base", "working-tree", "at"]
74
79
  }
75
80
  }
76
81
  },
@@ -114,6 +119,7 @@
114
119
  },
115
120
  "member": {
116
121
  "type": "object",
122
+ "patternProperties": { "^x-": { "description": "Added in contract 1 by #3595. An x- key the declaration holds on this object, with its value as written." } },
117
123
  "required": ["name", "dir", "kind", "roles", "upstream", "because", "readable", "reason"],
118
124
  "properties": {
119
125
  "name": { "type": "string" },
@@ -143,6 +149,11 @@
143
149
  "type": "array",
144
150
  "items": { "$ref": "#/$defs/generatedFile" }
145
151
  },
152
+ "agents": {
153
+ "description": "Added in contract 1 by #3615. The names of the agent sessions the declaration binds to the member (agents[].member or agents[].members), in declaration order, read where workspace.agentsFrom says. Empty when none is. A session bound to several members is listed on each.",
154
+ "type": "array",
155
+ "items": { "type": "string" }
156
+ },
146
157
  "reason": {
147
158
  "oneOf": [
148
159
  { "type": "null" },
@@ -26,6 +26,7 @@ import { formatError } from "../cli/format";
26
26
  import type { CommandContext } from "../cli/registry";
27
27
  import {
28
28
  declaredDiagrams,
29
+ extensionsAt,
29
30
  readDeclaration,
30
31
  readerVersion,
31
32
  resolveGroups,
@@ -41,6 +42,7 @@ import { memberGenerated, type LsGenerated } from "./ls-generated";
41
42
  import { loadKindRegistry, probeKind, type KindRegistry } from "./kinds";
42
43
  import type { WorkspaceTree } from "./tree";
43
44
  import { handToRootChant, locateWorkspace } from "./which-chant";
45
+ import { declaredSessions } from "./write-scope";
44
46
  import type { DeclaredKindReasonCode } from "./declared-kinds";
45
47
 
46
48
  /** The version of the `ls` output this chant writes. */
@@ -113,6 +115,8 @@ export interface LsMember {
113
115
  records: LsRecordKind[];
114
116
  /** The files the member generates, and the job names inside its forge CI files (#3050). */
115
117
  generated: LsGenerated[];
118
+ /** The agent sessions bound to the member, in declaration order (#3615); empty when none is. */
119
+ agents: string[];
116
120
  }
117
121
 
118
122
  /**
@@ -167,6 +171,12 @@ export type LsDocument =
167
171
  pins: Declaration["pins"];
168
172
  /** The workspace's own record kinds, from the top-level `records` (#2680). */
169
173
  records: LsRecordKind[];
174
+ /**
175
+ * Where the members' `agents` were read (#3615): the declaration at
176
+ * base, else the working tree's, as `chant workspace agent` reads
177
+ * them; under `--at`, the declaration at that revision.
178
+ */
179
+ agentsFrom: "base" | "working-tree" | "at";
170
180
  };
171
181
  members: LsMember[];
172
182
  groups: LsGroup[];
@@ -247,6 +257,9 @@ function readListing(query: LsQuery): { doc: LsDocument; declaration?: Declarati
247
257
  const kinds = query.kinds ?? loadKindRegistry(declaration.pins, rootOnDisk).registry;
248
258
  const groups = resolveGroups(declaration, tree);
249
259
  const repoPrefix = isAbsolute(located.root) ? "." : located.root;
260
+ // Sessions are read where the write paths read them, at base (#3615); a
261
+ // listing at a revision reads them there, so it describes one commit.
262
+ const sessions = at !== null ? { agents: declaration.agents, from: "at" as const } : (declaredSessions(query.cwd) ?? { agents: declaration.agents, from: "working-tree" as const });
250
263
  const members: LsMember[] = declaration.members.map((m) => {
251
264
  const reason = memberReason(m, tree, kinds);
252
265
  return {
@@ -260,6 +273,9 @@ function readListing(query: LsQuery): { doc: LsDocument; declaration?: Declarati
260
273
  reason,
261
274
  records: unloaded(m.records),
262
275
  generated: memberGenerated(m, tree, repoPrefix),
276
+ agents: sessions.agents.filter((a) => a.members.includes(m.name)).map((a) => a.name),
277
+ // The member's x- keys, as declared (#3595).
278
+ ...extensionsAt(declaration, m.pointer),
263
279
  };
264
280
  });
265
281
  const lsGroups: LsGroup[] = groups.map((g) => ({
@@ -292,6 +308,8 @@ function readListing(query: LsQuery): { doc: LsDocument; declaration?: Declarati
292
308
  minReader: declaration.minReader,
293
309
  pins: declaration.pins,
294
310
  records: unloaded(declaration.records),
311
+ agentsFrom: sessions.from,
312
+ ...extensionsAt(declaration, ""),
295
313
  },
296
314
  members,
297
315
  groups: lsGroups,