@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.
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/param-deps.d.ts +30 -2
- package/dist/discovery/param-deps.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +9 -2
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/fold-provenance.d.ts +14 -2
- package/dist/fold-provenance.d.ts.map +1 -1
- package/dist/import/ir-utils.d.ts.map +1 -1
- package/dist/import/parser.d.ts +8 -0
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/op/activities/factory.d.ts.map +1 -1
- package/dist/provenance.d.ts +11 -0
- package/dist/provenance.d.ts.map +1 -1
- package/dist/serializer-walker.d.ts.map +1 -1
- package/dist/stack-output.d.ts +5 -0
- package/dist/stack-output.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/dist/yaml.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/import.test.ts +5 -4
- package/src/cli/main.test.ts +2 -0
- package/src/cli/main.ts +18 -0
- package/src/discovery/fold-composite.test.ts +22 -0
- package/src/discovery/fold-host-composite.test.ts +23 -0
- package/src/discovery/fold-import.ts +73 -21
- package/src/discovery/fold-project-provenance.test.ts +52 -0
- package/src/discovery/param-deps.test.ts +43 -2
- package/src/discovery/param-deps.ts +101 -2
- package/src/fold/subset.ts +9 -2
- package/src/fold-provenance.test.ts +11 -0
- package/src/fold-provenance.ts +22 -2
- package/src/import/ir-utils.ts +6 -1
- package/src/import/parser.ts +8 -0
- package/src/op/activities/factory.ts +3 -1
- package/src/op/factory.e2e.test.ts +3 -1
- package/src/provenance.ts +13 -1
- package/src/serializer-walker.test.ts +10 -0
- package/src/serializer-walker.ts +17 -1
- package/src/stack-output.ts +6 -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
- package/src/yaml.test.ts +6 -0
- 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
|
-
|
|
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" },
|
package/src/workspace/ls.ts
CHANGED
|
@@ -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,
|