@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.
- package/README.md +1 -1
- package/cli.mjs +258 -20
- package/package.json +2 -2
- package/src/architecture-intent/judge.mjs +19 -6
- package/src/commands/adr.mjs +45 -4
- package/src/commands/change-intent.mjs +55 -8
- package/src/commands/change.mjs +332 -11
- package/src/commands/debt.mjs +26 -5
- package/src/commands/decisions.mjs +291 -0
- package/src/commands/delta-classify.mjs +257 -0
- package/src/commands/delta.mjs +269 -8
- package/src/commands/evolution.mjs +758 -5
- package/src/commands/explain.mjs +207 -1
- package/src/commands/history.mjs +81 -5
- package/src/commands/plan-context-command.mjs +163 -2
- package/src/commands/provenance-command.mjs +86 -17
- package/src/commands/provenance.mjs +60 -0
- package/src/commands/report.mjs +48 -1
- package/src/commands/trajectory.mjs +89 -3
- package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
- package/src/governance/adr-registry.mjs +252 -15
- package/src/governance/debt-ledger.mjs +261 -19
- package/src/governance/decision-fitness.mjs +213 -0
- package/src/governance/decision-graph.mjs +483 -0
- package/src/governance/decision-lineage.mjs +250 -0
- package/src/governance/evolution-event.mjs +470 -0
- package/src/governance/evolution-store.mjs +362 -0
- package/src/governance/provenance-record.mjs +150 -0
- package/src/providers/native/model.mjs +18 -4
- package/src/report/adr-text.mjs +109 -4
- package/src/report/change-text.mjs +21 -3
- package/src/report/debt-text.mjs +42 -6
- package/src/report/decisions-text.mjs +164 -0
- package/src/report/delta-text.mjs +36 -1
- package/src/report/evolution-text.mjs +231 -2
- package/src/report/explain-text.mjs +122 -1
- package/src/report/history-text.mjs +9 -3
- package/src/report/plan-context-text.mjs +94 -0
- package/src/report/provenance-text.mjs +67 -1
- package/src/report/report-text.mjs +53 -18
- package/src/report/snapshot-text.mjs +35 -1
- 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`,
|
|
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
|
|
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
|
|
82
|
-
export const ADR_STATUSES = Object.freeze([
|
|
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([
|
|
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
|
|
200
|
-
* id and the frontmatter block (null when the file has
|
|
201
|
-
*
|
|
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,
|
|
263
|
-
* id
|
|
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({
|
|
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
|
|