@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
@@ -12,12 +12,16 @@
12
12
  * nothing is inferred (#2525 rule 1).
13
13
  */
14
14
 
15
+ import { execFileSync } from "node:child_process";
15
16
  import { realpathSync } from "node:fs";
16
- import { relative } from "node:path";
17
+ import { dirname, join, relative, resolve, sep } from "node:path";
17
18
  import { formatError } from "../cli/format";
18
19
  import type { CommandContext } from "../cli/registry";
20
+ import { findWorkspaceRoot } from "../project-root";
21
+ import { fileDigest, isWorkspacePath } from "./record-assets";
19
22
  import { gitRevisionSource, gitRoot, resolveRevision, workingTreeSource } from "./record-source";
20
- import { loadRecordKind, readRecords, RecordReadError, type ReadErrorCode, type RecordEntry } from "./records";
23
+ import { loadRecordKind, readRecords, RecordReadError, type LoadedRecordKind, type ReadErrorCode, type ReadRecordsResult, type RecordEntry, type RecordHistory } from "./records";
24
+ import { gitTree, workingTree } from "./tree";
21
25
  import { activeAttestors, type ProvenanceLevel } from "./trust/attestor";
22
26
  import { policyAtBase, recordProvenance, resolveBase, type BaseSource, type RecordProvenance } from "./trust/provenance";
23
27
 
@@ -27,7 +31,7 @@ export const RECORDS_CONTRACT_VERSION = 1;
27
31
  /** `$id` of the JSON Schema for the `--json` output, shipped beside this file. */
28
32
  export const RECORDS_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records/v1/records.schema.json";
29
33
 
30
- const USAGE = "chant workspace records --kind <kind file> [--current] [--at <rev>] [--base <rev>] [--require attested] [--json]";
34
+ const USAGE = "chant workspace records --kind <kind file> [--current] [--at <rev>] [--base <rev>] [--require attested] [--json] | chant workspace records pin <path>";
31
35
 
32
36
  /** Exit code when the read worked and a record falls below `--require`. */
33
37
  export const EXIT_BELOW_REQUIRED = 2;
@@ -63,6 +67,8 @@ export type RecordsDocument =
63
67
  contract: number;
64
68
  kind: { name: string; schema: string; file: string };
65
69
  at: string | null;
70
+ /** The directory pinned paths resolve in, from the repository root: the workspace holding the kind file, or the repository root (#2549). */
71
+ workspaceRoot: string;
66
72
  current: boolean;
67
73
  trust: TrustView;
68
74
  records: RecordView[];
@@ -70,23 +76,84 @@ export type RecordsDocument =
70
76
  }
71
77
  | { $schema: string; contract: number; error: { code: ReadErrorCode; message: string } };
72
78
 
73
- /** Run the query and build the document `--json` prints. Never throws a {@link RecordReadError}. */
74
- export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument> {
79
+ /** A records read, before provenance. */
80
+ export interface RecordsRead {
81
+ loaded: LoadedRecordKind;
82
+ /** The repository root, or the working directory outside git. Record paths are relative to it. */
83
+ root: string;
84
+ /** The git top, or undefined outside git. */
85
+ top: string | undefined;
86
+ at: string | null;
87
+ /** Where pinned paths resolve, relative to `root` ("." for the root itself). */
88
+ workspaceRoot: string;
89
+ result: ReadRecordsResult;
90
+ }
91
+
92
+ /**
93
+ * Where a kind's pinned paths resolve: the workspace whose declaration sits
94
+ * nearest above the kind file, when it is inside the repository, or else the
95
+ * repository root. Relative to `root`, with / separators.
96
+ */
97
+ function pinRoot(kindFile: string, root: string): string {
98
+ const found = findWorkspaceRoot(dirname(kindFile));
99
+ if (!found) return ".";
100
+ const rel = relative(root, realpathOr(found.dir)).split(sep).join("/");
101
+ return rel === "" || rel.startsWith("..") ? "." : rel;
102
+ }
103
+
104
+ /**
105
+ * Commit times from the history of `rev` in the repository at `top`, asked
106
+ * only for a pin that might be stale. `prefix` is the workspace root from the
107
+ * repository root.
108
+ */
109
+ function gitHistory(top: string, rev: string, prefix: string): RecordHistory {
110
+ const times = (args: string[]): number[] => {
111
+ try {
112
+ const out = execFileSync("git", args, { cwd: top, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
113
+ return out.split("\n").filter(Boolean).map(Number);
114
+ } catch {
115
+ // No commits yet, or a path git doesn't know.
116
+ return [];
117
+ }
118
+ };
119
+ return {
120
+ fileChanged: (path) => times(["log", "-1", "--format=%ct", rev, "--", prefix === "." ? path : `${prefix}/${path}`])[0] ?? null,
121
+ // The latest commit that added the file: a record deleted and written again counts from the second time.
122
+ recorded: (path) => times(["log", "--diff-filter=A", "--format=%ct", rev, "--", path])[0] ?? null,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Load the kind and read its records, in the working tree or at `query.at`,
128
+ * with each pin checked in the same tree. Throws a {@link RecordReadError}.
129
+ * `chant workspace graph` and `check` read records through this too.
130
+ */
131
+ export async function readRecordsFor(query: Omit<RecordsQuery, "base">): Promise<RecordsRead> {
75
132
  // git reports its top through symlinks resolved (/var is /private/var on
76
133
  // macOS), so the directory has to be too, or record paths leave the repository.
77
134
  const cwd = realpathOr(query.cwd);
78
135
  const top = gitRoot(cwd);
79
136
  const root = top ?? cwd;
137
+ const loaded = await loadRecordKind(query.kind, cwd);
138
+ const workspaceRoot = pinRoot(loaded.file, root);
139
+ let at: string | null = null;
140
+ let source = workingTreeSource(root);
141
+ let assets = workingTree(workspaceRoot === "." ? root : join(root, ...workspaceRoot.split("/")));
142
+ if (query.at !== undefined) {
143
+ if (!top) throw new RecordReadError("not-a-git-repository", "--at reads git objects, and this directory is not in a git repository");
144
+ at = resolveRevision(top, query.at);
145
+ source = gitRevisionSource(top, at);
146
+ assets = gitTree(top, at, workspaceRoot === "." ? "" : workspaceRoot);
147
+ }
148
+ const history = top ? gitHistory(top, at ?? "HEAD", workspaceRoot) : undefined;
149
+ const result = await readRecords(loaded, { root, source, current: !!query.current, assets, ...(history ? { history } : {}) });
150
+ return { loaded, root, top, at, workspaceRoot, result };
151
+ }
152
+
153
+ /** Run the query and build the document `--json` prints. Never throws a {@link RecordReadError}. */
154
+ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument> {
80
155
  try {
81
- const loaded = await loadRecordKind(query.kind, cwd);
82
- let at: string | null = null;
83
- let source = workingTreeSource(root);
84
- if (query.at !== undefined) {
85
- if (!top) throw new RecordReadError("not-a-git-repository", "--at reads git objects, and this directory is not in a git repository");
86
- at = resolveRevision(top, query.at);
87
- source = gitRevisionSource(top, at);
88
- }
89
- const result = await readRecords(loaded, { root, source, current: !!query.current });
156
+ const { loaded, root, top, at, workspaceRoot, result } = await readRecordsFor(query);
90
157
  // Provenance, judged by the policy at base and never by the tree read (#2547).
91
158
  const base = top ? resolveBase(top, query.base) : { commit: null, from: null };
92
159
  const policy = top ? policyAtBase(top, base) : policyAtBase(root, base);
@@ -106,6 +173,7 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
106
173
  file: relative(root, loaded.file).split("\\").join("/"),
107
174
  },
108
175
  at,
176
+ workspaceRoot,
109
177
  current: !!query.current,
110
178
  trust: { base: base.commit, baseFrom: base.from, active: policy.active, signersPath: policy.signersPath, problems: policy.problems },
111
179
  records: result.records.map((r) => ({ ...r, provenance: provenance.get(r.path)! })),
@@ -117,8 +185,42 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
117
185
  }
118
186
  }
119
187
 
188
+ /**
189
+ * `chant workspace records pin <path>`: the `{path, sha256}` a decision's
190
+ * evidence entry holds for a file, with the path from the workspace root
191
+ * (the nearest declaration above the file, or the repository root).
192
+ */
193
+ export function pinFile(file: string, cwd: string): { path: string; sha256: string } | { error: string } {
194
+ const abs = realpathOr(resolve(cwd, file));
195
+ const top = gitRoot(dirname(abs));
196
+ const ws = findWorkspaceRoot(dirname(abs));
197
+ const base = ws ? realpathOr(ws.dir) : top ? realpathOr(top) : realpathOr(cwd);
198
+ const path = relative(base, abs).split(sep).join("/");
199
+ if (path.startsWith("..") || !isWorkspacePath(path)) return { error: `${file} is not a file path inside the workspace at ${base}` };
200
+ const sha256 = fileDigest(workingTree(base), path);
201
+ if (sha256 === undefined) return { error: `${file} is not a file` };
202
+ return { path, sha256 };
203
+ }
204
+
120
205
  export async function runWorkspaceRecords(ctx: CommandContext): Promise<number> {
121
206
  const { args } = ctx;
207
+ if (args.extraPositional === "pin") {
208
+ if (!args.extraPositional2) {
209
+ console.error(formatError({ message: "pin needs the path of a file", hint: USAGE }));
210
+ return 1;
211
+ }
212
+ const pin = pinFile(args.extraPositional2, process.cwd());
213
+ if ("error" in pin) {
214
+ console.error(formatError({ message: pin.error, hint: USAGE }));
215
+ return 1;
216
+ }
217
+ console.log(JSON.stringify(pin, null, 2));
218
+ return 0;
219
+ }
220
+ if (args.extraPositional) {
221
+ console.error(formatError({ message: `chant workspace records takes no argument but pin (got ${args.extraPositional})`, hint: USAGE }));
222
+ return 1;
223
+ }
122
224
  if (!args.kind) {
123
225
  console.error(formatError({ message: "--kind <kind file> is required", hint: USAGE }));
124
226
  return 1;
@@ -178,6 +280,7 @@ function formatRecords(records: RecordView[], summary: { total: number; valid: n
178
280
  const attested = r.provenance.level === "attested" ? ` attested by ${r.provenance.principal}` : "";
179
281
  lines.push(`${(r.id ?? "-").padEnd(idWidth)} ${(r.state ?? "-").padEnd(stateWidth)} ${title}${superseded}${attested}${flag}`);
180
282
  for (const reason of r.reasons) lines.push(`${" ".repeat(idWidth + 2)}${reason.code}: ${reason.message} (${r.path})`);
283
+ for (const warning of r.warnings) lines.push(`${" ".repeat(idWidth + 2)}warning ${warning.code}: ${warning.message} (${r.path})`);
181
284
  }
182
285
  lines.push(
183
286
  `${summary.total} records${at ? ` at ${at.slice(0, 8)}` : ""}: ${summary.valid} valid, ${summary.invalid} invalid, ${summary.superseded} superseded`,
@@ -26,6 +26,10 @@
26
26
  "type": ["string", "null"],
27
27
  "pattern": "^[0-9a-f]{40,64}$"
28
28
  },
29
+ "workspaceRoot": {
30
+ "type": "string",
31
+ "description": "Added in contract 1 by #2549. The directory a record's pinned paths resolve in, from the repository root with / separators: the workspace whose declaration sits nearest above the kind file, or \".\" for the repository root."
32
+ },
29
33
  "error": false,
30
34
  "current": { "type": "boolean", "description": "Whether superseded records were left out (--current)." },
31
35
  "trust": { "$ref": "#/$defs/trust" },
@@ -63,7 +67,17 @@
63
67
  "type": ["object", "null"],
64
68
  "description": "The front matter as JSON, following the kind's schema when valid is true. Null when it could not be parsed."
65
69
  },
66
- "provenance": { "$ref": "#/$defs/provenance" }
70
+ "provenance": { "$ref": "#/$defs/provenance" },
71
+ "assets": {
72
+ "description": "Added in contract 1 by #2549. Each workspace file the record pins by hash, checked against the tree read: the working tree, or the revision under --at. Empty for a kind that pins nothing.",
73
+ "type": "array",
74
+ "items": { "$ref": "#/$defs/asset" }
75
+ },
76
+ "warnings": {
77
+ "description": "Added in contract 1 by #2549. Findings that leave the record valid: a pinned file that changed, went missing or did not follow a superseding decision, or a supersedes link that has no effect yet. valid and --current do not look at them.",
78
+ "type": "array",
79
+ "items": { "$ref": "#/$defs/warning" }
80
+ }
67
81
  },
68
82
  "allOf": [
69
83
  {
@@ -73,6 +87,24 @@
73
87
  }
74
88
  ]
75
89
  },
90
+ "asset": {
91
+ "type": "object",
92
+ "required": ["path", "sha256", "actual", "state"],
93
+ "properties": {
94
+ "path": { "type": "string", "description": "From the workspace root (workspaceRoot), with / separators." },
95
+ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$", "description": "The hash the record pins." },
96
+ "actual": { "type": ["string", "null"], "pattern": "^[0-9a-f]{64}$", "description": "The hash of the file's bytes in the tree read, or null when the file is missing." },
97
+ "state": { "enum": ["pinned", "drifted", "missing", "stale"], "description": "pinned when the hashes match; drifted when they differ (a warning asset-drift); missing when the file is not there (a warning asset-missing); stale when the hashes match and a record this one supersedes pinned the same hash, with the file unchanged since this record was recorded (a warning asset-stale)." }
98
+ }
99
+ },
100
+ "warning": {
101
+ "type": "object",
102
+ "required": ["code", "message"],
103
+ "properties": {
104
+ "code": { "enum": ["asset-drift", "asset-missing", "asset-stale", "record-supersedes-pending"] },
105
+ "message": { "type": "string" }
106
+ }
107
+ },
76
108
  "trust": {
77
109
  "description": "Added in contract 1 by #2547. Where provenance was judged from: the policy read at the base revision, never the tree read.",
78
110
  "type": "object",
@@ -133,6 +133,56 @@ describe("readRecords", () => {
133
133
  ]);
134
134
  });
135
135
 
136
+ test("a decided record supersedes a decided one, under an equal approval rule (#2524 D4)", async () => {
137
+ write("ws-001-a.md", decision("ws-001", "decided"));
138
+ write("ws-002-b.md", decision("ws-002", "decided", ["ws-001"]));
139
+ const all = await read();
140
+ expect(all.records.map((r) => [r.id, r.supersededBy, r.valid, r.warnings])).toEqual([
141
+ ["ws-001", "ws-002", true, []],
142
+ ["ws-002", null, true, []],
143
+ ]);
144
+ const current = await read({ current: true });
145
+ expect(current.records.map((r) => r.id)).toEqual(["ws-002"]);
146
+ });
147
+
148
+ test("a proposed record supersedes nothing: the link is pending, as a warning on the new record", async () => {
149
+ write("ws-001-a.md", decision("ws-001", "decided"));
150
+ write("ws-002-b.md", decision("ws-002", "proposed", ["ws-001"]).replace(/^choice:\n option: .*\n reason: .*\n/m, "choice: null\n"));
151
+ const all = await read();
152
+ expect(all.records.map((r) => [r.id, r.supersededBy, r.valid])).toEqual([
153
+ ["ws-001", null, true],
154
+ ["ws-002", null, true],
155
+ ]);
156
+ expect(all.records[1].warnings.map((w) => w.code)).toEqual(["record-supersedes-pending"]);
157
+ expect((await read({ current: true })).records.map((r) => r.id)).toEqual(["ws-001", "ws-002"]);
158
+ });
159
+
160
+ test("a decided record can't supersede a ratified one; a ratified record supersedes any", async () => {
161
+ write("ws-001-a.md", decision("ws-001", "ratified"));
162
+ write("ws-002-b.md", decision("ws-002", "decided", ["ws-001"]));
163
+ write("ws-003-c.md", decision("ws-003", "decided"));
164
+ write("ws-004-d.md", decision("ws-004", "ratified", ["ws-003"]));
165
+ const all = await read();
166
+ expect(all.records.map((r) => [r.id, r.supersededBy, r.warnings.map((w) => w.code)])).toEqual([
167
+ ["ws-001", null, []],
168
+ ["ws-002", null, ["record-supersedes-pending"]],
169
+ ["ws-003", "ws-004", []],
170
+ ["ws-004", null, []],
171
+ ]);
172
+ });
173
+
174
+ test("a kind without approval ranks keeps the closed-state rule", async () => {
175
+ const kind = readFileSync(join(dir, "decisions", "decision.kind.mjs"), "utf-8").replace(/^ approval: .*\n/m, "");
176
+ writeFileSync(join(dir, "decisions", "decision.kind.mjs"), kind);
177
+ write("ws-001-a.md", decision("ws-001", "decided"));
178
+ write("ws-002-b.md", decision("ws-002", "decided", ["ws-001"]));
179
+ const all = await read();
180
+ expect(all.records.map((r) => [r.id, r.supersededBy, r.warnings])).toEqual([
181
+ ["ws-001", null, []],
182
+ ["ws-002", null, []],
183
+ ]);
184
+ });
185
+
136
186
  test("a record superseded twice keeps the first and flags the second", async () => {
137
187
  write("ws-001-a.md", decision("ws-001", "ratified"));
138
188
  write("ws-002-b.md", decision("ws-002", "ratified", ["ws-001"]));
@@ -22,7 +22,9 @@ import yaml from "js-yaml";
22
22
  import { z } from "zod";
23
23
  import { importLexiconModule, registerLexiconDeclarations } from "../lexicon-module";
24
24
  import type { ReasonCode } from "./reason-codes";
25
+ import { checkPins, pinEntries, type AssetPin } from "./record-assets";
25
26
  import type { RecordSource } from "./record-source";
27
+ import type { WorkspaceTree } from "./tree";
26
28
 
27
29
  // ── Reason codes ─────────────────────────────────────────────────────────────
28
30
 
@@ -44,6 +46,30 @@ export const RECORD_REASON_CODES = [
44
46
  ] as const satisfies readonly ReasonCode[];
45
47
  export type RecordReasonCode = (typeof RECORD_REASON_CODES)[number];
46
48
 
49
+ /**
50
+ * Why a record carries a warning. Closed, like the reason codes. A warning
51
+ * never makes a record invalid, and `--current` still lists the record.
52
+ */
53
+ export const RECORD_WARNING_CODES = [
54
+ /** A pinned file's bytes no longer hash to the pinned sha256 (#2549). */
55
+ "asset-drift",
56
+ /** A pinned file does not exist in the tree read (#2549). */
57
+ "asset-missing",
58
+ /**
59
+ * A pinned file is unchanged since a record this one supersedes pinned it at
60
+ * the same hash: the decision changed and the artifact did not follow (#2549).
61
+ */
62
+ "asset-stale",
63
+ /** A supersedes link from a record whose state is weaker than the one it names, so it has no effect yet (#2524 D4). */
64
+ "record-supersedes-pending",
65
+ ] as const satisfies readonly ReasonCode[];
66
+ export type RecordWarningCode = (typeof RECORD_WARNING_CODES)[number];
67
+
68
+ export interface RecordWarning {
69
+ code: RecordWarningCode;
70
+ message: string;
71
+ }
72
+
47
73
  /**
48
74
  * Why the read as a whole failed. Also closed. The command exits 1 with one of
49
75
  * these and returns no records.
@@ -114,11 +140,34 @@ export const recordKindSchema = z
114
140
  closedStates: z.array(z.string().min(1)),
115
141
  /** The front-matter list of links to superseded records, and the key in each entry that holds the target id. */
116
142
  supersedes: z.object({ field: z.string().min(1), key: z.string().min(1) }).strict(),
143
+ /**
144
+ * How strongly each state is approved (#2524 D4). With it, a supersedes
145
+ * link takes effect when the new record's rank is above 0 and at least the
146
+ * old record's, "under an equal or stricter approval rule". A state it
147
+ * leaves out ranks 0. Without it, a link takes effect only from a record
148
+ * in a closed state.
149
+ */
150
+ approval: z.record(z.string(), z.number().int().min(0)).optional(),
151
+ /**
152
+ * The front-matter list whose entries may pin a workspace file, as
153
+ * `{path, sha256}` (#2549). Optional: a kind without it pins nothing.
154
+ */
155
+ pins: z.object({ field: z.string().min(1) }).strict().optional(),
156
+ /**
157
+ * The front-matter list of what a record governs. Its `member:<name>` and
158
+ * `path:<path>` entries are the record's links in `chant workspace graph`
159
+ * (#2549). Optional.
160
+ */
161
+ constrains: z.object({ field: z.string().min(1) }).strict().optional(),
117
162
  })
118
163
  .strict()
119
164
  .refine((k) => k.closedStates.every((s) => k.states.includes(s)), {
120
165
  message: "every closed state must be listed in states",
121
166
  path: ["closedStates"],
167
+ })
168
+ .refine((k) => Object.keys(k.approval ?? {}).every((s) => k.states.includes(s)), {
169
+ message: "every state approval ranks must be listed in states",
170
+ path: ["approval"],
122
171
  });
123
172
 
124
173
  export type RecordKind = z.infer<typeof recordKindSchema>;
@@ -138,7 +187,7 @@ export interface LoadedRecordKind {
138
187
  * The kind is registered under a name no lexicon package can have (npm names
139
188
  * hold no `:`), imported and unregistered again, so no lexicon lookup sees it.
140
189
  */
141
- async function importKindModule(path: string): Promise<Record<string, unknown>> {
190
+ export async function importKindModule(path: string): Promise<Record<string, unknown>> {
142
191
  const name = `record-kind:${path}`;
143
192
  registerLexiconDeclarations([{ name, module: path }], dirname(path));
144
193
  try {
@@ -255,6 +304,10 @@ export interface RecordEntry {
255
304
  supersededBy: string | null;
256
305
  /** The front matter as JSON, or null when it could not be parsed. */
257
306
  data: Record<string, unknown> | null;
307
+ /** Each workspace file the record pins, checked against the tree read (#2549). Empty when nothing was checked. */
308
+ assets: AssetPin[];
309
+ /** Findings that leave the record valid, such as a pinned file that changed (#2549). */
310
+ warnings: RecordWarning[];
258
311
  }
259
312
 
260
313
  export interface ReadRecordsOptions {
@@ -263,6 +316,25 @@ export interface ReadRecordsOptions {
263
316
  source: RecordSource;
264
317
  /** Leave out records a closed record supersedes. */
265
318
  current?: boolean;
319
+ /**
320
+ * The workspace root the kind's pins resolve in: the working tree, or the
321
+ * revision read (#2549). Without it no pin is checked.
322
+ */
323
+ assets?: WorkspaceTree;
324
+ /**
325
+ * When files last changed and records were recorded, in the history of the
326
+ * revision read, for `asset-stale` (#2549). Without it only the hashes are
327
+ * compared.
328
+ */
329
+ history?: RecordHistory;
330
+ }
331
+
332
+ /** Commit times, in seconds since the epoch, read from git. */
333
+ export interface RecordHistory {
334
+ /** The last commit that changed `path` (from the workspace root), or null when unknown. */
335
+ fileChanged(path: string): number | null;
336
+ /** The commit that added the record at `path` (from the repository root), or null when it is not committed. */
337
+ recorded(path: string): number | null;
266
338
  }
267
339
 
268
340
  export interface ReadRecordsResult {
@@ -304,7 +376,7 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
304
376
  const entries: RecordEntry[] = [];
305
377
  for (const name of names.filter((n) => match.test(n)).sort()) {
306
378
  const path = dirRel === "." ? name : `${dirRel}/${name}`;
307
- const entry: RecordEntry = { id: null, path, state: null, valid: true, reasons: [], supersededBy: null, data: null };
379
+ const entry: RecordEntry = { id: null, path, state: null, valid: true, reasons: [], supersededBy: null, data: null, assets: [], warnings: [] };
308
380
  entries.push(entry);
309
381
  const fm = parseFrontMatter(options.source.read(path));
310
382
  if (!fm.ok) {
@@ -320,6 +392,11 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
320
392
  if (!result.ok) {
321
393
  entry.reasons.push({ code: "record-schema-invalid", message: result.errors.join("; ") });
322
394
  }
395
+ if (kind.pins && options.assets) {
396
+ const checked = checkPins(pinEntries(fm.value, kind.pins.field), options.assets);
397
+ entry.assets = checked.assets;
398
+ entry.warnings = checked.warnings;
399
+ }
323
400
  }
324
401
 
325
402
  // Ids: the first file in path order keeps an id; later ones are flagged.
@@ -332,10 +409,14 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
332
409
  }
333
410
 
334
411
  // Supersession comes from the new record's links, never from the old record.
335
- // A link takes effect only from a closed record (#2555: a later ratified
336
- // decision replaces an earlier one), and a record is superseded at most once
337
- // (#2524 D4).
412
+ // With approval ranks, a link takes effect under an equal or stricter
413
+ // approval rule: from a record ranked above 0 and at least as high as the
414
+ // one it names (#2524 D4). Without them, only from a closed record (#2555).
415
+ // A record is superseded at most once.
338
416
  const closed = new Set(kind.closedStates);
417
+ const rank = (state: string | null): number => (state === null ? 0 : (kind.approval?.[state] ?? 0));
418
+ const takesEffect = (from: RecordEntry, to: RecordEntry): boolean =>
419
+ kind.approval ? rank(from.state) > 0 && rank(from.state) >= rank(to.state) : from.state !== null && closed.has(from.state);
339
420
  for (const e of entries) {
340
421
  const links = e.data?.[kind.supersedes.field];
341
422
  if (!Array.isArray(links)) continue;
@@ -348,7 +429,16 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
348
429
  e.reasons.push({ code: "record-supersedes-unknown", message: `supersedes ${target}, which no record has` });
349
430
  continue;
350
431
  }
351
- if (e.state === null || !closed.has(e.state) || old === e) continue;
432
+ if (old === e) continue;
433
+ if (!takesEffect(e, old)) {
434
+ if (kind.approval) {
435
+ e.warnings.push({
436
+ code: "record-supersedes-pending",
437
+ message: `supersedes ${target}, which is ${old.state ?? "stateless"}; a ${e.state ?? "stateless"} record can't supersede it, so the link takes effect once this record is approved at least as strongly`,
438
+ });
439
+ }
440
+ continue;
441
+ }
352
442
  if (old.supersededBy !== null && old.supersededBy !== e.id) {
353
443
  e.reasons.push({
354
444
  code: "record-supersedes-conflict",
@@ -360,6 +450,26 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
360
450
  }
361
451
  }
362
452
 
453
+ // A pin that still matches, at the hash a record this one supersedes
454
+ // pinned, while the file has not changed since this record was recorded:
455
+ // the decision moved on and the artifact did not (#2549).
456
+ for (const e of entries) {
457
+ for (const a of e.assets) {
458
+ if (a.state !== "pinned") continue;
459
+ const old = entries.find((o) => o.supersededBy !== null && o.supersededBy === e.id && o.assets.some((p) => p.path === a.path && p.sha256 === a.sha256));
460
+ if (!old) continue;
461
+ const changed = options.history?.fileChanged(a.path) ?? null;
462
+ const recorded = options.history ? options.history.recorded(e.path) : null;
463
+ // A record not yet committed is recorded now, after every commit.
464
+ if (changed !== null && recorded !== null && changed > recorded) continue;
465
+ a.state = "stale";
466
+ e.warnings.push({
467
+ code: "asset-stale",
468
+ message: `${a.path} is pinned at the hash ${old.id} pinned, and it has not changed since: ${e.id} supersedes ${old.id}, and the artifact did not follow`,
469
+ });
470
+ }
471
+ }
472
+
363
473
  for (const e of entries) e.valid = e.reasons.length === 0;
364
474
  const records = options.current ? entries.filter((e) => e.supersededBy === null) : entries;
365
475
  return {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Re-pinning records after template substitution (#2549): a pin that held in
3
+ * the template follows the substituted file, one that didn't stays, and the
4
+ * rest of the record is kept byte for byte.
5
+ */
6
+
7
+ import { createHash } from "node:crypto";
8
+ import { describe, expect, test } from "vitest";
9
+ import { repinSubstituted } from "./template-pins";
10
+
11
+ const sha = (s: string) => createHash("sha256").update(s).digest("hex");
12
+ const buf = (s: string) => Buffer.from(s, "utf-8");
13
+
14
+ function record(pins: [string, string][]): string {
15
+ const evidence = pins.map(([path, hash]) => ` - title: "t"\n path: "${path}"\n sha256: "${hash}"\n`).join("");
16
+ return `---\nschema: 1\nid: "ref-002"\nevidence:\n - title: "a link"\n url: "https://example.com"\n${evidence}constrains:\n - "member:design"\n---\n\n# Body mentions ${pins[0]?.[1] ?? ""}\n`;
17
+ }
18
+
19
+ describe("repinSubstituted", () => {
20
+ const before = "title {{chant:name}}\n";
21
+ const after = "title Acme\n";
22
+ const other = "unchanged\n";
23
+
24
+ test("a pin that held on a substituted file gets the new hash; nothing else in the file changes", () => {
25
+ const text = record([["design/home.json", sha(before)], ["design/other.json", sha(other)]]);
26
+ const original = new Map([
27
+ ["chant.workspace.json", buf("{}")],
28
+ ["design/home.json", buf(before)],
29
+ ["design/other.json", buf(other)],
30
+ ["decisions/ref-002-x.md", buf(text)],
31
+ ]);
32
+ const substituted = new Map(original).set("design/home.json", buf(after));
33
+ const { files, repinned } = repinSubstituted(original, substituted, ["design/home.json"]);
34
+ expect(repinned).toEqual([{ record: "decisions/ref-002-x.md", paths: ["design/home.json"] }]);
35
+ const expected = text.replace(` sha256: "${sha(before)}"`, ` sha256: "${sha(after)}"`);
36
+ expect(files.get("decisions/ref-002-x.md")!.toString("utf-8")).toBe(expected);
37
+ // The body's copy of the old hash is not front matter, so it stays.
38
+ expect(expected).toContain(`# Body mentions ${sha(before)}`);
39
+ });
40
+
41
+ test("a pin that was already drifted in the template stays drifted", () => {
42
+ const text = record([["design/home.json", sha("something else")]]);
43
+ const original = new Map([
44
+ ["design/home.json", buf(before)],
45
+ ["decisions/ref-002-x.md", buf(text)],
46
+ ]);
47
+ const substituted = new Map(original).set("design/home.json", buf(after));
48
+ const { files, repinned } = repinSubstituted(original, substituted, ["design/home.json"]);
49
+ expect(repinned).toEqual([]);
50
+ expect(files.get("decisions/ref-002-x.md")!.toString("utf-8")).toBe(text);
51
+ });
52
+
53
+ test("paths resolve from the workspace declaration nearest above the record", () => {
54
+ const text = record([["design/home.json", sha(before)]]);
55
+ const original = new Map([
56
+ ["ws/chant.workspace.json", buf("{}")],
57
+ ["ws/design/home.json", buf(before)],
58
+ ["ws/decisions/ref-002-x.md", buf(text)],
59
+ ]);
60
+ const substituted = new Map(original).set("ws/design/home.json", buf(after));
61
+ const { repinned } = repinSubstituted(original, substituted, ["ws/design/home.json"]);
62
+ expect(repinned).toEqual([{ record: "ws/decisions/ref-002-x.md", paths: ["design/home.json"] }]);
63
+ });
64
+
65
+ test("nothing substituted, nothing re-pinned", () => {
66
+ const original = new Map([["decisions/ref-002-x.md", buf(record([["design/home.json", sha(before)]]))]]);
67
+ expect(repinSubstituted(original, original, []).repinned).toEqual([]);
68
+ });
69
+ });