@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,999 @@
1
+ /**
2
+ * The intent graph over one region: `chant workspace graph --intent
3
+ * <path[:start-end]>` (#2651; #2650 sections B and C1; #2524 D8, D15).
4
+ *
5
+ * A person points at some code and asks how it got this way and who decided
6
+ * it should be this way. This module gathers what the workspace records about
7
+ * that, as one document of nodes and edges, and decides nothing. It gathers
8
+ * three sources before it relates any of them, so no one of them frames the
9
+ * others:
10
+ *
11
+ * - The commits that touched the region, from git: `git log -L` for a line
12
+ * range, `git log --follow` for a file and `git log -- <dir>` for a
13
+ * directory. Each carries its trailers and its provenance level, and a
14
+ * plugin may join it to a unit of work, a contract and evidence
15
+ * (`intent-joins.ts`).
16
+ * - The decisions whose `constrains` cover the region: a `path:` entry that
17
+ * is the region or a directory above it, the region's `member:`, an issue
18
+ * the region's commits name, or a contract a plugin joined. Their
19
+ * supersession chains come along.
20
+ * - The artifacts those decisions pin. Artifacts relate to code only through
21
+ * decisions (#2549), so an artifact is in the graph because a decision in
22
+ * the graph pins it.
23
+ *
24
+ * Every gap the walk finds is a `finding` node with a closed code, never
25
+ * prose, so a reader can draw it and a test can assert it. A commit made
26
+ * inside a decision's window is not taken as that decision's work: unless
27
+ * the decision's own unit made it, it is `decided-by-window`, shown for the
28
+ * person to judge (#2656). A plugin's `commitJoins` may add findings of its
29
+ * own, in its own code namespace. chant emits the
30
+ * graph and hud renders it (#2524 D8, D15). Git is read through a local
31
+ * `git` subprocess only: no fetch, no network.
32
+ */
33
+
34
+ import { execFileSync } from "node:child_process";
35
+ import { realpathSync } from "node:fs";
36
+ import { basename, relative, resolve, sep } from "node:path";
37
+ import { readDeclaration, readerVersion, resolveGroups, WORKSPACE_ERROR_CODES, WorkspaceReadError, type Declaration } from "./declaration";
38
+ import { classifyFile, declaredFilesUnder } from "./generated-files";
39
+ import { entityDecisions, hasTrailer, readCommitJoins, runCommitJoins, type CommitJoins, type IntentCommit, type JoinedEntity, type PluginFinding } from "./intent-joins";
40
+ import { loadKindRegistry } from "./kinds";
41
+ import { resolveLinks, type LinkTableRow } from "./links";
42
+ import { sourceMemberHandles } from "./member-handles";
43
+ import { constraintCovers, isWorkspacePath, memberHolding } from "./record-assets";
44
+ import { importKindModule, loadRecordKind, RecordReadError, type LoadedRecordKind } from "./records";
45
+ import { queryRecords, type RecordView } from "./records-cli";
46
+ import type { PluginCode, ReasonCode } from "./reason-codes";
47
+ import { joinPath, skippedDir, type WorkspaceTree } from "./tree";
48
+ import { activeAttestors, type ProvenanceLevel } from "./trust/attestor";
49
+ import { commitProvenance, policyAtBase, resolveBase } from "./trust/provenance";
50
+ import { locateWorkspace, type LocatedWorkspace } from "./which-chant";
51
+
52
+ /** The version of the `intent` document this chant writes. */
53
+ export const INTENT_CONTRACT_VERSION = 1;
54
+
55
+ /** `$id` of the JSON Schema for the document, shipped beside this file. */
56
+ export const INTENT_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/intent/v1/intent.schema.json";
57
+
58
+ // ── Codes ────────────────────────────────────────────────────────────────────
59
+
60
+ /** The finding codes. Closed: a reader may switch on them. */
61
+ export const INTENT_FINDING_CODES = [
62
+ /** A commit touched the region when no decision constrained it at path granularity. */
63
+ "intent-commit-undecided",
64
+ /** A commit has no unit, no pull request reference and no decision covering the region at its time. */
65
+ "intent-commit-bare",
66
+ /** A pinned artifact's bytes no longer hash to the pin. */
67
+ "intent-pin-drifted",
68
+ /** A pinned artifact does not exist in the tree read. */
69
+ "intent-pin-missing",
70
+ /** An artifact that decisions in the graph pinned, and that no current decision pins. */
71
+ "intent-artifact-unpinned",
72
+ /** Every decision constraining the region is superseded. */
73
+ "intent-decision-superseded-live",
74
+ /** The current decisions constraining the region are all in states their kind does not close, such as decided and not ratified. */
75
+ "intent-decision-provisional",
76
+ /** The region is constrained only at member granularity. */
77
+ "intent-constraint-coarse",
78
+ /** A decision's path constraint names a path that does not exist. */
79
+ "intent-constraint-lost",
80
+ /** A decision's evidence has no hash: a URL, or a path with no sha256. */
81
+ "intent-evidence-unpinned",
82
+ /** A commit carries a trailer a plugin says claims authorship, and the commit is not attested. */
83
+ "intent-trailer-unverified",
84
+ /** No decision constrains the region at any granularity. */
85
+ "intent-region-unconstrained",
86
+ ] as const satisfies readonly ReasonCode[];
87
+ export type IntentFindingCode = (typeof INTENT_FINDING_CODES)[number];
88
+
89
+ /** Why part of the walk could not be read. The read still succeeds. */
90
+ export const INTENT_REASON_CODES = [
91
+ /** The repository is a shallow clone, so the history is cut. */
92
+ "intent-history-shallow",
93
+ /** A plugin's commitJoins failed for a commit. */
94
+ "intent-plugin-failed",
95
+ ] as const satisfies readonly ReasonCode[];
96
+ export type IntentReasonCode = (typeof INTENT_REASON_CODES)[number];
97
+
98
+ /** Why the read failed as a whole: the declaration's codes, the record kind's codes, or a region that isn't there. */
99
+ export const INTENT_ERROR_CODES = [
100
+ ...WORKSPACE_ERROR_CODES,
101
+ "kind-unreadable",
102
+ "kind-invalid",
103
+ "schema-unreadable",
104
+ "schema-id-mismatch",
105
+ "schema-invalid",
106
+ "location-missing",
107
+ /** The region's path, or its line range, does not exist in the tree read. */
108
+ "intent-region-invalid",
109
+ ] as const satisfies readonly ReasonCode[];
110
+ export type IntentErrorCode = (typeof INTENT_ERROR_CODES)[number];
111
+
112
+ export class IntentError extends Error {
113
+ constructor(
114
+ readonly code: IntentErrorCode,
115
+ message: string,
116
+ ) {
117
+ super(message);
118
+ this.name = "IntentError";
119
+ }
120
+ }
121
+
122
+ // ── The document ─────────────────────────────────────────────────────────────
123
+
124
+ export interface LineRange {
125
+ start: number;
126
+ end: number;
127
+ }
128
+
129
+ export interface RegionNode {
130
+ id: string;
131
+ kind: "region";
132
+ /** From the workspace root. */
133
+ path: string;
134
+ lines: LineRange | null;
135
+ /** The member whose directory holds the path, or null. */
136
+ member: string | null;
137
+ at: string | null;
138
+ type: "file" | "dir";
139
+ /** For a file region, whether the file is generated (#2524 D14); null for a directory. */
140
+ generated: boolean | null;
141
+ /** Set when the region was given as a graph node id. */
142
+ node: string | null;
143
+ }
144
+
145
+ export interface FileNode {
146
+ id: string;
147
+ kind: "file";
148
+ path: string;
149
+ member: string | null;
150
+ generated: boolean;
151
+ }
152
+
153
+ export interface MemberNode {
154
+ id: string;
155
+ kind: "member";
156
+ name: string;
157
+ dir: string;
158
+ memberKind: string;
159
+ }
160
+
161
+ export interface CommitNode {
162
+ id: string;
163
+ kind: "commit";
164
+ sha: string;
165
+ subject: string;
166
+ author: { name: string; email: string };
167
+ date: string;
168
+ trailers: Record<string, string[]>;
169
+ /** The pull request number the subject ends with, as a squash merge writes it, or null. */
170
+ pullRequest: number | null;
171
+ signature: { level: ProvenanceLevel; reason: string; principal?: string };
172
+ /** The line ranges the commit changed in the region, in the commit's own version of the file; null for a file or directory region. */
173
+ lines: LineRange[] | null;
174
+ /**
175
+ * How the decisions constraining the region by path relate to the commit
176
+ * (#2656): `decided` when it falls inside a decision's window and that
177
+ * decision's own unit made it, `decided-by-window` when it only falls
178
+ * inside a window, `undecided` when it falls outside every window, and
179
+ * null when no record kind was read.
180
+ */
181
+ state: CommitState | null;
182
+ }
183
+
184
+ export type CommitState = "decided" | "decided-by-window" | "undecided";
185
+
186
+ export interface JoinedNode {
187
+ id: string;
188
+ kind: "unit" | "contract" | "evidence";
189
+ /** The plugin's own id. */
190
+ ref: string;
191
+ /** The kind file that supplied it. */
192
+ plugin: string;
193
+ /** The fields the plugin gave, besides the id. */
194
+ data: Record<string, unknown>;
195
+ }
196
+
197
+ export interface EvidenceEntryNode {
198
+ id: string;
199
+ kind: "evidence";
200
+ ref: string;
201
+ plugin: null;
202
+ data: { title: string | null; url: string | null; path: string | null; as_of: string | null; sha256: string | null };
203
+ }
204
+
205
+ export interface DecisionNode {
206
+ id: string;
207
+ kind: "decision";
208
+ recordKind: string;
209
+ record: string;
210
+ /** The record file, from the repository root. */
211
+ path: string;
212
+ title: string | null;
213
+ state: string | null;
214
+ /** Whether the kind counts the state as closed (final), such as ratified. */
215
+ closed: boolean;
216
+ valid: boolean;
217
+ reasons: RecordView["reasons"];
218
+ provenance: { level: ProvenanceLevel; commit: string | null; reason: string };
219
+ decided_by: string | null;
220
+ decided_on: string | null;
221
+ reviews: { agree: number; dissent: number; abstain: number; openConcerns: number };
222
+ supersededBy: string | null;
223
+ /** The ids of the records this one's supersedes links name. */
224
+ supersedes: string[];
225
+ /** The constrains entries that cover the region, with their granularity. Empty for a decision in the graph only through supersession. */
226
+ constrains: { entry: string; granularity: Granularity }[];
227
+ }
228
+
229
+ export type PinState = "pinned" | "drifted" | "missing" | "stale" | "unpinned";
230
+
231
+ export interface ArtifactNode {
232
+ id: string;
233
+ kind: "artifact";
234
+ /** From the workspace root the pinning record's kind resolves pins in. */
235
+ path: string;
236
+ anchor: string | null;
237
+ /** The hash a current decision pins, or null when none does. */
238
+ pinnedSha256: string | null;
239
+ /** The file's hash in the tree read, or null when it is missing. */
240
+ currentSha256: string | null;
241
+ /** The state of a current decision's pin, or unpinned when only superseded decisions pin it. */
242
+ pinState: PinState;
243
+ }
244
+
245
+ export interface LinkNode {
246
+ id: string;
247
+ kind: "link";
248
+ row: LinkTableRow;
249
+ }
250
+
251
+ export interface FindingNode {
252
+ id: string;
253
+ kind: "finding";
254
+ /** A closed code, or a plugin's own `plugin:<name>:<code>` (#2656). */
255
+ code: IntentFindingCode | PluginCode;
256
+ message: string;
257
+ concerns: string[];
258
+ /** For a plugin's finding: the kind file that returned it. */
259
+ plugin?: string;
260
+ /** For a plugin's finding: the refs as the plugin gave them. The ones that name a node in the graph are in concerns. */
261
+ refs?: string[];
262
+ }
263
+
264
+ export type IntentNode = RegionNode | FileNode | MemberNode | CommitNode | JoinedNode | EvidenceEntryNode | DecisionNode | ArtifactNode | LinkNode | FindingNode;
265
+
266
+ export type Granularity = "path" | "member" | "contract" | "issue";
267
+
268
+ export type IntentEdge =
269
+ | { kind: "constrains"; from: string; to: string; granularity: Granularity; entry: string }
270
+ | { kind: "pins"; from: string; to: string; pinnedSha256: string | null; pinState: PinState }
271
+ | { kind: "touched-by"; from: string; to: string; lines: LineRange[] | null }
272
+ | { kind: "within"; from: string; to: string; state: "decided" | "decided-by-window" }
273
+ | { kind: "produced-by" | "serves" | "cites-evidence" | "supersedes" | "links"; from: string; to: string };
274
+
275
+ export interface IntentReason {
276
+ code: IntentReasonCode;
277
+ message: string;
278
+ }
279
+
280
+ interface Head {
281
+ $schema: string;
282
+ contract: number;
283
+ chant: string;
284
+ }
285
+
286
+ export type IntentDocument =
287
+ | (Head & {
288
+ at: string | null;
289
+ workspace: { name: string; root: string };
290
+ region: string;
291
+ history: { rev: string | null; follows: "line-range" | "file" | "directory"; shallow: boolean };
292
+ kinds: { file: string; name: string; records: string | null; joins: "function" | "data" | null }[];
293
+ nodes: IntentNode[];
294
+ edges: IntentEdge[];
295
+ reasons: IntentReason[];
296
+ summary: { commits: number; decisions: number; artifacts: number; findings: number };
297
+ })
298
+ | (Head & { error: { code: IntentErrorCode; message: string } });
299
+
300
+ export interface IntentQuery {
301
+ /** Where the walk up to the declaration starts, and what the region path is relative to. */
302
+ cwd: string;
303
+ /** `path`, `path:line` or `path:start-end`, or a graph node id `<member>/<id>`. */
304
+ region: string;
305
+ at?: string;
306
+ /** Kind files: record kinds, plugins with `commitJoins`, or both. Relative to `cwd`. */
307
+ kinds?: string[];
308
+ /**
309
+ * Resolve a graph node id to its source location, for a region given as a
310
+ * node id. The default reads the member's graph as `chant workspace graph
311
+ * --member` does.
312
+ */
313
+ resolveNode?: (cwd: string, at: string | undefined, member: string, id: string) => Promise<{ file: string; line: number | null } | null | undefined>;
314
+ }
315
+
316
+ export interface IntentResult {
317
+ doc: IntentDocument;
318
+ /** The read failed, or a plugin failed. */
319
+ failed: boolean;
320
+ }
321
+
322
+ // ── Git ──────────────────────────────────────────────────────────────────────
323
+
324
+ function git(top: string, args: string[], input?: string): string {
325
+ return execFileSync("git", args, { cwd: top, encoding: "utf-8", input, stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"], maxBuffer: 512 * 1024 * 1024 });
326
+ }
327
+
328
+ function tryGit(top: string, args: string[], input?: string): string | undefined {
329
+ try {
330
+ return git(top, args, input);
331
+ } catch {
332
+ return undefined;
333
+ }
334
+ }
335
+
336
+ /** The new-side line ranges of each hunk header in a patch. */
337
+ function hunkRanges(patch: string): LineRange[] {
338
+ const out: LineRange[] = [];
339
+ for (const m of patch.matchAll(/^@@+ [^@]*\+(\d+)(?:,(\d+))? @@/gm)) {
340
+ const start = Number(m[1]);
341
+ const count = m[2] === undefined ? 1 : Number(m[2]);
342
+ if (count > 0) out.push({ start, end: start + count - 1 });
343
+ }
344
+ return out;
345
+ }
346
+
347
+ /** The commits that touched the region, newest first, with the lines each changed for a line range. */
348
+ function regionHistory(top: string, rev: string, gitPath: string, type: "file" | "dir", lines: LineRange | null): { sha: string; lines: LineRange[] | null }[] {
349
+ if (lines) {
350
+ const out = tryGit(top, ["log", `-L${lines.start},${lines.end}:${gitPath}`, "--format=%x00%H", "--no-color", rev]);
351
+ if (out === undefined) return [];
352
+ return out
353
+ .split("\0")
354
+ .filter(Boolean)
355
+ .map((chunk) => {
356
+ const nl = chunk.indexOf("\n");
357
+ const sha = (nl < 0 ? chunk : chunk.slice(0, nl)).trim();
358
+ return { sha, lines: hunkRanges(nl < 0 ? "" : chunk.slice(nl + 1)) };
359
+ });
360
+ }
361
+ const args = type === "file" ? ["log", "--follow", "--format=%H", rev, "--", gitPath] : ["log", "--format=%H", rev, "--", gitPath === "" ? "." : gitPath];
362
+ const out = tryGit(top, args);
363
+ if (out === undefined) return [];
364
+ return out
365
+ .split("\n")
366
+ .map((s) => s.trim())
367
+ .filter(Boolean)
368
+ .map((sha) => ({ sha, lines: null }));
369
+ }
370
+
371
+ /** Subject, body, author, date and trailers of each commit. */
372
+ function commitDetails(top: string, shas: string[]): Map<string, IntentCommit> {
373
+ const out = new Map<string, IntentCommit>();
374
+ if (shas.length === 0) return out;
375
+ const text = git(top, ["log", "--no-walk=unsorted", "--stdin", "--format=%x00%H%x1f%an%x1f%ae%x1f%aI%x1f%(trailers:only,unfold,separator=%x1e)%x1f%B"], `${shas.join("\n")}\n`);
376
+ for (const record of text.split("\0")) {
377
+ if (!record) continue;
378
+ const [sha, name, email, date, trailerText, ...rest] = record.split("\x1f");
379
+ const message = rest.join("\x1f").replace(/\n+$/, "");
380
+ const nl = message.indexOf("\n");
381
+ const trailers: Record<string, string[]> = {};
382
+ for (const t of (trailerText ?? "").split("\x1e")) {
383
+ const colon = t.indexOf(":");
384
+ if (colon <= 0) continue;
385
+ const key = t.slice(0, colon).trim();
386
+ (trailers[key] ??= []).push(t.slice(colon + 1).trim());
387
+ }
388
+ out.set(sha.trim(), {
389
+ sha: sha.trim(),
390
+ subject: nl < 0 ? message : message.slice(0, nl),
391
+ body: nl < 0 ? "" : message.slice(nl + 1).replace(/^\n+/, ""),
392
+ author: { name, email },
393
+ date,
394
+ trailers,
395
+ });
396
+ }
397
+ return out;
398
+ }
399
+
400
+ /** The commit reachable from `rev` that last added `path` (from the repository root), or null. */
401
+ function addingCommit(top: string, rev: string, path: string): string | null {
402
+ const out = tryGit(top, ["log", "--diff-filter=A", "-1", "--format=%H", rev, "--", path]);
403
+ return out?.trim() || null;
404
+ }
405
+
406
+ /** `commit` and every commit between it and `rev` that has it as an ancestor. */
407
+ function descendants(top: string, commit: string, rev: string): Set<string> {
408
+ const out = new Set<string>([commit]);
409
+ const text = tryGit(top, ["rev-list", "--ancestry-path", `${commit}..${rev}`]);
410
+ for (const line of (text ?? "").split("\n")) if (line.trim()) out.add(line.trim());
411
+ return out;
412
+ }
413
+
414
+ /** `owner/repo` of the `origin` remote, lower case, or null. Read from local config; nothing is fetched. */
415
+ function originRepo(top: string): string | null {
416
+ const url = tryGit(top, ["config", "--get", "remote.origin.url"])?.trim();
417
+ const m = url?.match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/);
418
+ return m ? `${m[1]}/${m[2]}`.toLowerCase() : null;
419
+ }
420
+
421
+ // ── Helpers ──────────────────────────────────────────────────────────────────
422
+
423
+ const toPosix = (p: string) => (sep === "/" ? p : p.split(sep).join("/"));
424
+
425
+ function realpathOr(p: string): string {
426
+ try {
427
+ return realpathSync(p);
428
+ } catch {
429
+ return p;
430
+ }
431
+ }
432
+
433
+ /** Parse `path`, `path:line` or `path:start-end`. */
434
+ export function parseRegion(arg: string): { path: string; lines: LineRange | null } | { error: string } {
435
+ const m = arg.match(/^(.+?):(\d+)(?:-(\d+))?$/);
436
+ if (!m) return { path: arg, lines: null };
437
+ const start = Number(m[2]);
438
+ const end = m[3] === undefined ? start : Number(m[3]);
439
+ if (start < 1 || end < start) return { error: `${arg}: a line range is start-end with 1 <= start <= end` };
440
+ return { path: m[1], lines: { start, end } };
441
+ }
442
+
443
+ function regionId(path: string, lines: LineRange | null): string {
444
+ return `region:${path}${lines ? `:${lines.start}${lines.end === lines.start ? "" : `-${lines.end}`}` : ""}`;
445
+ }
446
+
447
+ function filesUnder(tree: WorkspaceTree, dir: string): string[] {
448
+ const out: string[] = [];
449
+ const walk = (d: string) => {
450
+ for (const e of (tree.list(d) ?? []).sort((a, b) => a.name.localeCompare(b.name))) {
451
+ const p = joinPath(d, e.name);
452
+ if (e.type === "dir") {
453
+ if (!skippedDir(e.name)) walk(p);
454
+ } else out.push(p);
455
+ }
456
+ };
457
+ walk(dir === "." ? "" : dir);
458
+ return out;
459
+ }
460
+
461
+ function isGenerated(declaration: Declaration, path: string): boolean {
462
+ const m = declaration.members.find((x) => x.name === memberHolding(path, declaration.members));
463
+ const dir = m?.dir ?? ".";
464
+ const rel = dir === "." ? path : path.slice(dir.length + 1);
465
+ return classifyFile(rel, declaredFilesUnder(declaration, dir)).class === "generated";
466
+ }
467
+
468
+ function stringOr(v: unknown): string | null {
469
+ return typeof v === "string" ? v : null;
470
+ }
471
+
472
+ function reviewSummary(data: Record<string, unknown> | null): DecisionNode["reviews"] {
473
+ const out = { agree: 0, dissent: 0, abstain: 0, openConcerns: 0 };
474
+ const list = data?.reviews;
475
+ if (!Array.isArray(list)) return out;
476
+ for (const r of list) {
477
+ if (r === null || typeof r !== "object") continue;
478
+ const review = r as Record<string, unknown>;
479
+ if (review.verdict === "agree") out.agree++;
480
+ else if (review.verdict === "abstain") out.abstain++;
481
+ else if (review.verdict === "dissent") {
482
+ out.dissent++;
483
+ if (review.addressed_by == null && review.withdrawn_on == null) out.openConcerns++;
484
+ }
485
+ }
486
+ return out;
487
+ }
488
+
489
+ const SHA256 = /^[0-9a-f]{64}$/;
490
+ const ISSUE = /^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([0-9]+)$/;
491
+ const PIN_ORDER: PinState[] = ["missing", "drifted", "stale", "pinned"];
492
+
493
+ /** The default node resolver: the member's graph, read as `workspace graph --member` reads it. */
494
+ async function resolveNodeFromGraph(cwd: string, at: string | undefined, member: string, id: string): Promise<{ file: string; line: number | null } | null> {
495
+ const { workspaceGraph } = await import("./graph-cli");
496
+ const { doc } = await workspaceGraph({ cwd, at, members: [member] });
497
+ if ("error" in doc) return null;
498
+ const node = doc.nodes.find((n) => n.id === id) as { sourceLoc?: { file: string; line?: number } } | undefined;
499
+ if (!node) return null;
500
+ return node.sourceLoc ? { file: node.sourceLoc.file, line: node.sourceLoc.line ?? null } : { file: ".", line: null };
501
+ }
502
+
503
+ // ── The walk ─────────────────────────────────────────────────────────────────
504
+
505
+ interface LoadedKind {
506
+ file: string;
507
+ /** Relative to the repository root, for the document. */
508
+ display: string;
509
+ /** The record kind's name, or the file's name without `.kind.mjs`: the namespace of its plugin findings. */
510
+ name: string;
511
+ records?: { loaded: LoadedRecordKind; views: RecordView[]; workspaceRoot: string };
512
+ joins?: CommitJoins;
513
+ }
514
+
515
+ async function loadKinds(query: IntentQuery, top: string): Promise<LoadedKind[]> {
516
+ const out: LoadedKind[] = [];
517
+ for (const k of query.kinds ?? []) {
518
+ const file = resolve(query.cwd, k);
519
+ const display = toPosix(relative(top, realpathOr(file)));
520
+ let mod: Record<string, unknown>;
521
+ try {
522
+ mod = await importKindModule(file);
523
+ } catch (err) {
524
+ throw new IntentError("kind-unreadable", `kind file ${k} could not be loaded: ${err instanceof Error ? err.message : String(err)}`);
525
+ }
526
+ const joins = readCommitJoins(mod);
527
+ if (typeof joins === "string") throw new IntentError("kind-invalid", `kind file ${k} has a commitJoins export that can't be read: ${joins}`);
528
+ const kind: LoadedKind = { file, display, name: basename(file).replace(/(?:\.kind)?\.[cm]?[jt]s$/, ""), ...(joins ? { joins } : {}) };
529
+ if (mod.recordKind !== undefined) {
530
+ const doc = await queryRecords({ kind: file, at: query.at, cwd: query.cwd });
531
+ if ("error" in doc) throw new IntentError(doc.error.code, doc.error.message);
532
+ try {
533
+ kind.records = { loaded: await loadRecordKind(file), views: doc.records, workspaceRoot: doc.workspaceRoot };
534
+ kind.name = kind.records.loaded.kind.name;
535
+ } catch (err) {
536
+ if (err instanceof RecordReadError) throw new IntentError(err.code as IntentErrorCode, err.message);
537
+ throw err;
538
+ }
539
+ } else if (!joins) {
540
+ throw new IntentError("kind-invalid", `kind file ${k} exports neither recordKind nor commitJoins`);
541
+ }
542
+ out.push(kind);
543
+ }
544
+ return out;
545
+ }
546
+
547
+ async function resolveRegion(query: IntentQuery, located: LocatedWorkspace, declaration: Declaration): Promise<{ path: string; lines: LineRange | null; node: string | null }> {
548
+ const parsed = parseRegion(query.region);
549
+ if ("error" in parsed) throw new IntentError("intent-region-invalid", parsed.error);
550
+ const rel = toPosix(relative(realpathOr(located.rootOnDisk), resolve(realpathOr(query.cwd), parsed.path)));
551
+ const path = rel === "" ? "." : rel;
552
+ if (path === "." || (isWorkspacePath(path) && !path.startsWith("../") && located.tree.stat(path) !== undefined)) {
553
+ return { path, lines: parsed.lines, node: null };
554
+ }
555
+ // A graph node id, <member>/<id>, when no such path exists (#2650 B1).
556
+ const slash = query.region.indexOf("/");
557
+ const member = slash > 0 ? declaration.members.find((m) => m.name === query.region.slice(0, slash)) : undefined;
558
+ if (member && !parsed.lines) {
559
+ const loc = await (query.resolveNode ?? resolveNodeFromGraph)(query.cwd, query.at, member.name, query.region);
560
+ if (loc) {
561
+ const file = joinPath(member.dir, loc.file);
562
+ return { path: file === "" ? "." : file, lines: loc.line ? { start: loc.line, end: loc.line } : null, node: query.region };
563
+ }
564
+ }
565
+ throw new IntentError("intent-region-invalid", `${query.region} is not a path in the workspace${located.tree.label}, or a graph node id`);
566
+ }
567
+
568
+ /** Build the intent document. Never throws an {@link IntentError}, {@link WorkspaceReadError} or {@link RecordReadError}. */
569
+ export async function intentGraph(query: IntentQuery): Promise<IntentResult> {
570
+ const head: Head = { $schema: INTENT_OUTPUT_SCHEMA_ID, contract: INTENT_CONTRACT_VERSION, chant: readerVersion() };
571
+ try {
572
+ return await walk(query, head);
573
+ } catch (err) {
574
+ if (err instanceof IntentError || err instanceof WorkspaceReadError || err instanceof RecordReadError) {
575
+ return { doc: { ...head, error: { code: err.code as IntentErrorCode, message: err.message } }, failed: true };
576
+ }
577
+ throw err;
578
+ }
579
+ }
580
+
581
+ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
582
+ const located = locateWorkspace(query.cwd, query.at);
583
+ const top = located.top;
584
+ if (!top) throw new IntentError("not-a-git-repository", "the intent graph reads git history, and this directory is not in a git repository");
585
+ const declaration = readDeclaration(located.tree, "", { rootChant: true });
586
+ const region = await resolveRegion(query, located, declaration);
587
+ const type = region.path === "." ? "dir" : located.tree.stat(region.path);
588
+ if (!type) throw new IntentError("intent-region-invalid", `${region.path} does not exist${located.tree.label}`);
589
+ if (region.lines) {
590
+ if (type !== "file") throw new IntentError("intent-region-invalid", `${region.path} is a directory, and a line range needs a file`);
591
+ const count = located.tree.read(region.path).replace(/\n$/, "").split("\n").length;
592
+ if (region.lines.end > count) throw new IntentError("intent-region-invalid", `${region.path} has ${count} lines${located.tree.label}, so ${region.lines.start}-${region.lines.end} is not in it`);
593
+ }
594
+ const kinds = await loadKinds(query, top);
595
+
596
+ const nodes = new Map<string, IntentNode>();
597
+ const edges: IntentEdge[] = [];
598
+ const reasons: IntentReason[] = [];
599
+ const findings: FindingNode[] = [];
600
+ const add = <N extends IntentNode>(n: N): N => {
601
+ if (!nodes.has(n.id)) nodes.set(n.id, n);
602
+ return nodes.get(n.id) as N;
603
+ };
604
+ const find = (code: IntentFindingCode, message: string, concerns: string[]) => {
605
+ findings.push({ id: `finding:${code}:${findings.filter((f) => f.code === code).length + 1}`, kind: "finding", code, message, concerns });
606
+ };
607
+
608
+ // The region, its files and its member.
609
+ const member = region.path === "." ? (declaration.members.find((m) => m.dir === ".")?.name ?? null) : memberHolding(region.path, declaration.members);
610
+ const rid = regionId(region.path, region.lines);
611
+ add<RegionNode>({ id: rid, kind: "region", path: region.path, lines: region.lines, member, at: located.at, type, generated: type === "file" ? isGenerated(declaration, region.path) : null, node: region.node });
612
+ const files = type === "dir" ? filesUnder(located.tree, region.path) : [];
613
+ for (const f of files) add<FileNode>({ id: `file:${f}`, kind: "file", path: f, member: memberHolding(f, declaration.members), generated: isGenerated(declaration, f) });
614
+ const memberNode = (name: string) => {
615
+ const m = declaration.members.find((x) => x.name === name);
616
+ return add<MemberNode>({ id: `member:${name}`, kind: "member", name, dir: m?.dir ?? "", memberKind: m?.kind ?? "" });
617
+ };
618
+ if (member) memberNode(member);
619
+
620
+ // 1. The region's history.
621
+ const rev = located.at ?? (tryGit(top, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"])?.trim() || null);
622
+ const shallow = tryGit(top, ["rev-parse", "--is-shallow-repository"])?.trim() === "true";
623
+ if (shallow) reasons.push({ code: "intent-history-shallow", message: "this is a shallow clone, so the history stops at its boundary and the oldest commit listed may stand for many" });
624
+ const workspacePrefix = located.root === "." ? "" : located.root;
625
+ const gitPath = joinPath(workspacePrefix, region.path);
626
+ const touched = rev ? regionHistory(top, rev, gitPath, type, region.lines) : [];
627
+ const details = commitDetails(top, touched.map((t) => t.sha));
628
+
629
+ // Each commit's provenance, judged by the policy at base as records judges a record's (#2547).
630
+ const policy = policyAtBase(top, resolveBase(top));
631
+ const attestors = policy.active ? await activeAttestors() : [];
632
+
633
+ const readAt = (path: string): string | undefined => {
634
+ if (!isWorkspacePath(path) || located.tree.stat(path) !== "file") return undefined;
635
+ return located.tree.read(path);
636
+ };
637
+ interface Joined {
638
+ unit?: string;
639
+ contracts: string[];
640
+ authorship: string[];
641
+ /** The record ids the commit's units and contracts say they carry out. */
642
+ decisions: string[];
643
+ }
644
+ const joined = new Map<string, Joined>();
645
+ const pluginFindings: { commit: string; plugin: string; finding: PluginFinding }[] = [];
646
+ const failedPlugins = new Set<string>();
647
+ for (const t of touched) {
648
+ const c = details.get(t.sha);
649
+ if (!c) continue;
650
+ const signature = policy.active
651
+ ? (() => {
652
+ const p = commitProvenance(top, policy, c.sha, attestors);
653
+ return { level: p.level, reason: p.reason, ...(p.principal ? { principal: p.principal } : {}) };
654
+ })()
655
+ : { level: "unattested" as const, reason: policy.problems.length ? policy.problems.join("; ") : `no signers file (${policy.signersPath}) at base; attestation is off` };
656
+ const pr = c.subject.match(/\(#([0-9]+)\)\s*$/);
657
+ const cid = `commit:${c.sha}`;
658
+ add<CommitNode>({ id: cid, kind: "commit", sha: c.sha, subject: c.subject, author: c.author, date: c.date, trailers: c.trailers, pullRequest: pr ? Number(pr[1]) : null, signature, lines: t.lines, state: null });
659
+ edges.push({ kind: "touched-by", from: rid, to: cid, lines: t.lines });
660
+
661
+ // 2. Each commit's origin, from the plugins.
662
+ const entry: Joined = { contracts: [], authorship: [], decisions: [] };
663
+ joined.set(c.sha, entry);
664
+ for (const k of kinds) {
665
+ if (!k.joins) continue;
666
+ let result;
667
+ try {
668
+ result = await runCommitJoins(k.joins, c, { read: readAt, at: located.at }, k.name);
669
+ } catch (err) {
670
+ const message = `${k.display}: commitJoins failed for ${c.sha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`;
671
+ if (!failedPlugins.has(message)) reasons.push({ code: "intent-plugin-failed", message });
672
+ failedPlugins.add(message);
673
+ continue;
674
+ }
675
+ const joinedNode = (kind: JoinedNode["kind"], e: JoinedEntity): string => {
676
+ const { id, ...data } = e;
677
+ return add<JoinedNode>({ id: `${kind}:${id}`, kind, ref: id, plugin: k.display, data }).id;
678
+ };
679
+ const unitId = result.unit ? joinedNode("unit", result.unit) : undefined;
680
+ const contractId = result.contract ? joinedNode("contract", result.contract) : undefined;
681
+ if (unitId) {
682
+ entry.unit = unitId;
683
+ edges.push({ kind: "produced-by", from: cid, to: unitId });
684
+ if (contractId) edges.push({ kind: "serves", from: unitId, to: contractId });
685
+ }
686
+ if (contractId) entry.contracts.push(result.contract!.id);
687
+ if (unitId) entry.decisions.push(...entityDecisions(result.unit), ...entityDecisions(result.contract));
688
+ for (const f of result.findings ?? []) pluginFindings.push({ commit: cid, plugin: k.display, finding: f });
689
+ const evidence = result.evidence === undefined ? [] : Array.isArray(result.evidence) ? result.evidence : [result.evidence];
690
+ for (const e of evidence) edges.push({ kind: "cites-evidence", from: unitId ?? contractId ?? cid, to: joinedNode("evidence", e) });
691
+ entry.authorship.push(...(result.authorship ?? []));
692
+ }
693
+ }
694
+
695
+ // 3. The decisions whose constrains cover the region, and their chains.
696
+ const regionFull = region.path === "." ? workspacePrefix : gitPath;
697
+ const origin = originRepo(top);
698
+ const commitRefs = new Set<string>();
699
+ for (const c of details.values()) for (const m of c.subject.matchAll(/#([0-9]+)/g)) commitRefs.add(m[1]);
700
+ const contractIds = new Set([...joined.values()].flatMap((j) => j.contracts));
701
+ interface Covering {
702
+ view: RecordView;
703
+ kind: LoadedKind;
704
+ node: DecisionNode;
705
+ }
706
+ const covering: Covering[] = [];
707
+ const decisionById = new Map<string, { view: RecordView; kind: LoadedKind }>();
708
+ const decisionId = (k: LoadedKind, id: string) => `record:${k.records!.loaded.kind.name}/${id}`;
709
+ const decisionNode = (k: LoadedKind, v: RecordView): DecisionNode => {
710
+ const kind = k.records!.loaded.kind;
711
+ const links = v.data?.[kind.supersedes.field];
712
+ const supersedes = Array.isArray(links)
713
+ ? links.map((l) => (l && typeof l === "object" ? (l as Record<string, unknown>)[kind.supersedes.key] : undefined)).filter((x): x is string => typeof x === "string")
714
+ : [];
715
+ return add<DecisionNode>({
716
+ id: decisionId(k, v.id!),
717
+ kind: "decision",
718
+ recordKind: kind.name,
719
+ record: v.id!,
720
+ path: v.path,
721
+ title: stringOr(v.data?.title),
722
+ state: v.state,
723
+ closed: v.state !== null && kind.closedStates.includes(v.state),
724
+ valid: v.valid,
725
+ reasons: v.reasons,
726
+ provenance: { level: v.provenance.level, commit: v.provenance.commit, reason: v.provenance.reason },
727
+ decided_by: stringOr(v.data?.decided_by),
728
+ decided_on: stringOr(v.data?.decided_on),
729
+ reviews: reviewSummary(v.data),
730
+ supersededBy: v.supersededBy,
731
+ supersedes,
732
+ constrains: [],
733
+ });
734
+ };
735
+ const fileNodesUnder = (full: string) => files.filter((f) => constraintCovers(full, joinPath(workspacePrefix, f)));
736
+ for (const k of kinds) {
737
+ if (!k.records) continue;
738
+ const field = k.records.loaded.kind.constrains?.field;
739
+ const kindPrefix = k.records.workspaceRoot === "." ? "" : k.records.workspaceRoot;
740
+ for (const v of k.records.views) {
741
+ if (v.id === null) continue;
742
+ decisionById.set(`${k.records.loaded.kind.name}/${v.id}`, { view: v, kind: k });
743
+ const list = field ? v.data?.[field] : undefined;
744
+ if (!Array.isArray(list)) continue;
745
+ const matched: { entry: string; granularity: Granularity; to: string }[] = [];
746
+ for (const entry of list) {
747
+ if (typeof entry !== "string") continue;
748
+ if (entry.startsWith("path:")) {
749
+ const p = entry.slice("path:".length);
750
+ if (!isWorkspacePath(p)) continue;
751
+ const full = joinPath(kindPrefix, p);
752
+ if (regionFull !== "" && constraintCovers(full, regionFull)) {
753
+ matched.push({ entry, granularity: "path", to: rid });
754
+ } else if (type === "dir" && full !== regionFull && (regionFull === "" || full.startsWith(`${regionFull}/`))) {
755
+ // A path below a directory region constrains the files under it, not the whole region.
756
+ for (const f of fileNodesUnder(full)) matched.push({ entry, granularity: "path", to: `file:${f}` });
757
+ }
758
+ } else if (entry.startsWith("member:")) {
759
+ if (member !== null && entry.slice("member:".length) === member) matched.push({ entry, granularity: "member", to: rid });
760
+ } else {
761
+ const issue = entry.match(ISSUE);
762
+ if (issue && origin !== null && issue[1].toLowerCase() === origin && commitRefs.has(issue[2])) matched.push({ entry, granularity: "issue", to: rid });
763
+ else if (contractIds.has(entry)) matched.push({ entry, granularity: "contract", to: `contract:${entry}` });
764
+ }
765
+ }
766
+ if (matched.length === 0) continue;
767
+ const node = decisionNode(k, v);
768
+ for (const m of matched) {
769
+ edges.push({ kind: "constrains", from: node.id, to: m.to, granularity: m.granularity, entry: m.entry });
770
+ if (m.to === rid || m.granularity === "contract") node.constrains.push({ entry: m.entry, granularity: m.granularity });
771
+ }
772
+ if (node.constrains.length > 0) covering.push({ view: v, kind: k, node });
773
+ }
774
+ }
775
+ // Supersession chains, both ways, as records derives them.
776
+ const queue = [...nodes.values()].filter((n): n is DecisionNode => n.kind === "decision");
777
+ while (queue.length > 0) {
778
+ const d = queue.shift()!;
779
+ const related: string[] = [];
780
+ if (d.supersededBy) related.push(`${d.recordKind}/${d.supersededBy}`);
781
+ for (const [key, { view }] of decisionById) if (key.startsWith(`${d.recordKind}/`) && view.supersededBy === d.record) related.push(key);
782
+ for (const key of related) {
783
+ const hit = decisionById.get(key);
784
+ if (!hit) continue;
785
+ const id = decisionId(hit.kind, hit.view.id!);
786
+ if (nodes.has(id)) continue;
787
+ queue.push(decisionNode(hit.kind, hit.view));
788
+ }
789
+ }
790
+ const decisions = [...nodes.values()].filter((n): n is DecisionNode => n.kind === "decision");
791
+ for (const d of decisions) {
792
+ if (d.supersededBy) {
793
+ const to = `record:${d.recordKind}/${d.supersededBy}`;
794
+ if (nodes.has(to)) edges.push({ kind: "supersedes", from: to, to: d.id });
795
+ }
796
+ }
797
+
798
+ // 4. The artifacts those decisions pin, and their evidence entries.
799
+ const pinsOf = new Map<string, { decision: DecisionNode; state: PinState; sha256: string | null; actual: string | null }[]>();
800
+ for (const d of decisions) {
801
+ const hit = decisionById.get(`${d.recordKind}/${d.record}`)!;
802
+ const kind = hit.kind.records!.loaded.kind;
803
+ for (const a of hit.view.assets) {
804
+ const list = pinsOf.get(a.path) ?? [];
805
+ list.push({ decision: d, state: a.state, sha256: a.sha256, actual: a.actual });
806
+ pinsOf.set(a.path, list);
807
+ }
808
+ const evidence = kind.pins ? hit.view.data?.[kind.pins.field] : undefined;
809
+ if (Array.isArray(evidence)) {
810
+ for (const e of evidence) {
811
+ if (e === null || typeof e !== "object" || Array.isArray(e)) continue;
812
+ const entry = e as Record<string, unknown>;
813
+ const hashed = typeof entry.sha256 === "string" && SHA256.test(entry.sha256);
814
+ if (isWorkspacePath(entry.path)) {
815
+ if (hashed) continue;
816
+ const list = pinsOf.get(entry.path) ?? [];
817
+ list.push({ decision: d, state: "unpinned", sha256: null, actual: null });
818
+ pinsOf.set(entry.path, list);
819
+ find("intent-evidence-unpinned", `${d.record} names ${entry.path} as evidence with no sha256, so a later edit can't be told from what was decided on`, [d.id, `artifact:${entry.path}`]);
820
+ continue;
821
+ }
822
+ if (typeof entry.url !== "string") continue;
823
+ const ev = add<EvidenceEntryNode>({
824
+ id: `evidence:${entry.url}`,
825
+ kind: "evidence",
826
+ ref: entry.url,
827
+ plugin: null,
828
+ data: { title: stringOr(entry.title), url: entry.url, path: null, as_of: stringOr(entry.as_of), sha256: hashed ? (entry.sha256 as string) : null },
829
+ });
830
+ edges.push({ kind: "cites-evidence", from: d.id, to: ev.id });
831
+ if (!hashed) find("intent-evidence-unpinned", `${d.record} cites ${entry.url} with no hash, so what the page said when it was decided can't be checked`, [d.id, ev.id]);
832
+ }
833
+ }
834
+ }
835
+ for (const [path, pins] of pinsOf) {
836
+ const aid = `artifact:${path}`;
837
+ const current = pins.filter((p) => p.decision.supersededBy === null && p.state !== "unpinned");
838
+ const worst = [...current].sort((a, b) => PIN_ORDER.indexOf(a.state) - PIN_ORDER.indexOf(b.state))[0];
839
+ add<ArtifactNode>({
840
+ id: aid,
841
+ kind: "artifact",
842
+ path,
843
+ anchor: null,
844
+ pinnedSha256: worst?.sha256 ?? null,
845
+ currentSha256: pins.find((p) => p.state !== "unpinned")?.actual ?? null,
846
+ pinState: worst?.state ?? "unpinned",
847
+ });
848
+ for (const p of pins) {
849
+ edges.push({ kind: "pins", from: p.decision.id, to: aid, pinnedSha256: p.sha256, pinState: p.state });
850
+ if (p.state === "drifted") find("intent-pin-drifted", `${path} changed after ${p.decision.record} pinned it: sha256 ${p.sha256?.slice(0, 12)} is pinned, the file hashes to ${p.actual?.slice(0, 12)}`, [p.decision.id, aid]);
851
+ if (p.state === "missing") find("intent-pin-missing", `${p.decision.record} pins ${path}, which does not exist${located.tree.label}`, [p.decision.id, aid]);
852
+ }
853
+ if (!worst && pins.some((p) => p.state !== "unpinned")) {
854
+ find("intent-artifact-unpinned", `${path} was pinned by ${[...new Set(pins.map((p) => p.decision.record))].join(", ")}, and no current decision pins it`, [aid, ...new Set(pins.map((p) => p.decision.id))]);
855
+ }
856
+ }
857
+
858
+ // A path constraint that no longer resolves (#2650 A15).
859
+ for (const d of decisions) {
860
+ const hit = decisionById.get(`${d.recordKind}/${d.record}`)!;
861
+ const field = hit.kind.records!.loaded.kind.constrains?.field;
862
+ const list = field ? hit.view.data?.[field] : undefined;
863
+ if (!Array.isArray(list)) continue;
864
+ const kindPrefix = hit.kind.records!.workspaceRoot === "." ? "" : hit.kind.records!.workspaceRoot;
865
+ for (const entry of list) {
866
+ if (typeof entry !== "string" || !entry.startsWith("path:")) continue;
867
+ const p = entry.slice("path:".length);
868
+ if (!isWorkspacePath(p)) continue;
869
+ const full = joinPath(kindPrefix, p);
870
+ const inTree = workspacePrefix === "" ? full : full.startsWith(`${workspacePrefix}/`) ? full.slice(workspacePrefix.length + 1) : undefined;
871
+ if (inTree !== undefined && located.tree.stat(inTree) === undefined) {
872
+ find("intent-constraint-lost", `${d.record} constrains ${p}, which does not exist${located.tree.label}`, [d.id]);
873
+ }
874
+ }
875
+ }
876
+
877
+ // Which decisions covered the region at each commit's time: from the commit
878
+ // that added the record until the commit that added the record superseding it.
879
+ const windows = new Map<string, { from: Set<string>; until: Set<string> | null }>();
880
+ if (rev) {
881
+ for (const c of covering) {
882
+ const added = addingCommit(top, rev, c.view.path);
883
+ const successor = c.view.supersededBy ? decisionById.get(`${c.node.recordKind}/${c.view.supersededBy}`) : undefined;
884
+ const replaced = successor ? addingCommit(top, rev, successor.view.path) : null;
885
+ windows.set(c.node.id, { from: added ? descendants(top, added, rev) : new Set(), until: replaced ? descendants(top, replaced, rev) : null });
886
+ }
887
+ }
888
+ const coveredAt = (sha: string, granularities: Granularity[]) =>
889
+ covering.filter((c) => {
890
+ if (!c.node.constrains.some((x) => granularities.includes(x.granularity))) return false;
891
+ const w = windows.get(c.node.id);
892
+ return !!w && w.from.has(sha) && !w.until?.has(sha);
893
+ });
894
+ const readsRecords = kinds.some((k) => k.records);
895
+ // A decision's own work: a commit whose unit, or that unit's contract, names
896
+ // the decision, or whose unit serves a contract the decision constrains.
897
+ const ownWork = (j: Joined, d: DecisionNode) =>
898
+ !!j.unit && (j.decisions.some((x) => x === d.record || x === `${d.recordKind}/${d.record}`) || d.constrains.some((x) => x.granularity === "contract" && j.contracts.includes(x.entry)));
899
+ for (const t of touched) {
900
+ const c = nodes.get(`commit:${t.sha}`) as CommitNode | undefined;
901
+ if (!c) continue;
902
+ const j = joined.get(t.sha)!;
903
+ if (readsRecords) {
904
+ const inWindow = coveredAt(t.sha, ["path"]);
905
+ for (const w of inWindow) edges.push({ kind: "within", from: c.id, to: w.node.id, state: ownWork(j, w.node) ? "decided" : "decided-by-window" });
906
+ c.state = inWindow.length === 0 ? "undecided" : inWindow.some((w) => ownWork(j, w.node)) ? "decided" : "decided-by-window";
907
+ }
908
+ if (readsRecords && c.state === "undecided") {
909
+ find("intent-commit-undecided", `${t.sha.slice(0, 8)} changed the region when no decision constrained ${region.path} by path`, [c.id, rid]);
910
+ }
911
+ if (readsRecords && !j.unit && c.pullRequest === null && coveredAt(t.sha, ["path", "member", "contract", "issue"]).length === 0) {
912
+ find("intent-commit-bare", `${t.sha.slice(0, 8)} names no unit, no pull request and no decision`, [c.id]);
913
+ }
914
+ const claims = [...new Set(j.authorship)].filter((key) => hasTrailer(c.trailers, key));
915
+ if (claims.length > 0 && c.signature.level !== "attested") {
916
+ find("intent-trailer-unverified", `${t.sha.slice(0, 8)} carries ${claims.join(", ")}, and the commit is ${c.signature.level}, so nothing vouches for the trailer`, [c.id]);
917
+ }
918
+ }
919
+
920
+ // Findings about the region as a whole.
921
+ if (readsRecords) {
922
+ const current = covering.filter((c) => c.node.supersededBy === null);
923
+ if (covering.length === 0) {
924
+ find("intent-region-unconstrained", `no decision constrains ${region.path}`, [rid]);
925
+ } else if (current.length === 0) {
926
+ find("intent-decision-superseded-live", `every decision constraining ${region.path} is superseded: ${covering.map((c) => `${c.node.record} by ${c.node.supersededBy}`).join(", ")}`, [rid, ...covering.map((c) => c.node.id)]);
927
+ } else if (current.every((c) => !c.node.closed)) {
928
+ find("intent-decision-provisional", `the decisions constraining ${region.path} are ${[...new Set(current.map((c) => c.node.state ?? "stateless"))].join(" or ")}, and none is in a closed state`, [rid, ...current.map((c) => c.node.id)]);
929
+ }
930
+ const granularities = new Set(covering.flatMap((c) => c.node.constrains.map((x) => x.granularity)));
931
+ if (covering.length > 0 && !granularities.has("path") && granularities.has("member")) {
932
+ find("intent-constraint-coarse", `${region.path} is constrained only through its member, ${member}`, [rid, ...covering.map((c) => c.node.id)]);
933
+ }
934
+ }
935
+
936
+ // 5. Links: the declared member links that touch the region's member, read in source.
937
+ if (member) {
938
+ let rows: LinkTableRow[] = [];
939
+ try {
940
+ const groups = resolveGroups(declaration, located.tree);
941
+ const kindsRegistry = loadKindRegistry(declaration.pins, located.rootOnDisk).registry;
942
+ rows = resolveLinks(declaration, sourceMemberHandles(declaration, located.tree, groups, kindsRegistry)).filter((r) => r.origin === "declared");
943
+ } catch (err) {
944
+ if (!(err instanceof WorkspaceReadError)) throw err;
945
+ }
946
+ rows.forEach((row, i) => {
947
+ const producer = "producer" in row ? row.producer : null;
948
+ if (row.consumer !== member && producer !== member) return;
949
+ const { pointer: _pointer, ...clean } = row as LinkTableRow & { pointer?: string };
950
+ add<LinkNode>({ id: `link:${i + 1}`, kind: "link", row: clean as LinkTableRow });
951
+ memberNode(row.consumer);
952
+ if (producer && declaration.members.some((m) => m.name === producer)) {
953
+ memberNode(producer);
954
+ edges.push({ kind: "links", from: `member:${row.consumer}`, to: `member:${producer}` });
955
+ }
956
+ });
957
+ }
958
+
959
+ // Findings last, in the order of the code list, so the same walk always prints the same way.
960
+ findings.sort((x, y) => INTENT_FINDING_CODES.indexOf(x.code as IntentFindingCode) - INTENT_FINDING_CODES.indexOf(y.code as IntentFindingCode));
961
+ // Then the plugins' findings, in commit order, each about its commit and the nodes its refs name.
962
+ const resolveRef = (ref: string): string | undefined => {
963
+ if (nodes.has(ref)) return ref;
964
+ for (const prefix of ["commit", "unit", "contract", "evidence", "artifact", "file", "member"]) if (nodes.has(`${prefix}:${ref}`)) return `${prefix}:${ref}`;
965
+ for (const n of nodes.values()) {
966
+ if (n.kind === "decision" && (ref === n.record || ref === `${n.recordKind}/${n.record}`)) return n.id;
967
+ if (n.kind === "commit" && /^[0-9a-f]{7,}$/.test(ref) && n.sha.startsWith(ref)) return n.id;
968
+ }
969
+ return undefined;
970
+ };
971
+ for (const { commit, plugin, finding } of pluginFindings) {
972
+ const code = finding.code as PluginCode;
973
+ const refs = finding.refs ?? [];
974
+ const concerns = [...new Set([commit, ...refs.map(resolveRef).filter((x): x is string => x !== undefined)])];
975
+ findings.push({ id: `finding:${code}:${findings.filter((f) => f.code === code).length + 1}`, kind: "finding", code, message: finding.message, concerns, plugin, refs });
976
+ }
977
+ for (const f of findings) nodes.set(f.id, f);
978
+ const all = [...nodes.values()];
979
+ return {
980
+ doc: {
981
+ ...head,
982
+ at: located.at,
983
+ workspace: { name: declaration.name, root: located.root },
984
+ region: rid,
985
+ history: { rev, follows: region.lines ? "line-range" : type === "file" ? "file" : "directory", shallow },
986
+ kinds: kinds.map((k) => ({ file: k.display, name: k.name, records: k.records?.loaded.kind.name ?? null, joins: k.joins?.form ?? null })),
987
+ nodes: all,
988
+ edges,
989
+ reasons,
990
+ summary: {
991
+ commits: all.filter((n) => n.kind === "commit").length,
992
+ decisions: all.filter((n) => n.kind === "decision").length,
993
+ artifacts: all.filter((n) => n.kind === "artifact").length,
994
+ findings: findings.length,
995
+ },
996
+ },
997
+ failed: reasons.some((r) => r.code === "intent-plugin-failed"),
998
+ };
999
+ }