@ecoma-io/archkeep 0.13.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 (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,539 @@
1
+ /**
2
+ * The ADR registry: `docs/adr/NNN-slug.md` files, parsed into one index an
3
+ * enforcer can resolve `decisionRef` against.
4
+ *
5
+ * An ADR (architecture decision record) is a Markdown file in the workspace's
6
+ * `docs/adr/` directory named `<NNN>-<slug>.md` — `NNN` a zero-padded number
7
+ * of at least three digits, `<slug>` a short dash-separated name. Each file
8
+ * declares, in frontmatter (the `---`-delimited block at the top, kept
9
+ * deliberately minimal), the fields an enforcer needs to make the record
10
+ * *enforceable*:
11
+ *
12
+ * - `id` — the record's own identity. Optional; when present it MUST equal the
13
+ * filename's id. The filesystem is the source of truth, so a file whose
14
+ * frontmatter id disagrees with its name is a loud error, never a drift the
15
+ * registry guesses at.
16
+ * - `status` — `proposed` (default), `accepted`, or `superseded`.
17
+ * - `supersedes` — optional list of ADR ids this record replaces, giving the
18
+ * supersession chain.
19
+ * - `bindings` — optional list of rule/fitness ids this ADR makes enforceable:
20
+ * the objects its decision binds. An ADR with no `bindings` is recorded but
21
+ * not yet enforceable; the moment a rule/fitness carries `decisionRef`
22
+ * naming it, the two sides of the binding exist.
23
+ *
24
+ * Frontmatter is a strict, minimal dialect — `key: value` lines, and list
25
+ * fields as `- item` continuation lines. It is never full YAML and never JSON
26
+ * (the same decision the intent model makes for `architecture-intent.json`: no
27
+ * parser leniency for this tool's own files).
28
+ *
29
+ * The invariant (`../../../../AGENTS.md`): an empty result must mean "no
30
+ * violation", and nothing else. For the registry that means four things:
31
+ *
32
+ * - **The registry is deterministic.** Files are read in byte-sorted filename
33
+ * order and every emitted list is sorted, so two runs over an unchanged
34
+ * `docs/adr/` produce byte-identical output.
35
+ * - **An unreadable registry is a loud failure, never an empty one.** A
36
+ * `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
38
+ * a `supersedes`/`bindings` entry that is not what the field requires —
39
+ * any of those throws, so a caller can never mistake "could not read the
40
+ * registry" for "no ADRs".
41
+ * - **A `decisionRef` that does not resolve is `unknown`, never `pass`.** The
42
+ * registry's `resolveDecisionRef` answers the two-name space — an ADR id
43
+ * (matching a file) or a rule/fitness id the workspace declares. Anything
44
+ * else is unknown, and the caller reports unknown (never clean).
45
+ * - **The registry trusts only git-tracked, in-workspace bytes.** A
46
+ * `docs/adr/` directory entry the tracked tree does not know about — a
47
+ * gitignored scratch file with an ADR-shaped name — is excluded exactly as
48
+ * if it were never there: no record, no id claimed, no error. So is a
49
+ * directory entry whose NAME is tracked but whose current bytes are not: a
50
+ * symlink (committed as one, or swapped in locally after the tracked-name
51
+ * check ran) whose target resolves outside the workspace root. Either way
52
+ * `resolveDecisionRef` answers `unknown` for the id, never `adr` — the same
53
+ * answer a name nobody ever wrote gets, so a planted file cannot make a
54
+ * `decisionRef` resolve against bytes this workspace never reviewed, with
55
+ * manual lookup the only thing that would otherwise have caught it.
56
+ * `../architecture-intent/model.mjs`'s `loadIntent` makes the identical call
57
+ * for `architecture-intent.json` (see its own header), and its `tracked`
58
+ * parameter is this module's `io.tracked` by another name. `docs/reference/adr.md`
59
+ * and `docs/usage/adr.md` already describe the registry as reading "the
60
+ * tracked `docs/adr/`" — this is what makes that sentence true.
61
+ *
62
+ * ## Remote lookup is opt-in and must never change local resolution
63
+ *
64
+ * A workspace may consult a remote catalog of decisions (an HTTP endpoint)
65
+ * that knows ADRs the local `docs/adr/` does not. Local knowledge always wins:
66
+ * a `decisionRef` the local registry already resolves stays resolved, and only
67
+ * an id the local tree does not know may be asked of the remote. A remote
68
+ * failure resolves nothing and throws nothing — an opt-in convenience must
69
+ * never make an enforceable rule unenforceable. `referenceTime()` stamps a
70
+ * fetch so a remote answer carries the moment it was taken.
71
+ */
72
+
73
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
74
+ import { join } from "node:path";
75
+
76
+ import { containmentViolation } from "../containment.mjs";
77
+
78
+ /** The directory, relative to a workspace root, where ADR files live. */
79
+ export const ADR_DIR = "docs/adr";
80
+
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"]);
83
+
84
+ /**
85
+ * Matches a valid ADR filename. The number is at least three digits so the
86
+ * format outgrows 999 records without breaking; the slug is dash-separated
87
+ * lowercase words.
88
+ */
89
+ export const ADR_FILE_PATTERN = /^(\d{3,})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$/u;
90
+
91
+ /** An ADR id — `NNN-slug` — must match the filename it lives in. */
92
+ export const ADR_ID_PATTERN = /^\d{3,}-[a-z0-9]+(?:-[a-z0-9]+)*$/u;
93
+
94
+ /** The frontmatter keys a record file may carry. */
95
+ const FRONTMATTER_KEYS = Object.freeze(["id", "status", "supersedes", "bindings"]);
96
+
97
+ /** A value's type, for an error message that shows what was actually there. */
98
+ function describe(value) {
99
+ if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
100
+ if (value === null) return "null";
101
+ return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
102
+ }
103
+
104
+ /** The `---`-delimited frontmatter block's text, or null when the file has none. */
105
+ function frontmatterBlock(text) {
106
+ if (!text.startsWith("---")) return null;
107
+ const end = text.indexOf("\n---", 3);
108
+ if (end === -1) {
109
+ throw new Error("frontmatter opened by '---' must close with a second '---' line");
110
+ }
111
+ return text.slice(3, end);
112
+ }
113
+
114
+ /** Strip a trailing `#` comment from a frontmatter value. */
115
+ function stripInlineComment(value) {
116
+ const hash = value.indexOf(" #");
117
+ return hash === -1 ? value : value.slice(0, hash).trim();
118
+ }
119
+
120
+ /**
121
+ * Parse the frontmatter block into a field map. The dialect is strict:
122
+ * `key: value` for scalars, `key:` followed by `- item` lines for lists, `#`
123
+ * for comments. Anything else is a loud parse error — a frontmatter line an
124
+ * enforcer cannot trust must never be silently dropped. That includes a key
125
+ * repeated within the same block: an unconditional `fields[key] = …` write
126
+ * would let the second occurrence silently discard the first — a scalar's
127
+ * earlier value, or, worse, an entire earlier `bindings`/`supersedes` list,
128
+ * since a second `key:` line resets `fields[key]` to a fresh empty array that
129
+ * the following `- item` lines then fill from nothing. So a repeated key
130
+ * throws instead of overwriting.
131
+ *
132
+ * @param {string} text The block between the two `---` delimiters.
133
+ * @param {string} at The record's id, for the message.
134
+ * @returns {Record<string, string|string[]|undefined>}
135
+ * @throws {Error} on a line that is not part of the dialect, including a key
136
+ * that already appears earlier in the same block.
137
+ */
138
+ export function parseFrontmatterFields(text, at) {
139
+ /** @type {Record<string, string|string[]|undefined>} */
140
+ const fields = {};
141
+ let currentList = null;
142
+ for (const line of text.split("\n")) {
143
+ const trimmed = line.trim();
144
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
145
+
146
+ const item = /^-\s+(.*)$/u.exec(trimmed);
147
+ if (item) {
148
+ if (currentList === null) {
149
+ throw new Error(`${at}: list item "${trimmed}" appears before any "key:" line`);
150
+ }
151
+ /** @type {string[]} */ (fields[currentList]).push(stripInlineComment(item[1]));
152
+ continue;
153
+ }
154
+
155
+ const key = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/u.exec(trimmed);
156
+ if (key) {
157
+ if (Object.hasOwn(fields, key[1])) {
158
+ throw new Error(
159
+ `${at}: duplicate frontmatter key "${key[1]}" — the first occurrence would be silently overwritten`,
160
+ );
161
+ }
162
+ const raw = key[2].trim();
163
+ if (raw === "") {
164
+ fields[key[1]] = [];
165
+ currentList = key[1];
166
+ } else {
167
+ fields[key[1]] = stripInlineComment(raw);
168
+ currentList = null;
169
+ }
170
+ continue;
171
+ }
172
+
173
+ throw new Error(`${at}: cannot parse frontmatter line "${trimmed}"`);
174
+ }
175
+ return fields;
176
+ }
177
+
178
+ /**
179
+ * The list a field holds: already an array (a `key:` list), or a
180
+ * comma-separated inline list written `key: a, b`. Both spellings stay legal.
181
+ *
182
+ * @param {string|string[]|undefined} value
183
+ * @returns {string[]}
184
+ */
185
+ function toList(value) {
186
+ if (value === undefined) return [];
187
+ if (Array.isArray(value)) return value.map((entry) => entry.trim());
188
+ return value
189
+ .split(",")
190
+ .map((entry) => entry.trim())
191
+ .filter((entry) => entry !== "");
192
+ }
193
+
194
+ /**
195
+ * One parsed record, every field validated. A record an enforcer cannot trust
196
+ * must never be read as an absent one (the invariant), so every malformed
197
+ * field throws here rather than degrading the record.
198
+ *
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[]}}
202
+ * @throws {Error} naming every violation at once.
203
+ */
204
+ export function validateRecord({ id, frontmatter }) {
205
+ const fields = frontmatter === null ? {} : parseFrontmatterFields(frontmatter, id);
206
+ const violations = [];
207
+
208
+ for (const key of Object.keys(fields)) {
209
+ if (!FRONTMATTER_KEYS.includes(key)) {
210
+ violations.push(
211
+ `${id}: unknown frontmatter key "${key}" — a record may carry only ${FRONTMATTER_KEYS.join(", ")}`,
212
+ );
213
+ }
214
+ }
215
+
216
+ if (fields.id !== undefined && fields.id !== id) {
217
+ violations.push(
218
+ `${id}: frontmatter id "${fields.id}" disagrees with the filename's "${id}" — the ` +
219
+ `registry keys on filenames, so rename the file or fix the id`,
220
+ );
221
+ }
222
+
223
+ if (
224
+ fields.status !== undefined &&
225
+ !ADR_STATUSES.includes(/** @type {string} */ (fields.status))
226
+ ) {
227
+ violations.push(`${id}: status "${fields.status}" is not one of ${ADR_STATUSES.join(", ")}`);
228
+ }
229
+
230
+ for (const ref of toList(fields.supersedes)) {
231
+ if (!ADR_ID_PATTERN.test(ref)) {
232
+ violations.push(`${id}: supersedes entry ${describe(ref)} is not an ADR id`);
233
+ }
234
+ }
235
+
236
+ for (const ref of toList(fields.bindings)) {
237
+ if (ref === "") {
238
+ violations.push(`${id}: bindings has an empty entry — a binding must name a rule/fitness id`);
239
+ }
240
+ }
241
+
242
+ if (violations.length > 0) {
243
+ throw new Error(`archkeep: malformed ADR registry:\n ${violations.join("\n ")}`);
244
+ }
245
+
246
+ return {
247
+ id,
248
+ status: typeof fields.status === "string" ? fields.status : "proposed",
249
+ supersedes: toList(fields.supersedes),
250
+ bindings: toList(fields.bindings),
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Read and index every ADR file under `root/docs/adr/`. Deterministic:
256
+ * filenames are byte-sorted, and every list in the returned records is already
257
+ * in the order the source file stated (kept stable — the registry never
258
+ * reorders what a record declares).
259
+ *
260
+ * An absent `docs/adr/` is an empty registry — a workspace that has not
261
+ * 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.
264
+ *
265
+ * @param {string} root Absolute workspace root.
266
+ * @param {{readdirSync?: (path: string) => string[], readFileSync?: (path: string, encoding: "utf8") => string,
267
+ * lstatSync?: (path: string) => {isSymbolicLink: () => boolean}, realpathSync?: (path: string) => string,
268
+ * tracked?: string[]}} [io]
269
+ * Injectable filesystem seams, defaulting to the sync `node:fs` calls this
270
+ * module uses so the CLI stays event-loop-simple. Tests inject an in-memory
271
+ * tree. `tracked` is the `git ls-files` list (`../workspace.mjs`'s
272
+ * `listTrackedFiles`); when provided, a directory entry whose `docs/adr/<name>`
273
+ * path is not in it is excluded before it is ever validated — see this
274
+ * module's header for why, and `../architecture-intent/model.mjs`'s
275
+ * `loadIntent` for the identical `tracked` contract this one mirrors. The
276
+ * `lstatSync`/`realpathSync` seams feed
277
+ * `../containment.mjs`'s `containmentViolation`, which resolves the deepest
278
+ * existing ancestor through every intermediate component — so a symlinked
279
+ * `docs/adr/` directory is excluded the same way an escaping entry file is.
280
+ * @returns {{records: object[], byId: Map<string, object>}}
281
+ * @throws {Error} on an unreadable registry.
282
+ */
283
+ export function loadAdrRegistry(root, io = {}) {
284
+ const readDir = io.readdirSync ?? readdirSync;
285
+ const readFile = io.readFileSync ?? readFileSync;
286
+ const lstat = io.lstatSync ?? lstatSync;
287
+ const realpath = io.realpathSync ?? realpathSync;
288
+ const dir = join(root, ADR_DIR);
289
+
290
+ /** @type {string[]} */
291
+ let names;
292
+ try {
293
+ // Every entry, unfiltered: the ONLY filename verdict this loader makes is
294
+ // the `ADR_FILE_PATTERN` refusal below. An `.endsWith(".md")` pre-filter
295
+ // here used to drop `0002-cased.MD` and `0003-thing.markdown` before that
296
+ // throw could see them — an ADR-shaped record the author wrote, silently
297
+ // absent from the index, so `resolveDecisionRef` answered `unknown` for an
298
+ // id sitting in the tree and the reverse lookup answered "no ADR in
299
+ // docs/adr/ binds rule:X — it is not enforced by any recorded decision"
300
+ // about a binding the registry had simply refused to look at. Two
301
+ // filters deciding the same question is how one of them goes quiet
302
+ // (`../../../../AGENTS.md`). `README.md` reaches that same throw, by the
303
+ // same rule, and always did: this directory is the registry, so a file in
304
+ // it that is not a record is a thing to say out loud, not to drop. The
305
+ // `io.tracked` filter below is a different question — whether the bytes
306
+ // are the reviewed repository state — and stays the one exclusion that is
307
+ // deliberately silent, for the reason this module's header states.
308
+ names = readDir(dir);
309
+ } catch (cause) {
310
+ if (cause?.code === "ENOENT") return { records: [], byId: new Map() };
311
+ throw new Error(`archkeep: cannot read ${ADR_DIR}: ${cause?.message ?? cause}`, { cause });
312
+ }
313
+ names.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
314
+
315
+ if (io.tracked !== undefined) {
316
+ const tracked = new Set(io.tracked);
317
+ names = names.filter((name) => tracked.has(`${ADR_DIR}/${name}`));
318
+ }
319
+
320
+ const records = [];
321
+ const byId = new Map();
322
+ for (const name of names) {
323
+ const match = ADR_FILE_PATTERN.exec(name);
324
+ if (!match) {
325
+ throw new Error(
326
+ `archkeep: ${ADR_DIR}/${name} is not a valid ADR filename — an ADR file must be ` +
327
+ `named NNN-slug.md (zero-padded number of at least three digits, then a dash-separated slug)`,
328
+ );
329
+ }
330
+ const id = `${match[1]}-${match[2]}`;
331
+ if (byId.has(id)) {
332
+ throw new Error(`archkeep: ${ADR_DIR} holds more than one file for id "${id}"`);
333
+ }
334
+ const filePath = join(dir, name);
335
+ // A tracked NAME says nothing about what currently sits on disk at that
336
+ // path: a symlink committed at mode 120000, one swapped in locally after
337
+ // the `tracked` filter above ran, or — the intermediate case — a
338
+ // symlinked `docs/adr/` itself, whose entries all pass the `tracked` filter
339
+ // as strings while `readdir` hands back the target's bytes. `../containment.mjs`'s
340
+ // `containmentViolation` walks the realpath of the deepest existing
341
+ // ancestor through every intermediate component, so it catches all three;
342
+ // the local `escapesWorkspace` it replaced only lstat'd the final file and
343
+ // let the intermediate case through, the same escape the write guard in
344
+ // `../../cli.mjs`'s `writeOutputReport` refuses (G-10). Excluded exactly
345
+ // like an untracked file — this module's header explains why silence here
346
+ // is the honest answer rather than a thrown error.
347
+ if (
348
+ // The real-fs guard `../architecture-intent/model.mjs`'s `loadIntent`
349
+ // uses for the identical reason: an injected in-memory reader a test
350
+ // drives is keyed by a fixture path that does not exist on disk, and
351
+ // probing a nonexistent root's ancestry would walk up to a real parent
352
+ // directory and misread it as an escape. Real roots only.
353
+ existsSync(root) &&
354
+ containmentViolation(root, filePath, { lstatSync: lstat, realpathSync: realpath }) !== null
355
+ ) {
356
+ continue;
357
+ }
358
+ let text;
359
+ try {
360
+ text = readFile(filePath, "utf8");
361
+ } catch (cause) {
362
+ throw new Error(`archkeep: cannot read ${ADR_DIR}/${name}: ${cause?.message ?? cause}`, {
363
+ cause,
364
+ });
365
+ }
366
+ const record = validateRecord({ id, frontmatter: frontmatterBlock(text) });
367
+ byId.set(id, record);
368
+ records.push(record);
369
+ }
370
+
371
+ return { records, byId };
372
+ }
373
+
374
+ /**
375
+ * The set of every rule/fitness id the workspace's ADRs bind — the ids that
376
+ * appear in any record's `bindings`. A `decisionRef` naming one of these names
377
+ * a rule/fitness the ADR registry makes enforceable; `adrsBinding` answers
378
+ * which record(s) bind it.
379
+ *
380
+ * @param {object[]} records
381
+ * @returns {Set<string>}
382
+ */
383
+ export function boundFitnessIds(records) {
384
+ const ids = new Set();
385
+ for (const record of records) {
386
+ for (const binding of record.bindings) ids.add(binding);
387
+ }
388
+ return ids;
389
+ }
390
+
391
+ /**
392
+ * The ADR records that bind a given rule/fitness id — the reverse lookup a
393
+ * binding-aware surface uses to show where a rule becomes enforceable.
394
+ *
395
+ * @param {object[]} records
396
+ * @param {string} fitnessId
397
+ * @returns {string[]} ADR ids, in registry order.
398
+ */
399
+ export function adrsBinding(records, fitnessId) {
400
+ return records.filter((record) => record.bindings.includes(fitnessId)).map((record) => record.id);
401
+ }
402
+
403
+ /**
404
+ * Strips the `adr:` prefix this tool's own governance-row docs recommend as
405
+ * an alternate ADR-id spelling (`../governance/row-schema.mjs`'s
406
+ * `decisionRef` field docs and its "does not resolve" error text both show
407
+ * `"adr:0012"` beside the bare `0012-slug` form as an equally valid ADR id).
408
+ * The registry never keys a record on that spelling — `validateRecord`
409
+ * derives every `byId` key from the filename alone, and a frontmatter `id`
410
+ * carrying the prefix would already fail the "must equal the filename" check
411
+ * — so a lookup that does not strip it first can never match, no matter how
412
+ * real the record is: the one spelling this tool itself suggests would be the
413
+ * one spelling that silently fails to resolve. Any other ref — including one
414
+ * that merely differs in case, like `ADR:` — is returned unchanged: only the
415
+ * exact documented spelling is an alias, never a fuzzy match that would hide
416
+ * a genuine typo behind a "resolved" answer.
417
+ *
418
+ * @param {string} ref
419
+ * @returns {string}
420
+ */
421
+ export function stripAdrPrefix(ref) {
422
+ return ref.startsWith("adr:") ? ref.slice(4) : ref;
423
+ }
424
+
425
+ /**
426
+ * The two-name-space resolution a `decisionRef` answers. Local knowledge
427
+ * always wins: an ADR id (matching a file in the registry, written bare or
428
+ * with the `adr:` prefix `stripAdrPrefix` strips) or a rule/fitness id the
429
+ * workspace declares in `knownFitness`. Anything else is unknown.
430
+ *
431
+ * The fitness half strips the documented `rule:`/`fitness:` prefixes
432
+ * (`../governance/row-schema.mjs`'s own decisionRef docs show both spellings)
433
+ * before the membership test: `knownFitness` holds the DECLARED names —
434
+ * a policy's `fitness` export names (`"hotspot"`), never prefixed strings —
435
+ * and a citation is the two spellings a row author can write. A ref that is
436
+ * neither an ADR id nor a declared name — including one that merely prefixes
437
+ * an undeclared name — is unknown, never a fuzzy match that would hide a
438
+ * typo behind a "resolved" answer (the same near-miss rule `stripAdrPrefix`
439
+ * documents for the ADR half).
440
+ *
441
+ * @param {Map<string, object>} byId The local registry index.
442
+ * @param {Set<string>} knownFitness Rule/fitness ids the workspace declares.
443
+ * @param {string} ref The decisionRef value.
444
+ * @returns {"adr"|"fitness"|"unknown"}
445
+ */
446
+ export function resolveDecisionRef(byId, knownFitness, ref) {
447
+ if (byId.has(stripAdrPrefix(ref))) return "adr";
448
+ if (knownFitness.has(stripRuleFitnessPrefix(ref))) return "fitness";
449
+ return "unknown";
450
+ }
451
+
452
+ /**
453
+ * Strips the `rule:`/`fitness:` prefix a governance-row `decisionRef` may
454
+ * carry (`../governance/row-schema.mjs` documents both spellings beside the
455
+ * bare name). Only the exact documented lowercase spellings are aliases —
456
+ * a differently-cased `RULE:x` is a near-miss like any other, never a fuzzy
457
+ * match — and a name already bare passes through unchanged.
458
+ *
459
+ * @param {string} ref
460
+ * @returns {string}
461
+ */
462
+ export function stripRuleFitnessPrefix(ref) {
463
+ return ref.startsWith("rule:") || ref.startsWith("fitness:")
464
+ ? ref.slice(ref.indexOf(":") + 1)
465
+ : ref;
466
+ }
467
+
468
+ /**
469
+ * The rule/fitness ids a policy DECLARES — the `fitness` export's `name`
470
+ * fields on the loaded boundary config (F04: a `decisionRef` claiming a
471
+ * fitness rule must be judged against the ids the executed policy actually
472
+ * declares, never against the ADRs' own `bindings` lists, which let a
473
+ * citation resolve itself). Absent when the policy declares none — and then
474
+ * no `fitness:`-shaped ref can ever resolve, which is the correct answer: a
475
+ * rule that cannot be measured is no more bound than one that does not exist.
476
+ *
477
+ * A `fitness` export that is not an array, or that holds a row that is not a
478
+ * plain object, is malformed — `findFitnessViolations`
479
+ * (`./fitness-registry.mjs`) is what reports that, by name. This function
480
+ * runs BEFORE that validation (`findBoundaryConfigViolations` builds its
481
+ * default `io.resolve` from this, ahead of the `findFitnessViolations` call),
482
+ * so it must never throw on a shape the validator has not yet had a chance to
483
+ * name: a `.map` on a non-array, or a `.name` read off a non-object row,
484
+ * would surface as a raw, unprefixed `TypeError` instead of the contracted
485
+ * `archkeep: <path> is malformed: fitness: …` message — loud, but naming
486
+ * nothing, which is its own silent-direction failure (`../../../../AGENTS.md`).
487
+ * Defensive here does not mean silent: an unusable `fitness` still yields no
488
+ * declared names, so a row citing one resolves to "unknown" exactly as it
489
+ * would once `findFitnessViolations` reports the malformed shape and the run
490
+ * exits non-zero.
491
+ *
492
+ * @param {unknown} config The loaded boundary config, or `null`/`undefined`
493
+ * when a caller has none, or any other shape a not-yet-validated `fitness`
494
+ * export may carry.
495
+ * @returns {Set<string>}
496
+ */
497
+ export function declaredFitnessNames(config) {
498
+ const fitness = /** @type {{fitness?: unknown}} */ (config)?.fitness;
499
+ if (!Array.isArray(fitness)) return new Set();
500
+ const names = [];
501
+ for (const row of fitness) {
502
+ if (row !== null && typeof row === "object" && typeof row.name === "string") {
503
+ names.push(row.name);
504
+ }
505
+ }
506
+ return new Set(names);
507
+ }
508
+
509
+ /**
510
+ * `resolveDecisionRef`, applied across a list of governance rows in one pass
511
+ * — the bulk form a report walks once per run rather than re-deriving the
512
+ * same two-name-space check per row owner. `check`'s violations, `context`'s
513
+ * matched constraints, and `drift`'s/`provenance`'s intent and config rows
514
+ * all share this one function, so a `decisionRef` is judged identically
515
+ * everywhere it is rendered. A row with no `decisionRef` — or an empty one,
516
+ * a shape `../governance/row-schema.mjs` already refuses at load time — is
517
+ * skipped: this answers "which CITATIONS are unverifiable", not "which rows
518
+ * are incomplete" (a different question `provenance`'s `hasOrigin` answers).
519
+ *
520
+ * @param {{kind: string, row: object}[]} rows Each row paired with the label
521
+ * its owner uses to identify it (`depConstraints[0]`, `forbidden[2]`, …).
522
+ * @param {Map<string, object>} byId The local ADR registry index.
523
+ * @param {Set<string>} knownFitness Rule/fitness ids the workspace declares.
524
+ * @returns {{kind: string, row: object, decisionRef: string}[]} Empty when
525
+ * every citation resolves — a list, not a bare boolean, so a caller can
526
+ * name which row is unverifiable rather than only that one is
527
+ * (`../../../../AGENTS.md`: an empty result must mean "no violation").
528
+ */
529
+ export function unresolvedDecisionRefRows(rows, byId, knownFitness) {
530
+ const unresolved = [];
531
+ for (const { kind, row } of rows) {
532
+ const ref = row?.decisionRef;
533
+ if (typeof ref !== "string" || ref.trim() === "") continue;
534
+ if (resolveDecisionRef(byId, knownFitness, ref) === "unknown") {
535
+ unresolved.push({ kind, row, decisionRef: ref });
536
+ }
537
+ }
538
+ return unresolved;
539
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The shared reference-time clock for every governance capability that emits
3
+ * a timestamp or an age (waivers, debt, health — not the envelope itself,
4
+ * which stays byte-deterministic without one).
5
+ *
6
+ * The contract the evidence format depends on is the one the whole governance
7
+ * wave shares: **determinism and time are resolved by injection, never by
8
+ * absolute wall-clock asserts.** A feature that emits a timestamp takes its
9
+ * reference time from `referenceTime`, and a test drives the same code with a
10
+ * fixed time — so the evidence a run produces is reproducible byte-for-byte
11
+ * over an unchanged tree AND an unchanged injected clock, and a test never
12
+ * depends on the machine it runs on.
13
+ *
14
+ * `referenceTime` returns a stable ISO-8601 UTC instant. It is a function so
15
+ * the injectable default is trivial (delegate to `Date`) and the injectable
16
+ * override is trivial (a constant). Both are the same call shape, so a
17
+ * feature that takes an optional clock reads `clock ?? referenceTime` and
18
+ * works either way.
19
+ */
20
+
21
+ /**
22
+ * The reference time as a stable ISO-8601 UTC string.
23
+ *
24
+ * @returns {string} e.g. `"2026-08-16T10:00:00.000Z"`.
25
+ */
26
+ export function referenceTime() {
27
+ return new Date().toISOString();
28
+ }
29
+
30
+ /**
31
+ * The shape a clock must have for a **record-`on`** producer. `now()`
32
+ * returns a non-empty string.
33
+ *
34
+ * @typedef {object} Clock
35
+ * @property {() => string} now
36
+ */
37
+
38
+ /**
39
+ * Refuses a value that is not a usable record clock, naming what was wrong.
40
+ *
41
+ * Loud on purpose: a clock handed to a place that would emit `on` and silently
42
+ * ignored is exactly the silent direction — a record that claims a "when" it
43
+ * never obtained. A caller that passes no clock at all never reaches this
44
+ * check (the `on`-without-clock path is `provenance-record.mjs`'s own
45
+ * refusal).
46
+ *
47
+ * @param {unknown} clock
48
+ * @returns {string[]} Empty when `clock` is a usable clock.
49
+ */
50
+ export function clockViolations(clock) {
51
+ if (clock === null || typeof clock !== "object") {
52
+ return [`clock must be an object with a now() function, got ${describe(clock)}`];
53
+ }
54
+ const now = /** @type {{now?: unknown}} */ (clock).now;
55
+ if (typeof now !== "function") {
56
+ return ["clock.now must be a function returning a non-empty string"];
57
+ }
58
+ const value = now();
59
+ if (typeof value !== "string" || value.length === 0) {
60
+ return ["clock.now() must return a non-empty string"];
61
+ }
62
+ return [];
63
+ }
64
+
65
+ /** A value's type, for an error message that shows what was actually there. */
66
+ function describe(value) {
67
+ if (value === null) return "null";
68
+ return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
69
+ }