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