@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,319 @@
1
+ /**
2
+ * Asset pins and record links (#2549): a decision's evidence may pin a
3
+ * workspace file by path and hash; `records` checks the pin in the tree it
4
+ * reads and reports drift as a warning that leaves the record valid; `graph
5
+ * --kind` emits `asset` and `constrains` rows; `check --kind` reports drift
6
+ * as WSP111.
7
+ */
8
+
9
+ import { execFileSync } from "node:child_process";
10
+ import { createHash } from "node:crypto";
11
+ import { readFileSync, rmSync, writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import Ajv from "ajv";
14
+ import { afterAll, describe, expect, test } from "vitest";
15
+ import { cleanScratch, commitAll, contract, declaration, repo, REPO } from "./__fixtures__/contract-repo";
16
+ import { runChecks } from "./lineage-check";
17
+ import { constraintCovers, isWorkspacePath, memberHolding, WORKSPACE_PATH_PATTERN } from "./record-assets";
18
+ import { pinFile, queryRecords, type RecordsDocument } from "./records-cli";
19
+ import { parseFrontMatter, RECORD_WARNING_CODES } from "./records";
20
+ import { workspaceGraph } from "./graph-cli";
21
+ import graphSchema from "./graph.schema.json";
22
+ import recordsSchema from "./records.schema.json";
23
+
24
+ afterAll(cleanScratch);
25
+
26
+ const DECISIONS = join(REPO, "docs", "design", "decisions");
27
+ const SAMPLE = readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8");
28
+ const KIND = readFileSync(join(DECISIONS, "decision.kind.mjs"), "utf-8");
29
+ const SCHEMA = readFileSync(join(DECISIONS, "decision.schema.json"), "utf-8");
30
+ const sha = (text: string | Buffer) => createHash("sha256").update(text).digest("hex");
31
+
32
+ /** ws-003 with its id replaced, and extra evidence and constrains entries. */
33
+ function decision(id: string, evidence: string[], constrains: string[] = [], state = "decided", supersedes: string[] = []): string {
34
+ let text = SAMPLE.replace(/^id: .*$/m, `id: "${id}"`).replace(/^state: .*$/m, `state: "${state}"`);
35
+ text = text.replace(/^evidence:\n/m, `evidence:\n${evidence.join("")}`);
36
+ text = text.replace(/^constrains:(?: \[\])?\n/m, `constrains:\n${constrains.map((c) => ` - "${c}"\n`).join("")}`);
37
+ const links = supersedes.length === 0 ? "supersedes: []" : `supersedes:\n${supersedes.map((d) => ` - decision: "${d}"`).join("\n")}`;
38
+ return text.replace(/^supersedes:(?: \[\])?\n(?: .*\n)*/m, `${links}\n`);
39
+ }
40
+
41
+ const pin = (path: string, hash: string) => ` - title: "the spec"\n path: "${path}"\n sha256: "${hash}"\n`;
42
+
43
+ const SPEC = '{ "screen": "home" }\n';
44
+ const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff, 0xfe]);
45
+
46
+ /** A git repository holding a workspace at `ws/`, whose decisions pin files in its `design` member. */
47
+ function workspace(extra: Record<string, string> = {}): string {
48
+ const root = repo({
49
+ "ws/chant.workspace.json": declaration([
50
+ { name: "app", dir: "app", kind: "other", because: "an app" },
51
+ { name: "design", dir: "design", kind: "other", because: "design data" },
52
+ ]),
53
+ "ws/app/src/server.mjs": "export {};\n",
54
+ "ws/design/screens/home.json": SPEC,
55
+ "ws/decisions/decision.kind.mjs": KIND,
56
+ "ws/decisions/decision.schema.json": SCHEMA,
57
+ "ws/decisions/ws-101-spec.md": decision("ws-101", [pin("design/screens/home.json", sha(SPEC))], ["member:design", "path:app/src", "path:app/nope", "member:ghost"]),
58
+ ...extra,
59
+ });
60
+ writeFileSync(join(root, "ws", "design", "screens", "home.png"), PNG);
61
+ return root;
62
+ }
63
+
64
+ function records(doc: RecordsDocument) {
65
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
66
+ return doc;
67
+ }
68
+
69
+ describe("the path grammar", () => {
70
+ test("the decision schema holds the same pattern for evidence and constrains", () => {
71
+ const schema = JSON.parse(SCHEMA) as { definitions: { workspacePath: { pattern: string } }; properties: { constrains: { items: { pattern: string } } } };
72
+ expect(schema.definitions.workspacePath.pattern).toBe(`^${WORKSPACE_PATH_PATTERN}$`);
73
+ expect(schema.properties.constrains.items.pattern).toContain(`|path:${WORKSPACE_PATH_PATTERN})$`);
74
+ });
75
+
76
+ test.each([
77
+ ["design/screens/home.json", true],
78
+ [".github/workflows/ci.yml", true],
79
+ ["app", true],
80
+ ["/etc/passwd", false],
81
+ ["../outside", false],
82
+ ["a/../b", false],
83
+ ["./a", false],
84
+ ["a//b", false],
85
+ ["a/", false],
86
+ ["a\\b", false],
87
+ ["", false],
88
+ ])("%s is a workspace path: %s", (path, ok) => {
89
+ expect(isWorkspacePath(path)).toBe(ok);
90
+ });
91
+
92
+ test("the schema takes a path pin with a hash, refuses one without, and keeps url entries as they were", () => {
93
+ const validate = new Ajv({ allErrors: true, strict: false }).compile(JSON.parse(SCHEMA) as object);
94
+ const base = { title: "t" };
95
+ const withEvidence = (evidence: unknown[]) => {
96
+ const fm = parseFrontMatter(decision("ws-101", []));
97
+ if (!fm.ok) throw new Error(fm.message);
98
+ return { ...fm.value, evidence };
99
+ };
100
+ expect(validate(withEvidence([{ ...base, path: "design/a.json", sha256: sha("x") }]))).toBe(true);
101
+ expect(validate(withEvidence([{ ...base, url: "https://example.com", sha256: null }]))).toBe(true);
102
+ expect(validate(withEvidence([{ ...base, path: "design/a.json" }]))).toBe(false);
103
+ expect(validate(withEvidence([{ ...base, path: "design/a.json", sha256: null }]))).toBe(false);
104
+ expect(validate(withEvidence([{ ...base, path: "../a.json", sha256: sha("x") }]))).toBe(false);
105
+ expect(validate(withEvidence([{ ...base, path: "a.json", url: "https://example.com", sha256: sha("x") }]))).toBe(false);
106
+ expect(validate({ ...withEvidence([{ ...base, url: "https://example.com" }]), constrains: ["path:app/src", "member:app", "INTENTIUS/chant#1"] })).toBe(true);
107
+ expect(validate({ ...withEvidence([{ ...base, url: "https://example.com" }]), constrains: ["path:../x"] })).toBe(false);
108
+ });
109
+
110
+ test("a path belongs to the deepest member holding it, and a path: constraint covers what is below it", () => {
111
+ const members = [
112
+ { name: "root", dir: "." },
113
+ { name: "app", dir: "app" },
114
+ { name: "api", dir: "app/api" },
115
+ ];
116
+ expect(memberHolding("app/api/x.ts", members)).toBe("api");
117
+ expect(memberHolding("app/x.ts", members)).toBe("app");
118
+ expect(memberHolding("apps/x.ts", members)).toBe("root");
119
+ expect(memberHolding("x", [{ name: "app", dir: "app" }])).toBeNull();
120
+ expect(constraintCovers("app", "app/src/server.mjs")).toBe(true);
121
+ expect(constraintCovers("app", "apps/x")).toBe(false);
122
+ });
123
+ });
124
+
125
+ describe("chant workspace records checks each pin", () => {
126
+ const { expectValid } = contract(recordsSchema);
127
+
128
+ test("a pin that matches is pinned, resolved from the workspace holding the kind", async () => {
129
+ const root = workspace();
130
+ const doc = records(await queryRecords({ kind: "ws/decisions/decision.kind.mjs", cwd: root }));
131
+ expectValid(doc);
132
+ expect(doc.workspaceRoot).toBe("ws");
133
+ const [r] = doc.records;
134
+ expect(r.valid).toBe(true);
135
+ expect(r.warnings).toEqual([]);
136
+ expect(r.assets).toEqual([{ path: "design/screens/home.json", sha256: sha(SPEC), actual: sha(SPEC), state: "pinned" }]);
137
+ });
138
+
139
+ test("an edited file is asset-drift and a missing one asset-missing; the record stays valid and current", async () => {
140
+ const root = workspace({
141
+ "ws/decisions/ws-102-gone.md": decision("ws-102", [pin("design/screens/gone.json", sha("x"))]),
142
+ });
143
+ writeFileSync(join(root, "ws", "design", "screens", "home.json"), '{ "screen": "home", "edited": true }\n');
144
+ const doc = records(await queryRecords({ kind: "ws/decisions/decision.kind.mjs", current: true, cwd: root }));
145
+ expectValid(doc);
146
+ expect(doc.records.map((r) => [r.id, r.valid, r.warnings.map((w) => w.code), r.assets.map((a) => a.state)])).toEqual([
147
+ ["ws-101", true, ["asset-drift"], ["drifted"]],
148
+ ["ws-102", true, ["asset-missing"], ["missing"]],
149
+ ]);
150
+ expect(doc.summary.invalid).toBe(0);
151
+ expect(doc.records[1].assets[0].actual).toBeNull();
152
+ });
153
+
154
+ test("the bytes are hashed, not the text", async () => {
155
+ const root = workspace({ "ws/decisions/ws-103-png.md": decision("ws-103", [pin("design/screens/home.png", sha(PNG))]) });
156
+ const doc = records(await queryRecords({ kind: "ws/decisions/decision.kind.mjs", cwd: root }));
157
+ expect(doc.records.find((r) => r.id === "ws-103")!.assets[0].state).toBe("pinned");
158
+ });
159
+
160
+ test("--at checks the pin against the file as committed at that revision", async () => {
161
+ const root = workspace();
162
+ const first = commitAll(root, "one");
163
+ writeFileSync(join(root, "ws", "design", "screens", "home.json"), "{}\n");
164
+ const second = commitAll(root, "two");
165
+ const at = async (rev: string) => records(await queryRecords({ kind: "ws/decisions/decision.kind.mjs", at: rev, cwd: root })).records[0].assets[0].state;
166
+ expect(await at(first)).toBe("pinned");
167
+ expect(await at(second)).toBe("drifted");
168
+ });
169
+
170
+ test("the schema lists exactly the warning codes", () => {
171
+ expect(recordsSchema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES]);
172
+ });
173
+
174
+ test("records pin <path> prints the entry's path from the workspace root and the file's hash", () => {
175
+ const root = workspace();
176
+ expect(pinFile("screens/home.json", join(root, "ws", "design"))).toEqual({ path: "design/screens/home.json", sha256: sha(SPEC) });
177
+ expect(pinFile("ws/design/screens/home.png", root)).toEqual({ path: "design/screens/home.png", sha256: sha(PNG) });
178
+ expect(pinFile("design", join(root, "ws"))).toEqual({ error: "design is not a file" });
179
+ });
180
+ });
181
+
182
+ describe("chant workspace graph --kind", () => {
183
+ const { expectValid } = contract(graphSchema);
184
+
185
+ test("emits an asset row per pin and a constrains row per member or path, and the document validates", async () => {
186
+ const root = workspace({ "ws/decisions/ws-104-old.md": decision("ws-104", [pin("design/screens/home.json", sha("old"))]) });
187
+ writeFileSync(join(root, "ws", "decisions", "ws-105-new.md"), decision("ws-105", [], [], "ratified", ["ws-104"]));
188
+ const { doc, failed } = await workspaceGraph({ cwd: join(root, "ws"), kind: join(root, "ws", "decisions", "decision.kind.mjs") });
189
+ expect(failed).toBe(false);
190
+ expectValid(doc);
191
+ if ("error" in doc) throw new Error(doc.error.message);
192
+ expect(doc.records.map((r) => [r.kind, r.id, r.supersededBy])).toEqual([
193
+ ["decision", "ws-101", null],
194
+ ["decision", "ws-104", "ws-105"],
195
+ ["decision", "ws-105", null],
196
+ ]);
197
+ const rows = doc.links.map((r) => ("record" in r ? [r.kind, r.record, r.target, r.member, r.status] : []));
198
+ // ws-104 is superseded, so its drifted pin is no row.
199
+ expect(rows).toEqual([
200
+ ["asset", "ws-101", "design/screens/home.json", "design", "pinned"],
201
+ ["constrains", "ws-101", "member:design", "design", "resolved"],
202
+ ["constrains", "ws-101", "path:app/src", "app", "resolved"],
203
+ ["constrains", "ws-101", "path:app/nope", "app", "missing"],
204
+ ["constrains", "ws-101", "member:ghost", null, "missing"],
205
+ ]);
206
+ });
207
+
208
+ test("a kind that can't be read fails the command and leaves the records empty", async () => {
209
+ const root = workspace();
210
+ const errors: string[] = [];
211
+ const { doc, failed } = await workspaceGraph({ cwd: join(root, "ws"), kind: join(root, "nope.mjs"), onStderr: (t) => errors.push(t) });
212
+ expect(failed).toBe(true);
213
+ expectValid(doc);
214
+ expect(errors.join("")).toContain("kind-unreadable");
215
+ });
216
+ });
217
+
218
+ describe("chant workspace check --kind", () => {
219
+ test("reports a drifted pin as a WSP111 warning on the record, and passes", async () => {
220
+ const root = workspace();
221
+ writeFileSync(join(root, "ws", "design", "screens", "home.json"), "{}\n");
222
+ const doc = await runChecks(join(root, "ws"), undefined, { kind: "decisions/decision.kind.mjs" });
223
+ if ("error" in doc) throw new Error(doc.error.message);
224
+ const found = doc.declaration!.diagnostics.filter((d) => d.ruleId.startsWith("WSP11"));
225
+ expect(found.map((d) => [d.ruleId, d.severity, d.file])).toEqual([["WSP111", "warning", "decisions/ws-101-spec.md"]]);
226
+ expect(doc.ok).toBe(true);
227
+ });
228
+
229
+ test("without --kind no record is read; an unreadable kind is a WSP114 error", async () => {
230
+ const root = workspace();
231
+ writeFileSync(join(root, "ws", "design", "screens", "home.json"), "{}\n");
232
+ const plain = await runChecks(join(root, "ws"));
233
+ if ("error" in plain) throw new Error(plain.error.message);
234
+ expect(plain.declaration!.diagnostics.some((d) => d.ruleId.startsWith("WSP11"))).toBe(false);
235
+ const bad = await runChecks(join(root, "ws"), undefined, { kind: "decisions/nope.mjs" });
236
+ if ("error" in bad) throw new Error(bad.error.message);
237
+ expect(bad.declaration!.diagnostics.map((d) => d.ruleId)).toContain("WSP114");
238
+ expect(bad.ok).toBe(false);
239
+ });
240
+ });
241
+
242
+ describe("asset-stale: the decision changed and the artifact did not follow", () => {
243
+ /** Commit everything in `root` at `seconds` since the epoch, so commit times are ordered. */
244
+ const commitAt = (root: string, seconds: number) => {
245
+ const env = { ...process.env, GIT_AUTHOR_DATE: `@${seconds} +0000`, GIT_COMMITTER_DATE: `@${seconds} +0000`, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.com", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.com" };
246
+ execFileSync("git", ["add", "-A"], { cwd: root, env });
247
+ execFileSync("git", ["commit", "-q", "--allow-empty", "-m", String(seconds)], { cwd: root, env });
248
+ };
249
+ const kind = "ws/decisions/decision.kind.mjs";
250
+ const superseding = (hash: string) => decision("ws-106", [pin("design/screens/home.json", hash)], [], "decided", ["ws-101"]);
251
+
252
+ test("a superseding record that pins the old hash of an unchanged file is stale; drift and missing stay apart", async () => {
253
+ const root = workspace({
254
+ // ws-107 supersedes ws-102 and pins a file that is gone; ws-108 supersedes ws-103 and pins a file that changed.
255
+ "ws/decisions/ws-102-gone.md": decision("ws-102", [pin("design/screens/gone.json", sha("x"))]),
256
+ "ws/decisions/ws-103-other.md": decision("ws-103", [pin("design/screens/other.json", sha("one\n"))]),
257
+ "ws/design/screens/other.json": "one\n",
258
+ });
259
+ commitAt(root, 1_700_000_000);
260
+ writeFileSync(join(root, "ws", "decisions", "ws-106-new.md"), superseding(sha(SPEC)));
261
+ writeFileSync(join(root, "ws", "decisions", "ws-107-gone.md"), decision("ws-107", [pin("design/screens/gone.json", sha("x"))], [], "decided", ["ws-102"]));
262
+ writeFileSync(join(root, "ws", "decisions", "ws-108-other.md"), decision("ws-108", [pin("design/screens/other.json", sha("one\n"))], [], "decided", ["ws-103"]));
263
+ writeFileSync(join(root, "ws", "design", "screens", "other.json"), "two\n");
264
+ commitAt(root, 1_700_000_100);
265
+
266
+ const doc = records(await queryRecords({ kind, current: true, cwd: root }));
267
+ contract(recordsSchema).expectValid(doc);
268
+ expect(doc.records.map((r) => [r.id, r.valid, r.assets.map((a) => a.state), r.warnings.map((w) => w.code)])).toEqual([
269
+ ["ws-106", true, ["stale"], ["asset-stale"]],
270
+ ["ws-107", true, ["missing"], ["asset-missing"]],
271
+ ["ws-108", true, ["drifted"], ["asset-drift"]],
272
+ ]);
273
+
274
+ // The same read at the revision agrees.
275
+ const at = records(await queryRecords({ kind, current: true, at: "HEAD", cwd: root }));
276
+ expect(at.records[0].assets[0].state).toBe("stale");
277
+
278
+ const { doc: graph } = await workspaceGraph({ cwd: join(root, "ws"), kind: join(root, kind) });
279
+ contract(graphSchema).expectValid(graph);
280
+ if ("error" in graph) throw new Error(graph.error.message);
281
+ expect(graph.links.flatMap((r) => ("record" in r && r.kind === "asset" ? [[r.record, r.status]] : []))).toEqual([
282
+ ["ws-106", "stale"],
283
+ ["ws-107", "missing"],
284
+ ["ws-108", "drifted"],
285
+ ]);
286
+
287
+ const check = await runChecks(join(root, "ws"), undefined, { kind: "decisions/decision.kind.mjs" });
288
+ if ("error" in check) throw new Error(check.error.message);
289
+ expect(check.declaration!.diagnostics.filter((d) => d.ruleId.startsWith("WSP11")).map((d) => d.ruleId).sort()).toEqual(["WSP111", "WSP112", "WSP113"]);
290
+ });
291
+
292
+ test("re-pinning the new version of the file clears it", async () => {
293
+ const root = workspace();
294
+ commitAt(root, 1_700_000_000);
295
+ const next = '{ "screen": "home", "v": 2 }\n';
296
+ writeFileSync(join(root, "ws", "design", "screens", "home.json"), next);
297
+ writeFileSync(join(root, "ws", "decisions", "ws-106-new.md"), superseding(sha(next)));
298
+ const doc = records(await queryRecords({ kind, current: true, cwd: root }));
299
+ expect(doc.records.map((r) => [r.id, r.assets.map((a) => a.state), r.warnings])).toEqual([["ws-106", ["pinned"], []]]);
300
+ });
301
+
302
+ test("a file committed again after the record was recorded is not stale, even at the same hash", async () => {
303
+ const root = workspace();
304
+ commitAt(root, 1_700_000_000);
305
+ writeFileSync(join(root, "ws", "decisions", "ws-106-new.md"), superseding(sha(SPEC)));
306
+ commitAt(root, 1_700_000_100);
307
+ const spec = join(root, "ws", "design", "screens", "home.json");
308
+ rmSync(spec);
309
+ commitAt(root, 1_700_000_200);
310
+ writeFileSync(spec, SPEC);
311
+ commitAt(root, 1_700_000_300);
312
+ const doc = records(await queryRecords({ kind, current: true, cwd: root }));
313
+ expect(doc.records[0].assets[0].state).toBe("pinned");
314
+ // At the revision where the record was recorded, the file had not moved since ws-101 pinned it.
315
+ const log = execFileSync("git", ["log", "--format=%H"], { cwd: root, encoding: "utf-8" }).trim().split("\n");
316
+ const at = records(await queryRecords({ kind, current: true, at: log[2], cwd: root }));
317
+ expect(at.records[0].assets[0].state).toBe("stale");
318
+ });
319
+ });
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Asset pins and record links (#2549; #2524 D4, D6, D18).
3
+ *
4
+ * Artifact relationships are derived from decisions. A decision record pins
5
+ * the workspace files it rests on in `evidence`, each as `{title, path,
6
+ * sha256}`, and names what it governs in `constrains`, as `member:<name>` or
7
+ * `path:<path>`. There is no direct link from a design artifact to code or to
8
+ * another artifact: a reader walks from a file to the decisions whose
9
+ * `constrains` cover it, and from them to their pinned assets.
10
+ *
11
+ * A pin is checked against the tree a read looks at (the working tree, or a
12
+ * revision's git objects). A file whose bytes hash differently is `drifted`,
13
+ * and one that is gone is `missing`. Neither makes the record invalid: the
14
+ * record is still what was decided, and the drift is reported beside it.
15
+ *
16
+ * Which front-matter fields hold pins and links is the kind's data
17
+ * (`pins.field`, `constrains.field`), so this module never names `evidence`.
18
+ */
19
+
20
+ import { sha256Hex } from "../content-digest";
21
+ import type { RecordWarning } from "./records";
22
+ import type { WorkspaceTree } from "./tree";
23
+
24
+ /**
25
+ * A path from the workspace root: `/` separators, no leading `/`, no `.` or
26
+ * `..` segment, no empty segment, no backslash or control character and no
27
+ * trailing `/`. The decision schema holds the same pattern.
28
+ */
29
+ export const WORKSPACE_PATH_PATTERN = String.raw`(?!/)(?!(?:[^/]*/)*\.{1,2}(?:/|$))(?!.*//)[^\\\u0000-\u001f]*[^/\\\u0000-\u001f]`;
30
+ const WORKSPACE_PATH = new RegExp(`^${WORKSPACE_PATH_PATTERN}$`, "u");
31
+ const SHA256 = /^[0-9a-f]{64}$/;
32
+
33
+ export function isWorkspacePath(value: unknown): value is string {
34
+ return typeof value === "string" && WORKSPACE_PATH.test(value);
35
+ }
36
+
37
+ /**
38
+ * `pinned`: the file hashes to the pin. `drifted`: it doesn't. `missing`: it
39
+ * isn't there. `stale`: it hashes to the pin, which a record this one
40
+ * supersedes pinned too, so the decision changed and the artifact did not.
41
+ */
42
+ export type PinState = "pinned" | "drifted" | "missing" | "stale";
43
+
44
+ /** One pinned file, as the tree read has it. */
45
+ export interface AssetPin {
46
+ /** From the workspace root. */
47
+ path: string;
48
+ /** The hash the record pins. */
49
+ sha256: string;
50
+ /** The hash of the file in the tree read, or null when it is missing. */
51
+ actual: string | null;
52
+ state: PinState;
53
+ }
54
+
55
+ /** The well-formed pins in a record's `field` list: entries with a workspace path and a hex sha256. */
56
+ export function pinEntries(data: Record<string, unknown> | null, field: string): { path: string; sha256: string }[] {
57
+ const list = data?.[field];
58
+ if (!Array.isArray(list)) return [];
59
+ const out: { path: string; sha256: string }[] = [];
60
+ for (const e of list) {
61
+ if (e === null || typeof e !== "object" || Array.isArray(e)) continue;
62
+ const { path, sha256 } = e as Record<string, unknown>;
63
+ if (isWorkspacePath(path) && typeof sha256 === "string" && SHA256.test(sha256)) out.push({ path, sha256 });
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /** The hex SHA-256 of the file at `path` in `tree`, or undefined when it is not a file there. */
69
+ export function fileDigest(tree: WorkspaceTree, path: string): string | undefined {
70
+ if (tree.stat(path) !== "file") return undefined;
71
+ const bytes = tree.bytes ? tree.bytes(path) : Buffer.from(tree.read(path), "utf-8");
72
+ return sha256Hex(bytes);
73
+ }
74
+
75
+ /** Check each pin against `tree`, rooted at the workspace root. */
76
+ export function checkPins(pins: { path: string; sha256: string }[], tree: WorkspaceTree): { assets: AssetPin[]; warnings: RecordWarning[] } {
77
+ const assets: AssetPin[] = [];
78
+ const warnings: RecordWarning[] = [];
79
+ for (const pin of pins) {
80
+ const actual = fileDigest(tree, pin.path) ?? null;
81
+ const state: PinState = actual === null ? "missing" : actual === pin.sha256 ? "pinned" : "drifted";
82
+ assets.push({ ...pin, actual, state });
83
+ if (state === "missing") {
84
+ warnings.push({ code: "asset-missing", message: `evidence pins ${pin.path}, which does not exist${tree.label}` });
85
+ } else if (state === "drifted") {
86
+ warnings.push({
87
+ code: "asset-drift",
88
+ message: `${pin.path} changed since it was pinned: sha256 ${pin.sha256.slice(0, 12)} is pinned, the file${tree.label} hashes to ${actual!.slice(0, 12)}`,
89
+ });
90
+ }
91
+ }
92
+ return { assets, warnings };
93
+ }
94
+
95
+ // ── Record links ─────────────────────────────────────────────────────────────
96
+
97
+ /** The kinds of link a record has in `chant workspace graph`. Closed. */
98
+ export const RECORD_LINK_KINDS = ["asset", "constrains"] as const;
99
+
100
+ /** A link from a record to a workspace path or member, a row of the graph's `links`. */
101
+ export interface RecordLinkRow {
102
+ kind: (typeof RECORD_LINK_KINDS)[number];
103
+ origin: "declared";
104
+ resolves: "source";
105
+ /** The record's kind, such as `decision`. */
106
+ recordKind: string;
107
+ /** The record's id. */
108
+ record: string;
109
+ /** The record file, from the repository root. */
110
+ recordPath: string;
111
+ /** For `asset`, the pinned path; for `constrains`, the entry as written (`member:<name>` or `path:<path>`). */
112
+ target: string;
113
+ /** The member the target is, or holds it; null when no member does. */
114
+ member: string | null;
115
+ /** `pinned`, `drifted`, `missing` or `stale` for an asset; `resolved` or `missing` for constrains. */
116
+ status: PinState | "resolved";
117
+ reason: string | null;
118
+ /** For an asset: the pinned hash and the hash in the tree read. */
119
+ sha256?: string;
120
+ actual?: string | null;
121
+ }
122
+
123
+ /** The member whose directory holds `path` (the deepest one), or null. */
124
+ export function memberHolding(path: string, members: readonly { name: string; dir: string }[]): string | null {
125
+ let best: { name: string; dir: string } | null = null;
126
+ for (const m of members) {
127
+ const inside = m.dir === "." || path === m.dir || path.startsWith(`${m.dir}/`);
128
+ if (inside && (!best || best.dir === "." || m.dir.length > best.dir.length)) best = m;
129
+ }
130
+ return best?.name ?? null;
131
+ }
132
+
133
+ /** Whether a `path:` constraint covers `file`: the same path, or a directory above it. */
134
+ export function constraintCovers(constraint: string, file: string): boolean {
135
+ return file === constraint || file.startsWith(`${constraint}/`);
136
+ }
137
+
138
+ /** A record as the link rows read it. */
139
+ export interface LinkedRecord {
140
+ id: string | null;
141
+ path: string;
142
+ supersededBy: string | null;
143
+ data: Record<string, unknown> | null;
144
+ assets: AssetPin[];
145
+ }
146
+
147
+ /**
148
+ * The link rows of the records of one kind: an `asset` row per pin and a
149
+ * `constrains` row per `member:` or `path:` entry. Superseded records and
150
+ * records with no id have none, since their links no longer hold. Paths
151
+ * resolve in `tree` (the workspace root), members in `members`.
152
+ */
153
+ export function recordLinkRows(
154
+ kindName: string,
155
+ records: readonly LinkedRecord[],
156
+ constrainsField: string | undefined,
157
+ tree: WorkspaceTree,
158
+ members: readonly { name: string; dir: string }[],
159
+ ): RecordLinkRow[] {
160
+ const rows: RecordLinkRow[] = [];
161
+ for (const r of records) {
162
+ if (r.id === null || r.supersededBy !== null) continue;
163
+ const base = { origin: "declared" as const, resolves: "source" as const, recordKind: kindName, record: r.id, recordPath: r.path };
164
+ for (const a of r.assets) {
165
+ rows.push({
166
+ kind: "asset",
167
+ ...base,
168
+ target: a.path,
169
+ member: memberHolding(a.path, members),
170
+ status: a.state,
171
+ reason:
172
+ a.state === "missing"
173
+ ? `${a.path} does not exist${tree.label}`
174
+ : a.state === "drifted"
175
+ ? `${a.path} changed since ${r.id} pinned it`
176
+ : a.state === "stale"
177
+ ? `${a.path} has not changed since a record ${r.id} supersedes pinned it`
178
+ : null,
179
+ sha256: a.sha256,
180
+ actual: a.actual,
181
+ });
182
+ }
183
+ const list = constrainsField ? r.data?.[constrainsField] : undefined;
184
+ if (!Array.isArray(list)) continue;
185
+ for (const entry of list) {
186
+ if (typeof entry !== "string") continue;
187
+ if (entry.startsWith("member:")) {
188
+ const name = entry.slice("member:".length);
189
+ const known = members.some((m) => m.name === name);
190
+ rows.push({ kind: "constrains", ...base, target: entry, member: known ? name : null, status: known ? "resolved" : "missing", reason: known ? null : `${name} is not a member of this workspace` });
191
+ } else if (entry.startsWith("path:")) {
192
+ const path = entry.slice("path:".length);
193
+ if (!isWorkspacePath(path)) continue;
194
+ const exists = tree.stat(path) !== undefined;
195
+ rows.push({
196
+ kind: "constrains",
197
+ ...base,
198
+ target: entry,
199
+ member: memberHolding(path, members),
200
+ status: exists ? "resolved" : "missing",
201
+ reason: exists ? null : `${path} does not exist${tree.label}`,
202
+ });
203
+ }
204
+ }
205
+ }
206
+ return rows;
207
+ }