@intentius/chant 0.94.0 → 0.95.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/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/op/builders.d.ts +14 -3
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +6 -3
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/operator.d.ts +90 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/steward-beside.d.ts +84 -0
- package/dist/op/steward-beside.d.ts.map +1 -0
- package/dist/op/steward.d.ts +87 -2
- package/dist/op/steward.d.ts.map +1 -1
- package/dist/workspace/box-services.d.ts +31 -0
- package/dist/workspace/box-services.d.ts.map +1 -0
- package/dist/workspace/compose-graph.d.ts +11 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +5 -1
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/declaration.d.ts +37 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +57 -1
- package/dist/workspace/graph-cache.d.ts +168 -0
- package/dist/workspace/graph-cache.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +11 -5
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/kind-readers.d.ts +39 -0
- package/dist/workspace/kind-readers.d.ts.map +1 -0
- package/dist/workspace/kinds.d.ts +29 -0
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/member-commands.d.ts +15 -1
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/member-run.d.ts +2 -0
- package/dist/workspace/member-run.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +3 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +10 -1
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +4 -2
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +8 -3
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +23 -7
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/dist/workspace/status.d.ts +12 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work-evidence.d.ts +1 -1
- package/dist/workspace/work-evidence.d.ts.map +1 -1
- package/dist/workspace/workspace-kinds.schema.json +26 -0
- package/package.json +1 -1
- package/src/cli/commands/carve-bridge.test.ts +7 -3
- package/src/cli/handlers/operator-steward-signal.e2e.test.ts +97 -0
- package/src/cli/handlers/operator.ts +53 -11
- package/src/cli/handlers/run.test.ts +71 -0
- package/src/cli/handlers/run.ts +65 -7
- package/src/cli/main.ts +17 -6
- package/src/cli/mcp/workspace-tools.ts +1 -1
- package/src/cli/registry.ts +2 -0
- package/src/cli/static-config-read.test.ts +8 -2
- package/src/meta/source-is-text.test.ts +21 -3
- package/src/okf.test.ts +6 -1
- package/src/op/builders.ts +14 -3
- package/src/op/index.ts +8 -2
- package/src/op/operator.ts +264 -16
- package/src/op/steward-beside.test.ts +267 -0
- package/src/op/steward-beside.ts +219 -0
- package/src/op/steward-points.test.ts +61 -1
- package/src/op/steward.ts +135 -3
- package/src/workspace/box-services.test.ts +129 -0
- package/src/workspace/box-services.ts +51 -0
- package/src/workspace/checks/boxes.test.ts +1 -0
- package/src/workspace/compose-graph.test.ts +1 -0
- package/src/workspace/compose-graph.ts +11 -0
- package/src/workspace/composites.test.ts +1 -1
- package/src/workspace/composites.ts +12 -5
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -2
- package/src/workspace/declaration.schema.json +57 -1
- package/src/workspace/declaration.ts +104 -0
- package/src/workspace/graph-cache.test.ts +343 -0
- package/src/workspace/graph-cache.ts +409 -0
- package/src/workspace/graph-cli.ts +93 -26
- package/src/workspace/graph-contract.test.ts +129 -6
- package/src/workspace/graph.schema.json +21 -1
- package/src/workspace/kind-readers.e2e.test.ts +68 -0
- package/src/workspace/kind-readers.test.ts +134 -0
- package/src/workspace/kind-readers.ts +111 -0
- package/src/workspace/kinds.test.ts +49 -0
- package/src/workspace/kinds.ts +59 -2
- package/src/workspace/member-commands.test.ts +15 -0
- package/src/workspace/member-commands.ts +47 -7
- package/src/workspace/member-run.ts +10 -2
- package/src/workspace/reason-codes.ts +3 -1
- package/src/workspace/records-amend.schema.json +1 -0
- package/src/workspace/records-cli.ts +20 -12
- package/src/workspace/records-contract.test.ts +2 -1
- package/src/workspace/records-new.schema.json +1 -0
- package/src/workspace/records-quorum.test.ts +10 -3
- package/src/workspace/records-sessions-write.test.ts +2 -1
- package/src/workspace/records-write.test.ts +101 -1
- package/src/workspace/records-write.ts +50 -6
- package/src/workspace/records.ts +22 -5
- package/src/workspace/status-contract.test.ts +31 -1
- package/src/workspace/status-stewards.ts +39 -6
- package/src/workspace/status.schema.json +49 -4
- package/src/workspace/status.ts +22 -0
- package/src/workspace/trust/record-seal.test.ts +26 -2
- package/src/workspace/work-evidence.schema.json +1 -0
- package/src/workspace/workspace-kinds.schema.json +26 -0
package/src/workspace/kinds.ts
CHANGED
|
@@ -67,6 +67,16 @@ export type KindOutputs =
|
|
|
67
67
|
/** The names listed here, plus any the member entry lists in `outputs` (`other`, and kinds from a package). */
|
|
68
68
|
| { from: "declared"; names: string[] };
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* How `chant workspace graph` reads a member of a package's kind (#2874): a
|
|
72
|
+
* lexicon, by name, and that lexicon's config namespace for a reader project
|
|
73
|
+
* chant writes itself. See {@link substituteGraphConfig} for the placeholders.
|
|
74
|
+
*/
|
|
75
|
+
export interface KindGraph {
|
|
76
|
+
lexicon: string;
|
|
77
|
+
config: Record<string, unknown>;
|
|
78
|
+
}
|
|
79
|
+
|
|
70
80
|
export interface MemberKind {
|
|
71
81
|
name: string;
|
|
72
82
|
/** One line for listings and error messages. */
|
|
@@ -84,6 +94,10 @@ export interface MemberKind {
|
|
|
84
94
|
outputs: KindOutputs;
|
|
85
95
|
/** Where the kind comes from: `builtin`, or the package that supplies it. */
|
|
86
96
|
source: string;
|
|
97
|
+
/** The supplying package's directory, absolute; unset for a built-in kind. */
|
|
98
|
+
packageDir?: string;
|
|
99
|
+
/** How `workspace graph` reads a member of this kind (#2874); unset, the member is `kind-not-run`. */
|
|
100
|
+
graph?: KindGraph;
|
|
87
101
|
}
|
|
88
102
|
|
|
89
103
|
export interface KindRegistry {
|
|
@@ -310,7 +324,7 @@ export function parseKindData(text: string, source: string): KindData {
|
|
|
310
324
|
const problems: string[] = [];
|
|
311
325
|
const kinds: MemberKind[] = [];
|
|
312
326
|
const seen = new Set<string>();
|
|
313
|
-
for (const k of (raw as { kinds: { name: string; description: string; precedence: number; probe: FileProbe; outputs?: string[] }[] }).kinds) {
|
|
327
|
+
for (const k of (raw as { kinds: { name: string; description: string; precedence: number; probe: FileProbe; outputs?: string[]; graph?: KindGraph }[] }).kinds) {
|
|
314
328
|
if (BUILTIN_KIND_NAMES.includes(k.name)) {
|
|
315
329
|
problems.push(`${source}: kind ${k.name} is built in and can't be supplied by a package`);
|
|
316
330
|
continue;
|
|
@@ -331,6 +345,7 @@ export function parseKindData(text: string, source: string): KindData {
|
|
|
331
345
|
shape: "member",
|
|
332
346
|
outputs: { from: "declared", names: [...(k.outputs ?? [])] },
|
|
333
347
|
source,
|
|
348
|
+
...(k.graph ? { graph: { lexicon: k.graph.lexicon, config: structuredClone(k.graph.config) } } : {}),
|
|
334
349
|
});
|
|
335
350
|
}
|
|
336
351
|
return { kinds, problems };
|
|
@@ -389,7 +404,49 @@ export function readPackageKinds(packageDir: string, source?: string): PackageKi
|
|
|
389
404
|
} catch {
|
|
390
405
|
return { kinds: [], problems: [`${name}: exports["${KINDS_SUBPATH}"] names ${target}, which does not exist`], file };
|
|
391
406
|
}
|
|
392
|
-
|
|
407
|
+
const data = parseKindData(text, name);
|
|
408
|
+
const packageName = typeof pkg.name === "string" ? pkg.name : undefined;
|
|
409
|
+
for (const k of data.kinds) {
|
|
410
|
+
k.packageDir = packageDir;
|
|
411
|
+
if (k.graph && packageName !== lexiconPackageName(k.graph.lexicon)) {
|
|
412
|
+
data.problems.push(
|
|
413
|
+
`${name}: kind ${k.name} reads its members with lexicon ${k.graph.lexicon}, which is ${lexiconPackageName(k.graph.lexicon)}, ` +
|
|
414
|
+
`and this package is ${packageName ?? "unnamed"}; the kind is kept, and workspace graph lists its members with kind-not-run`,
|
|
415
|
+
);
|
|
416
|
+
delete k.graph;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return { ...data, file };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The package a lexicon name resolves to. */
|
|
423
|
+
export function lexiconPackageName(lexicon: string): string {
|
|
424
|
+
return `@intentius/chant-lexicon-${lexicon}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Where each placeholder in a kind's graph config points (#2874). */
|
|
428
|
+
export interface GraphPlaceholders {
|
|
429
|
+
/** The member's name. */
|
|
430
|
+
member: string;
|
|
431
|
+
/** The member's directory, absolute. */
|
|
432
|
+
dir: string;
|
|
433
|
+
/** The workspace root, absolute. */
|
|
434
|
+
workspace: string;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* A kind's graph config with `{member}`, `{dir}` and `{workspace}` replaced
|
|
439
|
+
* in every string key and value. Other braces are left as they are.
|
|
440
|
+
*/
|
|
441
|
+
export function substituteGraphConfig(config: Record<string, unknown>, at: GraphPlaceholders): Record<string, unknown> {
|
|
442
|
+
const text = (v: string) => v.replace(/\{(member|dir|workspace)\}/g, (_, key: keyof GraphPlaceholders) => at[key]);
|
|
443
|
+
const walk = (v: unknown): unknown => {
|
|
444
|
+
if (typeof v === "string") return text(v);
|
|
445
|
+
if (Array.isArray(v)) return v.map(walk);
|
|
446
|
+
if (v !== null && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [text(k), walk(x)]));
|
|
447
|
+
return v;
|
|
448
|
+
};
|
|
449
|
+
return walk(config) as Record<string, unknown>;
|
|
393
450
|
}
|
|
394
451
|
|
|
395
452
|
/**
|
|
@@ -156,6 +156,21 @@ describe("member command lines", () => {
|
|
|
156
156
|
expect(memberArgv("audit", unit, args({ failOn: "error" }))).toEqual(["audit", ".", "--format", "json", "--fail-on", "error"]);
|
|
157
157
|
expect(memberArgv("graph", unit, args({ format: "mermaid" }))).toEqual(["graph", ".", "--format", "ir"]);
|
|
158
158
|
});
|
|
159
|
+
|
|
160
|
+
test("graph hands each member the live read's flags as they are (#2875)", () => {
|
|
161
|
+
expect(memberArgv("graph", unit, args({ env: "prod", live: true, overlay: true, traffic: "100 rps, p50" }))).toEqual([
|
|
162
|
+
"graph", ".", "--format", "ir", "--env", "prod", "--live", "--overlay", "--traffic", "100 rps, p50",
|
|
163
|
+
]);
|
|
164
|
+
expect(memberArgv("graph", unit, args({ env: "prod" }))).toEqual(["graph", ".", "--format", "ir", "--env", "prod"]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a reader unit takes --env only with --live, which needs one (#2874, #2875)", () => {
|
|
168
|
+
const reader = { ...unit, reader: { lexicon: "terraform", config: {}, packageDir: "/p" } } as typeof unit;
|
|
169
|
+
expect(memberArgv("graph", reader, args({ env: "prod" }))).toEqual(["graph", ".", "--format", "ir"]);
|
|
170
|
+
expect(memberArgv("graph", reader, args({ env: "prod", live: true, overlay: true }))).toEqual([
|
|
171
|
+
"graph", ".", "--format", "ir", "--env", "prod", "--live", "--overlay",
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
159
174
|
});
|
|
160
175
|
|
|
161
176
|
describe("the member-run protocol", () => {
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* Members of kind `other` are never read, and a nested `workspace` member
|
|
10
10
|
* runs its own workspace commands, so both are listed as skipped with the
|
|
11
11
|
* reason code `kind-not-run`.
|
|
12
|
+
* - For `graph` only, members of a pinned package's kind whose kinds file
|
|
13
|
+
* says how to read them (#2874, a `graph` block): each runs `chant graph`
|
|
14
|
+
* in a reader project chant writes for it (`kind-readers.ts`). Other
|
|
15
|
+
* package kinds are `kind-not-run`, as before.
|
|
12
16
|
* - For `build` and `lint` only, every project an example group matches
|
|
13
17
|
* (ws-051): groups are built and linted, and have no ledger, audit or place
|
|
14
18
|
* in the graph. On a large repository this is most of the run, so
|
|
@@ -38,7 +42,7 @@ import { findWorkspaceRoot } from "../project-root";
|
|
|
38
42
|
import type { ComposedMember, MemberReason } from "./compose-graph";
|
|
39
43
|
import { mergeAudit, mergeSarif, type MemberOutput } from "./compose-reports";
|
|
40
44
|
import { readDeclaration, resolveGroups, rootExclusions, WorkspaceReadError, type Declaration } from "./declaration";
|
|
41
|
-
import { builtinKindRegistry, type KindRegistry } from "./kinds";
|
|
45
|
+
import { builtinKindRegistry, type KindGraph, type KindRegistry } from "./kinds";
|
|
42
46
|
import { memberReason } from "./ls";
|
|
43
47
|
import { MEMBER_RUN_PROTOCOL, PROTOCOL_PREFIX, type MemberRunLine, type MemberRunRequest } from "./member-run";
|
|
44
48
|
import { workingTree, type WorkspaceTree } from "./tree";
|
|
@@ -58,6 +62,12 @@ export interface RunUnit {
|
|
|
58
62
|
abs: string;
|
|
59
63
|
/** Directories (relative to `dir`) left out of discovery; set for member `.`. */
|
|
60
64
|
exclude: string[];
|
|
65
|
+
/**
|
|
66
|
+
* Set for a member read through its kind's `graph` block (#2874). `abs` is
|
|
67
|
+
* the member's directory until `kind-readers.ts` writes the reader project
|
|
68
|
+
* and points `abs` at it.
|
|
69
|
+
*/
|
|
70
|
+
reader?: KindGraph & { packageDir: string };
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
export interface SkippedEntry {
|
|
@@ -189,6 +199,25 @@ export function planMembers(verb: WorkspaceVerb, root: string, options: PlanOpti
|
|
|
189
199
|
}
|
|
190
200
|
continue;
|
|
191
201
|
}
|
|
202
|
+
const byKind = verb === "graph" && entry.kind !== "chant" ? kinds.get(entry.kind) : undefined;
|
|
203
|
+
if (byKind?.graph && byKind.packageDir) {
|
|
204
|
+
const reason = memberReason(entry, tree, kinds);
|
|
205
|
+
if (reason) {
|
|
206
|
+
unreadable.push({ name: entry.name, dir: entry.dir, kind: entry.kind, reason });
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
units.push({
|
|
210
|
+
id: entry.name,
|
|
211
|
+
member: entry.name,
|
|
212
|
+
kind: entry.kind,
|
|
213
|
+
group: false,
|
|
214
|
+
dir: entry.dir,
|
|
215
|
+
abs: entry.dir === "." ? root : join(root, entry.dir),
|
|
216
|
+
exclude: [],
|
|
217
|
+
reader: { ...byKind.graph, packageDir: byKind.packageDir },
|
|
218
|
+
});
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
192
221
|
if (entry.kind !== "chant") {
|
|
193
222
|
const why =
|
|
194
223
|
entry.kind === "workspace"
|
|
@@ -265,8 +294,15 @@ export function memberArgv(verb: WorkspaceVerb, unit: RunUnit, args: ParsedArgs)
|
|
|
265
294
|
if (args.tier) argv.push("--tier", args.tier);
|
|
266
295
|
if (args.failOn) argv.push("--fail-on", args.failOn);
|
|
267
296
|
if (args.maxFiles !== undefined) argv.push("--max-files", String(args.maxFiles));
|
|
268
|
-
} else
|
|
269
|
-
|
|
297
|
+
} else {
|
|
298
|
+
// graph. A reader project declares no environments, so a source read
|
|
299
|
+
// leaves --env to the chant members. A live read needs one, and a reader
|
|
300
|
+
// project with none declared takes any name, so --live carries it (#2875).
|
|
301
|
+
if (args.env && (!unit.reader || args.live)) argv.push("--env", args.env);
|
|
302
|
+
// The live read's flags go to every member as they are (#2875).
|
|
303
|
+
if (args.live) argv.push("--live");
|
|
304
|
+
if (args.overlay) argv.push("--overlay");
|
|
305
|
+
if (args.traffic) argv.push("--traffic", args.traffic);
|
|
270
306
|
}
|
|
271
307
|
return argv;
|
|
272
308
|
}
|
|
@@ -286,6 +322,8 @@ export interface UnitResult extends MemberOutput {
|
|
|
286
322
|
chant: string | null;
|
|
287
323
|
/** How the member ran: inside its toolchain's shared process, or in a level-0 process of its own. */
|
|
288
324
|
mode: "member-run" | "per-member";
|
|
325
|
+
/** When the member's command finished, as an ISO time (#2875). */
|
|
326
|
+
finishedAt?: string;
|
|
289
327
|
}
|
|
290
328
|
|
|
291
329
|
interface Spawned {
|
|
@@ -349,13 +387,15 @@ async function runGroup(verb: WorkspaceVerb, group: ToolchainGroup, root: string
|
|
|
349
387
|
units: units.map((u) => ({ id: u.id, dir: u.abs, argv: argvs.get(u.id)!, ...(u.exclude.length ? { exclude: u.exclude } : {}) })),
|
|
350
388
|
};
|
|
351
389
|
const answer = await run(toolchain.command, ["workspace", "member-run"], root, JSON.stringify(request));
|
|
390
|
+
const ended = new Date().toISOString();
|
|
352
391
|
const parsed = parseMemberRunOutput(answer.stdout);
|
|
353
392
|
if (parsed) {
|
|
354
393
|
if (parsed.stray) process.stderr.write(`${parsed.stray}\n`);
|
|
355
394
|
return units.map((unit) => {
|
|
356
395
|
const r = parsed.results.get(unit.id);
|
|
357
396
|
const base = { unit, toolchain, chant: parsed.chant, mode: "member-run" as const, id: unit.id, member: unit.member, dir: unit.dir, exclude: unit.exclude };
|
|
358
|
-
|
|
397
|
+
// A member-run from before #2875 names no time; the group's end is the closest one.
|
|
398
|
+
if (r) return { ...base, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, finishedAt: r.finishedAt ?? ended };
|
|
359
399
|
return { ...base, exitCode: answer.exitCode || 1, stdout: "", stderr: `the member-run process for ${toolchain.identity} ended before running this member\n${answer.stderr}` };
|
|
360
400
|
});
|
|
361
401
|
}
|
|
@@ -363,7 +403,7 @@ async function runGroup(verb: WorkspaceVerb, group: ToolchainGroup, root: string
|
|
|
363
403
|
const out: UnitResult[] = [];
|
|
364
404
|
for (const unit of units) {
|
|
365
405
|
const r = await run(toolchain.command, argvs.get(unit.id)!, unit.abs);
|
|
366
|
-
out.push({ unit, toolchain, chant: null, mode: "per-member", id: unit.id, member: unit.member, dir: unit.dir, exclude: unit.exclude, ...r });
|
|
406
|
+
out.push({ unit, toolchain, chant: null, mode: "per-member", id: unit.id, member: unit.member, dir: unit.dir, exclude: unit.exclude, ...r, finishedAt: new Date().toISOString() });
|
|
367
407
|
}
|
|
368
408
|
return out;
|
|
369
409
|
}
|
|
@@ -385,7 +425,7 @@ const USAGE: Record<WorkspaceVerb, string> = {
|
|
|
385
425
|
build: "chant workspace build [dir] [--member <name>] [-o <dir>] [--format json|yaml] [--env <env>] [--param k=v] [--dry-run]",
|
|
386
426
|
lint: "chant workspace lint [dir] [--member <name>] [--format stylish|json|sarif] [-o <file>] [--fix] [--dry-run]",
|
|
387
427
|
audit: "chant workspace audit [dir] [--member <name>] [--format stylish|json] [-o <file>] [--tier <tier>] [--fail-on <level>] [--dry-run]",
|
|
388
|
-
graph: "chant workspace graph [dir] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--dry-run]",
|
|
428
|
+
graph: "chant workspace graph [dir] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--live [--overlay] [--traffic <level>]] [--no-cache] [--dry-run]",
|
|
389
429
|
};
|
|
390
430
|
|
|
391
431
|
export function describePlan(plan: MemberPlan): string {
|
|
@@ -461,7 +501,7 @@ function formatAuditText(doc: ReturnType<typeof mergeAudit>, plan: MemberPlan):
|
|
|
461
501
|
}
|
|
462
502
|
|
|
463
503
|
export function memberStatus(name: string, dir: string, kind: string, status: ComposedMember["status"], reason: MemberReason | null, chant: string | null): ComposedMember {
|
|
464
|
-
return { name, dir, kind, status, reason, chant, irVersion: null };
|
|
504
|
+
return { name, dir, kind, status, reason, chant, irVersion: null, live: false };
|
|
465
505
|
}
|
|
466
506
|
|
|
467
507
|
export async function runWorkspaceMembers(ctx: CommandContext, verb: WorkspaceVerb): Promise<number> {
|
|
@@ -49,7 +49,15 @@ export interface MemberRunUnit {
|
|
|
49
49
|
|
|
50
50
|
export type MemberRunLine =
|
|
51
51
|
| { type: "header"; protocol: number; chant: string }
|
|
52
|
-
| {
|
|
52
|
+
| {
|
|
53
|
+
type: "result";
|
|
54
|
+
id: string;
|
|
55
|
+
exitCode: number;
|
|
56
|
+
stdout: string;
|
|
57
|
+
stderr: string;
|
|
58
|
+
/** When the member's command finished, as an ISO time (#2875). A member-run from before it leaves this out. */
|
|
59
|
+
finishedAt?: string;
|
|
60
|
+
};
|
|
53
61
|
|
|
54
62
|
/** A `process.exit` inside a command, turned into a value. */
|
|
55
63
|
class ExitRequest extends Error {
|
|
@@ -125,7 +133,7 @@ export async function runMemberUnits(request: MemberRunRequest, run: (argv: stri
|
|
|
125
133
|
if (startEnv === undefined) delete process.env[ENV_VAR];
|
|
126
134
|
else process.env[ENV_VAR] = startEnv;
|
|
127
135
|
process.chdir(startDir);
|
|
128
|
-
emit({ type: "result", id: unit.id, ...result });
|
|
136
|
+
emit({ type: "result", id: unit.id, ...result, finishedAt: new Date().toISOString() });
|
|
129
137
|
}
|
|
130
138
|
return 0;
|
|
131
139
|
}
|
|
@@ -38,6 +38,7 @@ export const REASONS = {
|
|
|
38
38
|
// --at and git.
|
|
39
39
|
"not-a-git-repository": "--at, or a ledger read, needs a git repository and there is none.",
|
|
40
40
|
"revision-unknown": "--at names no commit.",
|
|
41
|
+
"live-at-revision": "graph was given --live and --at: a live read is of the account now, not of a revision.",
|
|
41
42
|
// status.
|
|
42
43
|
"environment-invalid": "An environment name that can't name a ledger directory.",
|
|
43
44
|
// A member or group that can't be read (ls, graph).
|
|
@@ -47,7 +48,7 @@ export const REASONS = {
|
|
|
47
48
|
"no-matches": "An example group matches no directory holding a chant project.",
|
|
48
49
|
// A member graph leaves out (graph, and the other per-member commands).
|
|
49
50
|
"kind-not-run": "The member's kind is one the per-member commands don't run, such as other.",
|
|
50
|
-
"command-failed": "The member's own command exited with a failure.",
|
|
51
|
+
"command-failed": "The member's own command exited with a failure, or the lexicon that reads the member through its kind's graph block isn't installed where the workspace resolves its pin.",
|
|
51
52
|
"output-unreadable": "The member's command printed something that isn't the document asked for.",
|
|
52
53
|
"ir-version-unsupported": "The member's IR has a version this chant can't read.",
|
|
53
54
|
// A ledger status can't fully read.
|
|
@@ -129,6 +130,7 @@ export const REASONS = {
|
|
|
129
130
|
"review-sign-failed": "--sign was given and no seal could be made: the key can't be read or used, git names no ssh signing key, or ssh-keygen is not installed.",
|
|
130
131
|
"record-state-not-initial": "A record written through chant serve mcp gives a state other than the kind's first: a new record opens proposed, and a person moves it on.",
|
|
131
132
|
"source-harvest-not-proposed": "A harvested record (source.via harvest) was written in a state other than the kind's first: a harvest proposes, and a person decides.",
|
|
133
|
+
"ratify-quorum-not-met": "The write puts a record in its kind's ratified state (reviews.ratified), and the record's quorum is not met: too few agreeing verdicts count.",
|
|
132
134
|
"record-sign-failed": "--sign was given and no author seal could be made: the record names no author, the key can't be read or used, git names no ssh signing key, or ssh-keygen is not installed.",
|
|
133
135
|
// A review given in a session (records review --session, #2693).
|
|
134
136
|
"session-unknown": "--session names no session of a session kind whose subjects are the record's kind.",
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
RecordReadError,
|
|
39
39
|
type LoadedRecordKind,
|
|
40
40
|
type Quorum,
|
|
41
|
+
type QuorumOptions,
|
|
41
42
|
type ReadErrorCode,
|
|
42
43
|
type ReadRecordsResult,
|
|
43
44
|
type RecordEntry,
|
|
@@ -210,6 +211,24 @@ function declaredQuorum(tree: WorkspaceTree): { need: number; needFrom: "declara
|
|
|
210
211
|
return { need: DEFAULT_QUORUM, needFrom: "default" };
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
/**
|
|
215
|
+
* The options a kind's quorum is computed with, or undefined when the kind
|
|
216
|
+
* has no reviews list: the need from the declaration in the tree read, and
|
|
217
|
+
* the agents, whether verdicts need a seal, and the keys a seal verifies
|
|
218
|
+
* against, all from the policy at base (#2671, #2687). `records` and the
|
|
219
|
+
* ratified check of `records new` and `amend` (#2873) both use it.
|
|
220
|
+
*/
|
|
221
|
+
export async function quorumOptionsFor(loaded: LoadedRecordKind, tree: WorkspaceTree, policy: TrustPolicy): Promise<QuorumOptions | undefined> {
|
|
222
|
+
if (!loaded.kind.reviews) return undefined;
|
|
223
|
+
const { checkVerdictSeal } = await import("./trust/seal");
|
|
224
|
+
return {
|
|
225
|
+
...declaredQuorum(tree),
|
|
226
|
+
agents: new Set((policy.roles[AGENT_ROLE] ?? []).map(normalisePrincipal)),
|
|
227
|
+
attestation: policy.active,
|
|
228
|
+
verifySeal: (v: SealInput) => checkVerdictSeal(policy, v),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
213
232
|
/**
|
|
214
233
|
* Where a kind's pinned paths resolve: the workspace whose declaration sits
|
|
215
234
|
* nearest above the kind file, when it is inside the repository, or else the
|
|
@@ -300,19 +319,8 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
|
|
|
300
319
|
paths: result.records.map((r) => r.path),
|
|
301
320
|
attestors: policy.active ? await activeAttestors() : [],
|
|
302
321
|
});
|
|
303
|
-
// The quorum: the need from the declaration in the tree read, agents,
|
|
304
|
-
// whether verdicts need a seal, and the keys a seal verifies against,
|
|
305
|
-
// all from the policy at base (#2671, #2687).
|
|
306
322
|
const seals = loaded.kind.reviews ? await import("./trust/seal") : undefined;
|
|
307
|
-
const
|
|
308
|
-
const quorumOptions = checkVerdictSeal
|
|
309
|
-
? {
|
|
310
|
-
...declaredQuorum(tree),
|
|
311
|
-
agents: new Set((policy.roles[AGENT_ROLE] ?? []).map(normalisePrincipal)),
|
|
312
|
-
attestation: policy.active,
|
|
313
|
-
verifySeal: (v: SealInput) => checkVerdictSeal(policy, v),
|
|
314
|
-
}
|
|
315
|
-
: undefined;
|
|
323
|
+
const quorumOptions = await quorumOptionsFor(loaded, tree, policy);
|
|
316
324
|
const records: RecordView[] = result.records.map((r) => ({
|
|
317
325
|
...r,
|
|
318
326
|
provenance: provenance.get(r.path)!,
|
|
@@ -116,7 +116,8 @@ describe("records output schema", () => {
|
|
|
116
116
|
JSON.stringify({ name: "w", schema: 1, quorum: 1, members: [{ name: "docs", dir: "docs", kind: "other", because: "decisions only" }] }),
|
|
117
117
|
);
|
|
118
118
|
const base = readFileSync(join(dir, "ws-003-seal-scope.md"), "utf-8").replace(/^id: .*$/m, 'id: "ws-900"');
|
|
119
|
-
|
|
119
|
+
// The decision kind names a ratified state, so the state leaves the digest too (#2873).
|
|
120
|
+
const digest = recordTextDigest(base, ["reviews", "seal", "state"]);
|
|
120
121
|
const entry = (reviewer: string, verdict: string, extra = "") => ` - reviewer: "${reviewer}"\n verdict: "${verdict}"\n on: "2026-09-24"${extra}`;
|
|
121
122
|
const reviews = [
|
|
122
123
|
entry("alice", "agree", `\n digest: "${digest}"`),
|
|
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
16
16
|
import { workingTreeSource } from "./record-source";
|
|
17
|
-
import { computeQuorum, DEFAULT_QUORUM, loadRecordKind, normalisePrincipal, readRecords, recordTextDigest, type QuorumOptions, type RecordEntry } from "./records";
|
|
17
|
+
import { computeQuorum, DEFAULT_QUORUM, loadRecordKind, normalisePrincipal, readRecords, recordTextDigest as rawDigest, type QuorumOptions, type RecordEntry } from "./records";
|
|
18
18
|
|
|
19
19
|
const REPO = join(import.meta.dirname, "..", "..", "..", "..");
|
|
20
20
|
const DECISIONS = join(REPO, "docs", "design", "decisions");
|
|
@@ -34,6 +34,13 @@ function review(reviewer: string, verdict: string, extra: Record<string, string>
|
|
|
34
34
|
.join("\n");
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* A decision's digest: the decision kind names a ratified state, so its
|
|
39
|
+
* state line leaves the digest with the reviews and seal blocks (#2873).
|
|
40
|
+
* The rule tests below hold for that list as for the reviews block alone.
|
|
41
|
+
*/
|
|
42
|
+
const recordTextDigest = (text: string, fields: string | readonly string[] | null = ["reviews", "seal", "state"]) => rawDigest(text, fields);
|
|
43
|
+
|
|
37
44
|
const BARE = recordTextDigest(SAMPLE);
|
|
38
45
|
|
|
39
46
|
describe("recordTextDigest", () => {
|
|
@@ -41,7 +48,7 @@ describe("recordTextDigest", () => {
|
|
|
41
48
|
expect(BARE).toMatch(/^[0-9a-f]{64}$/);
|
|
42
49
|
const lines = SAMPLE.split("\n");
|
|
43
50
|
const without = lines.filter((l) => l !== "reviews: []").join("\n");
|
|
44
|
-
expect(
|
|
51
|
+
expect(rawDigest(SAMPLE)).toBe(rawDigest(without, null));
|
|
45
52
|
});
|
|
46
53
|
|
|
47
54
|
test("does not move when a verdict is added, changed or removed", () => {
|
|
@@ -78,7 +85,7 @@ describe("recordTextDigest", () => {
|
|
|
78
85
|
const file = join(dir, "ws-003.md");
|
|
79
86
|
writeFileSync(file, withReviews(`${review("alice", "agree")}\n${review("bob", "dissent")}`));
|
|
80
87
|
// The recipe in docs/src/content/docs/cli/workspace-records.mdx.
|
|
81
|
-
const awk = `awk 'NR==1&&$0=="---"{fm=1;print;next} fm&&$0=="---"{fm=0;skip=0;print;next} fm&&/^reviews[ \\t]*:/{skip=1;next} fm&&skip&&/^([ \\t#-]|$)/{next} {skip=0;print}' "${file}" | shasum -a 256`;
|
|
88
|
+
const awk = `awk 'NR==1&&$0=="---"{fm=1;print;next} fm&&$0=="---"{fm=0;skip=0;print;next} fm&&/^(reviews|seal|state)[ \\t]*:/{skip=1;next} fm&&skip&&/^([ \\t#-]|$)/{next} {skip=0;print}' "${file}" | shasum -a 256`;
|
|
82
89
|
const out = execFileSync("sh", ["-c", awk], { encoding: "utf-8" });
|
|
83
90
|
expect(out.split(" ")[0]).toBe(BARE);
|
|
84
91
|
} finally {
|
|
@@ -164,7 +164,8 @@ describe("records review --session (#2693)", () => {
|
|
|
164
164
|
const root = declared();
|
|
165
165
|
const { path } = await opened(root);
|
|
166
166
|
const decision = join(root, "decisions", "ref-001-how-the-app-is-deployed.md");
|
|
167
|
-
|
|
167
|
+
// The decision kind names a ratified state, so the state leaves the digest too (#2873).
|
|
168
|
+
const digest = recordTextDigest(readFileSync(decision, "utf-8"), ["reviews", "seal", "state"]);
|
|
168
169
|
|
|
169
170
|
const dry = await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-0002", dryRun: true, cwd: root });
|
|
170
171
|
review.expectValid(dry);
|
|
@@ -10,7 +10,8 @@ import { cpSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync
|
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
13
|
-
import { parseFrontMatter, recordTextDigest } from "./records";
|
|
13
|
+
import { parseFrontMatter, recordTextDigest as textDigest } from "./records";
|
|
14
|
+
import { queryRecords } from "./records-cli";
|
|
14
15
|
import { allocateId, amendRecord, newRecord, renderRecord, reviewRecord, toYaml } from "./records-write";
|
|
15
16
|
|
|
16
17
|
const REPO = join(import.meta.dirname, "..", "..", "..", "..");
|
|
@@ -19,6 +20,9 @@ const KIND = "decisions/decision.kind.mjs";
|
|
|
19
20
|
|
|
20
21
|
type Data = Record<string, unknown>;
|
|
21
22
|
|
|
23
|
+
/** The digest of a decision: the kind names a ratified state, so the state leaves the digest with the reviews and seal (#2873). */
|
|
24
|
+
const recordTextDigest = (text: string) => textDigest(text, ["reviews", "seal", "state"]);
|
|
25
|
+
|
|
22
26
|
const SAMPLE = (() => {
|
|
23
27
|
const fm = parseFrontMatter(readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8"));
|
|
24
28
|
if (!fm.ok) throw new Error(fm.message);
|
|
@@ -282,6 +286,7 @@ describe("records amend", () => {
|
|
|
282
286
|
test("a decided record may be ratified, re-pin its evidence and take reviews, but not move down", async () => {
|
|
283
287
|
const evidence = [{ title: "A spec", url: "https://example.com/spec" }];
|
|
284
288
|
expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ evidence }), cwd: dir })).toMatchObject({ changed: ["evidence"] });
|
|
289
|
+
for (const by of ["alice", "bob"]) await reviewRecord({ kind: KIND, id: "ws-001", verdict: "agree", by, cwd: dir });
|
|
285
290
|
expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ state: "ratified" }), cwd: dir })).toMatchObject({ changed: ["state"] });
|
|
286
291
|
put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
|
|
287
292
|
expect(code(await amendRecord({ kind: KIND, id: "ws-006", fields: fields({ state: "withdrawn" }), cwd: dir }))).toBe("amend-supersede-instead");
|
|
@@ -319,6 +324,101 @@ describe("records amend", () => {
|
|
|
319
324
|
});
|
|
320
325
|
});
|
|
321
326
|
|
|
327
|
+
describe("ratifying against the quorum (#2873)", () => {
|
|
328
|
+
const ratify = (id = "ws-001") => amendRecord({ kind: KIND, id, fields: fields({ state: "ratified" }), cwd: dir });
|
|
329
|
+
const agree = (by: string, id = "ws-001") => reviewRecord({ kind: KIND, id, verdict: "agree", by, cwd: dir });
|
|
330
|
+
async function quorumOf(id: string) {
|
|
331
|
+
const doc = await queryRecords({ kind: KIND, cwd: dir });
|
|
332
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
333
|
+
return doc.records.find((r) => r.id === id)!.quorum!;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
test("below the quorum, ratified is refused with the count and what did not count, and nothing is written", async () => {
|
|
337
|
+
expect(SAMPLE.decided_by).toBe("lex00");
|
|
338
|
+
let before = snapshot();
|
|
339
|
+
let doc = await ratify();
|
|
340
|
+
expect(code(doc)).toBe("ratify-quorum-not-met");
|
|
341
|
+
expect("error" in doc && doc.error.message).toMatch(/^ws-001 can't be ratified yet: its quorum needs 2 agreeing verdicts that count, and it has 0\. Add verdicts with chant workspace records review ws-001/);
|
|
342
|
+
expect(touched(before, snapshot())).toEqual([]);
|
|
343
|
+
// One agreement and the decider's own: one counts.
|
|
344
|
+
await agree("alice");
|
|
345
|
+
await agree("lex00");
|
|
346
|
+
before = snapshot();
|
|
347
|
+
doc = await ratify();
|
|
348
|
+
expect("error" in doc && doc.error.message).toMatch(/it has 1 \(not counted: lex00, review-decider\)/);
|
|
349
|
+
expect(touched(before, snapshot())).toEqual([]);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("at the quorum, ratified is taken, and the verdicts still count on the ratified record", async () => {
|
|
353
|
+
await agree("alice");
|
|
354
|
+
await agree("bob");
|
|
355
|
+
const judged = recordTextDigest(text("ws-001-one.md"));
|
|
356
|
+
expect(await ratify()).toMatchObject({ changed: ["state"] });
|
|
357
|
+
expect(data("ws-001-one.md").state).toBe("ratified");
|
|
358
|
+
expect(recordTextDigest(text("ws-001-one.md"))).toBe(judged);
|
|
359
|
+
expect(await quorumOf("ws-001")).toMatchObject({ need: 2, agreed: 2, met: true, notCounted: [] });
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("a met quorum with an open concern ratifies, and the concern stays listed", async () => {
|
|
363
|
+
await agree("alice");
|
|
364
|
+
await agree("bob");
|
|
365
|
+
await reviewRecord({ kind: KIND, id: "ws-001", verdict: "dissent", by: "carol", note: "It leaves out X.", cwd: dir });
|
|
366
|
+
expect(code(await ratify())).toBe("ok");
|
|
367
|
+
expect(await quorumOf("ws-001")).toMatchObject({ met: true, metWithObjections: true, openConcerns: [{ reviewer: "carol" }] });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("verdicts on text an amendment changed do not count toward ratifying", async () => {
|
|
371
|
+
await agree("alice");
|
|
372
|
+
await agree("bob");
|
|
373
|
+
await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ evidence: [] }), cwd: dir });
|
|
374
|
+
const doc = await ratify();
|
|
375
|
+
expect("error" in doc && doc.error.message).toMatch(/it has 0 \(not counted: alice, review-older-digest; bob, review-older-digest\)/);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("the amendment that ratifies is checked as written, with its own changes to the reviews and pins", async () => {
|
|
379
|
+
await agree("alice");
|
|
380
|
+
const digest = recordTextDigest(text("ws-001-one.md"));
|
|
381
|
+
const reviews = [...(data("ws-001-one.md").reviews as Data[]), { reviewer: "bob", verdict: "agree", on: "2026-09-26", digest }];
|
|
382
|
+
expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ state: "ratified", reviews }), cwd: dir })).toMatchObject({ changed: ["state", "reviews"] });
|
|
383
|
+
put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
|
|
384
|
+
await agree("alice", "ws-006");
|
|
385
|
+
await agree("bob", "ws-006");
|
|
386
|
+
// Re-pinning in the same write moves the digest, so the verdicts no longer match what would be ratified.
|
|
387
|
+
const doc = await amendRecord({ kind: KIND, id: "ws-006", fields: fields({ state: "ratified", evidence: [] }), cwd: dir });
|
|
388
|
+
expect(code(doc)).toBe("ratify-quorum-not-met");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("records new refuses a record that opens ratified below its quorum", async () => {
|
|
392
|
+
const fresh = decision({ title: "Straight in", state: "ratified" });
|
|
393
|
+
delete fresh.id;
|
|
394
|
+
const before = snapshot();
|
|
395
|
+
expect(code(await newRecord({ kind: KIND, fields: fields(fresh), cwd: dir }))).toBe("ratify-quorum-not-met");
|
|
396
|
+
expect(touched(before, snapshot())).toEqual([]);
|
|
397
|
+
expect(code(await newRecord({ kind: KIND, fields: fields({ ...fresh, state: "decided" }), cwd: dir }))).toBe("ok");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("a kind without reviews.ratified, or without reviews, takes ratified with no quorum, and its digest keeps the state", async () => {
|
|
401
|
+
const kindFile = join(dir, "decisions", "decision.kind.mjs");
|
|
402
|
+
const withRatified = readFileSync(kindFile, "utf-8");
|
|
403
|
+
expect(withRatified).toContain('reviews: { field: "reviews", decider: "decided_by", ratified: "ratified" },');
|
|
404
|
+
writeFileSync(kindFile, withRatified.replace(', ratified: "ratified" }', " }"));
|
|
405
|
+
const judged = textDigest(text("ws-001-one.md"), ["reviews", "seal"]);
|
|
406
|
+
expect(await ratify()).toMatchObject({ changed: ["state"] });
|
|
407
|
+
expect(textDigest(text("ws-001-one.md"), ["reviews", "seal"])).not.toBe(judged);
|
|
408
|
+
writeFileSync(kindFile, withRatified.replace(' reviews: { field: "reviews", decider: "decided_by", ratified: "ratified" },\n', ""));
|
|
409
|
+
put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
|
|
410
|
+
expect(await ratify("ws-006")).toMatchObject({ changed: ["state"] });
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("a kind whose reviews.ratified is not one of its states is refused", async () => {
|
|
414
|
+
const kindFile = join(dir, "decisions", "decision.kind.mjs");
|
|
415
|
+
writeFileSync(kindFile, readFileSync(kindFile, "utf-8").replace('ratified: "ratified" }', 'ratified: "approved" }'));
|
|
416
|
+
const doc = await ratify();
|
|
417
|
+
expect(code(doc)).toBe("kind-invalid");
|
|
418
|
+
expect("error" in doc && doc.error.message).toMatch(/reviews\.ratified must be one of states/);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
322
422
|
describe("records review", () => {
|
|
323
423
|
test("appends a dated verdict bound to the digest of the text it judged", async () => {
|
|
324
424
|
const judged = recordTextDigest(text("ws-001-one.md"));
|