@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,12 @@
7
7
  * `path:app/server.mjs` and pins the spec by hash.
8
8
  * - c2 adds `app/server.mjs` inside dec-001's window, with a `Unit: U-0001`
9
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.
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:`.
11
16
  * - c3 adds dec-002, which supersedes dec-001, pins the spec at the same hash
12
17
  * and constrains `member:design` only.
13
18
  * - c4 edits `app/server.mjs` after that, with no decision covering it.
@@ -59,11 +64,15 @@ export function commitJoins(commit, context) {
59
64
  if (!id) return undefined;
60
65
  if (id === "U-BROKEN") throw new Error("no such unit");
61
66
  const unit = JSON.parse(context.read(\`units/\${id}.json\`));
67
+ if (unit.badCode) return { findings: [{ code: "plugin:other:x", message: "wrong namespace" }] };
62
68
  return {
63
- unit: { id, role: unit.role, outcome: unit.outcome },
69
+ unit: { id, role: unit.role, outcome: unit.outcome, ...(unit.decisions ? { decisions: unit.decisions } : {}) },
64
70
  contract: { id: unit.contract, status: "closed" },
65
71
  evidence: [{ id: "E-1", ok: true }],
66
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
+ : [],
67
76
  };
68
77
  }
69
78
  `;
@@ -72,7 +81,8 @@ export function commitJoins(commit, context) {
72
81
  const DATA_PLUGIN = `export const commitJoins = { trailers: { unit: "Unit" }, records: { unit: "units/{id}.json" }, authorship: ["Made-By"] };\n`;
73
82
 
74
83
  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";
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";
76
86
 
77
87
  let root: string;
78
88
  const sha: Record<string, string> = {};
@@ -106,11 +116,15 @@ beforeAll(() => {
106
116
  "plugins/units.kind.mjs": PLUGIN,
107
117
  "plugins/units-data.kind.mjs": DATA_PLUGIN,
108
118
  "plugins/empty.kind.mjs": "export const nothing = 1;\n",
109
- "units/U-0001.json": JSON.stringify({ role: "implement", contract: "C-001", outcome: "done" }),
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 }),
110
122
  });
111
123
  sha.c1 = commit(["decide the server"]);
112
124
  writeFiles(root, { "app/server.mjs": SERVER_1 });
113
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"]);
114
128
  writeFiles(root, { "decisions/dec-002-design.md": decision("dec-002", { constrains: ["member:design"], evidence: [pin], supersedes: ["dec-001"] }) });
115
129
  sha.c3 = commit(["move the decision to the design member"]);
116
130
  writeFiles(root, { "app/server.mjs": SERVER_2 });
@@ -139,8 +153,8 @@ describe("the intent graph of app/server.mjs (#2651)", () => {
139
153
  expect(doc).toMatchObject({ contract: 1, at: null, workspace: { name: "studio", root: "." }, region: "region:app/server.mjs" });
140
154
  expect(doc.history).toEqual({ rev: sha.c4, follows: "file", shallow: false });
141
155
  expect(doc.kinds).toEqual([
142
- { file: KIND, records: "decision", joins: null },
143
- { file: "plugins/units.kind.mjs", records: null, joins: "function" },
156
+ { file: KIND, name: "decision", records: "decision", joins: null },
157
+ { file: "plugins/units.kind.mjs", name: "units", records: null, joins: "function" },
144
158
  ]);
145
159
  expect(doc.reasons).toEqual([]);
146
160
 
@@ -148,10 +162,13 @@ describe("the intent graph of app/server.mjs (#2651)", () => {
148
162
  "region:app/server.mjs",
149
163
  "member:app",
150
164
  `commit:${sha.c4}`,
165
+ `commit:${sha.c2b}`,
166
+ "unit:U-0002",
167
+ "contract:ctr-002",
168
+ "evidence:E-1",
151
169
  `commit:${sha.c2}`,
152
170
  "unit:U-0001",
153
171
  "contract:C-001",
154
- "evidence:E-1",
155
172
  "record:decision/dec-001",
156
173
  "record:decision/dec-002",
157
174
  "artifact:design/screens/home.json",
@@ -159,6 +176,7 @@ describe("the intent graph of app/server.mjs (#2651)", () => {
159
176
  "finding:intent-commit-bare:1",
160
177
  "finding:intent-decision-superseded-live:1",
161
178
  "finding:intent-trailer-unverified:1",
179
+ "finding:plugin:units:criteria-changed-undecided:1",
162
180
  ]);
163
181
 
164
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 });
@@ -172,9 +190,13 @@ describe("the intent graph of app/server.mjs (#2651)", () => {
172
190
  pullRequest: null,
173
191
  signature: { level: "unattested" },
174
192
  lines: null,
193
+ // U-0001's record names dec-001: the decision's own work.
194
+ state: "decided",
175
195
  });
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" } });
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"] } });
178
200
  expect(node(doc, "contract:C-001")).toMatchObject({ kind: "contract", ref: "C-001", data: { status: "closed" } });
179
201
  expect(node(doc, "evidence:E-1")).toMatchObject({ kind: "evidence", ref: "E-1", data: { ok: true } });
180
202
  expect(node(doc, "record:decision/dec-001")).toMatchObject({
@@ -206,6 +228,10 @@ describe("the intent graph of app/server.mjs (#2651)", () => {
206
228
 
207
229
  expect(edges(doc)).toEqual([
208
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",
209
235
  `touched-by region:app/server.mjs -> commit:${sha.c2}`,
210
236
  `produced-by commit:${sha.c2} -> unit:U-0001`,
211
237
  "serves unit:U-0001 -> contract:C-001",
@@ -214,28 +240,82 @@ describe("the intent graph of app/server.mjs (#2651)", () => {
214
240
  "supersedes record:decision/dec-002 -> record:decision/dec-001",
215
241
  "pins record:decision/dec-001 -> artifact:design/screens/home.json (pinned)",
216
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" },
217
249
  ]);
218
250
 
219
- // c2 falls inside dec-001's window (from c1 until c3); c4 comes after it.
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.
220
255
  expect(findings(doc)).toEqual([
221
256
  ["intent-commit-undecided", [`commit:${sha.c4}`, "region:app/server.mjs"]],
222
257
  ["intent-commit-bare", [`commit:${sha.c4}`]],
223
258
  ["intent-decision-superseded-live", ["region:app/server.mjs", "record:decision/dec-001"]],
224
259
  ["intent-trailer-unverified", [`commit:${sha.c2}`]],
260
+ ["plugin:units:criteria-changed-undecided", [`commit:${sha.c2b}`, "contract:ctr-002", "record:decision/dec-001"]],
225
261
  ]);
226
- expect(doc.summary).toEqual({ commits: 2, decisions: 2, artifacts: 1, findings: 4 });
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 });
227
272
  });
228
273
 
229
- test("the text walk runs region, decisions, artifacts, commits, findings, one line each", async () => {
274
+ test("the text walk runs region, decisions with their in-window commits, artifacts, commits, findings, one line each", async () => {
230
275
  const text = formatIntent(await walk("app/server.mjs"));
231
276
  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"]);
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
+ ]);
233
301
  expect(lines[0]).toBe("region app/server.mjs (file, member app) in the working tree");
234
302
  expect(lines[1]).toContain("dec-001 decided, superseded by dec-002");
235
303
  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");
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 ");
239
319
  });
240
320
 
241
321
  test("a line range follows the lines with git log -L, and names the lines each commit changed", async () => {
@@ -250,15 +330,27 @@ describe("the intent graph of app/server.mjs (#2651)", () => {
250
330
 
251
331
  test("the data form of commitJoins joins the same unit, with no plugin code", async () => {
252
332
  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" } });
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
+ });
255
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");
256
346
  expect(findings(doc).map(([c]) => c)).toContain("intent-trailer-unverified");
257
347
  });
258
348
 
259
349
  test("without --kind there are no decisions, so no decision findings", async () => {
260
350
  const doc = await walk("app/server.mjs", { kinds: [] });
261
- expect(doc.nodes.map((n) => n.kind)).toEqual(["region", "member", "commit", "commit"]);
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([]);
262
354
  expect(findings(doc)).toEqual([]);
263
355
  });
264
356
 
@@ -360,6 +452,17 @@ describe("findings the fixture does not raise (#2651)", () => {
360
452
  ),
361
453
  );
362
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
+
363
466
  test(
364
467
  "a decision constraining an issue the region's commits name covers it at issue granularity",
365
468
  withFile("decisions/dec-003-issue.md", decision("dec-003", { constrains: ["acme/studio#12"] }), async () => {
@@ -406,7 +509,22 @@ describe("reads that fail, and parts that can't be read (#2651)", () => {
406
509
  if ("error" in doc) throw new Error(doc.error.message);
407
510
  expect(failed).toBe(true);
408
511
  expect(doc.reasons).toEqual([{ code: "intent-plugin-failed", message: expect.stringContaining("no such unit") }]);
409
- expect(doc.summary.commits).toBe(3);
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);
410
528
  } finally {
411
529
  git(root, "reset", "-q", "--hard", sha.c4);
412
530
  }
@@ -446,3 +564,100 @@ describe("reads that fail, and parts that can't be read (#2651)", () => {
446
564
  expect(parseRegion("a/b.ts:0")).toHaveProperty("error");
447
565
  });
448
566
  });
567
+
568
+ describe("commitJoins lists records and names its findings (#2663)", () => {
569
+ test("a plugin joins commits by listing its records, with no trailer at all (#2663)", async () => {
570
+ const SCAN = `
571
+ export function commitJoins(commit, context) {
572
+ const walk = (dir) => (context.list(dir) ?? []).flatMap((p) => (p.endsWith("/") ? walk(p) : [p]));
573
+ for (const path of walk("unit-records")) {
574
+ const id = path.split("/").at(-1).replace(/\\.(json|md)$/, "");
575
+ const text = context.read(path);
576
+ if (path.endsWith(".json")) {
577
+ const record = JSON.parse(text);
578
+ if (record.result?.commit !== commit.sha) continue;
579
+ const probe = { missing: context.list("nope"), escape: context.list(".."), file: context.list(path), root: context.list(".").includes("unit-records/") };
580
+ return { unit: { id, path, probe }, contract: { id: record.contract } };
581
+ }
582
+ if (text.includes(commit.sha)) return { unit: { id, path } };
583
+ }
584
+ return undefined;
585
+ }
586
+ `;
587
+ writeFiles(root, {
588
+ "plugins/scan.kind.mjs": SCAN,
589
+ "unit-records/U-0100.json": JSON.stringify({ contract: "C-100", result: { commit: sha.c2 } }),
590
+ "unit-records/archive/U-0104.md": `---\nid: U-0104\n---\n\nLanded as ${sha.c4}.\n`,
591
+ });
592
+ commit(["record the units after their commits"]);
593
+ try {
594
+ const doc = await walk("app/server.mjs", { kinds: [KIND, "plugins/scan.kind.mjs"] });
595
+ // c4 carries no trailer; the markdown record in a subdirectory names it.
596
+ expect(node(doc, `commit:${sha.c4}`)).toMatchObject({ trailers: {} });
597
+ expect(doc.edges).toContainEqual({ kind: "produced-by", from: `commit:${sha.c4}`, to: "unit:U-0104" });
598
+ expect(node(doc, "unit:U-0104")).toMatchObject({ data: { path: "unit-records/archive/U-0104.md" } });
599
+ expect(doc.edges).toContainEqual({ kind: "produced-by", from: `commit:${sha.c2}`, to: "unit:U-0100" });
600
+ expect(doc.edges).toContainEqual({ kind: "serves", from: "unit:U-0100", to: "contract:C-100" });
601
+ expect(node(doc, "unit:U-0100")).toMatchObject({ data: { path: "unit-records/U-0100.json", probe: { root: true } } });
602
+ // Not a directory there, or outside the workspace: undefined.
603
+ expect((node(doc, "unit:U-0100") as { data: unknown }).data).toMatchObject({ probe: { missing: undefined, escape: undefined, file: undefined, root: true } });
604
+ expect(node(doc, `commit:${sha.c2b}`) && doc.edges.some((e) => e.kind === "produced-by" && e.from === `commit:${sha.c2b}`)).toBe(false);
605
+ expect(doc.reasons).toEqual([]);
606
+
607
+ // At c4 the records were not written yet: list answers from the tree read, so nothing joins.
608
+ const before = await walk("app/server.mjs", { kinds: [KIND, "plugins/scan.kind.mjs"], at: sha.c4 });
609
+ expect(before.nodes.filter((n) => n.kind === "unit")).toEqual([]);
610
+ expect(before.reasons).toEqual([]);
611
+ } finally {
612
+ git(root, "reset", "-q", "--hard", sha.c4);
613
+ }
614
+ });
615
+
616
+ test("commitJoinsName names a plugin's findings apart from its record kind's name (#2663)", async () => {
617
+ const NAMED = (name: string) => `
618
+ export { recordKind } from "./decision.kind.mjs";
619
+ export const commitJoinsName = ${JSON.stringify(name)};
620
+ export function commitJoins(commit) {
621
+ if (commit.trailers["Unit"]?.[0] !== "U-0002") return undefined;
622
+ return { findings: [{ code: "plugin:studio:contract-moved", message: "moved", refs: [commit.sha] }] };
623
+ }
624
+ `;
625
+ writeFiles(root, { "decisions/studio.kind.mjs": NAMED("studio"), "decisions/other.kind.mjs": NAMED("other") });
626
+ commit(["name the findings"]);
627
+ try {
628
+ const doc = await walk("app/server.mjs", { kinds: ["decisions/studio.kind.mjs"] });
629
+ expect(doc.kinds).toEqual([{ file: "decisions/studio.kind.mjs", name: "studio", records: "decision", joins: "function" }]);
630
+ expect(doc.reasons).toEqual([]);
631
+ expect(doc.nodes).toContainEqual(expect.objectContaining({ kind: "finding", code: "plugin:studio:contract-moved", concerns: [`commit:${sha.c2b}`] }));
632
+ // The decisions still come from the record kind called decision.
633
+ expect(node(doc, "record:decision/dec-001")).toBeDefined();
634
+
635
+ // Under another name, the same code is outside the namespace, and the record kind's name is no longer it either.
636
+ const { doc: other, failed } = await intentGraph({ cwd: root, region: "app/server.mjs", kinds: [join(root, "decisions/other.kind.mjs")] });
637
+ expectValid(other);
638
+ if ("error" in other) throw new Error(other.error.message);
639
+ expect(failed).toBe(true);
640
+ expect(other.reasons).toEqual([{ code: "intent-plugin-failed", message: expect.stringContaining("plugin:other:<code>") }]);
641
+ } finally {
642
+ git(root, "reset", "-q", "--hard", sha.c4);
643
+ }
644
+ });
645
+
646
+ test("a malformed commitJoinsName is kind-invalid (#2663)", async () => {
647
+ writeFiles(root, { "plugins/badname.kind.mjs": `export const commitJoinsName = "a:b";\nexport function commitJoins() {}\n`, "plugins/nameonly.kind.mjs": `export const commitJoinsName = "x";\n` });
648
+ commit(["malformed names"]);
649
+ try {
650
+ for (const [file, text] of [
651
+ ["plugins/badname.kind.mjs", "no colon or space"],
652
+ ["plugins/nameonly.kind.mjs", "no commitJoins"],
653
+ ]) {
654
+ const { doc } = await intentGraph({ cwd: root, region: "app/server.mjs", kinds: [join(root, file)] });
655
+ expectValid(doc);
656
+ expect("error" in doc && doc.error.code).toBe("kind-invalid");
657
+ expect("error" in doc && doc.error.message).toContain(text);
658
+ }
659
+ } finally {
660
+ git(root, "reset", "-q", "--hard", sha.c4);
661
+ }
662
+ });
663
+ });