@intentius/chant 0.83.0 → 0.85.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 (66) 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 +6 -0
  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/workspace/__fixtures__/contract-repo.d.ts +7 -0
  18. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  19. package/dist/workspace/composites.d.ts +139 -0
  20. package/dist/workspace/composites.d.ts.map +1 -0
  21. package/dist/workspace/graph-cli.d.ts +16 -1
  22. package/dist/workspace/graph-cli.d.ts.map +1 -1
  23. package/dist/workspace/intent-cli.d.ts +21 -0
  24. package/dist/workspace/intent-cli.d.ts.map +1 -0
  25. package/dist/workspace/intent-joins.d.ts +121 -0
  26. package/dist/workspace/intent-joins.d.ts.map +1 -0
  27. package/dist/workspace/intent.d.ts +310 -0
  28. package/dist/workspace/intent.d.ts.map +1 -0
  29. package/dist/workspace/member-commands.d.ts +7 -2
  30. package/dist/workspace/member-commands.d.ts.map +1 -1
  31. package/dist/workspace/reason-codes.d.ts +29 -0
  32. package/dist/workspace/reason-codes.d.ts.map +1 -1
  33. package/dist/workspace/records.d.ts +7 -1
  34. package/dist/workspace/records.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/src/cli/handlers/graph.ts +4 -0
  37. package/src/cli/main.test.ts +20 -0
  38. package/src/cli/main.ts +18 -0
  39. package/src/cli/mcp/resource-handlers.ts +17 -0
  40. package/src/cli/mcp/server.test.ts +140 -4
  41. package/src/cli/mcp/server.ts +5 -1
  42. package/src/cli/mcp/tools/composites.ts +98 -0
  43. package/src/cli/mcp/tools/search.ts +47 -5
  44. package/src/cli/registry.ts +6 -0
  45. package/src/components/cli-support.test.ts +16 -0
  46. package/src/components/cli-support.ts +8 -2
  47. package/src/composite.ts +9 -0
  48. package/src/lexicon.ts +47 -0
  49. package/src/workspace/__fixtures__/contract-repo.ts +17 -0
  50. package/src/workspace/composites.schema.json +471 -0
  51. package/src/workspace/composites.test.ts +244 -0
  52. package/src/workspace/composites.ts +295 -0
  53. package/src/workspace/graph-cli.ts +39 -3
  54. package/src/workspace/intent-cli.ts +119 -0
  55. package/src/workspace/intent-joins.ts +210 -0
  56. package/src/workspace/intent.schema.json +1141 -0
  57. package/src/workspace/intent.test.ts +566 -0
  58. package/src/workspace/intent.ts +999 -0
  59. package/src/workspace/member-commands.ts +11 -5
  60. package/src/workspace/read-contract.test.ts +46 -3
  61. package/src/workspace/reason-codes.test.ts +39 -2
  62. package/src/workspace/reason-codes.ts +40 -0
  63. package/src/workspace/records-contract.test.ts +22 -2
  64. package/src/workspace/records.schema.json +2 -2
  65. package/src/workspace/records.test.ts +93 -0
  66. package/src/workspace/records.ts +55 -11
@@ -0,0 +1,566 @@
1
+ /**
2
+ * The intent graph over one region (#2651), on a workspace built in a
3
+ * throwaway git repository:
4
+ *
5
+ * - c1 declares two members, app and design, adds the screen spec
6
+ * `design/screens/home.json` and decision dec-001, which constrains
7
+ * `path:app/server.mjs` and pins the spec by hash.
8
+ * - c2 adds `app/server.mjs` inside dec-001's window, with a `Unit: U-0001`
9
+ * trailer that the fixture plugin maps to a unit, a contract and evidence,
10
+ * and a `Made-By` trailer the plugin says claims authorship. U-0001's
11
+ * record names dec-001, so c2 is dec-001's own work.
12
+ * - c2b edits `app/server.mjs` inside dec-001's window too, from unit
13
+ * U-0002, whose record names no decision and which changed its contract's
14
+ * criteria. It is decided by the window only (#2656), and the plugin
15
+ * returns a finding for it in its own namespace, `plugin:units:`.
16
+ * - c3 adds dec-002, which supersedes dec-001, pins the spec at the same hash
17
+ * and constrains `member:design` only.
18
+ * - c4 edits `app/server.mjs` after that, with no decision covering it.
19
+ *
20
+ * Every node, edge and finding of the walk from `app/server.mjs` is asserted,
21
+ * and variants cover the findings that fixture does not raise.
22
+ */
23
+
24
+ import { readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
27
+ import { cleanScratch, contract, git, REPO, repo, writeFiles } from "./__fixtures__/contract-repo";
28
+ import { formatIntent } from "./intent-cli";
29
+ import { intentGraph, parseRegion, type IntentDocument, type IntentNode } from "./intent";
30
+ import intentSchema from "./intent.schema.json";
31
+ import { parseFrontMatter } from "./records";
32
+ import { sha256Hex } from "../content-digest";
33
+
34
+ const REF = join(REPO, "reference-workspace", "decisions");
35
+ const BASE = (() => {
36
+ const fm = parseFrontMatter(readFileSync(join(REF, "ref-001-how-the-app-is-deployed.md"), "utf-8"));
37
+ if (!fm.ok) throw new Error(fm.message);
38
+ return fm.value;
39
+ })();
40
+
41
+ const HOME = `${JSON.stringify({ route: "/", regions: ["header", "status"] }, null, 2)}\n`;
42
+ const HOME_SHA = sha256Hex(Buffer.from(HOME));
43
+
44
+ /** A decision record: ref-001 with its id, state and links replaced. JSON is YAML, so the front matter is JSON. */
45
+ function decision(id: string, fields: { state?: string; constrains: string[]; evidence?: unknown[]; supersedes?: string[] }): string {
46
+ const data = {
47
+ ...BASE,
48
+ id,
49
+ title: `Decision ${id}`,
50
+ state: fields.state ?? "decided",
51
+ supersedes: (fields.supersedes ?? []).map((d) => ({ decision: d })),
52
+ evidence: fields.evidence ?? [],
53
+ constrains: fields.constrains,
54
+ };
55
+ return `---\n${JSON.stringify(data, null, 2)}\n---\n\n# ${id}\n`;
56
+ }
57
+
58
+ const pin = { title: "The home screen spec", path: "design/screens/home.json", sha256: HOME_SHA, as_of: "2026-09-24T12:00:00Z" };
59
+
60
+ /** The fixture plugin: a Unit trailer names a unit whose record names its contract. */
61
+ const PLUGIN = `
62
+ export function commitJoins(commit, context) {
63
+ const id = commit.trailers["Unit"]?.[0];
64
+ if (!id) return undefined;
65
+ if (id === "U-BROKEN") throw new Error("no such unit");
66
+ const unit = JSON.parse(context.read(\`units/\${id}.json\`));
67
+ if (unit.badCode) return { findings: [{ code: "plugin:other:x", message: "wrong namespace" }] };
68
+ return {
69
+ unit: { id, role: unit.role, outcome: unit.outcome, ...(unit.decisions ? { decisions: unit.decisions } : {}) },
70
+ contract: { id: unit.contract, status: "closed" },
71
+ evidence: [{ id: "E-1", ok: true }],
72
+ authorship: commit.trailers["Made-By"] ? ["Made-By"] : [],
73
+ findings: unit.criteriaChanged
74
+ ? [{ code: "plugin:units:criteria-changed-undecided", message: \`\${unit.contract} changed its criteria in this commit with no decision\`, refs: [commit.sha, unit.contract, "dec-001", "no-such-thing"] }]
75
+ : [],
76
+ };
77
+ }
78
+ `;
79
+
80
+ /** The same joins, as data only. */
81
+ const DATA_PLUGIN = `export const commitJoins = { trailers: { unit: "Unit" }, records: { unit: "units/{id}.json" }, authorship: ["Made-By"] };\n`;
82
+
83
+ const SERVER_1 = "// the app\nexport const status = 'Running.';\nexport const port = 8080;\n";
84
+ const SERVER_1B = "// the app\nexport const status = 'Running.';\nexport const port = 9090;\n";
85
+ const SERVER_2 = "// the app\nexport const status = 'Up.';\nexport const port = 9090;\n";
86
+
87
+ let root: string;
88
+ const sha: Record<string, string> = {};
89
+ const KIND = "decisions/decision.kind.mjs";
90
+
91
+ function commit(message: string[]): string {
92
+ git(root, "add", "-A");
93
+ git(root, "commit", "-q", ...message.flatMap((m) => ["-m", m]));
94
+ return git(root, "rev-parse", "HEAD");
95
+ }
96
+
97
+ beforeAll(() => {
98
+ root = repo({
99
+ "chant.workspace.json": JSON.stringify(
100
+ {
101
+ name: "studio",
102
+ schema: 1,
103
+ members: [
104
+ { name: "app", dir: "app", kind: "other", because: "a plain Node server" },
105
+ { name: "design", dir: "design", kind: "other", because: "the design data member" },
106
+ ],
107
+ },
108
+ null,
109
+ 2,
110
+ ),
111
+ "app/README.md": "The app.\n",
112
+ "design/screens/home.json": HOME,
113
+ "decisions/decision.kind.mjs": readFileSync(join(REF, "decision.kind.mjs"), "utf-8"),
114
+ "decisions/decision.schema.json": readFileSync(join(REF, "decision.schema.json"), "utf-8"),
115
+ "decisions/dec-001-server.md": decision("dec-001", { constrains: ["path:app/server.mjs"], evidence: [pin] }),
116
+ "plugins/units.kind.mjs": PLUGIN,
117
+ "plugins/units-data.kind.mjs": DATA_PLUGIN,
118
+ "plugins/empty.kind.mjs": "export const nothing = 1;\n",
119
+ "units/U-0001.json": JSON.stringify({ role: "implement", contract: "C-001", outcome: "done", decisions: ["dec-001"] }),
120
+ "units/U-0002.json": JSON.stringify({ role: "implement", contract: "ctr-002", outcome: "done", criteriaChanged: true }),
121
+ "units/U-BAD.json": JSON.stringify({ badCode: true }),
122
+ });
123
+ sha.c1 = commit(["decide the server"]);
124
+ writeFiles(root, { "app/server.mjs": SERVER_1 });
125
+ sha.c2 = commit(["add the server", "Unit: U-0001\nMade-By: agent"]);
126
+ writeFiles(root, { "app/server.mjs": SERVER_1B });
127
+ sha.c2b = commit(["move the port", "Unit: U-0002"]);
128
+ writeFiles(root, { "decisions/dec-002-design.md": decision("dec-002", { constrains: ["member:design"], evidence: [pin], supersedes: ["dec-001"] }) });
129
+ sha.c3 = commit(["move the decision to the design member"]);
130
+ writeFiles(root, { "app/server.mjs": SERVER_2 });
131
+ sha.c4 = commit(["tweak the status line"]);
132
+ });
133
+ afterAll(cleanScratch);
134
+
135
+ const { expectValid } = contract(intentSchema);
136
+
137
+ type Result = Exclude<IntentDocument, { error: unknown }>;
138
+
139
+ async function walk(region: string, options: { kinds?: string[]; at?: string; cwd?: string } = {}): Promise<Result> {
140
+ const { doc } = await intentGraph({ cwd: options.cwd ?? root, region, at: options.at, kinds: (options.kinds ?? [KIND, "plugins/units.kind.mjs"]).map((k) => join(root, k)) });
141
+ expectValid(doc);
142
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
143
+ return doc;
144
+ }
145
+
146
+ const node = (doc: Result, id: string): IntentNode | undefined => doc.nodes.find((n) => n.id === id);
147
+ const findings = (doc: Result) => doc.nodes.filter((n) => n.kind === "finding").map((n) => (n.kind === "finding" ? [n.code, n.concerns] : []));
148
+ const edges = (doc: Result) => doc.edges.map((e) => `${e.kind} ${e.from} -> ${e.to}${"granularity" in e ? ` (${e.granularity})` : ""}${"pinState" in e ? ` (${e.pinState})` : ""}`);
149
+
150
+ describe("the intent graph of app/server.mjs (#2651)", () => {
151
+ test("lists every node, edge and finding", async () => {
152
+ const doc = await walk("app/server.mjs");
153
+ expect(doc).toMatchObject({ contract: 1, at: null, workspace: { name: "studio", root: "." }, region: "region:app/server.mjs" });
154
+ expect(doc.history).toEqual({ rev: sha.c4, follows: "file", shallow: false });
155
+ expect(doc.kinds).toEqual([
156
+ { file: KIND, name: "decision", records: "decision", joins: null },
157
+ { file: "plugins/units.kind.mjs", name: "units", records: null, joins: "function" },
158
+ ]);
159
+ expect(doc.reasons).toEqual([]);
160
+
161
+ expect(doc.nodes.map((n) => n.id)).toEqual([
162
+ "region:app/server.mjs",
163
+ "member:app",
164
+ `commit:${sha.c4}`,
165
+ `commit:${sha.c2b}`,
166
+ "unit:U-0002",
167
+ "contract:ctr-002",
168
+ "evidence:E-1",
169
+ `commit:${sha.c2}`,
170
+ "unit:U-0001",
171
+ "contract:C-001",
172
+ "record:decision/dec-001",
173
+ "record:decision/dec-002",
174
+ "artifact:design/screens/home.json",
175
+ "finding:intent-commit-undecided:1",
176
+ "finding:intent-commit-bare:1",
177
+ "finding:intent-decision-superseded-live:1",
178
+ "finding:intent-trailer-unverified:1",
179
+ "finding:plugin:units:criteria-changed-undecided:1",
180
+ ]);
181
+
182
+ expect(node(doc, "region:app/server.mjs")).toEqual({ id: "region:app/server.mjs", kind: "region", path: "app/server.mjs", lines: null, member: "app", at: null, type: "file", generated: false, node: null });
183
+ expect(node(doc, "member:app")).toEqual({ id: "member:app", kind: "member", name: "app", dir: "app", memberKind: "other" });
184
+ expect(node(doc, `commit:${sha.c2}`)).toMatchObject({
185
+ kind: "commit",
186
+ sha: sha.c2,
187
+ subject: "add the server",
188
+ author: { name: "t", email: "t@example.com" },
189
+ trailers: { Unit: ["U-0001"], "Made-By": ["agent"] },
190
+ pullRequest: null,
191
+ signature: { level: "unattested" },
192
+ lines: null,
193
+ // U-0001's record names dec-001: the decision's own work.
194
+ state: "decided",
195
+ });
196
+ // Inside dec-001's window, from a unit that names no decision (#2656).
197
+ expect(node(doc, `commit:${sha.c2b}`)).toMatchObject({ subject: "move the port", state: "decided-by-window" });
198
+ expect(node(doc, `commit:${sha.c4}`)).toMatchObject({ subject: "tweak the status line", trailers: {}, state: "undecided" });
199
+ expect(node(doc, "unit:U-0001")).toEqual({ id: "unit:U-0001", kind: "unit", ref: "U-0001", plugin: "plugins/units.kind.mjs", data: { role: "implement", outcome: "done", decisions: ["dec-001"] } });
200
+ expect(node(doc, "contract:C-001")).toMatchObject({ kind: "contract", ref: "C-001", data: { status: "closed" } });
201
+ expect(node(doc, "evidence:E-1")).toMatchObject({ kind: "evidence", ref: "E-1", data: { ok: true } });
202
+ expect(node(doc, "record:decision/dec-001")).toMatchObject({
203
+ kind: "decision",
204
+ recordKind: "decision",
205
+ record: "dec-001",
206
+ path: "decisions/dec-001-server.md",
207
+ state: "decided",
208
+ closed: false,
209
+ valid: true,
210
+ decided_by: "lex00",
211
+ reviews: { agree: 0, dissent: 0, abstain: 0, openConcerns: 0 },
212
+ supersededBy: "dec-002",
213
+ supersedes: [],
214
+ constrains: [{ entry: "path:app/server.mjs", granularity: "path" }],
215
+ provenance: { level: "unattested" },
216
+ });
217
+ expect(node(doc, "record:decision/dec-002")).toMatchObject({ record: "dec-002", supersededBy: null, supersedes: ["dec-001"], constrains: [] });
218
+ expect(node(doc, "artifact:design/screens/home.json")).toEqual({
219
+ id: "artifact:design/screens/home.json",
220
+ kind: "artifact",
221
+ path: "design/screens/home.json",
222
+ anchor: null,
223
+ pinnedSha256: HOME_SHA,
224
+ currentSha256: HOME_SHA,
225
+ // dec-002 pins the hash dec-001 pinned, and the spec has not changed since (#2549 asset-stale).
226
+ pinState: "stale",
227
+ });
228
+
229
+ expect(edges(doc)).toEqual([
230
+ `touched-by region:app/server.mjs -> commit:${sha.c4}`,
231
+ `touched-by region:app/server.mjs -> commit:${sha.c2b}`,
232
+ `produced-by commit:${sha.c2b} -> unit:U-0002`,
233
+ "serves unit:U-0002 -> contract:ctr-002",
234
+ "cites-evidence unit:U-0002 -> evidence:E-1",
235
+ `touched-by region:app/server.mjs -> commit:${sha.c2}`,
236
+ `produced-by commit:${sha.c2} -> unit:U-0001`,
237
+ "serves unit:U-0001 -> contract:C-001",
238
+ "cites-evidence unit:U-0001 -> evidence:E-1",
239
+ "constrains record:decision/dec-001 -> region:app/server.mjs (path)",
240
+ "supersedes record:decision/dec-002 -> record:decision/dec-001",
241
+ "pins record:decision/dec-001 -> artifact:design/screens/home.json (pinned)",
242
+ "pins record:decision/dec-002 -> artifact:design/screens/home.json (stale)",
243
+ `within commit:${sha.c2b} -> record:decision/dec-001`,
244
+ `within commit:${sha.c2} -> record:decision/dec-001`,
245
+ ]);
246
+ expect(doc.edges.filter((e) => e.kind === "within")).toEqual([
247
+ { kind: "within", from: `commit:${sha.c2b}`, to: "record:decision/dec-001", state: "decided-by-window" },
248
+ { kind: "within", from: `commit:${sha.c2}`, to: "record:decision/dec-001", state: "decided" },
249
+ ]);
250
+
251
+ // c2 and c2b fall inside dec-001's window (from c1 until c3), so neither is
252
+ // intent-commit-undecided; c4 comes after it. The plugin's finding is about
253
+ // c2b, and its refs resolve to the nodes they name, except the one that
254
+ // names nothing in the graph.
255
+ expect(findings(doc)).toEqual([
256
+ ["intent-commit-undecided", [`commit:${sha.c4}`, "region:app/server.mjs"]],
257
+ ["intent-commit-bare", [`commit:${sha.c4}`]],
258
+ ["intent-decision-superseded-live", ["region:app/server.mjs", "record:decision/dec-001"]],
259
+ ["intent-trailer-unverified", [`commit:${sha.c2}`]],
260
+ ["plugin:units:criteria-changed-undecided", [`commit:${sha.c2b}`, "contract:ctr-002", "record:decision/dec-001"]],
261
+ ]);
262
+ expect(node(doc, "finding:plugin:units:criteria-changed-undecided:1")).toEqual({
263
+ id: "finding:plugin:units:criteria-changed-undecided:1",
264
+ kind: "finding",
265
+ code: "plugin:units:criteria-changed-undecided",
266
+ message: "ctr-002 changed its criteria in this commit with no decision",
267
+ concerns: [`commit:${sha.c2b}`, "contract:ctr-002", "record:decision/dec-001"],
268
+ plugin: "plugins/units.kind.mjs",
269
+ refs: [sha.c2b, "ctr-002", "dec-001", "no-such-thing"],
270
+ });
271
+ expect(doc.summary).toEqual({ commits: 3, decisions: 2, artifacts: 1, findings: 5 });
272
+ });
273
+
274
+ test("the text walk runs region, decisions with their in-window commits, artifacts, commits, findings, one line each", async () => {
275
+ const text = formatIntent(await walk("app/server.mjs"));
276
+ const lines = text.split("\n");
277
+ expect(lines.map((l) => l.trim().split(/\s+/)[0])).toEqual([
278
+ "region",
279
+ "decision",
280
+ "within",
281
+ "decided",
282
+ "ask",
283
+ "decision",
284
+ "artifact",
285
+ "commit",
286
+ "commit",
287
+ "unit",
288
+ "contract",
289
+ "evidence",
290
+ "commit",
291
+ "unit",
292
+ "contract",
293
+ "evidence",
294
+ "finding",
295
+ "finding",
296
+ "finding",
297
+ "finding",
298
+ "finding",
299
+ "3",
300
+ ]);
301
+ expect(lines[0]).toBe("region app/server.mjs (file, member app) in the working tree");
302
+ expect(lines[1]).toContain("dec-001 decided, superseded by dec-002");
303
+ expect(lines[1]).toContain("path:app/server.mjs (path)");
304
+ // The in-window commits under their decision, and the question once (#2656, #2650 B).
305
+ expect(lines[2]).toBe(` within ${sha.c2b.slice(0, 8)} move the port; unit U-0002, in dec-001's window and not its work`);
306
+ expect(lines[3]).toBe(` decided ${sha.c2.slice(0, 8)} add the server; unit U-0001, dec-001's own work`);
307
+ expect(lines[4]).toBe(" ask is this drift, a superseding decision nobody wrote down, or the decision being wrong?");
308
+ expect(lines[6]).toBe(`artifact design/screens/home.json stale; pinned by dec-001 at ${HOME_SHA.slice(0, 8)} (pinned), dec-002 at ${HOME_SHA.slice(0, 8)} (stale); now ${HOME_SHA.slice(0, 8)}`);
309
+ expect(lines[7]).toContain(`${sha.c4.slice(0, 8)} `);
310
+ expect(lines.at(-2)).toBe("finding plugin:units:criteria-changed-undecided: ctr-002 changed its criteria in this commit with no decision");
311
+ expect(lines.at(-1)).toBe("3 commits, 2 decisions, 1 artifacts, 5 findings");
312
+ });
313
+
314
+ test("a decision whose in-window commits are all its own work asks nothing", async () => {
315
+ const doc = await walk("app/server.mjs", { at: sha.c2 });
316
+ const text = formatIntent(doc);
317
+ expect(text).toContain(` decided ${sha.c2.slice(0, 8)} add the server`);
318
+ expect(text).not.toContain(" ask ");
319
+ });
320
+
321
+ test("a line range follows the lines with git log -L, and names the lines each commit changed", async () => {
322
+ const doc = await walk("app/server.mjs:2");
323
+ expect(doc.region).toBe("region:app/server.mjs:2");
324
+ expect(doc.history.follows).toBe("line-range");
325
+ expect(doc.edges.filter((e) => e.kind === "touched-by")).toEqual([
326
+ { kind: "touched-by", from: "region:app/server.mjs:2", to: `commit:${sha.c4}`, lines: [{ start: 2, end: 2 }] },
327
+ { kind: "touched-by", from: "region:app/server.mjs:2", to: `commit:${sha.c2}`, lines: [{ start: 2, end: 2 }] },
328
+ ]);
329
+ });
330
+
331
+ test("the data form of commitJoins joins the same unit, with no plugin code", async () => {
332
+ const doc = await walk("app/server.mjs", { kinds: [KIND, "plugins/units-data.kind.mjs"] });
333
+ expect(doc.kinds[1]).toEqual({ file: "plugins/units-data.kind.mjs", name: "units-data", records: null, joins: "data" });
334
+ expect(node(doc, "unit:U-0001")).toEqual({
335
+ id: "unit:U-0001",
336
+ kind: "unit",
337
+ ref: "U-0001",
338
+ plugin: "plugins/units-data.kind.mjs",
339
+ data: { role: "implement", contract: "C-001", outcome: "done", decisions: ["dec-001"] },
340
+ });
341
+ expect(doc.edges).toContainEqual({ kind: "produced-by", from: `commit:${sha.c2}`, to: "unit:U-0001" });
342
+ // The unit record's decisions field makes c2 dec-001's own work in the data form too.
343
+ expect(node(doc, `commit:${sha.c2}`)).toMatchObject({ state: "decided" });
344
+ expect(node(doc, `commit:${sha.c2b}`)).toMatchObject({ state: "decided-by-window" });
345
+ expect(findings(doc).map(([c]) => c)).not.toContain("plugin:units:criteria-changed-undecided");
346
+ expect(findings(doc).map(([c]) => c)).toContain("intent-trailer-unverified");
347
+ });
348
+
349
+ test("without --kind there are no decisions, so no decision findings", async () => {
350
+ const doc = await walk("app/server.mjs", { kinds: [] });
351
+ expect(doc.nodes.map((n) => n.kind)).toEqual(["region", "member", "commit", "commit", "commit"]);
352
+ expect(doc.nodes.filter((n) => n.kind === "commit").map((n) => n.kind === "commit" && n.state)).toEqual([null, null, null]);
353
+ expect(doc.edges.filter((e) => e.kind === "within")).toEqual([]);
354
+ expect(findings(doc)).toEqual([]);
355
+ });
356
+
357
+ test("--at reads the tree and the history at that commit", async () => {
358
+ const doc = await walk("app/server.mjs", { at: sha.c2 });
359
+ expect(doc.at).toBe(sha.c2);
360
+ expect(doc.history.rev).toBe(sha.c2);
361
+ expect(doc.nodes.filter((n) => n.kind === "commit").map((n) => n.id)).toEqual([`commit:${sha.c2}`]);
362
+ expect(node(doc, "record:decision/dec-001")).toMatchObject({ supersededBy: null });
363
+ expect(node(doc, "record:decision/dec-002")).toBeUndefined();
364
+ // dec-001 is current and decided: the one commit is covered, and the decision is provisional.
365
+ expect(findings(doc).map(([c]) => c)).toEqual(["intent-decision-provisional", "intent-trailer-unverified"]);
366
+ expect(node(doc, "artifact:design/screens/home.json")).toMatchObject({ pinState: "pinned" });
367
+ });
368
+ });
369
+
370
+ describe("findings the fixture does not raise (#2651)", () => {
371
+ function withFile(path: string, text: string | null, fn: () => Promise<void>): () => Promise<void> {
372
+ return async () => {
373
+ const full = join(root, path);
374
+ let before: string | null = null;
375
+ try {
376
+ before = readFileSync(full, "utf-8");
377
+ } catch {
378
+ before = null;
379
+ }
380
+ if (text === null) unlinkSync(full);
381
+ else writeFileSync(full, text);
382
+ try {
383
+ await fn();
384
+ } finally {
385
+ if (before === null) rmSync(full, { force: true });
386
+ else writeFileSync(full, before);
387
+ }
388
+ };
389
+ }
390
+
391
+ test(
392
+ "a pinned artifact that changed is intent-pin-drifted, for each decision pinning it",
393
+ withFile("design/screens/home.json", HOME.replace('"/"', '"/home"'), async () => {
394
+ const doc = await walk("app/server.mjs");
395
+ expect(node(doc, "artifact:design/screens/home.json")).toMatchObject({ pinState: "drifted", pinnedSha256: HOME_SHA });
396
+ expect(findings(doc).filter(([c]) => c === "intent-pin-drifted")).toEqual([
397
+ ["intent-pin-drifted", ["record:decision/dec-001", "artifact:design/screens/home.json"]],
398
+ ["intent-pin-drifted", ["record:decision/dec-002", "artifact:design/screens/home.json"]],
399
+ ]);
400
+ }),
401
+ );
402
+
403
+ test(
404
+ "a pinned artifact that is gone is intent-pin-missing",
405
+ withFile("design/screens/home.json", null, async () => {
406
+ const doc = await walk("app/server.mjs");
407
+ expect(node(doc, "artifact:design/screens/home.json")).toMatchObject({ pinState: "missing", currentSha256: null });
408
+ expect(findings(doc).filter(([c]) => c === "intent-pin-missing")).toHaveLength(2);
409
+ }),
410
+ );
411
+
412
+ test(
413
+ "an artifact only a superseded decision pins is intent-artifact-unpinned",
414
+ withFile("decisions/dec-002-design.md", decision("dec-002", { constrains: ["member:design"], supersedes: ["dec-001"] }), async () => {
415
+ const doc = await walk("app/server.mjs");
416
+ expect(node(doc, "artifact:design/screens/home.json")).toMatchObject({ pinState: "unpinned", pinnedSha256: null });
417
+ expect(findings(doc)).toContainEqual(["intent-artifact-unpinned", ["artifact:design/screens/home.json", "record:decision/dec-001"]]);
418
+ }),
419
+ );
420
+
421
+ test("a directory no decision constrains is intent-region-unconstrained, and a path below it constrains its file", async () => {
422
+ const doc = await walk("app");
423
+ expect(doc.history.follows).toBe("directory");
424
+ expect(doc.nodes.filter((n) => n.kind === "file").map((n) => n.id)).toEqual(["file:app/README.md", "file:app/server.mjs"]);
425
+ expect(doc.edges).toContainEqual({ kind: "constrains", from: "record:decision/dec-001", to: "file:app/server.mjs", granularity: "path", entry: "path:app/server.mjs" });
426
+ expect(findings(doc).map(([c]) => c)).toContain("intent-region-unconstrained");
427
+ expect(node(doc, "record:decision/dec-001")).toMatchObject({ constrains: [] });
428
+ });
429
+
430
+ test(
431
+ "a member-only, decided constraint is coarse and provisional; a lost path and an unhashed URL are reported",
432
+ withFile(
433
+ "decisions/dec-002-design.md",
434
+ decision("dec-002", {
435
+ constrains: ["member:design", "path:design/old.json"],
436
+ evidence: [pin, { title: "A page", url: "https://example.com/page", as_of: "2026-09-24T12:00:00Z" }],
437
+ supersedes: ["dec-001"],
438
+ }),
439
+ async () => {
440
+ const doc = await walk("design/screens/home.json");
441
+ expect(node(doc, "record:decision/dec-002")).toMatchObject({ constrains: [{ entry: "member:design", granularity: "member" }] });
442
+ expect(doc.edges).toContainEqual({ kind: "cites-evidence", from: "record:decision/dec-002", to: "evidence:https://example.com/page" });
443
+ expect(findings(doc).map(([c]) => c)).toEqual([
444
+ "intent-commit-undecided",
445
+ "intent-commit-bare",
446
+ "intent-decision-provisional",
447
+ "intent-constraint-coarse",
448
+ "intent-constraint-lost",
449
+ "intent-evidence-unpinned",
450
+ ]);
451
+ },
452
+ ),
453
+ );
454
+
455
+ test(
456
+ "a commit whose unit serves a contract the decision constrains is the decision's own work (#2656)",
457
+ withFile("decisions/dec-001-server.md", decision("dec-001", { constrains: ["path:app/server.mjs", "ctr-002"], evidence: [pin] }), async () => {
458
+ const doc = await walk("app/server.mjs");
459
+ expect(doc.edges).toContainEqual({ kind: "constrains", from: "record:decision/dec-001", to: "contract:ctr-002", granularity: "contract", entry: "ctr-002" });
460
+ expect(node(doc, `commit:${sha.c2b}`)).toMatchObject({ state: "decided" });
461
+ expect(doc.edges).toContainEqual({ kind: "within", from: `commit:${sha.c2b}`, to: "record:decision/dec-001", state: "decided" });
462
+ expect(formatIntent(doc)).not.toContain(" ask ");
463
+ }),
464
+ );
465
+
466
+ test(
467
+ "a decision constraining an issue the region's commits name covers it at issue granularity",
468
+ withFile("decisions/dec-003-issue.md", decision("dec-003", { constrains: ["acme/studio#12"] }), async () => {
469
+ git(root, "remote", "add", "origin", "git@github.com:acme/studio.git");
470
+ try {
471
+ writeFiles(root, { "app/server.mjs": `${SERVER_2}// more\n` });
472
+ const c5 = commit(["more (#12)"]);
473
+ try {
474
+ const doc = await walk("app/server.mjs");
475
+ expect(node(doc, `commit:${c5}`)).toMatchObject({ pullRequest: 12 });
476
+ expect(doc.edges).toContainEqual({ kind: "constrains", from: "record:decision/dec-003", to: "region:app/server.mjs", granularity: "issue", entry: "acme/studio#12" });
477
+ } finally {
478
+ git(root, "reset", "-q", "--hard", sha.c4);
479
+ }
480
+ } finally {
481
+ git(root, "remote", "remove", "origin");
482
+ }
483
+ }),
484
+ );
485
+ });
486
+
487
+ describe("reads that fail, and parts that can't be read (#2651)", () => {
488
+ test("a path that does not exist, or a range past its end, is intent-region-invalid", async () => {
489
+ for (const region of ["app/nope.mjs", "app/server.mjs:9", "app:1"]) {
490
+ const { doc, failed } = await intentGraph({ cwd: root, region, kinds: [] });
491
+ expectValid(doc);
492
+ expect(failed).toBe(true);
493
+ expect("error" in doc && doc.error.code).toBe("intent-region-invalid");
494
+ }
495
+ });
496
+
497
+ test("a kind file with neither recordKind nor commitJoins is kind-invalid", async () => {
498
+ const { doc } = await intentGraph({ cwd: root, region: "app/server.mjs", kinds: [join(root, "plugins/empty.kind.mjs")] });
499
+ expectValid(doc);
500
+ expect("error" in doc && doc.error.code).toBe("kind-invalid");
501
+ });
502
+
503
+ test("a plugin that throws is intent-plugin-failed, and the rest of the walk stands", async () => {
504
+ writeFiles(root, { "app/server.mjs": `${SERVER_2}// broken\n` });
505
+ commit(["break the unit", "Unit: U-BROKEN"]);
506
+ try {
507
+ const { doc, failed } = await intentGraph({ cwd: root, region: "app/server.mjs", kinds: [join(root, KIND), join(root, "plugins/units.kind.mjs")] });
508
+ expectValid(doc);
509
+ if ("error" in doc) throw new Error(doc.error.message);
510
+ expect(failed).toBe(true);
511
+ expect(doc.reasons).toEqual([{ code: "intent-plugin-failed", message: expect.stringContaining("no such unit") }]);
512
+ expect(doc.summary.commits).toBe(4);
513
+ } finally {
514
+ git(root, "reset", "-q", "--hard", sha.c4);
515
+ }
516
+ });
517
+
518
+ test("a plugin finding outside the kind's namespace is intent-plugin-failed (#2656)", async () => {
519
+ writeFiles(root, { "app/server.mjs": `${SERVER_2}// bad code\n` });
520
+ commit(["a finding in someone else's namespace", "Unit: U-BAD"]);
521
+ try {
522
+ const { doc, failed } = await intentGraph({ cwd: root, region: "app/server.mjs", kinds: [join(root, KIND), join(root, "plugins/units.kind.mjs")] });
523
+ expectValid(doc);
524
+ if ("error" in doc) throw new Error(doc.error.message);
525
+ expect(failed).toBe(true);
526
+ expect(doc.reasons).toEqual([{ code: "intent-plugin-failed", message: expect.stringContaining("plugin:units:<code>") }]);
527
+ expect(doc.nodes.some((n) => n.kind === "finding" && n.code === "plugin:other:x")).toBe(false);
528
+ } finally {
529
+ git(root, "reset", "-q", "--hard", sha.c4);
530
+ }
531
+ });
532
+
533
+ test("a shallow clone is intent-history-shallow", async () => {
534
+ const shallow = join(root, "..", `${root.split("/").at(-1)}-shallow`);
535
+ git(join(root, ".."), "clone", "-q", "--depth", "1", `file://${root}`, shallow);
536
+ try {
537
+ const { doc } = await intentGraph({ cwd: shallow, region: "app/server.mjs", kinds: [] });
538
+ expectValid(doc);
539
+ if ("error" in doc) throw new Error(doc.error.message);
540
+ expect(doc.history.shallow).toBe(true);
541
+ expect(doc.reasons.map((r) => r.code)).toEqual(["intent-history-shallow"]);
542
+ } finally {
543
+ rmSync(shallow, { recursive: true, force: true });
544
+ }
545
+ });
546
+
547
+ test("a graph node id resolves to its sourceLoc", async () => {
548
+ const { doc } = await intentGraph({
549
+ cwd: root,
550
+ region: "app/Server",
551
+ kinds: [],
552
+ resolveNode: async (_cwd, _at, member, id) => (member === "app" && id === "app/Server" ? { file: "server.mjs", line: 2 } : null),
553
+ });
554
+ expectValid(doc);
555
+ if ("error" in doc) throw new Error(doc.error.message);
556
+ expect(node(doc, doc.region)).toMatchObject({ path: "app/server.mjs", lines: { start: 2, end: 2 }, node: "app/Server" });
557
+ });
558
+
559
+ test("parseRegion reads path, path:line and path:start-end", () => {
560
+ expect(parseRegion("a/b.ts")).toEqual({ path: "a/b.ts", lines: null });
561
+ expect(parseRegion("a/b.ts:7")).toEqual({ path: "a/b.ts", lines: { start: 7, end: 7 } });
562
+ expect(parseRegion("a/b.ts:3-9")).toEqual({ path: "a/b.ts", lines: { start: 3, end: 9 } });
563
+ expect(parseRegion("a/b.ts:9-3")).toHaveProperty("error");
564
+ expect(parseRegion("a/b.ts:0")).toHaveProperty("error");
565
+ });
566
+ });