@intentius/chant 0.81.0 → 0.83.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 (67) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/content-digest.d.ts +2 -0
  3. package/dist/content-digest.d.ts.map +1 -1
  4. package/dist/workspace/checks/records.d.ts +33 -0
  5. package/dist/workspace/checks/records.d.ts.map +1 -0
  6. package/dist/workspace/checks.d.ts +8 -0
  7. package/dist/workspace/checks.d.ts.map +1 -1
  8. package/dist/workspace/compose-graph.d.ts +22 -6
  9. package/dist/workspace/compose-graph.d.ts.map +1 -1
  10. package/dist/workspace/graph-cli.d.ts +11 -4
  11. package/dist/workspace/graph-cli.d.ts.map +1 -1
  12. package/dist/workspace/lineage-check.d.ts +5 -2
  13. package/dist/workspace/lineage-check.d.ts.map +1 -1
  14. package/dist/workspace/lineage-cli.d.ts +5 -1
  15. package/dist/workspace/lineage-cli.d.ts.map +1 -1
  16. package/dist/workspace/lineage-init.d.ts +47 -4
  17. package/dist/workspace/lineage-init.d.ts.map +1 -1
  18. package/dist/workspace/lineage-lock.d.ts +23 -2
  19. package/dist/workspace/lineage-lock.d.ts.map +1 -1
  20. package/dist/workspace/lineage-upgrade-cli.d.ts +1 -1
  21. package/dist/workspace/lineage-upgrade.d.ts +8 -2
  22. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  23. package/dist/workspace/reason-codes.d.ts +4 -0
  24. package/dist/workspace/reason-codes.d.ts.map +1 -1
  25. package/dist/workspace/record-assets.d.ts +108 -0
  26. package/dist/workspace/record-assets.d.ts.map +1 -0
  27. package/dist/workspace/records-cli.d.ts +32 -1
  28. package/dist/workspace/records-cli.d.ts.map +1 -1
  29. package/dist/workspace/records.d.ts +41 -0
  30. package/dist/workspace/records.d.ts.map +1 -1
  31. package/dist/workspace/template-pins.d.ts +33 -0
  32. package/dist/workspace/template-pins.d.ts.map +1 -0
  33. package/dist/workspace/tree.d.ts +5 -0
  34. package/dist/workspace/tree.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/src/cli/handlers/init.ts +7 -3
  37. package/src/cli/main.ts +16 -8
  38. package/src/content-digest.ts +5 -0
  39. package/src/workspace/behold-kinds.test.ts +10 -14
  40. package/src/workspace/checks/records.ts +83 -0
  41. package/src/workspace/checks.test.ts +2 -0
  42. package/src/workspace/checks.ts +13 -3
  43. package/src/workspace/compose-graph.test.ts +2 -1
  44. package/src/workspace/compose-graph.ts +23 -6
  45. package/src/workspace/graph-cli.ts +32 -6
  46. package/src/workspace/graph-contract.test.ts +2 -1
  47. package/src/workspace/graph.schema.json +131 -2
  48. package/src/workspace/lineage-check.ts +22 -5
  49. package/src/workspace/lineage-cli.ts +21 -1
  50. package/src/workspace/lineage-init-dir.test.ts +243 -0
  51. package/src/workspace/lineage-init.ts +223 -24
  52. package/src/workspace/lineage-lock.ts +18 -3
  53. package/src/workspace/lineage-upgrade-cli.ts +2 -2
  54. package/src/workspace/lineage-upgrade.test.ts +29 -0
  55. package/src/workspace/lineage-upgrade.ts +130 -13
  56. package/src/workspace/member-commands.ts +1 -1
  57. package/src/workspace/reason-codes.test.ts +2 -1
  58. package/src/workspace/reason-codes.ts +5 -0
  59. package/src/workspace/record-assets.test.ts +319 -0
  60. package/src/workspace/record-assets.ts +207 -0
  61. package/src/workspace/records-cli.ts +117 -14
  62. package/src/workspace/records.schema.json +33 -1
  63. package/src/workspace/records.test.ts +50 -0
  64. package/src/workspace/records.ts +115 -5
  65. package/src/workspace/template-pins.test.ts +69 -0
  66. package/src/workspace/template-pins.ts +117 -0
  67. package/src/workspace/tree.ts +12 -0
@@ -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
+ }
@@ -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"]));