@ecoma-io/archkeep 0.16.1 → 0.18.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 (42) hide show
  1. package/README.md +1 -1
  2. package/cli.mjs +258 -20
  3. package/package.json +2 -2
  4. package/src/architecture-intent/judge.mjs +19 -6
  5. package/src/commands/adr.mjs +45 -4
  6. package/src/commands/change-intent.mjs +55 -8
  7. package/src/commands/change.mjs +332 -11
  8. package/src/commands/debt.mjs +26 -5
  9. package/src/commands/decisions.mjs +291 -0
  10. package/src/commands/delta-classify.mjs +257 -0
  11. package/src/commands/delta.mjs +269 -8
  12. package/src/commands/evolution.mjs +758 -5
  13. package/src/commands/explain.mjs +207 -1
  14. package/src/commands/history.mjs +81 -5
  15. package/src/commands/plan-context-command.mjs +163 -2
  16. package/src/commands/provenance-command.mjs +86 -17
  17. package/src/commands/provenance.mjs +60 -0
  18. package/src/commands/report.mjs +48 -1
  19. package/src/commands/trajectory.mjs +89 -3
  20. package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
  21. package/src/governance/adr-registry.mjs +252 -15
  22. package/src/governance/debt-ledger.mjs +261 -19
  23. package/src/governance/decision-fitness.mjs +213 -0
  24. package/src/governance/decision-graph.mjs +483 -0
  25. package/src/governance/decision-lineage.mjs +250 -0
  26. package/src/governance/evolution-event.mjs +470 -0
  27. package/src/governance/evolution-store.mjs +362 -0
  28. package/src/governance/provenance-record.mjs +150 -0
  29. package/src/providers/native/model.mjs +18 -4
  30. package/src/report/adr-text.mjs +109 -4
  31. package/src/report/change-text.mjs +21 -3
  32. package/src/report/debt-text.mjs +42 -6
  33. package/src/report/decisions-text.mjs +164 -0
  34. package/src/report/delta-text.mjs +36 -1
  35. package/src/report/evolution-text.mjs +231 -2
  36. package/src/report/explain-text.mjs +122 -1
  37. package/src/report/history-text.mjs +9 -3
  38. package/src/report/plan-context-text.mjs +94 -0
  39. package/src/report/provenance-text.mjs +67 -1
  40. package/src/report/report-text.mjs +53 -18
  41. package/src/report/snapshot-text.mjs +35 -1
  42. package/src/report/trajectory-text.mjs +30 -1
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Fixture scaffolding for the Wave 3 W8 evolution-lifecycle conformance suite
3
+ * (`../evolution-lifecycle.integration.test.mjs`). This module is the ONE home
4
+ * for the real-git workspace builders that suite uses — a throwaway native Go
5
+ * workspace per case, materialized through real `git`, driven through the real
6
+ * `archkeep evolution` entry point.
7
+ *
8
+ * It deliberately reuses the native-workspace recipe already proven by
9
+ * `../commands/evolution.cli.integration.test.mjs` (an `archkeep.json` model,
10
+ * a `module-boundaries.config.mjs` law, and Go sources) rather than inventing
11
+ * a second convention, and threads the same environment guard (`../process.mjs`).
12
+ *
13
+ * Nothing here decides a verdict. It builds trees and drives the CLI; the
14
+ * assertions live in the suite. Keeping the builders here (and only here) is
15
+ * what the W8 task boundary requires: fixture scaffolding lives in
16
+ * `./fixtures/evolution-lifecycle/`, nowhere else.
17
+ */
18
+
19
+ import { execFileSync } from "node:child_process";
20
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+
24
+ import { EXIT, runCli } from "../../../cli.mjs";
25
+ import { SPAWN_BUDGET_MS, SPAWN_TEST_BUDGET_MS } from "../../../spawn-budget.mjs";
26
+ import { environmentForTree } from "../../workspace.mjs";
27
+
28
+ export { SPAWN_TEST_BUDGET_MS, EXIT };
29
+
30
+ /** Identity flags keeping every fixture commit independent of the machine. */
31
+ const IDENTITY = ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"];
32
+
33
+ /**
34
+ * Runs git in `cwd` through the same environment guard production uses, with
35
+ * the single-spawn budget on every child so a wedged git fails the test rather
36
+ * than blocking the worker thread forever.
37
+ */
38
+ export function git(cwd, ...args) {
39
+ return execFileSync("git", args, {
40
+ cwd,
41
+ env: environmentForTree(),
42
+ encoding: "utf8",
43
+ timeout: SPAWN_BUDGET_MS,
44
+ killSignal: "SIGKILL",
45
+ });
46
+ }
47
+
48
+ /** Writes `text` to `root/relativePath`, creating parent directories. */
49
+ export function writeIn(root, relativePath, text) {
50
+ mkdirSync(join(root, relativePath, ".."), { recursive: true });
51
+ writeFileSync(join(root, relativePath), text);
52
+ }
53
+
54
+ /** Stages every change and commits with the fixture identity; returns the SHA. */
55
+ export function commit(root, message) {
56
+ git(root, ...IDENTITY, "add", "-A");
57
+ git(root, ...IDENTITY, "commit", "-q", "-m", message);
58
+ return git(root, "rev-parse", "HEAD").trim();
59
+ }
60
+
61
+ /** Resolves the current HEAD of the fixture. */
62
+ export function headOf(root) {
63
+ return git(root, "rev-parse", "HEAD").trim();
64
+ }
65
+
66
+ /**
67
+ * Opens a brand-new throwaway native git workspace (never the repository's own
68
+ * tree). `archkeep.json` declares two Go projects on two layers, exactly the
69
+ * MODEL `../commands/evolution.cli.integration.test.mjs` uses, so a case can
70
+ * lay an edge between them and the native provider draws it.
71
+ *
72
+ * @returns {{root: string}}
73
+ */
74
+ export function createWorkspace() {
75
+ const root = mkdtempSync(join(tmpdir(), "archkeep-lifecycle-"));
76
+ git(root, "init", "-q", "-b", "main");
77
+ writeIn(root, "archkeep.json", `${MODEL()}\n`);
78
+ writeIn(root, "libs/alpha/go.mod", "module example.com/alpha\n\ngo 1.22\n");
79
+ writeIn(root, "libs/beta/go.mod", "module example.com/beta\n\ngo 1.22\n");
80
+ return { root };
81
+ }
82
+
83
+ /**
84
+ * The native workspace model: two Go projects on two layers (alpha is
85
+ * `layer:a`, beta is `layer:b`), the law file exempted from coverage.
86
+ */
87
+ const MODEL = () =>
88
+ JSON.stringify(
89
+ {
90
+ projects: {
91
+ declared: [
92
+ { root: "libs/alpha", name: "alpha", tags: ["layer:a"] },
93
+ { root: "libs/beta", name: "beta", tags: ["layer:b"] },
94
+ ],
95
+ },
96
+ coverage: {
97
+ exempt: [{ path: "module-boundaries.config.mjs", reason: "the workspace's own law" }],
98
+ },
99
+ },
100
+ null,
101
+ 2,
102
+ );
103
+
104
+ /** The eight options a valid boundary law must carry, per `policyFrom`. */
105
+ const OPTIONS = `export const moduleBoundaryOptions = {
106
+ allow: [],
107
+ buildTargets: ["build"],
108
+ enforceBuildableLibDependency: false,
109
+ allowCircularSelfDependency: false,
110
+ checkDynamicDependenciesExceptions: [],
111
+ ignoredCircularDependencies: [],
112
+ banTransitiveDependencies: false,
113
+ checkNestedExternalImports: false,
114
+ };
115
+ `;
116
+
117
+ export const ALPHA_CLEAN = `package alpha
118
+
119
+ func Name() string { return "alpha" }
120
+ `;
121
+
122
+ export const ALPHA_REACHING = `package alpha
123
+
124
+ import (
125
+ "example.com/beta"
126
+ )
127
+
128
+ func Name() string { return "alpha" + beta.Suffix() }
129
+ `;
130
+
131
+ export const BETA = `package beta
132
+
133
+ func Suffix() string { return "-beta" }
134
+ `;
135
+
136
+ /**
137
+ * Writes a `module-boundaries.config.mjs` law at `root` with the given
138
+ * `depConstraints` rows and optional `fitness` array.
139
+ *
140
+ * @param {string} root
141
+ * @param {{rows?: string, fitness?: string}} [law]
142
+ */
143
+ export function writeLaw(root, { rows = "", fitness } = {}) {
144
+ writeIn(
145
+ root,
146
+ "module-boundaries.config.mjs",
147
+ `export const depConstraints = [\n${rows}\n];\n${OPTIONS}` +
148
+ (fitness === undefined ? "" : `\nexport const fitness = ${fitness};\n`),
149
+ );
150
+ }
151
+
152
+ /**
153
+ * A single permitted layer rule (a may reach b). The same ONE_ROW the
154
+ * evolution CLI integration fixtures use, so an allowed alpha→beta edge never
155
+ * trips a boundary rule.
156
+ */
157
+ export const ALLOW_A_TO_B = ` { sourceTag: "layer:a", onlyDependOnLibsWithTags: ["layer:b"] },`;
158
+
159
+ /**
160
+ * Writes `architecture-intent.json` at `root`. `sections` carries the top-level
161
+ * keys directly (`version`, `boundaries`, `allowed`, `forbidden`,
162
+ * `dependencies`, …); `version` defaults to "1".
163
+ */
164
+ export function writeIntent(root, sections) {
165
+ writeIn(root, "architecture-intent.json", `${JSON.stringify(sections, null, 2)}\n`);
166
+ }
167
+
168
+ /**
169
+ * Writes one ADR record under `docs/adr/`, the shape `adr-registry.mjs` reads.
170
+ * `record` is the frontmatter map (`{id, status, supersedes?, bindings?}`).
171
+ */
172
+ export function writeAdr(root, filename, record) {
173
+ const lines = ["---", `id: ${record.id}`, `status: ${record.status}`];
174
+ if (record.supersedes?.length) {
175
+ lines.push("supersedes:");
176
+ for (const target of record.supersedes) lines.push(` - ${target}`);
177
+ }
178
+ if (record.bindings?.length) {
179
+ lines.push("bindings:");
180
+ for (const binding of record.bindings) lines.push(` - ${binding}`);
181
+ }
182
+ lines.push("---", "", `# ${record.id}`, "");
183
+ writeIn(root, join("docs/adr", filename), `${lines.join("\n")}\n`);
184
+ }
185
+
186
+ /**
187
+ * Drives the CLI in-process over `cwd`, capturing streams. Returns the exit
188
+ * code and joined `out`/`err`. `runCli` is the real entry point
189
+ * (`../cli.mjs`), never a shell-out to a binary named `archkeep`.
190
+ */
191
+ export async function runEvolution(cwd, argv) {
192
+ const out = [];
193
+ const err = [];
194
+ const exitCode = await runCli(argv, {
195
+ out: (text) => out.push(text),
196
+ err: (text) => err.push(text),
197
+ cwd,
198
+ });
199
+ return { exitCode, out: out.join("\n"), err: err.join("\n") };
200
+ }
201
+
202
+ /**
203
+ * Invokes `evolution --base <base> [--head <head>] [--event-out <dir>] [--format json]`.
204
+ *
205
+ * @param {string} base The base revision (full SHA).
206
+ * @param {{head?: string, eventOut?: string, format?: string}} [options]
207
+ */
208
+ export function evolutionArgs(base, { head, eventOut, format = "json" } = {}) {
209
+ const args = ["evolution", "--base", base];
210
+ if (head) args.push("--head", head);
211
+ if (eventOut) args.push("--event-out", eventOut);
212
+ if (format) args.push("--format", format);
213
+ return args;
214
+ }
215
+
216
+ /**
217
+ * Parses the `--format json` envelope out of a successful evolution run.
218
+ */
219
+ export function parseEnvelope(run) {
220
+ if (run.exitCode !== EXIT.ok) throw new Error(`evolution exited ${run.exitCode}: ${run.err}`);
221
+ return JSON.parse(run.out);
222
+ }
223
+
224
+ /** The parsed event files in `dir`, in filename order. */
225
+ export function readEvents(dir) {
226
+ return readdirSync(dir)
227
+ .filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"))
228
+ .sort()
229
+ .map((name) => JSON.parse(readFileSync(join(dir, name), "utf8")));
230
+ }
231
+
232
+ /** The event store's file names in `dir`, in filename order. */
233
+ export function eventFiles(dir) {
234
+ return readdirSync(dir)
235
+ .filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"))
236
+ .sort();
237
+ }
238
+
239
+ /** Removes a throwaway workspace. */
240
+ export function dispose(root) {
241
+ rmSync(root, { recursive: true, force: true });
242
+ }
@@ -13,14 +13,27 @@
13
13
  * filename's id. The filesystem is the source of truth, so a file whose
14
14
  * frontmatter id disagrees with its name is a loud error, never a drift the
15
15
  * registry guesses at.
16
- * - `status` — `proposed` (default), `accepted`, or `superseded`.
16
+ * - `status` — the lifecycle state: `proposed` (default), `accepted`,
17
+ * `active`, `superseded`, or `retired`. Only `accepted` and `active` carry
18
+ * authority (`hasAuthority`); `active` is the accepted decision currently in
19
+ * force, `superseded` was replaced by a later decision, `retired` was
20
+ * withdrawn without a replacement.
17
21
  * - `supersedes` — optional list of ADR ids this record replaces, giving the
18
- * supersession chain.
22
+ * supersession chain. The reverse link — `supersededBy` — is derived on
23
+ * every record at load: the records whose `supersedes` names this one.
24
+ * - `created` / `updated` — optional STRING values from the committed bytes,
25
+ * the decision's own timeline. Never generated from the wall clock.
19
26
  * - `bindings` — optional list of rule/fitness ids this ADR makes enforceable:
20
27
  * the objects its decision binds. An ADR with no `bindings` is recorded but
21
28
  * not yet enforceable; the moment a rule/fitness carries `decisionRef`
22
29
  * naming it, the two sides of the binding exist.
23
30
  *
31
+ * The record's markdown body may surface the decision's prose as optional
32
+ * fields when the `## ` heading is present — `context`, `decision`,
33
+ * `rationale`, `alternatives` (also spelled `## Refused alternatives`, the
34
+ * spelling this repository's own records use), `consequences`, `assumptions`.
35
+ * Body prose is free markdown and never throws; only frontmatter is strict.
36
+ *
24
37
  * Frontmatter is a strict, minimal dialect — `key: value` lines, and list
25
38
  * fields as `- item` continuation lines. It is never full YAML and never JSON
26
39
  * (the same decision the intent model makes for `architecture-intent.json`: no
@@ -34,10 +47,14 @@
34
47
  * `docs/adr/` produce byte-identical output.
35
48
  * - **An unreadable registry is a loud failure, never an empty one.** A
36
49
  * `docs/adr/` directory that exists but holds a file that will not parse, a
37
- * duplicate id, a status outside the three, an unknown frontmatter key, or
50
+ * duplicate id, a status outside the five, an unknown frontmatter key, or
38
51
  * a `supersedes`/`bindings` entry that is not what the field requires —
39
52
  * any of those throws, so a caller can never mistake "could not read the
40
- * registry" for "no ADRs".
53
+ * registry" for "no ADRs". So does a supersession graph that cannot be
54
+ * true (`validateLineage`): a `supersedes` target that is not a record, a
55
+ * record that supersedes itself, a cycle, a `superseded` record with no
56
+ * successor, a successor without authority, or an authoritative record
57
+ * (`active`/`accepted`) superseded by another.
41
58
  * - **A `decisionRef` that does not resolve is `unknown`, never `pass`.** The
42
59
  * registry's `resolveDecisionRef` answers the two-name space — an ADR id
43
60
  * (matching a file) or a rule/fitness id the workspace declares. Anything
@@ -78,8 +95,27 @@ import { containmentViolation } from "../containment.mjs";
78
95
  /** The directory, relative to a workspace root, where ADR files live. */
79
96
  export const ADR_DIR = "docs/adr";
80
97
 
81
- /** The three statuses a record may carry. Any other value is a load error. */
82
- export const ADR_STATUSES = Object.freeze(["proposed", "accepted", "superseded"]);
98
+ /** The five lifecycle statuses a record may carry. Any other value is a load error. */
99
+ export const ADR_STATUSES = Object.freeze([
100
+ "proposed",
101
+ "accepted",
102
+ "active",
103
+ "superseded",
104
+ "retired",
105
+ ]);
106
+
107
+ /**
108
+ * Whether a status carries decision authority. Only `active` — the accepted
109
+ * decision currently in force — and `accepted` — a decision made and
110
+ * recorded — have it. `proposed` (a draft), `superseded` (replaced, authority
111
+ * transferred) and `retired` (withdrawn without a replacement) do not.
112
+ *
113
+ * @param {string} status
114
+ * @returns {boolean}
115
+ */
116
+ export function hasAuthority(status) {
117
+ return status === "active" || status === "accepted";
118
+ }
83
119
 
84
120
  /**
85
121
  * Matches a valid ADR filename. The number is at least three digits so the
@@ -92,7 +128,14 @@ export const ADR_FILE_PATTERN = /^(\d{3,})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$/u;
92
128
  export const ADR_ID_PATTERN = /^\d{3,}-[a-z0-9]+(?:-[a-z0-9]+)*$/u;
93
129
 
94
130
  /** The frontmatter keys a record file may carry. */
95
- const FRONTMATTER_KEYS = Object.freeze(["id", "status", "supersedes", "bindings"]);
131
+ const FRONTMATTER_KEYS = Object.freeze([
132
+ "id",
133
+ "status",
134
+ "supersedes",
135
+ "bindings",
136
+ "created",
137
+ "updated",
138
+ ]);
96
139
 
97
140
  /** A value's type, for an error message that shows what was actually there. */
98
141
  function describe(value) {
@@ -116,6 +159,68 @@ function stripInlineComment(value) {
116
159
  const hash = value.indexOf(" #");
117
160
  return hash === -1 ? value : value.slice(0, hash).trim();
118
161
  }
162
+ /** The markdown body after the frontmatter block, or the whole text when the file has none. */
163
+ function bodyBlock(text) {
164
+ if (!text.startsWith("---")) return text;
165
+ const end = text.indexOf("\n---", 3);
166
+ if (end === -1) return text; // frontmatterBlock throws this case first
167
+ return text.slice(end + 4).replace(/^\r?\n/, "");
168
+ }
169
+
170
+ /**
171
+ * The prose fields the body may surface, keyed by the exact `## ` heading
172
+ * that carries them. `## Refused alternatives` — the spelling this
173
+ * repository's own records use (`docs/adr/0003-…`, `docs/adr/0004-…`) — maps
174
+ * to the same field as the `## Alternatives` spelling the ADR template names.
175
+ * The body's `## Status` heading is deliberately absent: status is a
176
+ * frontmatter field, and the body's retelling is not the model's.
177
+ */
178
+ const PROSE_FIELDS = Object.freeze({
179
+ Context: "context",
180
+ Decision: "decision",
181
+ Rationale: "rationale",
182
+ Alternatives: "alternatives",
183
+ "Refused alternatives": "alternatives",
184
+ Consequences: "consequences",
185
+ Assumptions: "assumptions",
186
+ });
187
+
188
+ /**
189
+ * The optional prose fields parsed out of a record's body. Each `## ` heading
190
+ * in `PROSE_FIELDS` opens its field; the field's content is everything from
191
+ * the line after the heading to the line before the next `## ` heading
192
+ * (sub-headings like `###` stay inside their field). An absent heading is an
193
+ * absent field, and a body that is only prose never throws — frontmatter is
194
+ * the one strict dialect. A `## ` heading outside the field list closes the
195
+ * open field without opening one; a heading repeated within one body keeps
196
+ * the last occurrence, the one deterministic choice a non-throwing parser
197
+ * can make.
198
+ *
199
+ * @param {string} body The markdown body after the frontmatter block.
200
+ * @returns {Record<string, string>} Only the fields whose headings were present.
201
+ */
202
+ function parseProseFields(body) {
203
+ /** @type {Record<string, string>} */
204
+ const fields = {};
205
+ let current = null;
206
+ /** @type {string[]} */
207
+ let buffer = [];
208
+ const flush = () => {
209
+ if (current !== null) fields[current] = buffer.join("\n").trim();
210
+ };
211
+ for (const line of body.split("\n")) {
212
+ const heading = /^##\s+(.+?)\s*$/u.exec(line);
213
+ if (heading !== null) {
214
+ flush();
215
+ current = PROSE_FIELDS[heading[1]] ?? null;
216
+ buffer = [];
217
+ } else if (current !== null) {
218
+ buffer.push(line);
219
+ }
220
+ }
221
+ flush();
222
+ return fields;
223
+ }
119
224
 
120
225
  /**
121
226
  * Parse the frontmatter block into a field map. The dialect is strict:
@@ -194,14 +299,19 @@ function toList(value) {
194
299
  /**
195
300
  * One parsed record, every field validated. A record an enforcer cannot trust
196
301
  * must never be read as an absent one (the invariant), so every malformed
197
- * field throws here rather than degrading the record.
302
+ * field throws here rather than degrading the record. The record's body is
303
+ * the exception by design: prose is free markdown and never throws — only
304
+ * frontmatter is strict.
198
305
  *
199
- * @param {{id: string, frontmatter: string|null}} parsed The filename-derived
200
- * id and the frontmatter block (null when the file has none).
201
- * @returns {{id: string, status: string, supersedes: string[], bindings: string[]}}
306
+ * @param {{id: string, frontmatter: string|null, body?: string}} parsed The
307
+ * filename-derived id and the frontmatter block (null when the file has
308
+ * none); the markdown body defaults to the empty string.
309
+ * @returns {{id: string, status: string, created?: string, updated?: string,
310
+ * supersedes: string[], bindings: string[], context?: string, decision?: string,
311
+ * rationale?: string, alternatives?: string, consequences?: string, assumptions?: string}}
202
312
  * @throws {Error} naming every violation at once.
203
313
  */
204
- export function validateRecord({ id, frontmatter }) {
314
+ export function validateRecord({ id, frontmatter, body = "" }) {
205
315
  const fields = frontmatter === null ? {} : parseFrontmatterFields(frontmatter, id);
206
316
  const violations = [];
207
317
 
@@ -227,6 +337,16 @@ export function validateRecord({ id, frontmatter }) {
227
337
  violations.push(`${id}: status "${fields.status}" is not one of ${ADR_STATUSES.join(", ")}`);
228
338
  }
229
339
 
340
+ for (const key of ["created", "updated"]) {
341
+ const value = fields[key];
342
+ if (value !== undefined && typeof value !== "string") {
343
+ violations.push(
344
+ `${id}: ${key} must be a single string value — the decision's own timeline from the ` +
345
+ `committed bytes, never generated — got ${describe(value)}`,
346
+ );
347
+ }
348
+ }
349
+
230
350
  for (const ref of toList(fields.supersedes)) {
231
351
  if (!ADR_ID_PATTERN.test(ref)) {
232
352
  violations.push(`${id}: supersedes entry ${describe(ref)} is not an ADR id`);
@@ -246,11 +366,121 @@ export function validateRecord({ id, frontmatter }) {
246
366
  return {
247
367
  id,
248
368
  status: typeof fields.status === "string" ? fields.status : "proposed",
369
+ ...(typeof fields.created === "string" ? { created: fields.created } : {}),
370
+ ...(typeof fields.updated === "string" ? { updated: fields.updated } : {}),
249
371
  supersedes: toList(fields.supersedes),
250
372
  bindings: toList(fields.bindings),
373
+ ...parseProseFields(body),
251
374
  };
252
375
  }
253
376
 
377
+ /**
378
+ * Validates the supersession graph across every record and derives each
379
+ * record's `supersededBy` — the reverse of `supersedes`, the ids of the
380
+ * records whose `supersedes` names this one. A chain that cannot be true is
381
+ * itself an unreadable registry (the invariant), thrown as one message naming
382
+ * every violation, never returned as partial fact:
383
+ *
384
+ * 1. every `supersedes` target must be a record;
385
+ * 2. a record may not supersede itself;
386
+ * 3. the graph may not contain a cycle — no record may transitively replace
387
+ * itself;
388
+ * 4. a `superseded` record must have at least one successor (`retired` is its
389
+ * own reason and needs none) — a `superseded` status with no successor is
390
+ * dangling;
391
+ * 5. a successor — the record declaring `supersedes` — must itself carry
392
+ * authority, so `proposed` and `superseded` records may not replace
393
+ * another;
394
+ * 6. the contradiction rule: an authoritative record (`active`/`accepted`)
395
+ * may not be superseded by another — authority and supersession are
396
+ * mutually exclusive states.
397
+ *
398
+ * Deterministic: checks run in the records' registry (byte-sorted filename)
399
+ * order, and every derived `supersededBy` list is in the order the
400
+ * superseding records loaded.
401
+ *
402
+ * @param {object[]} records The validated records, in registry order.
403
+ * @returns {object[]} The same records, each carrying its derived
404
+ * `supersededBy` string array.
405
+ * @throws {Error} naming every lineage violation at once.
406
+ */
407
+ export function validateLineage(records) {
408
+ const byId = new Map(records.map((record) => [record.id, record]));
409
+ /** @type {Map<string, string[]>} */
410
+ const supersededBy = new Map(records.map((record) => [record.id, []]));
411
+ const violations = [];
412
+
413
+ for (const record of records) {
414
+ for (const ref of record.supersedes) {
415
+ if (!byId.has(ref)) {
416
+ violations.push(`${record.id} supersedes ${ref}, which is not an ADR in ${ADR_DIR}`);
417
+ continue;
418
+ }
419
+ if (ref === record.id) {
420
+ violations.push(`${record.id} supersedes itself — a record cannot replace itself`);
421
+ continue;
422
+ }
423
+ if (record.status === "proposed" || record.status === "superseded") {
424
+ violations.push(
425
+ `${record.id} is ${record.status} and supersedes ${ref} — only a record with ` +
426
+ `authority (active or accepted) may be a successor`,
427
+ );
428
+ }
429
+ supersededBy.get(ref).push(record.id);
430
+ }
431
+ }
432
+
433
+ // Cycles: a stack-based DFS over the declared graph. A node seen again on
434
+ // the current stack is a cycle, named from the first repeated node so the
435
+ // reported chain is the cycle itself, not a prefix of it.
436
+ const visiting = new Set();
437
+ const visited = new Set();
438
+ const stack = [];
439
+ const visit = (id) => {
440
+ if (visited.has(id)) return;
441
+ if (visiting.has(id)) {
442
+ violations.push(`supersedes cycle: ${[...stack.slice(stack.indexOf(id)), id].join(" -> ")}`);
443
+ return;
444
+ }
445
+ visiting.add(id);
446
+ stack.push(id);
447
+ const record = byId.get(id);
448
+ if (record !== undefined) {
449
+ for (const ref of record.supersedes) visit(ref);
450
+ }
451
+ stack.pop();
452
+ visiting.delete(id);
453
+ visited.add(id);
454
+ };
455
+ for (const record of records) visit(record.id);
456
+
457
+ for (const record of records) {
458
+ const successors = supersededBy.get(record.id);
459
+ if (record.status === "superseded" && successors.length === 0) {
460
+ violations.push(
461
+ `${record.id} is superseded but nothing supersedes it — a superseded record needs at ` +
462
+ `least one successor (retire it instead if it was withdrawn without a replacement)`,
463
+ );
464
+ }
465
+ if ((record.status === "active" || record.status === "accepted") && successors.length > 0) {
466
+ violations.push(
467
+ `${record.id} is ${record.status} but superseded by [${successors.join(", ")}] — a ` +
468
+ `record with authority may not be superseded by another`,
469
+ );
470
+ }
471
+ }
472
+
473
+ for (const record of records) {
474
+ record.supersededBy = supersededBy.get(record.id);
475
+ }
476
+
477
+ if (violations.length > 0) {
478
+ throw new Error(`archkeep: malformed ADR registry:\n ${violations.join("\n ")}`);
479
+ }
480
+
481
+ return records;
482
+ }
483
+
254
484
  /**
255
485
  * Read and index every ADR file under `root/docs/adr/`. Deterministic:
256
486
  * filenames are byte-sorted, and every list in the returned records is already
@@ -259,8 +489,9 @@ export function validateRecord({ id, frontmatter }) {
259
489
  *
260
490
  * An absent `docs/adr/` is an empty registry — a workspace that has not
261
491
  * adopted ADRs yet is not a failure, and has nothing to resolve. A directory
262
- * that exists but holds an unreadable file, a malformed record, or a duplicate
263
- * id throws; the caller maps that to exit 3, never to an empty list.
492
+ * that exists but holds an unreadable file, a malformed record, a duplicate
493
+ * id, or a supersession graph that cannot be true (see `validateLineage`)
494
+ * throws; the caller maps that to exit 3, never to an empty list.
264
495
  *
265
496
  * @param {string} root Absolute workspace root.
266
497
  * @param {{readdirSync?: (path: string) => string[], readFileSync?: (path: string, encoding: "utf8") => string,
@@ -363,11 +594,17 @@ export function loadAdrRegistry(root, io = {}) {
363
594
  cause,
364
595
  });
365
596
  }
366
- const record = validateRecord({ id, frontmatter: frontmatterBlock(text) });
597
+ const record = validateRecord({
598
+ id,
599
+ frontmatter: frontmatterBlock(text),
600
+ body: bodyBlock(text),
601
+ });
367
602
  byId.set(id, record);
368
603
  records.push(record);
369
604
  }
370
605
 
606
+ validateLineage(records);
607
+
371
608
  return { records, byId };
372
609
  }
373
610