|
| 1 | +#!/usr/bin/env node |
| 2 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 3 | +// |
| 4 | +// check-adr-anchors — keep an accepted ADR's decision readable from the code it |
| 5 | +// governs. |
| 6 | +// |
| 7 | +// ## The failure this exists for |
| 8 | +// |
| 9 | +// framework#3723. Three accepted ADRs said the same thing — ADR-0057 D4 ("feed |
| 10 | +// the names to better-auth ONLY so invitations are accepted, never as the |
| 11 | +// authority for RBAC"), ADR-0090 D3's word ban ("distribution = position"), and |
| 12 | +// ADR-0095 D3 ("no enforcement-time code path may consult the better-auth role |
| 13 | +// directly"). A patch-level changeset reversed all three by making app-declared |
| 14 | +// names storable in `sys_member.role`; a follow-up then made the derivation |
| 15 | +// automatic in every host. Nobody noticed for a day, and the issue tracking it |
| 16 | +// was closed, reopened and rewritten three times while the cause moved. |
| 17 | +// |
| 18 | +// The mechanism of that failure is worth naming precisely, because it is not |
| 19 | +// "someone was careless": **the file being edited never mentioned the ADRs that |
| 20 | +// governed it.** `auth-manager.ts` cited ADR-0105 D8 (why `delegated_admin` is |
| 21 | +// registered) and said nothing about why the app-role loop next to it was a |
| 22 | +// violation. An author — human or agent — reading that file could not have |
| 23 | +// known. ADRs are only binding if the code they bind points back at them. |
| 24 | +// |
| 25 | +// ## What this checks |
| 26 | +// |
| 27 | +// For each entry in `scripts/adr-anchors.json`: the file exists, every ADR id |
| 28 | +// listed for it names a real record under `docs/adr/`, and every one of those |
| 29 | +// ids still appears somewhere in the file. That is all — a presence check, |
| 30 | +// deliberately dumb: |
| 31 | +// |
| 32 | +// - It cannot be satisfied usefully by a drive-by edit that guts the logic, |
| 33 | +// because the failure text carries the INVARIANT, not just an id to paste |
| 34 | +// back. |
| 35 | +// - It costs nothing to keep green while the invariant holds, and it fails |
| 36 | +// the moment someone rewrites a governed block and drops the rationale with |
| 37 | +// it — which is exactly the diff that needs a second look. |
| 38 | +// |
| 39 | +// It does NOT verify the code still obeys the ADR; no static check can. It |
| 40 | +// guarantees the next author is TOLD which decision they are standing on. The |
| 41 | +// enforcement of each invariant lives in its own tests (see the ADR). |
| 42 | +// |
| 43 | +// ## Adding an entry |
| 44 | +// |
| 45 | +// Add one when an accepted ADR's decision is realized in code that would look |
| 46 | +// arbitrary — or plausibly wrong, or improvable — to someone reading the file |
| 47 | +// alone. That is the tell: if a reasonable engineer could "fix" it and be |
| 48 | +// reverting a decision, anchor it. Do not anchor everything; a map of |
| 49 | +// everything is a map of nothing, and each entry must earn its failure mode. |
| 50 | +// |
| 51 | +// node scripts/check-adr-anchors.mjs |
| 52 | + |
| 53 | +import { readFileSync, readdirSync, existsSync } from 'node:fs'; |
| 54 | +import { join } from 'node:path'; |
| 55 | + |
| 56 | +const ROOT = process.cwd(); |
| 57 | +const MAP_PATH = 'scripts/adr-anchors.json'; |
| 58 | +const ADR_DIR = 'docs/adr'; |
| 59 | + |
| 60 | +/** An ADR id as written in code comments: `ADR-0090`. */ |
| 61 | +const ADR_ID = /^ADR-(\d{4})$/; |
| 62 | + |
| 63 | +let anchors; |
| 64 | +try { |
| 65 | + ({ anchors } = JSON.parse(readFileSync(join(ROOT, MAP_PATH), 'utf8'))); |
| 66 | +} catch (e) { |
| 67 | + console.error(`check-adr-anchors: cannot read ${MAP_PATH} — ${e.message}`); |
| 68 | + process.exit(1); |
| 69 | +} |
| 70 | +if (!Array.isArray(anchors)) { |
| 71 | + console.error(`check-adr-anchors: ${MAP_PATH} must carry an "anchors" array.`); |
| 72 | + process.exit(1); |
| 73 | +} |
| 74 | + |
| 75 | +/** Decision records that actually exist, by number: `0090` → `0090-permission-model-...md`. */ |
| 76 | +const records = new Set(); |
| 77 | +try { |
| 78 | + for (const f of readdirSync(join(ROOT, ADR_DIR))) { |
| 79 | + const m = /^(\d{4})-/.exec(f); |
| 80 | + if (m) records.add(m[1]); |
| 81 | + } |
| 82 | +} catch { |
| 83 | + console.error(`check-adr-anchors: no ${ADR_DIR}/ directory — run from the repo root.`); |
| 84 | + process.exit(1); |
| 85 | +} |
| 86 | + |
| 87 | +const errors = []; |
| 88 | +let checked = 0; |
| 89 | + |
| 90 | +for (const entry of anchors) { |
| 91 | + const { file, adrs, invariant } = entry ?? {}; |
| 92 | + |
| 93 | + if (typeof file !== 'string' || !Array.isArray(adrs) || adrs.length === 0) { |
| 94 | + errors.push(`${MAP_PATH}: every anchor needs a "file" and a non-empty "adrs" array (got ${JSON.stringify(entry)}).`); |
| 95 | + continue; |
| 96 | + } |
| 97 | + if (typeof invariant !== 'string' || invariant.trim() === '') { |
| 98 | + // The invariant IS the value of this check — an entry without one degrades |
| 99 | + // the failure into "put this string back", which teaches nothing. |
| 100 | + errors.push(`${MAP_PATH}: anchor for ${file} has no "invariant" — state what the ADR decided, in a sentence or two.`); |
| 101 | + continue; |
| 102 | + } |
| 103 | + |
| 104 | + const abs = join(ROOT, file); |
| 105 | + if (!existsSync(abs)) { |
| 106 | + errors.push( |
| 107 | + `${file}: anchored file is missing. If it moved, update ${MAP_PATH}; if the code is gone, say so in ` + |
| 108 | + `the ADR — a decision whose implementation vanished is one to revisit, not to drop silently.`, |
| 109 | + ); |
| 110 | + continue; |
| 111 | + } |
| 112 | + |
| 113 | + const body = readFileSync(abs, 'utf8'); |
| 114 | + for (const adr of adrs) { |
| 115 | + const m = ADR_ID.exec(adr); |
| 116 | + if (!m) { |
| 117 | + errors.push(`${MAP_PATH}: "${adr}" is not an ADR id (expected e.g. ADR-0090).`); |
| 118 | + continue; |
| 119 | + } |
| 120 | + // Anchoring a withdrawn or never-written record sends the next author to a |
| 121 | + // dead end (cf. ADR-0107, withdrawn before it landed). |
| 122 | + if (!records.has(m[1])) { |
| 123 | + errors.push(`${MAP_PATH}: ${adr} has no record under ${ADR_DIR}/ — anchor a decision that exists.`); |
| 124 | + continue; |
| 125 | + } |
| 126 | + if (!body.includes(adr)) { |
| 127 | + errors.push( |
| 128 | + `${file}: no longer references ${adr}.\n` + |
| 129 | + ` ${invariant}\n` + |
| 130 | + ` If the code still obeys it, restore the reference where the decision shows up.\n` + |
| 131 | + ` If you are deliberately changing it, that needs a superseding ADR under ${ADR_DIR}/ — ` + |
| 132 | + `not a comment edit — and then an update to ${MAP_PATH}.`, |
| 133 | + ); |
| 134 | + } |
| 135 | + } |
| 136 | + checked++; |
| 137 | +} |
| 138 | + |
| 139 | +if (errors.length) { |
| 140 | + console.error(`check-adr-anchors: ${errors.length} problem(s)\n`); |
| 141 | + for (const e of errors) console.error(' • ' + e); |
| 142 | + console.error( |
| 143 | + '\n Why this check exists: an accepted ADR was reversed by a patch-level changeset (#3723) because\n' + |
| 144 | + ' the code it governed never named it. Anchors keep the decision reachable from the diff.\n', |
| 145 | + ); |
| 146 | + process.exit(1); |
| 147 | +} |
| 148 | +console.log(`check-adr-anchors: OK (${checked} anchored file(s), every governing ADR still referenced).`); |
0 commit comments