@intentius/chant 0.84.0 → 0.86.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 (152) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/mcp/resource-handlers.d.ts +2 -1
  3. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  4. package/dist/cli/mcp/server.d.ts +1 -0
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/mcp/tools/composites.d.ts +44 -0
  7. package/dist/cli/mcp/tools/composites.d.ts.map +1 -0
  8. package/dist/cli/mcp/tools/search.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +13 -1
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/cli-support.d.ts +4 -0
  12. package/dist/components/cli-support.d.ts.map +1 -1
  13. package/dist/composite.d.ts +6 -0
  14. package/dist/composite.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +44 -0
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts +13 -0
  18. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  19. package/dist/workspace/__fixtures__/contract-repo.d.ts +7 -0
  20. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  21. package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
  22. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
  23. package/dist/workspace/checks/records.d.ts +1 -0
  24. package/dist/workspace/checks/records.d.ts.map +1 -1
  25. package/dist/workspace/checks.d.ts +4 -0
  26. package/dist/workspace/checks.d.ts.map +1 -1
  27. package/dist/workspace/composites.d.ts +152 -0
  28. package/dist/workspace/composites.d.ts.map +1 -0
  29. package/dist/workspace/conformance/index.d.ts +211 -0
  30. package/dist/workspace/conformance/index.d.ts.map +1 -0
  31. package/dist/workspace/conformance/vitest.d.ts +11 -0
  32. package/dist/workspace/conformance/vitest.d.ts.map +1 -0
  33. package/dist/workspace/declaration.d.ts +28 -0
  34. package/dist/workspace/declaration.d.ts.map +1 -1
  35. package/dist/workspace/declaration.schema.json +40 -0
  36. package/dist/workspace/declared-kinds.d.ts +43 -0
  37. package/dist/workspace/declared-kinds.d.ts.map +1 -0
  38. package/dist/workspace/graph-cli.d.ts +24 -2
  39. package/dist/workspace/graph-cli.d.ts.map +1 -1
  40. package/dist/workspace/intent-cli.d.ts +6 -1
  41. package/dist/workspace/intent-cli.d.ts.map +1 -1
  42. package/dist/workspace/intent-joins.d.ts +74 -9
  43. package/dist/workspace/intent-joins.d.ts.map +1 -1
  44. package/dist/workspace/intent.d.ts +90 -6
  45. package/dist/workspace/intent.d.ts.map +1 -1
  46. package/dist/workspace/ls.d.ts +31 -1
  47. package/dist/workspace/ls.d.ts.map +1 -1
  48. package/dist/workspace/member-commands.d.ts +7 -2
  49. package/dist/workspace/member-commands.d.ts.map +1 -1
  50. package/dist/workspace/reason-codes.d.ts +52 -2
  51. package/dist/workspace/reason-codes.d.ts.map +1 -1
  52. package/dist/workspace/record-sessions.d.ts +51 -0
  53. package/dist/workspace/record-sessions.d.ts.map +1 -0
  54. package/dist/workspace/record-source.d.ts +2 -0
  55. package/dist/workspace/record-source.d.ts.map +1 -1
  56. package/dist/workspace/records-cli.d.ts +65 -4
  57. package/dist/workspace/records-cli.d.ts.map +1 -1
  58. package/dist/workspace/records-since.d.ts +90 -0
  59. package/dist/workspace/records-since.d.ts.map +1 -0
  60. package/dist/workspace/records-write.d.ts +164 -0
  61. package/dist/workspace/records-write.d.ts.map +1 -0
  62. package/dist/workspace/records.d.ts +202 -15
  63. package/dist/workspace/records.d.ts.map +1 -1
  64. package/dist/workspace/runtimes.d.ts +60 -0
  65. package/dist/workspace/runtimes.d.ts.map +1 -0
  66. package/dist/workspace/status-gates.d.ts +90 -0
  67. package/dist/workspace/status-gates.d.ts.map +1 -0
  68. package/dist/workspace/status.d.ts +17 -0
  69. package/dist/workspace/status.d.ts.map +1 -1
  70. package/dist/workspace/work.d.ts +56 -0
  71. package/dist/workspace/work.d.ts.map +1 -0
  72. package/package.json +19 -1
  73. package/src/cli/handlers/graph.ts +4 -0
  74. package/src/cli/main.test.ts +9 -0
  75. package/src/cli/main.ts +56 -3
  76. package/src/cli/mcp/resource-handlers.ts +17 -0
  77. package/src/cli/mcp/server.test.ts +140 -4
  78. package/src/cli/mcp/server.ts +5 -1
  79. package/src/cli/mcp/tools/composites.ts +98 -0
  80. package/src/cli/mcp/tools/search.ts +47 -5
  81. package/src/cli/registry.ts +13 -1
  82. package/src/components/cli-support.test.ts +16 -0
  83. package/src/components/cli-support.ts +8 -2
  84. package/src/composite.ts +9 -0
  85. package/src/lexicon.ts +47 -0
  86. package/src/lifecycle/gate-ledger.ts +14 -0
  87. package/src/workspace/__fixtures__/contract-repo.ts +17 -0
  88. package/src/workspace/__fixtures__/sessions.ts +66 -0
  89. package/src/workspace/checks/records.ts +19 -0
  90. package/src/workspace/checks.test.ts +2 -0
  91. package/src/workspace/checks.ts +7 -1
  92. package/src/workspace/composites.schema.json +533 -0
  93. package/src/workspace/composites.test.ts +334 -0
  94. package/src/workspace/composites.ts +316 -0
  95. package/src/workspace/conformance/__fixture__/app/package.json +7 -0
  96. package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
  97. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
  98. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +364 -0
  99. package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
  100. package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
  101. package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
  102. package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
  103. package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
  104. package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
  105. package/src/workspace/conformance/conformance.test.ts +149 -0
  106. package/src/workspace/conformance/index.mjs +31 -0
  107. package/src/workspace/conformance/index.ts +453 -0
  108. package/src/workspace/conformance/vitest.ts +62 -0
  109. package/src/workspace/declaration.schema.json +40 -0
  110. package/src/workspace/declaration.ts +62 -0
  111. package/src/workspace/declared-kinds.test.ts +321 -0
  112. package/src/workspace/declared-kinds.ts +76 -0
  113. package/src/workspace/graph-cli.ts +40 -4
  114. package/src/workspace/intent-cli.ts +54 -7
  115. package/src/workspace/intent-joins.test.ts +60 -0
  116. package/src/workspace/intent-joins.ts +117 -20
  117. package/src/workspace/intent.schema.json +357 -19
  118. package/src/workspace/intent.test.ts +235 -20
  119. package/src/workspace/intent.ts +396 -51
  120. package/src/workspace/ls.schema.json +34 -0
  121. package/src/workspace/ls.ts +69 -4
  122. package/src/workspace/member-commands.ts +11 -5
  123. package/src/workspace/read-contract.test.ts +52 -3
  124. package/src/workspace/reason-codes.test.ts +48 -4
  125. package/src/workspace/reason-codes.ts +67 -2
  126. package/src/workspace/record-assets.test.ts +3 -1
  127. package/src/workspace/record-sessions.ts +105 -0
  128. package/src/workspace/record-source.ts +14 -5
  129. package/src/workspace/records-amend.schema.json +167 -0
  130. package/src/workspace/records-cli.ts +246 -19
  131. package/src/workspace/records-contract.test.ts +77 -2
  132. package/src/workspace/records-formats.test.ts +640 -0
  133. package/src/workspace/records-new.schema.json +158 -0
  134. package/src/workspace/records-quorum.test.ts +196 -0
  135. package/src/workspace/records-review.schema.json +202 -0
  136. package/src/workspace/records-sessions.test.ts +108 -0
  137. package/src/workspace/records-since.schema.json +193 -0
  138. package/src/workspace/records-since.test.ts +174 -0
  139. package/src/workspace/records-since.ts +259 -0
  140. package/src/workspace/records-write-contract.test.ts +125 -0
  141. package/src/workspace/records-write.test.ts +373 -0
  142. package/src/workspace/records-write.ts +736 -0
  143. package/src/workspace/records.schema.json +187 -9
  144. package/src/workspace/records.test.ts +93 -0
  145. package/src/workspace/records.ts +683 -49
  146. package/src/workspace/runtimes.ts +107 -0
  147. package/src/workspace/status-contract.test.ts +163 -0
  148. package/src/workspace/status-gates.ts +215 -0
  149. package/src/workspace/status.schema.json +69 -3
  150. package/src/workspace/status.ts +35 -2
  151. package/src/workspace/work.test.ts +388 -0
  152. package/src/workspace/work.ts +163 -0
@@ -7,7 +7,8 @@
7
7
  *
8
8
  * With `--intent <path[:start-end]>` it prints the intent graph over one
9
9
  * region instead (#2651, `intent.ts`), a document of its own in the read
10
- * contract.
10
+ * contract. With `--composites` it prints each composite instance with the
11
+ * components that can deploy it (#2662, `composites.ts`), another.
11
12
  *
12
13
  * The document is part of the read contract, described by `graph.schema.json`
13
14
  * beside this file. It is printed for a failure too, with the error's reason
@@ -48,7 +49,7 @@ export const GRAPH_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/worksp
48
49
  export const GRAPH_ERROR_CODES = WORKSPACE_ERROR_CODES;
49
50
 
50
51
  const USAGE =
51
- "chant workspace graph [dir] [--at <rev>] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--dry-run] | chant workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]";
52
+ "chant workspace graph [dir] [--at <rev>] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--dry-run] | chant workspace graph --composites [--at <rev>] [--member <name>] [-o <file>] | chant workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]";
52
53
 
53
54
  interface Head {
54
55
  $schema: string;
@@ -78,12 +79,32 @@ export interface GraphQuery {
78
79
  * rows of `links`.
79
80
  */
80
81
  kind?: string;
82
+ /**
83
+ * Also run each member's `chant graph --components --format ir` (#2662),
84
+ * under the same toolchain and at the same revision, and return the answers
85
+ * in {@link GraphResult.components}. The document is unchanged.
86
+ */
87
+ components?: boolean;
88
+ /**
89
+ * Called once the members have run, with the directory they were read from
90
+ * (the exported tree for `--at`, before it is removed) and the declared
91
+ * members the read covers. `--composites` reads each member's runtimes
92
+ * here (#2674).
93
+ */
94
+ inTree?: (root: string, members: readonly { name: string; dir: string; kind: string }[]) => Promise<void>;
81
95
  }
82
96
 
83
97
  export interface GraphResult {
84
98
  doc: GraphDocument;
85
99
  /** A member failed or couldn't be read, or the declaration couldn't be read. */
86
100
  failed: boolean;
101
+ /** With {@link GraphQuery.components}: each member's component graph run, in plan order. */
102
+ components?: UnitResult[];
103
+ }
104
+
105
+ /** The command line a member runs for its component graph (#2662). */
106
+ export function componentGraphArgv(args: Partial<ParsedArgs>): string[] {
107
+ return ["graph", ".", "--components", "--format", "ir", ...(args.env ? ["--env", args.env] : [])];
87
108
  }
88
109
 
89
110
  /**
@@ -179,7 +200,13 @@ export async function workspaceGraph(query: GraphQuery): Promise<GraphResult> {
179
200
  exported = exportRevision(located, plan.groups.flatMap((g) => g.units.map((u) => u.dir)));
180
201
  plan = planMembers("graph", exported, { only: query.members, reader: query.reader, tree: workingTree(exported) });
181
202
  }
182
- const results = await executePlan(plan, (query.args ?? {}) as ParsedArgs);
203
+ const args = (query.args ?? {}) as ParsedArgs;
204
+ const [results, components] = await Promise.all([
205
+ executePlan(plan, args),
206
+ query.components ? executePlan(plan, args, () => componentGraphArgv(args)) : Promise.resolve(undefined),
207
+ ]);
208
+ if (query.inTree) await query.inTree(exported ?? located.rootOnDisk, declaration.members.filter((m) => !query.members?.length || query.members.includes(m.name)));
209
+ for (const r of components ?? []) if (r.stderr.trim() && query.onStderr) query.onStderr(r.stderr.endsWith("\n") ? r.stderr : `${r.stderr}\n`);
183
210
  for (const r of results) if (r.stderr.trim() && query.onStderr) query.onStderr(r.stderr.endsWith("\n") ? r.stderr : `${r.stderr}\n`);
184
211
  const { inputs, failed } = compose(plan, results, query.members, declaration.members);
185
212
  // Links (#2539) resolve against the declaration that was read, the revision's for --at, and the kinds installed now.
@@ -201,7 +228,7 @@ export async function workspaceGraph(query: GraphQuery): Promise<GraphResult> {
201
228
  query.onStderr?.(`${formatError({ message: `--kind ${query.kind}: ${err.code}: ${err.message}`, hint: USAGE })}\n`);
202
229
  }
203
230
  }
204
- return { doc: { ...head, at: located.at, ...graph }, failed: failed || recordsFailed };
231
+ return { doc: { ...head, at: located.at, ...graph }, failed: failed || recordsFailed, ...(components ? { components } : {}) };
205
232
  } catch (err) {
206
233
  if (!(err instanceof WorkspaceReadError)) throw err;
207
234
  return { doc: { ...head, error: { code: err.code, message: err.message, location: err.location ?? null } }, failed: true };
@@ -221,6 +248,15 @@ export async function runWorkspaceGraph(ctx: CommandContext): Promise<number> {
221
248
  const handed = await handToRootChant(cwd, args.at);
222
249
  if (handed !== undefined) return handed;
223
250
 
251
+ // Composite instances joined to components (#2662) are their own document.
252
+ if (args.composites) {
253
+ if (args.intent !== undefined || args.kind !== undefined) {
254
+ console.error(formatError({ message: "--composites takes neither --intent nor --kind", hint: USAGE }));
255
+ return 1;
256
+ }
257
+ if (!args.dryRun) return (await import("./composites")).runWorkspaceComposites(ctx, cwd);
258
+ }
259
+
224
260
  // The intent graph over one region (#2651) is its own document.
225
261
  if (args.intent !== undefined) return (await import("./intent-cli")).runWorkspaceIntent(ctx, cwd);
226
262
 
@@ -3,13 +3,16 @@
3
3
  * (#2651): the intent graph over one region (`intent.ts`), printed as JSON
4
4
  * with `--json` or as a walk, one line per node, in the order of #2650
5
5
  * section B: the region, its decisions, their artifacts, the commits, and the
6
- * findings.
6
+ * findings. Under each decision come the commits made inside its window, and
7
+ * when any of them is not the decision's own work, the question #2650 B puts
8
+ * to the person about it (#2656). Without `--kind`, the walk reads every
9
+ * record kind the declaration names (#2680).
7
10
  */
8
11
 
9
12
  import { resolve } from "node:path";
10
13
  import { formatError } from "../cli/format";
11
14
  import type { CommandContext } from "../cli/registry";
12
- import { intentGraph, type ArtifactNode, type CommitNode, type DecisionNode, type IntentDocument, type IntentEdge, type IntentNode } from "./intent";
15
+ import { intentGraph, type ArtifactNode, type CommitNode, type DecisionNode, type IntentDocument, type IntentEdge, type IntentNode, type WorkNode } from "./intent";
13
16
 
14
17
  const USAGE = "chant workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]";
15
18
 
@@ -25,6 +28,28 @@ function ofKind<K extends IntentNode["kind"]>(doc: Result, kind: K): Extract<Int
25
28
 
26
29
  const short = (sha: string | null | undefined) => (sha ? sha.slice(0, 8) : "none");
27
30
 
31
+ /** What the walk asks about a commit made inside a decision's window that is not the decision's own work (#2650 B4, step 5). */
32
+ export const IN_WINDOW_QUESTION = "is this drift, a superseding decision nobody wrote down, or the decision being wrong?";
33
+
34
+ /** The commits inside a decision's window, under its line, then the question once when any is not its own work. */
35
+ function withinLines(doc: Result, d: DecisionNode): string[] {
36
+ const within = doc.edges.filter((e): e is Extract<IntentEdge, { kind: "within" }> => e.kind === "within" && e.to === d.id);
37
+ const out: string[] = [];
38
+ for (const e of within) {
39
+ const c = doc.nodes.find((n): n is CommitNode => n.kind === "commit" && n.id === e.from);
40
+ if (!c) continue;
41
+ const unit = edgesFrom(doc, c.id, "produced-by")
42
+ .map((p) => doc.nodes.find((n) => n.id === p.to))
43
+ .map((n) => (n && "ref" in n ? n.ref : undefined))
44
+ .filter(Boolean);
45
+ const by = unit.length > 0 ? `unit ${unit.join(", ")}` : "no unit";
46
+ const label = e.state === "decided" ? "decided " : "within ";
47
+ out.push(` ${label} ${short(c.sha)} ${c.subject}; ${by}${e.state === "decided" ? `, ${d.record}'s own work` : `, in ${d.record}'s window and not its work`}`);
48
+ }
49
+ if (within.some((e) => e.state === "decided-by-window")) out.push(` ask ${IN_WINDOW_QUESTION}`);
50
+ return out;
51
+ }
52
+
28
53
  function decisionLine(d: DecisionNode): string {
29
54
  const via = d.constrains.length > 0 ? d.constrains.map((c) => `${c.entry} (${c.granularity})`).join(", ") : "through supersession only";
30
55
  const by = d.decided_by ? `, decided by ${d.decided_by}${d.decided_on ? ` on ${d.decided_on}` : ""}` : "";
@@ -33,6 +58,22 @@ function decisionLine(d: DecisionNode): string {
33
58
  return `decision ${d.record} ${d.state ?? "stateless"}${superseded}: ${d.title ?? d.path}; constrains ${via}${by}; ${reviews}; ${d.provenance.level}${d.valid ? "" : `; invalid: ${d.reasons.map((r) => r.code).join(", ")}`}`;
34
59
  }
35
60
 
61
+ /** A work item, then the commits made inside its window (#2683). */
62
+ function workLines(doc: Result, w: WorkNode): string[] {
63
+ const via = w.constrains.length > 0 ? w.constrains.map((c) => `${c.entry} (${c.granularity})`).join(", ") : "through a link only";
64
+ const implemented = w.implements.length > 0 ? `; implements ${w.implements.map((d) => `${d.id} (${d.state ?? "unknown"})`).join(", ")}` : "";
65
+ const readiness = w.ready ? "; ready" : w.blockedBy.length > 0 ? `; blocked by ${w.blockedBy.map((b) => `${b.id} (${b.state ?? "unknown"})`).join(", ")}` : "";
66
+ const owner = w.owner ? `, owned by ${w.owner}` : "";
67
+ const from = w.source ? `; from ${w.source.finding} on ${w.source.region}` : "";
68
+ const out = [`work ${w.record} ${w.state ?? "stateless"}${owner}: ${w.title ?? w.path}; constrains ${via}${implemented}${readiness}${from}`];
69
+ for (const e of doc.edges.filter((x) => x.kind === "within" && x.to === w.id)) {
70
+ const c = doc.nodes.find((n): n is CommitNode => n.kind === "commit" && n.id === e.from);
71
+ if (c) out.push(` worked ${short(c.sha)} ${c.subject}; in ${w.record}'s window`);
72
+ }
73
+ for (const x of w.warnings) out.push(` warning ${x.code}: ${x.message}`);
74
+ return out;
75
+ }
76
+
36
77
  function artifactLine(doc: Result, a: ArtifactNode): string {
37
78
  const by = doc.edges
38
79
  .filter((e): e is Extract<IntentEdge, { kind: "pins" }> => e.kind === "pins" && e.to === a.id)
@@ -63,17 +104,22 @@ export function formatIntent(doc: Result): string {
63
104
  }
64
105
  const files = ofKind(doc, "file");
65
106
  if (files.length > 0) out.push(`files ${files.length} under the region, ${files.filter((f) => f.generated).length} generated`);
66
- for (const d of ofKind(doc, "decision")) out.push(decisionLine(d));
107
+ for (const d of ofKind(doc, "decision")) out.push(decisionLine(d), ...withinLines(doc, d));
108
+ for (const w of ofKind(doc, "work")) out.push(...workLines(doc, w));
67
109
  for (const a of ofKind(doc, "artifact")) out.push(artifactLine(doc, a));
68
110
  for (const c of ofKind(doc, "commit")) out.push(...commitLine(doc, c));
69
111
  for (const l of ofKind(doc, "link")) {
70
112
  const r = l.row;
71
113
  out.push(`link ${r.consumer} reads ${"producer" in r ? `${r.producer} ${r.output}` : r.input} (${r.status})`);
72
114
  }
73
- for (const f of ofKind(doc, "finding")) out.push(`finding ${f.code}: ${f.message}`);
115
+ for (const f of ofKind(doc, "finding")) {
116
+ const by = f.addressedBy && f.addressedBy.length > 0 ? `; addressed by ${f.addressedBy.map((w) => `${w.id} (${w.state ?? "unknown"})`).join(", ")}` : "";
117
+ out.push(`finding ${f.code}: ${f.message}${by}`);
118
+ }
74
119
  for (const r of doc.reasons) out.push(`reason ${r.code}: ${r.message}`);
75
120
  const kinds = doc.kinds.length === 0 ? "; no --kind, so no decisions were read" : "";
76
- out.push(`${doc.summary.commits} commits, ${doc.summary.decisions} decisions, ${doc.summary.artifacts} artifacts, ${doc.summary.findings} findings${kinds}`);
121
+ const work = ofKind(doc, "work").length;
122
+ out.push(`${doc.summary.commits} commits, ${doc.summary.decisions} decisions, ${work > 0 ? `${work} work ${work === 1 ? "item" : "items"}, ` : ""}${doc.summary.artifacts} artifacts, ${doc.summary.findings} findings${kinds}`);
77
123
  return out.join("\n");
78
124
  }
79
125
 
@@ -83,8 +129,9 @@ export async function runWorkspaceIntent(ctx: CommandContext, cwd: string): Prom
83
129
  console.error(formatError({ message: "--intent needs a region: a path, path:line or path:start-end", hint: USAGE }));
84
130
  return 1;
85
131
  }
86
- const kinds = args.kinds ?? (args.kind !== undefined ? [args.kind] : []);
87
- const { doc, failed } = await intentGraph({ cwd, region: args.intent, at: args.at, kinds: kinds.map((k) => resolve(k)) });
132
+ // No --kind: undefined, so the walk reads the kinds the declaration names (#2680).
133
+ const kinds = args.kinds ?? (args.kind !== undefined ? [args.kind] : undefined);
134
+ const { doc, failed } = await intentGraph({ cwd, region: args.intent, at: args.at, kinds: kinds?.map((k) => resolve(k)) });
88
135
  if (args.json) console.log(JSON.stringify(doc, null, 2));
89
136
  if ("error" in doc) {
90
137
  console.error(formatError({ message: `${doc.error.code}: ${doc.error.message}`, hint: USAGE }));
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The commit-join hook's data form and its export checks, with no git
3
+ * (#2651, #2663).
4
+ */
5
+
6
+ import { describe, expect, test } from "vitest";
7
+ import { joinByData, readCommitJoins, trailerValue, trailerValues, type CommitJoinContext, type IntentCommit } from "./intent-joins";
8
+
9
+ const commit = (trailers: Record<string, string[]>): IntentCommit => ({ sha: "a".repeat(40), subject: "s", body: "", author: { name: "t", email: "t@example.com" }, date: "2026-09-24T00:00:00Z", trailers });
10
+
11
+ const files: Record<string, string> = {
12
+ "evidence/E-1.json": JSON.stringify({ id: "ignored", kind: "test-run" }),
13
+ "evidence/E-3.json": JSON.stringify({ kind: "review" }),
14
+ "units/U-1.json": JSON.stringify({ role: "implement" }),
15
+ };
16
+ const context: CommitJoinContext = { read: (p) => files[p], list: () => undefined, at: null };
17
+
18
+ describe("the data form reads every trailer value (#2663)", () => {
19
+ test("each value of the evidence trailer, under any case of its key, is one piece of evidence", () => {
20
+ const data = { trailers: { unit: "Unit", evidence: "Evidence" }, records: { unit: "units/{id}.json", evidence: "evidence/{id}.json" } };
21
+ const out = joinByData(data, commit({ Unit: ["U-1"], Evidence: ["E-1", " E-2 ", "E-1"], evidence: ["E-3", ""] }), context);
22
+ expect(out).toEqual({
23
+ unit: { id: "U-1", role: "implement" },
24
+ evidence: [
25
+ { id: "E-1", kind: "test-run" },
26
+ { id: "E-2" },
27
+ { id: "E-3", kind: "review" },
28
+ ],
29
+ });
30
+ });
31
+
32
+ test("a unit or contract trailer gives its first value, since a commit has one of each", () => {
33
+ const out = joinByData({ trailers: { unit: "Unit", contract: "Contract" } }, commit({ Unit: ["U-1", "U-2"], contract: ["C-1"], Contract: ["C-2"] }), context);
34
+ expect(out.unit).toEqual({ id: "U-1" });
35
+ expect(out.contract?.id).toBe(trailerValue({ contract: ["C-1"], Contract: ["C-2"] }, "Contract"));
36
+ });
37
+
38
+ test("trailerValues keeps git's order, trims, and drops empty values and repeats", () => {
39
+ expect(trailerValues({ "Chud-Evidence": ["h1", "h2"], "chud-evidence": ["h2", " h3", " "] }, "CHUD-EVIDENCE")).toEqual(["h1", "h2", "h3"]);
40
+ expect(trailerValues({}, "X")).toEqual([]);
41
+ expect(trailerValue({ X: [" "] }, "x")).toBeUndefined();
42
+ });
43
+ });
44
+
45
+ describe("commitJoinsName (#2663)", () => {
46
+ test("names the findings of either form, and leaves the data form's own keys alone", () => {
47
+ const join = () => undefined;
48
+ expect(readCommitJoins({ commitJoins: join, commitJoinsName: "chud" })).toEqual({ form: "function", join, name: "chud" });
49
+ expect(readCommitJoins({ commitJoins: { trailers: { unit: "Unit" } }, commitJoinsName: "chud" })).toEqual({ form: "data", data: { trailers: { unit: "Unit" } }, name: "chud" });
50
+ expect(readCommitJoins({ commitJoins: join })).toEqual({ form: "function", join });
51
+ // A name inside the data form is still a key the data form does not have.
52
+ expect(readCommitJoins({ commitJoins: { name: "chud", trailers: {} } })).toMatch(/Unrecognized key/);
53
+ });
54
+
55
+ test("a name that can't be a plugin:<name>: segment, or one with no joins, is refused", () => {
56
+ for (const bad of ["a:b", "a b", "", 7]) expect(readCommitJoins({ commitJoins: () => undefined, commitJoinsName: bad }), String(bad)).toMatch(/^commitJoinsName:/);
57
+ expect(readCommitJoins({ commitJoinsName: "chud" })).toMatch(/no commitJoins/);
58
+ expect(readCommitJoins({})).toBeUndefined();
59
+ });
60
+ });
@@ -9,18 +9,41 @@
9
9
  *
10
10
  * - A function `commitJoins(commit, context)` that returns the unit, contract
11
11
  * and evidence for one commit, or nothing. It is given the commit's sha,
12
- * subject, body, author, date and trailers, and a `read` function for files
13
- * at the revision read.
12
+ * subject, body, author, date and trailers, and a context whose `read(path)`
13
+ * returns a file and whose `list(dir)` lists a directory, both in the tree
14
+ * read (#2663). With `list` a plugin can find the record that names a
15
+ * commit, such as a unit record whose `result.commit` is the commit's sha,
16
+ * so the commit needs no trailer at all.
14
17
  * - Data, which core interprets with no plugin code: the trailer keys that
15
18
  * name a unit, a contract or evidence, the record paths to read for each
16
- * (`units/{id}.json`), and the trailer keys that claim authorship.
19
+ * (`units/{id}.json`), and the trailer keys that claim authorship. Every
20
+ * value of the evidence trailer is read; a unit or contract trailer gives
21
+ * its first value, since a commit has one unit and one contract.
17
22
  *
18
23
  * Either way core never parses a plugin's own trailer or record format: it
19
24
  * reads the trailers git reports and hands them over, and a key means
20
25
  * something only because a kind file said so.
26
+ *
27
+ * Two parts of a join's answer carry meaning core acts on (#2656). A unit or
28
+ * contract may list `decisions`, the ids of the decision records it carries
29
+ * out; a commit whose unit or contract names a decision is that decision's own
30
+ * work. The function form may also return `findings`, each a code in the
31
+ * kind's own namespace (`plugin:<name>:<code>`), a message and the refs it is
32
+ * about, which the graph carries as finding nodes. That is how a plugin says
33
+ * what it knows and core does not, such as a contract whose criteria changed
34
+ * in a commit that names no decision.
35
+ *
36
+ * `<name>` is the kind's name by default: its record kind's name, or the
37
+ * file's name without `.kind.mjs`. A kind file may name its findings itself
38
+ * with a sibling export, `commitJoinsName` (#2663), so joins that live beside
39
+ * a record kind called `decision` can still report `plugin:chud:<code>`. It is
40
+ * a sibling export rather than a property because the data form is a closed
41
+ * object whose keys are all joins, and a function's `name` property is
42
+ * already its own name.
21
43
  */
22
44
 
23
45
  import { z } from "zod";
46
+ import { isPluginCode } from "./reason-codes";
24
47
 
25
48
  /** A commit as the hook sees it. */
26
49
  export interface IntentCommit {
@@ -37,16 +60,41 @@ export interface IntentCommit {
37
60
  export interface CommitJoinContext {
38
61
  /** The text of a file from the workspace root, in the tree read; undefined when it is not a file there. */
39
62
  read(path: string): string | undefined;
63
+ /**
64
+ * The entries directly inside directory `dir`, in the same tree as `read`
65
+ * (#2663): paths from the workspace root, sorted, a directory's with a
66
+ * trailing `/`. `""` or `"."` is the workspace root. Undefined when `dir` is
67
+ * not a directory there.
68
+ *
69
+ * The tree is the one the graph reads, the commit `--at` names or the
70
+ * working tree, and not each joined commit's own tree: a record that names
71
+ * a commit's sha was written after that commit, so it is only in a later
72
+ * tree.
73
+ */
74
+ list(dir: string): string[] | undefined;
40
75
  /** The full commit id read with `--at`, or null for the working tree. */
41
76
  at: string | null;
42
77
  }
43
78
 
44
- /** A plugin's unit, contract or evidence: an id and whatever fields the plugin records. */
79
+ /**
80
+ * A plugin's unit, contract or evidence: an id and whatever fields the plugin
81
+ * records. On a unit or contract, `decisions`, a list of record ids, names the
82
+ * decisions it carries out (#2656).
83
+ */
45
84
  export interface JoinedEntity {
46
85
  id: string;
47
86
  [field: string]: unknown;
48
87
  }
49
88
 
89
+ /** A finding a plugin contributes for one commit (#2656). */
90
+ export interface PluginFinding {
91
+ /** `plugin:<name>:<code>`, where `<name>` is the kind file's `commitJoinsName`, or else the kind's name. */
92
+ code: string;
93
+ message: string;
94
+ /** What the finding is about: node ids, commit shas, record ids, unit, contract or evidence ids, or paths. */
95
+ refs?: string[];
96
+ }
97
+
50
98
  /** What a join says about one commit. Every part is optional. */
51
99
  export interface CommitJoin {
52
100
  /** The unit of work that produced the commit. */
@@ -57,6 +105,8 @@ export interface CommitJoin {
57
105
  evidence?: JoinedEntity | JoinedEntity[];
58
106
  /** Trailer keys on this commit that claim who wrote it, which the plugin vouches for. */
59
107
  authorship?: string[];
108
+ /** Findings about this commit, in the kind's own code namespace. Function form only. */
109
+ findings?: PluginFinding[];
60
110
  }
61
111
 
62
112
  export type CommitJoinsFunction = (commit: IntentCommit, context: CommitJoinContext) => CommitJoin | null | undefined | Promise<CommitJoin | null | undefined>;
@@ -78,24 +128,49 @@ export const commitJoinsDataSchema = z
78
128
 
79
129
  export type CommitJoinsData = z.infer<typeof commitJoinsDataSchema>;
80
130
 
81
- /** A kind file's `commitJoins` export, checked: a function, or data. */
82
- export type CommitJoins = { form: "function"; join: CommitJoinsFunction } | { form: "data"; data: CommitJoinsData };
131
+ /**
132
+ * A kind file's `commitJoins` export, checked: a function, or data. `name` is
133
+ * its `commitJoinsName` export, when it has one: the namespace of its findings.
134
+ */
135
+ export type CommitJoins = ({ form: "function"; join: CommitJoinsFunction } | { form: "data"; data: CommitJoinsData }) & { name?: string };
136
+
137
+ /** What a `commitJoinsName` may be: the `<name>` of `plugin:<name>:<code>`. */
138
+ const JOINS_NAME = /^[^:\s]+$/;
83
139
 
84
- /** Read a kind module's `commitJoins` export. Undefined when it has none; a string when it is malformed. */
140
+ /**
141
+ * Read a kind module's `commitJoins` export and its `commitJoinsName`.
142
+ * Undefined when it has no `commitJoins`; a string when either is malformed,
143
+ * or when it names findings it has no joins for.
144
+ */
85
145
  export function readCommitJoins(mod: Record<string, unknown>): CommitJoins | string | undefined {
86
146
  const value = mod.commitJoins;
87
- if (value === undefined) return undefined;
88
- if (typeof value === "function") return { form: "function", join: value as CommitJoinsFunction };
147
+ const name = mod.commitJoinsName;
148
+ if (value === undefined) return name === undefined ? undefined : "commitJoinsName: a kind file with no commitJoins has no findings to name";
149
+ if (name !== undefined && (typeof name !== "string" || !JOINS_NAME.test(name))) return "commitJoinsName: a string with no colon or space, the <name> of plugin:<name>:<code>";
150
+ const named = name === undefined ? {} : { name: name as string };
151
+ if (typeof value === "function") return { form: "function", join: value as CommitJoinsFunction, ...named };
89
152
  const parsed = commitJoinsDataSchema.safeParse(value);
90
153
  if (!parsed.success) return parsed.error.issues.map((i) => `commitJoins${i.path.length ? `.${i.path.join(".")}` : ""}: ${i.message}`).join("; ");
91
- return { form: "data", data: parsed.data };
154
+ return { form: "data", data: parsed.data, ...named };
155
+ }
156
+
157
+ /** Every value of trailer `key`, trimmed, without empty ones or repeats, compared without case as git does. */
158
+ export function trailerValues(trailers: Record<string, string[]>, key: string): string[] {
159
+ const want = key.toLowerCase();
160
+ const out: string[] = [];
161
+ for (const [k, values] of Object.entries(trailers)) {
162
+ if (k.toLowerCase() !== want) continue;
163
+ for (const v of values) {
164
+ const t = v.trim();
165
+ if (t && !out.includes(t)) out.push(t);
166
+ }
167
+ }
168
+ return out;
92
169
  }
93
170
 
94
171
  /** The first value of trailer `key`, compared without case as git does. */
95
172
  export function trailerValue(trailers: Record<string, string[]>, key: string): string | undefined {
96
- const want = key.toLowerCase();
97
- for (const [k, values] of Object.entries(trailers)) if (k.toLowerCase() === want && values.length > 0) return values[0].trim() || undefined;
98
- return undefined;
173
+ return trailerValues(trailers, key)[0];
99
174
  }
100
175
 
101
176
  /** Whether the commit carries trailer `key`. */
@@ -122,21 +197,32 @@ function recordFields(template: string | undefined, id: string, context: CommitJ
122
197
  /** Interpret the data form for one commit. Throws when a named record can't be read as JSON. */
123
198
  export function joinByData(data: CommitJoinsData, commit: IntentCommit, context: CommitJoinContext): CommitJoin {
124
199
  const out: CommitJoin = {};
125
- for (const part of ["unit", "contract", "evidence"] as const) {
200
+ const entity = (part: "unit" | "contract" | "evidence", id: string): JoinedEntity => ({ ...recordFields(data.records?.[part], id, context), id });
201
+ for (const part of ["unit", "contract"] as const) {
126
202
  const key = data.trailers[part];
127
203
  const id = key ? trailerValue(commit.trailers, key) : undefined;
128
- if (!id) continue;
129
- const entity: JoinedEntity = { ...recordFields(data.records?.[part], id, context), id };
130
- if (part === "evidence") out.evidence = entity;
131
- else out[part] = entity;
204
+ if (id) out[part] = entity(part, id);
132
205
  }
206
+ // Each value of the evidence trailer is one piece of evidence (#2663).
207
+ const evidence = data.trailers.evidence ? trailerValues(commit.trailers, data.trailers.evidence) : [];
208
+ if (evidence.length > 0) out.evidence = evidence.map((id) => entity("evidence", id));
133
209
  const claimed = (data.authorship ?? []).filter((k) => hasTrailer(commit.trailers, k));
134
210
  if (claimed.length > 0) out.authorship = claimed;
135
211
  return out;
136
212
  }
137
213
 
138
- /** Run a kind's joins for one commit, checking what a function returns. */
139
- export async function runCommitJoins(joins: CommitJoins, commit: IntentCommit, context: CommitJoinContext): Promise<CommitJoin> {
214
+ /** The record ids a unit or contract says it carries out: its `decisions` field, when that is a list of strings. */
215
+ export function entityDecisions(entity: JoinedEntity | undefined): string[] {
216
+ const list = entity?.decisions;
217
+ return Array.isArray(list) ? list.filter((d): d is string => typeof d === "string" && d !== "") : [];
218
+ }
219
+
220
+ /**
221
+ * Run a kind's joins for one commit, checking what a function returns.
222
+ * `name` is the namespace its findings' codes must use: the kind file's
223
+ * `commitJoinsName`, or else the kind's name.
224
+ */
225
+ export async function runCommitJoins(joins: CommitJoins, commit: IntentCommit, context: CommitJoinContext, name: string): Promise<CommitJoin> {
140
226
  if (joins.form === "data") return joinByData(joins.data, commit, context);
141
227
  const result = await joins.join(commit, context);
142
228
  if (result === null || result === undefined) return {};
@@ -161,5 +247,16 @@ export async function runCommitJoins(joins: CommitJoins, commit: IntentCommit, c
161
247
  if (!Array.isArray(result.authorship) || !result.authorship.every((k) => typeof k === "string")) throw new Error("commitJoins returned authorship that is not a list of trailer keys");
162
248
  out.authorship = result.authorship;
163
249
  }
250
+ if (result.findings !== undefined && result.findings !== null) {
251
+ if (!Array.isArray(result.findings)) throw new Error("commitJoins returned findings that are not a list");
252
+ out.findings = result.findings.map((f: unknown) => {
253
+ if (f === null || typeof f !== "object" || Array.isArray(f)) throw new Error("commitJoins returned a finding that is not an object");
254
+ const { code, message, refs } = f as Record<string, unknown>;
255
+ if (!isPluginCode(code, name)) throw new Error(`commitJoins returned the finding code ${JSON.stringify(code)}, and a plugin's codes are plugin:${name}:<code>, with <code> in lower case words joined by dashes`);
256
+ if (typeof message !== "string" || message === "") throw new Error(`commitJoins returned the finding ${code} with no message`);
257
+ if (refs !== undefined && (!Array.isArray(refs) || !refs.every((r) => typeof r === "string"))) throw new Error(`commitJoins returned the finding ${code} with refs that are not a list of strings`);
258
+ return { code, message, refs: (refs as string[] | undefined) ?? [] };
259
+ });
260
+ }
164
261
  return out;
165
262
  }