ruvnet-brain 4.5.15 → 4.5.17
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/bin/install.mjs +1 -1
- package/config/practical-rule-catalog.json +111 -0
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +13 -1
- package/plugin/hooks/hook-contracts.json +20 -4
- package/plugin/scripts/adr-currency-gate.mjs +75 -31
- package/plugin/scripts/advocacy-outcomes.mjs +18 -4
- package/plugin/scripts/agentdb-recall.mjs +161 -26
- package/plugin/scripts/capability-inventory-receipt.mjs +16 -7
- package/plugin/scripts/capability-registry.mjs +55 -114
- package/plugin/scripts/capacity-aware-parallel-work.mjs +1 -1
- package/plugin/scripts/codex-hook-adapter.mjs +33 -6
- package/plugin/scripts/codex-hook-wrapper.mjs +23 -3
- package/plugin/scripts/continuity-brief.mjs +55 -22
- package/plugin/scripts/continuity-events.mjs +93 -17
- package/plugin/scripts/continuity-hook-policy.mjs +1 -1
- package/plugin/scripts/continuity-journal.mjs +77 -29
- package/plugin/scripts/decision-gate.mjs +63 -22
- package/plugin/scripts/decision-outcomes.mjs +192 -199
- package/plugin/scripts/detach.mjs +53 -13
- package/plugin/scripts/doc-currency.mjs +993 -0
- package/plugin/scripts/ground-ruvnet.sh +16 -3
- package/plugin/scripts/grounding-answer.mjs +18 -16
- package/plugin/scripts/grounding-stamp.sh +3 -0
- package/plugin/scripts/grounding-turn-evidence.mjs +60 -65
- package/plugin/scripts/grounding-turn-gate.mjs +34 -29
- package/plugin/scripts/grounding-turn-mark.mjs +101 -17
- package/plugin/scripts/hook-context-budget.mjs +90 -0
- package/plugin/scripts/hook-registry.mjs +129 -1
- package/plugin/scripts/hook-shim.mjs +20 -2
- package/plugin/scripts/lesson-gate.mjs +1 -1
- package/plugin/scripts/project-capture-queue.mjs +22 -15
- package/plugin/scripts/project-progression-producer.mjs +15 -8
- package/plugin/scripts/project-progression-reader.mjs +14 -4
- package/plugin/scripts/project-progression-session-start.mjs +29 -17
- package/plugin/scripts/project-progression-sources.mjs +24 -10
- package/plugin/scripts/project-progression-store.mjs +7 -2
- package/plugin/scripts/project-store-resolver.mjs +10 -7
- package/plugin/scripts/project-transition-hook.mjs +20 -12
- package/plugin/scripts/session-snapshot-hook.mjs +20 -9
- package/plugin/scripts/session-start-budget.mjs +18 -0
- package/plugin/scripts/session-start-core.mjs +32 -6
- package/plugin/scripts/session-start-trace.mjs +2 -1
- package/plugin/scripts/turn-outcome-capture.mjs +7 -5
- package/plugin/scripts/unprompted-runtime.mjs +36 -10
- package/plugin/scripts/user-settings.mjs +7 -1
- package/scripts/claude-controlled-terminal.mjs +119 -33
- package/scripts/codex-managed-terminal.mjs +76 -8
- package/scripts/doc-currency.mjs +6 -990
- package/scripts/hook-qualify-core.mjs +1 -1
- package/scripts/model-managed-prompt.mjs +17 -4
- package/scripts/model-managed-workflow-service.mjs +121 -15
- package/scripts/model-routing-controller.mjs +112 -21
- package/scripts/model-routing-execution-adapters.mjs +10 -5
- package/scripts/model-routing-gateway.mjs +1 -1
- package/scripts/model-terminal-launchers.mjs +1 -0
- package/scripts/practical-rule-selector.mjs +51 -0
- package/scripts/product-integrity-contract.mjs +11 -4
- package/scripts/source-scope-receipt.mjs +9 -4
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/doc-currency.mjs — DOCUMENT CURRENCY. ADR-034 / DDD-0008, decided originally in ADR-0009
|
|
3
|
+
// on 2026-07-06 and left as prose for sixteen days, during which zero of this repo's scripts
|
|
4
|
+
// implemented it. This is the gate half of that decision.
|
|
5
|
+
//
|
|
6
|
+
// node scripts/doc-currency.mjs --report human table: every doc, its stamps, its verdict
|
|
7
|
+
// node scripts/doc-currency.mjs --check exit 1 on a real violation (CI / pre-push)
|
|
8
|
+
// node scripts/doc-currency.mjs --fix backfill ONLY dates git can prove; label them
|
|
9
|
+
// node scripts/doc-currency.mjs --json machine-readable, same evaluation
|
|
10
|
+
//
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
// THE ONE LAW (ADR-0024, already law here): "a status must be RE-DERIVED from the verifiable
|
|
13
|
+
// artifact, never read from a self-asserted field." Every value this tool prints is derived from
|
|
14
|
+
// git or the filesystem. A frontmatter `impl:` is treated as an untrusted CLAIM to be checked
|
|
15
|
+
// against the derivation, never as an input to it.
|
|
16
|
+
//
|
|
17
|
+
// FIVE FAILURE MODES THIS DESIGN EXISTS TO AVOID — each one found by adversarial review of the
|
|
18
|
+
// naive version, each one reproduced against this repo before being fixed here:
|
|
19
|
+
//
|
|
20
|
+
// 1. A missing or renamed governed path made `git rev-parse HEAD:<p>` print the literal
|
|
21
|
+
// "HEAD:<p>" to stdout and exit 128. Hashing that literal produced a STABLE digest that could
|
|
22
|
+
// never change again — so renaming a governed file (the most common way a doc goes stale)
|
|
23
|
+
// froze its verification green forever. Here: resolution is checked per path via
|
|
24
|
+
// `git cat-file --batch-check`, and ANY unresolvable member makes the digest uncomputable.
|
|
25
|
+
// A digest is never computed over a guess. (see resolveGoverned / computeDigest)
|
|
26
|
+
//
|
|
27
|
+
// 2. A DIRECTORY in `governs:` hashed the whole tree. `HEAD:docs/adr` resolves to a tree object
|
|
28
|
+
// that changes when ANY of the 34 ADRs changes — mass-expiring unrelated verifications on day
|
|
29
|
+
// one. That is the wolf-cry that gets a gate switched off. Here: trees are a hard finding, and
|
|
30
|
+
// the fix (a glob) is printed.
|
|
31
|
+
//
|
|
32
|
+
// 3. The digest covered only the CODE, so editing the DOCUMENT left the stamp green. You could
|
|
33
|
+
// rewrite a verified ADR to describe behaviour the code never had and nothing expired. Here
|
|
34
|
+
// the document's own normative body is an input to the digest, so either side moving expires
|
|
35
|
+
// the verification. The currency log is excluded, because appending the row that RECORDS a
|
|
36
|
+
// verification must not invalidate that same verification.
|
|
37
|
+
//
|
|
38
|
+
// 4. `impl:` used ANY-semantics ("≥1 governed path is wired"), which reported `wired` for a doc
|
|
39
|
+
// governing an unwired file plus one wired helper — i.e. green on exactly the built-but-
|
|
40
|
+
// unwired failure the rung was invented to catch. Here impl is derived PER PATH and the
|
|
41
|
+
// WEAKEST member wins, with the unwired members named.
|
|
42
|
+
//
|
|
43
|
+
// 5. Falling back to `date:` when `updated:` is absent counted a CREATION stamp as a drifted
|
|
44
|
+
// UPDATE stamp. That is how "4 of 20 stamps are wrong" was measured when only 1 committed ADR
|
|
45
|
+
// actually had a drifted `updated:` — three of the four accused carry no `updated:` key at
|
|
46
|
+
// all. Absent is absent here; it is reported as absent and never inferred from a neighbour.
|
|
47
|
+
//
|
|
48
|
+
// FALSE POSITIVES ARE THE DESIGNED FAILURE MODE. A gate that cries wolf gets bypassed and a
|
|
49
|
+
// bypassed gate protects nothing (ADR-0024 said the same about its own scope). So: age is NOWHERE
|
|
50
|
+
// in the drift formula — only movement of the governed set is; a doc with a dirty working tree is
|
|
51
|
+
// exempted from stamp checks rather than accused; a doc with no `governs:` has no drift and cannot
|
|
52
|
+
// be flagged stale; and the twelve legacy ADRs whose frontmatter is a bare `id:` are REPORTED
|
|
53
|
+
// forever and BLOCK never (they predate the convention; blocking twelve pre-existing violations on
|
|
54
|
+
// day one is how a gate dies).
|
|
55
|
+
|
|
56
|
+
import fs from 'node:fs';
|
|
57
|
+
import path from 'node:path';
|
|
58
|
+
import crypto from 'node:crypto';
|
|
59
|
+
import { spawnSync } from 'node:child_process';
|
|
60
|
+
import { fileURLToPath } from 'node:url';
|
|
61
|
+
|
|
62
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
63
|
+
export const REPO_ROOT = path.resolve(HERE, '..', '..');
|
|
64
|
+
export const DEFAULT_DIRS = ['docs/adr', 'docs/ddd'];
|
|
65
|
+
|
|
66
|
+
// Label for the digest recipe. Bumping it deliberately expires every stamp, which is the honest
|
|
67
|
+
// behaviour when the meaning of a stamp changes.
|
|
68
|
+
export const DIGEST_RECIPE = 'sha256-manifest';
|
|
69
|
+
|
|
70
|
+
// Ladder. Lower is weaker. `unknown` sits below everything because "we cannot tell" must never
|
|
71
|
+
// outrank "we checked and it is not built".
|
|
72
|
+
export const IMPL_LADDER = ['unknown', 'unbuilt', 'built', 'wired', 'verified'];
|
|
73
|
+
const rank = (v) => { const i = IMPL_LADDER.indexOf(v); return i < 0 ? 0 : i; };
|
|
74
|
+
export const weakest = (vals) => (vals.length ? vals.reduce((a, b) => (rank(b) < rank(a) ? b : a)) : 'unknown');
|
|
75
|
+
|
|
76
|
+
// rUv's ADR enum, unchanged and un-extended (ruflo-adr REFERENCE.md). `Implemented` is deliberately
|
|
77
|
+
// NOT here: it is a fifth value this repo invented on someone else's key, and it is the lie-shaped
|
|
78
|
+
// one — it says something about code on a key that means something about a decision.
|
|
79
|
+
export const RUV_STATUSES = ['Proposed', 'Accepted', 'Deprecated', 'Superseded'];
|
|
80
|
+
|
|
81
|
+
// ── git ─────────────────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
// Every call is `-C root` so fixtures in tmp dirs are first-class; nothing reads process.cwd().
|
|
83
|
+
|
|
84
|
+
function git(root, args, { input } = {}) {
|
|
85
|
+
const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', input, maxBuffer: 32 * 1024 * 1024 });
|
|
86
|
+
return { ok: r.status === 0, code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isGitRepo(root) {
|
|
90
|
+
return git(root, ['rev-parse', '--git-dir']).ok;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function hasCommits(root) {
|
|
94
|
+
return git(root, ['rev-parse', '--verify', 'HEAD']).ok;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Last commit date (author date, in the committer's own timezone — the date a human would have
|
|
98
|
+
// typed) + sha, for one path. Empty when the path has never been committed.
|
|
99
|
+
export function lastCommit(root, rel) {
|
|
100
|
+
const r = git(root, ['log', '-1', '--format=%ad|%H', '--date=short', '--', rel]);
|
|
101
|
+
if (!r.ok || !r.out) return null;
|
|
102
|
+
const [date, sha] = r.out.split('|');
|
|
103
|
+
return { date, sha };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The commit that ADDED the file, following renames. `--diff-filter=A` can emit several rows across
|
|
107
|
+
// a rename chain; the earliest (last line) is the creation.
|
|
108
|
+
export function firstCommit(root, rel) {
|
|
109
|
+
const r = git(root, ['log', '--follow', '--diff-filter=A', '--format=%ad|%H', '--date=short', '--', rel]);
|
|
110
|
+
if (!r.ok || !r.out) return null;
|
|
111
|
+
const lines = r.out.split('\n').filter(Boolean);
|
|
112
|
+
if (!lines.length) return null;
|
|
113
|
+
const [date, sha] = lines[lines.length - 1].split('|');
|
|
114
|
+
return { date, sha };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Working tree differs from HEAD, or the file is not tracked at all. A dirty doc's "last commit
|
|
118
|
+
// date" is not the date of its current contents, so every stamp comparison is suspended for it.
|
|
119
|
+
export function isDirty(root, rel) {
|
|
120
|
+
const r = git(root, ['status', '--porcelain', '--', rel]);
|
|
121
|
+
return r.ok ? r.out.length > 0 : false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── frontmatter ─────────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
// Deliberately a small hand parser rather than a YAML dependency: the shapes are fixed, the repo
|
|
126
|
+
// has no YAML dep, and a parse failure here must degrade to "unreadable", never throw.
|
|
127
|
+
|
|
128
|
+
export function parseFrontmatter(text) {
|
|
129
|
+
const lines = text.split('\n');
|
|
130
|
+
if (lines[0]?.trim() !== '---') return { present: false, keys: {}, endLine: -1, raw: [] };
|
|
131
|
+
let end = -1;
|
|
132
|
+
for (let i = 1; i < lines.length; i++) if (lines[i].trim() === '---') { end = i; break; }
|
|
133
|
+
if (end < 0) return { present: false, keys: {}, endLine: -1, raw: [] };
|
|
134
|
+
|
|
135
|
+
const keys = {};
|
|
136
|
+
const raw = lines.slice(1, end);
|
|
137
|
+
let listKey = null;
|
|
138
|
+
for (const ln of raw) {
|
|
139
|
+
const item = ln.match(/^\s+-\s+(.*)$/);
|
|
140
|
+
if (item && listKey) { keys[listKey].push(item[1].trim().replace(/^["']|["']$/g, '')); continue; }
|
|
141
|
+
const kv = ln.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
|
|
142
|
+
if (!kv) continue;
|
|
143
|
+
const [, k, vRaw] = kv;
|
|
144
|
+
const v = vRaw.trim();
|
|
145
|
+
if (v === '') { listKey = k; keys[k] = []; continue; }
|
|
146
|
+
listKey = null;
|
|
147
|
+
if (v.startsWith('[') && v.endsWith(']')) {
|
|
148
|
+
keys[k] = v.slice(1, -1).split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
149
|
+
} else {
|
|
150
|
+
keys[k] = v.replace(/^["']|["']$/g, '');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { present: true, keys, endLine: end, raw };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The part of a document a verification is a reading OF. Frontmatter is excluded (it holds the
|
|
157
|
+
// stamp itself — including it would make every stamp invalidate itself the moment it was written).
|
|
158
|
+
// The currency log is excluded for the same reason one rung up: recording a verification appends a
|
|
159
|
+
// row, and that row must not expire the verification it records.
|
|
160
|
+
export function normativeBody(text) {
|
|
161
|
+
const fm = parseFrontmatter(text);
|
|
162
|
+
const lines = text.split('\n');
|
|
163
|
+
const body = fm.present ? lines.slice(fm.endLine + 1) : lines;
|
|
164
|
+
const out = [];
|
|
165
|
+
let skipping = false;
|
|
166
|
+
for (const ln of body) {
|
|
167
|
+
const h = ln.match(/^#{1,6}\s+(.*)$/);
|
|
168
|
+
if (h) skipping = /^currency log\b/i.test(h[1].trim());
|
|
169
|
+
if (!skipping) out.push(ln.replace(/[ \t]+$/, ''));
|
|
170
|
+
}
|
|
171
|
+
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
|
175
|
+
|
|
176
|
+
// ── the governed set ────────────────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
// Resolve tracked declarations against actual working bytes. HEAD establishes membership,
|
|
179
|
+
// not the content currently being verified. Globs expand through the index; a
|
|
180
|
+
// literal entry is looked up with `git cat-file --batch-check`, which distinguishes blob / tree /
|
|
181
|
+
// missing in ONE process and — unlike `git rev-parse HEAD:<p>` — never prints the requested path
|
|
182
|
+
// back as if it were an answer.
|
|
183
|
+
export function resolveGoverned(root, entries) {
|
|
184
|
+
const results = [];
|
|
185
|
+
if (!entries.length) return results;
|
|
186
|
+
|
|
187
|
+
const expanded = [];
|
|
188
|
+
for (const e of entries) {
|
|
189
|
+
const clean = String(e).trim();
|
|
190
|
+
if (!clean) continue;
|
|
191
|
+
if (/[*?[\]]/.test(clean)) {
|
|
192
|
+
const ls = git(root, ['ls-files', '--', clean]);
|
|
193
|
+
const hits = ls.ok ? ls.out.split('\n').filter(Boolean) : [];
|
|
194
|
+
if (!hits.length) { expanded.push({ path: clean, from: clean, glob: true, empty: true }); continue; }
|
|
195
|
+
for (const h of hits) expanded.push({ path: h, from: clean, glob: true });
|
|
196
|
+
} else {
|
|
197
|
+
expanded.push({ path: clean.replace(/\/+$/, ''), from: clean, trailingSlash: /\/$/.test(clean) });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const lookups = expanded.filter((e) => !e.empty);
|
|
202
|
+
const byPath = new Map();
|
|
203
|
+
if (lookups.length && hasCommits(root)) {
|
|
204
|
+
const input = lookups.map((e) => `HEAD:${e.path}`).join('\n') + '\n';
|
|
205
|
+
const r = git(root, ['cat-file', '--batch-check'], { input });
|
|
206
|
+
const lines = r.out ? r.out.split('\n') : [];
|
|
207
|
+
lines.forEach((ln, i) => {
|
|
208
|
+
const m = ln.match(/^([0-9a-f]{40})\s+(\w+)\s+(\d+)$/);
|
|
209
|
+
const e = lookups[i];
|
|
210
|
+
if (!e) return;
|
|
211
|
+
if (m) byPath.set(e.path, { sha: m[1], type: m[2] });
|
|
212
|
+
else byPath.set(e.path, { sha: null, type: 'missing' });
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const e of expanded) {
|
|
217
|
+
const abs = path.join(root, e.path);
|
|
218
|
+
const onDisk = fs.existsSync(abs);
|
|
219
|
+
if (e.empty) {
|
|
220
|
+
results.push({ ...e, type: 'no-match', sha: null, onDisk: false, resolved: false });
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const hit = byPath.get(e.path) || { sha: null, type: onDisk ? 'untracked' : 'missing' };
|
|
224
|
+
let type = hit.type === 'missing' && onDisk ? 'untracked' : hit.type;
|
|
225
|
+
let sha = hit.sha;
|
|
226
|
+
if (type === 'blob') {
|
|
227
|
+
try {
|
|
228
|
+
const relative = path.relative(path.resolve(root), path.resolve(abs));
|
|
229
|
+
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
230
|
+
throw new Error('governed path escapes project');
|
|
231
|
+
}
|
|
232
|
+
let current = path.resolve(root);
|
|
233
|
+
for (const part of relative.split(path.sep)) {
|
|
234
|
+
current = path.join(current, part);
|
|
235
|
+
if (fs.lstatSync(current).isSymbolicLink()) throw new Error('governed path contains a symlink');
|
|
236
|
+
}
|
|
237
|
+
if (!fs.lstatSync(abs).isFile()) throw new Error('governed blob is not a regular file');
|
|
238
|
+
const bytes = fs.readFileSync(abs);
|
|
239
|
+
// Git blob identity without writing objects or invoking one process per file.
|
|
240
|
+
// Unchanged bytes retain the existing recipe; staged and unstaged edits both expire it.
|
|
241
|
+
sha = crypto.createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
|
|
242
|
+
} catch {
|
|
243
|
+
type = onDisk ? 'unsafe-working-source' : 'missing';
|
|
244
|
+
sha = null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
results.push({ ...e, type, sha, onDisk, resolved: type === 'blob' });
|
|
248
|
+
}
|
|
249
|
+
return results;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// A digest is a value you cannot type from memory — that is its entire job. It is therefore
|
|
253
|
+
// computed ONLY when every input is a real, resolved blob. If any member is a tree, missing,
|
|
254
|
+
// untracked, or an empty glob, the answer is `null` plus a reason. Never a hash over a placeholder.
|
|
255
|
+
export function computeDigest(root, docRelPath, docText, governed) {
|
|
256
|
+
const unresolved = governed.filter((g) => !g.resolved);
|
|
257
|
+
if (!governed.length) return { digest: null, reason: 'governs: is empty — nothing to verify against' };
|
|
258
|
+
if (unresolved.length) {
|
|
259
|
+
return {
|
|
260
|
+
digest: null,
|
|
261
|
+
reason: `unresolvable governed path(s): ${unresolved.map((g) => `${g.path} (${g.type})`).join(', ')}`,
|
|
262
|
+
unresolved,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const manifest = [
|
|
266
|
+
...governed.map((g) => `blob ${g.sha} ${g.path}`).sort(),
|
|
267
|
+
`doc ${sha256(normativeBody(docText))} ${docRelPath}`,
|
|
268
|
+
`recipe ${DIGEST_RECIPE}`,
|
|
269
|
+
].join('\n');
|
|
270
|
+
return { digest: sha256(manifest).slice(0, 12), manifest };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Is this path referenced from something that is neither a test nor a document? The gap between
|
|
274
|
+
// "exists" and "someone calls it" is this repo's signature failure (a capability registry with zero
|
|
275
|
+
// call sites; lessons mined, weighted and consumed by nobody). It is also the one check with a real
|
|
276
|
+
// false-negative rate — hook-invoked scripts and dynamic dispatch have no greppable caller — so a
|
|
277
|
+
// negative result WARNS and never blocks.
|
|
278
|
+
export function findCallers(root, rel) {
|
|
279
|
+
const base = path.basename(rel);
|
|
280
|
+
const r = git(root, ['grep', '-l', '-I', '--untracked', '-F', '-e', rel, '-e', base]);
|
|
281
|
+
if (!r.ok || !r.out) return [];
|
|
282
|
+
return r.out.split('\n').filter(Boolean).filter((f) => {
|
|
283
|
+
if (f === rel) return false;
|
|
284
|
+
if (/(^|\/)tests?\//.test(f)) return false;
|
|
285
|
+
if (/\.test\.[cm]?[jt]s$/.test(f)) return false;
|
|
286
|
+
if (/\.(md|markdown)$/i.test(f)) return false;
|
|
287
|
+
return true;
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── impl derivation ─────────────────────────────────────────────────────────────────────────────
|
|
292
|
+
// PER PATH, then the WEAKEST wins. Any-semantics ("one member is wired ⇒ wired") reports green on
|
|
293
|
+
// exactly the built-but-unwired case the rung exists to catch.
|
|
294
|
+
export function deriveImpl(root, governed, { checkWiring = true, callerCache = new Map(), callerLookup = findCallers } = {}) {
|
|
295
|
+
if (!governed.length) return { impl: 'unknown', perPath: [], unwired: [], reason: 'no governs: set' };
|
|
296
|
+
const perPath = governed.map((g) => {
|
|
297
|
+
if (!g.onDisk && !g.resolved) return { path: g.path, impl: 'unbuilt', type: g.type };
|
|
298
|
+
let callers = [];
|
|
299
|
+
if (checkWiring) {
|
|
300
|
+
if (!callerCache.has(g.path)) callerCache.set(g.path, callerLookup(root, g.path));
|
|
301
|
+
callers = callerCache.get(g.path);
|
|
302
|
+
}
|
|
303
|
+
return { path: g.path, impl: callers.length ? 'wired' : 'built', callers, type: g.type };
|
|
304
|
+
});
|
|
305
|
+
return {
|
|
306
|
+
impl: weakest(perPath.map((p) => p.impl)),
|
|
307
|
+
perPath,
|
|
308
|
+
unwired: perPath.filter((p) => p.impl === 'built').map((p) => p.path),
|
|
309
|
+
missing: perPath.filter((p) => p.impl === 'unbuilt').map((p) => p.path),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── drift ───────────────────────────────────────────────────────────────────────────────────────
|
|
314
|
+
// Drift is MOVEMENT, never age. `presumed-stale` says "nobody has checked since the code moved",
|
|
315
|
+
// which is exactly what is true. A doc untouched for months whose governed code has not moved is
|
|
316
|
+
// `current`, silently. That property is what keeps this gate alive.
|
|
317
|
+
export const DRIFT_COMMITS_STALE = 2;
|
|
318
|
+
export const DRIFT_DAYS_STALE = 7;
|
|
319
|
+
|
|
320
|
+
export function deriveDrift(root, docRel, governed, { floorSha = null } = {}) {
|
|
321
|
+
const paths = governed.filter((g) => g.resolved).map((g) => g.path);
|
|
322
|
+
if (!paths.length) return { state: 'not-applicable', commits: 0, days: 0, reason: 'no resolvable governed paths' };
|
|
323
|
+
const doc = lastCommit(root, docRel);
|
|
324
|
+
if (!doc) return { state: 'not-applicable', commits: 0, days: 0, reason: 'document has no git history' };
|
|
325
|
+
|
|
326
|
+
// A VERSION BUMP IS NOT DRIFT (2026-08-06). `scripts/sync-version.mjs` rewrites the plugin
|
|
327
|
+
// manifests, package.json, kb/package.json and RVF-GENERATIONS.json on EVERY release bump, and
|
|
328
|
+
// several ADRs legitimately `govern:` those files. So every bump marked those documents
|
|
329
|
+
// presumed-stale whether or not anything they decide had moved — measured four times in one day
|
|
330
|
+
// on ADR-050/051/057/058, each time resolving to "re-read, nothing changed but a version string".
|
|
331
|
+
//
|
|
332
|
+
// That is not a harmless nuisance. A gate that fires on every bump teaches people to stamp
|
|
333
|
+
// without reading, and this repo has already blanket-stamped 61 ADRs from a bad grep exactly
|
|
334
|
+
// once. It also blocks unattended release automation, which cannot author a currency row.
|
|
335
|
+
//
|
|
336
|
+
// Drift is MOVEMENT IN WHAT THE DOCUMENT DECIDES. A commit whose entire diff inside the governed
|
|
337
|
+
// paths is version-identifier lines cannot have changed any decision, so it does not count. Any
|
|
338
|
+
// commit touching one substantive line still counts in full — this narrows the trigger, never the
|
|
339
|
+
// verdict.
|
|
340
|
+
const floor = floorSha && git(root, ['rev-parse', '--verify', `${floorSha}^{commit}`]);
|
|
341
|
+
const resolvedFloor = floor?.ok ? floor.out : null;
|
|
342
|
+
const shas = (() => {
|
|
343
|
+
// With --changed, only movement introduced after the candidate merge-base can consume the
|
|
344
|
+
// candidate's drift budget. Historical debt remains visible in an unscoped report, but cannot
|
|
345
|
+
// turn one new governed edit into a threshold breach merely because the ADR was already stale.
|
|
346
|
+
const range = resolvedFloor
|
|
347
|
+
? ['HEAD', `^${doc.sha}`, `^${resolvedFloor}`]
|
|
348
|
+
: [`${doc.sha}..HEAD`];
|
|
349
|
+
const r = git(root, ['rev-list', ...range, '--', ...paths]);
|
|
350
|
+
return r.ok && r.out ? r.out.split('\n').filter(Boolean) : [];
|
|
351
|
+
})();
|
|
352
|
+
const VERSION_FIELD = /^[+-]\s*"?(version|releaseTag|brainVersion|softwareVersion|tag)"?\s*[:=]/i;
|
|
353
|
+
const isVersionOnly = (sha) => {
|
|
354
|
+
const d = git(root, ['show', '--unified=0', '--format=', sha, '--', ...paths]);
|
|
355
|
+
if (!d.ok || !d.out) return false;
|
|
356
|
+
const changed = d.out.split('\n')
|
|
357
|
+
.filter((l) => /^[+-]/.test(l) && !/^(\+\+\+|---)/.test(l));
|
|
358
|
+
// No changed lines at all (pure rename/mode) is not evidence of a decision change either, but
|
|
359
|
+
// be conservative: only EXEMPT when there is at least one line and every one is a version field.
|
|
360
|
+
return changed.length > 0 && changed.every((l) => VERSION_FIELD.test(l));
|
|
361
|
+
};
|
|
362
|
+
const substantive = shas.filter((s) => !isVersionOnly(s));
|
|
363
|
+
const commits = substantive.length;
|
|
364
|
+
|
|
365
|
+
const codeR = substantive.length
|
|
366
|
+
? git(root, ['show', '-s', '--format=%ad', '--date=short', substantive[0]])
|
|
367
|
+
: null;
|
|
368
|
+
const codeDate = codeR?.ok && codeR.out ? codeR.out.split('\n')[0] : null;
|
|
369
|
+
|
|
370
|
+
let anchorDate = doc.date;
|
|
371
|
+
if (resolvedFloor && git(root, ['merge-base', '--is-ancestor', doc.sha, resolvedFloor]).ok) {
|
|
372
|
+
const floorR = git(root, ['show', '-s', '--format=%ad', '--date=short', resolvedFloor]);
|
|
373
|
+
if (floorR.ok && floorR.out) anchorDate = floorR.out.split('\n')[0];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
let days = 0;
|
|
377
|
+
if (codeDate) {
|
|
378
|
+
const d0 = Date.parse(`${anchorDate}T00:00:00Z`);
|
|
379
|
+
const d1 = Date.parse(`${codeDate}T00:00:00Z`);
|
|
380
|
+
if (Number.isFinite(d0) && Number.isFinite(d1)) days = Math.max(0, Math.round((d1 - d0) / 86400000));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let state = 'current';
|
|
384
|
+
if (commits > 0) {
|
|
385
|
+
state = (commits >= DRIFT_COMMITS_STALE || days >= DRIFT_DAYS_STALE) ? 'presumed-stale' : 'lagging';
|
|
386
|
+
}
|
|
387
|
+
return { state, commits, days, docDate: doc.date, docSha: doc.sha, codeDate, paths };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── currency log ────────────────────────────────────────────────────────────────────────────────
|
|
391
|
+
// A *why* is judged on STRUCTURE, never on meaning — a gate that claimed to judge sincerity would
|
|
392
|
+
// be a fifth lie-shaped status. It must carry at least one RESOLVABLE referent. This raises the
|
|
393
|
+
// cost of filler; it does not detect insincerity and must never claim to. What it reliably kills is
|
|
394
|
+
// the empty why ("updated docs"), which is the failure that actually happens.
|
|
395
|
+
export function countReferents(root, why) {
|
|
396
|
+
const refs = [];
|
|
397
|
+
for (const m of why.matchAll(/(?:^|[\s`'"(])((?:[\w.-]+\/)+[\w.-]+\.[A-Za-z0-9]+)/g)) {
|
|
398
|
+
if (fs.existsSync(path.join(root, m[1]))) refs.push({ kind: 'path', value: m[1] });
|
|
399
|
+
}
|
|
400
|
+
for (const m of why.matchAll(/\b(ADR|DDD|SEC)-0*(\d{1,4})\b/gi)) {
|
|
401
|
+
refs.push({ kind: 'doc-id', value: `${m[1].toUpperCase()}-${m[2]}` });
|
|
402
|
+
}
|
|
403
|
+
for (const m of why.matchAll(/\b([0-9a-f]{7,40})\b/g)) {
|
|
404
|
+
if (hasCommits(root) && git(root, ['cat-file', '-e', `${m[1]}^{commit}`]).ok) refs.push({ kind: 'sha', value: m[1] });
|
|
405
|
+
}
|
|
406
|
+
for (const m of why.matchAll(/(?:^|\s)#(\d{1,6})\b/g)) refs.push({ kind: 'issue', value: `#${m[1]}` });
|
|
407
|
+
for (const m of why.matchAll(/\d{4}-\d{2}-\d{2}/g)) {
|
|
408
|
+
if (/["'“”‘’*]/.test(why)) refs.push({ kind: 'dated-quote', value: m[0] });
|
|
409
|
+
}
|
|
410
|
+
return refs;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function parseCurrencyLog(root, text) {
|
|
414
|
+
const lines = text.split('\n');
|
|
415
|
+
let inSection = false;
|
|
416
|
+
const rows = [];
|
|
417
|
+
for (const ln of lines) {
|
|
418
|
+
const h = ln.match(/^#{1,6}\s+(.*)$/);
|
|
419
|
+
if (h) { inSection = /^currency log\b/i.test(h[1].trim()); continue; }
|
|
420
|
+
if (!inSection) continue;
|
|
421
|
+
if (!ln.trim().startsWith('|')) continue;
|
|
422
|
+
const cells = ln.split('|').slice(1, -1).map((c) => c.trim());
|
|
423
|
+
if (cells.length < 3) continue;
|
|
424
|
+
if (/^-{2,}$/.test(cells[0].replace(/[: ]/g, '-'))) continue;
|
|
425
|
+
if (/^date$/i.test(cells[0])) continue;
|
|
426
|
+
const [date, what, ...rest] = cells;
|
|
427
|
+
const why = rest.join(' | ');
|
|
428
|
+
rows.push({ date, what, why, referents: countReferents(root, why) });
|
|
429
|
+
}
|
|
430
|
+
return { present: rows.length > 0 || /^#{1,6}\s+currency log\b/im.test(text), rows };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ── evaluation ──────────────────────────────────────────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
const BLOCK = 'block';
|
|
436
|
+
const WARN = 'warn';
|
|
437
|
+
|
|
438
|
+
export function listDocs(root, dirs = DEFAULT_DIRS) {
|
|
439
|
+
const out = [];
|
|
440
|
+
for (const d of dirs) {
|
|
441
|
+
const abs = path.join(root, d);
|
|
442
|
+
if (!fs.existsSync(abs)) continue;
|
|
443
|
+
for (const name of fs.readdirSync(abs).sort()) {
|
|
444
|
+
if (!/^\d{4}.*\.md$/.test(name)) continue;
|
|
445
|
+
out.push(path.posix.join(d.split(path.sep).join('/'), name));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return out;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// A changed-scope check must discover its documents before doing the expensive git-derived
|
|
452
|
+
// evaluation. The declarations are intentionally read from the document itself here; resolving
|
|
453
|
+
// every governed blob and caller for every historical document defeats the purpose of --changed.
|
|
454
|
+
// This matcher mirrors the supported declaration shapes (literal paths and the simple * / ** /
|
|
455
|
+
// ? globs used in this repository) and is conservative for directory declarations.
|
|
456
|
+
function declarationIntersectsTouched(declaration, touched) {
|
|
457
|
+
let pattern = String(declaration || '').trim().replace(/^\.\//, '');
|
|
458
|
+
if (!pattern) return false;
|
|
459
|
+
const directory = pattern.endsWith('/');
|
|
460
|
+
pattern = pattern.replace(/\/+$/, '');
|
|
461
|
+
if (!/[*?\[\]]/.test(pattern)) {
|
|
462
|
+
return [...touched].some((file) => file === pattern || (directory && file.startsWith(`${pattern}/`)));
|
|
463
|
+
}
|
|
464
|
+
let source = '^';
|
|
465
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
466
|
+
const char = pattern[i];
|
|
467
|
+
if (char === '*' && pattern[i + 1] === '*') {
|
|
468
|
+
source += '.*';
|
|
469
|
+
i += 1;
|
|
470
|
+
} else if (char === '*') source += '[^/]*';
|
|
471
|
+
else if (char === '?') source += '[^/]';
|
|
472
|
+
else if (char === '[') {
|
|
473
|
+
const end = pattern.indexOf(']', i + 1);
|
|
474
|
+
if (end > i + 1) {
|
|
475
|
+
source += pattern.slice(i, end + 1);
|
|
476
|
+
i = end;
|
|
477
|
+
} else source += '\\[';
|
|
478
|
+
} else source += char.replace(/[\\^$+?.()|{}]/g, '\\$&');
|
|
479
|
+
}
|
|
480
|
+
const matcher = new RegExp(`${source}$`);
|
|
481
|
+
return [...touched].some((file) => matcher.test(file));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Return only documents whose own file or governed declaration intersects a candidate diff.
|
|
486
|
+
* `evaluateDoc` remains the authority for the final derived result; this function only narrows
|
|
487
|
+
* the set of documents that need that work in a `--changed` check.
|
|
488
|
+
*/
|
|
489
|
+
export function changedDocumentCandidates(root, touched, dirs = DEFAULT_DIRS) {
|
|
490
|
+
const changed = touched instanceof Set ? touched : new Set(touched || []);
|
|
491
|
+
return listDocs(root, dirs).filter((rel) => {
|
|
492
|
+
if (changed.has(rel)) return true;
|
|
493
|
+
const text = fs.readFileSync(path.join(root, rel), 'utf8');
|
|
494
|
+
const frontmatter = parseFrontmatter(text);
|
|
495
|
+
const declared = frontmatter.keys.governs;
|
|
496
|
+
const governs = Array.isArray(declared) ? declared : declared ? [declared] : [];
|
|
497
|
+
return governs.some((entry) => declarationIntersectsTouched(entry, changed));
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function evaluateDoc(root, rel, opts = {}) {
|
|
502
|
+
const { checkWiring = true } = opts;
|
|
503
|
+
const abs = path.join(root, rel);
|
|
504
|
+
const text = fs.readFileSync(abs, 'utf8');
|
|
505
|
+
const fm = parseFrontmatter(text);
|
|
506
|
+
const k = fm.keys;
|
|
507
|
+
|
|
508
|
+
const CONVENTION_KEYS = ['status', 'date', 'updated', 'impl', 'governs', 'verified', 'verified_digest', 'reviewed_digest'];
|
|
509
|
+
const declared = CONVENTION_KEYS.filter((key) => k[key] !== undefined);
|
|
510
|
+
// A document predating the convention carries NONE of its keys. That is a derived property of the
|
|
511
|
+
// file, not a hardcoded list of filenames — a new doc written to the convention can never be
|
|
512
|
+
// mistaken for one, and nobody has to maintain a grandfather roster.
|
|
513
|
+
const legacy = fm.present && declared.length === 0;
|
|
514
|
+
|
|
515
|
+
const findings = [];
|
|
516
|
+
const add = (level, code, message, extra = {}) => findings.push({ level, code, message, ...extra });
|
|
517
|
+
|
|
518
|
+
const tracked = hasCommits(root) && git(root, ['ls-files', '--error-unmatch', '--', rel]).ok;
|
|
519
|
+
const dirty = !tracked || isDirty(root, rel);
|
|
520
|
+
const docCommit = tracked ? lastCommit(root, rel) : null;
|
|
521
|
+
const addedCommit = tracked ? firstCommit(root, rel) : null;
|
|
522
|
+
|
|
523
|
+
const doc = {
|
|
524
|
+
file: rel,
|
|
525
|
+
id: k.id ?? null,
|
|
526
|
+
legacy,
|
|
527
|
+
tracked,
|
|
528
|
+
dirty,
|
|
529
|
+
status: k.status ?? null,
|
|
530
|
+
date: k.date ?? null,
|
|
531
|
+
updated: k.updated ?? null,
|
|
532
|
+
updatedPinned: k.updated_pinned === true || k.updated_pinned === 'true',
|
|
533
|
+
implStored: k.impl ?? null,
|
|
534
|
+
verifiedStamp: k.verified ?? null,
|
|
535
|
+
verifiedDigestStored: k.verified_digest ?? null,
|
|
536
|
+
governsDeclared: Array.isArray(k.governs) ? k.governs : (k.governs ? [String(k.governs)] : []),
|
|
537
|
+
docCommit,
|
|
538
|
+
addedCommit,
|
|
539
|
+
findings,
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
if (!fm.present) {
|
|
543
|
+
add(WARN, 'no-frontmatter', 'no YAML frontmatter — cannot carry stamps; not currency-checkable');
|
|
544
|
+
doc.impl = 'unknown';
|
|
545
|
+
doc.drift = { state: 'not-applicable', commits: 0, days: 0 };
|
|
546
|
+
doc.digest = { computed: null, stored: null, match: null };
|
|
547
|
+
return doc;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ── stamps ────────────────────────────────────────────────────────────────────────────────────
|
|
551
|
+
// Legacy docs are reported and never blocked. Retro-stamping twelve documents is not a push-time
|
|
552
|
+
// job, and a gate whose first act is to block twelve pre-existing violations gets disabled.
|
|
553
|
+
const stampLevel = legacy ? WARN : BLOCK;
|
|
554
|
+
if (legacy) add(WARN, 'legacy-unstamped', `frontmatter carries none of ${CONVENTION_KEYS.join('/')} — predates the convention; reported, never blocked`);
|
|
555
|
+
|
|
556
|
+
// The enum is judged on the LEADING TOKEN. `status: Proposed (awaiting Stuart's approval)` is a
|
|
557
|
+
// Proposed decision with a note attached, and flagging it would be a false positive on a doc doing
|
|
558
|
+
// nothing wrong — the parenthetical is how a human says something the enum has no room for.
|
|
559
|
+
const statusWord = doc.status ? (doc.status.match(/^[A-Za-z]+/)?.[0] ?? doc.status) : null;
|
|
560
|
+
if (!doc.status) add(stampLevel, 'missing-status', 'no `status:` — the decision state is unstated in the machine-readable half');
|
|
561
|
+
else if (!RUV_STATUSES.includes(statusWord)) {
|
|
562
|
+
// `Implemented` is the specific historical case: a fifth value invented on rUv's key, saying
|
|
563
|
+
// something about CODE on a key that means something about a DECISION.
|
|
564
|
+
add(WARN, 'status-not-in-enum', `status: ${doc.status} is not one of ${RUV_STATUSES.join('|')} (rUv's enum)`
|
|
565
|
+
+ (/^implemented$/i.test(doc.status) ? ' — this is the code-state axis; it belongs on `impl:`, which is derived' : ''));
|
|
566
|
+
}
|
|
567
|
+
if (!doc.date) add(stampLevel, 'missing-created', 'no `date:` — creation is unstamped');
|
|
568
|
+
if (!doc.updated) add(stampLevel, 'missing-updated', 'no `updated:` — last-change is unstamped (NOT inferred from `date:`)');
|
|
569
|
+
|
|
570
|
+
if (!tracked) add(WARN, 'no-git-history', 'not committed — every git-derived value is unavailable for this document');
|
|
571
|
+
|
|
572
|
+
// Stamp-vs-git. Suspended entirely when the working tree is dirty: the last COMMIT date is not
|
|
573
|
+
// the date of the current CONTENTS, so accusing a doc mid-edit is a guaranteed false positive.
|
|
574
|
+
if (doc.updated && docCommit && !dirty) {
|
|
575
|
+
const d = Date.parse(`${doc.updated}T00:00:00Z`);
|
|
576
|
+
const g = Date.parse(`${docCommit.date}T00:00:00Z`);
|
|
577
|
+
if (Number.isFinite(d) && Number.isFinite(g)) {
|
|
578
|
+
const deltaDays = Math.round((g - d) / 86400000);
|
|
579
|
+
if (deltaDays >= 1) {
|
|
580
|
+
// `updated_pinned: true` — the date is a HISTORICAL RECORD, not a currency stamp.
|
|
581
|
+
//
|
|
582
|
+
// Some documents record WHEN SOMETHING HAPPENED, not when the file was last edited.
|
|
583
|
+
// ADR-050's `updated: 2026-08-02` is an incident cutoff and is asserted by
|
|
584
|
+
// tests/unit/fix-workstream-guidance. A blanket "make the stamp match the last commit"
|
|
585
|
+
// rule cannot tell those apart, so it silently rewrote the pinned date — putting two gates
|
|
586
|
+
// into direct contradiction, one demanding the pinned value and one demanding the commit
|
|
587
|
+
// date, with no way to satisfy both. The document itself is the only thing that knows
|
|
588
|
+
// which kind of date it carries, so it now says so, and --fix must never overwrite it.
|
|
589
|
+
if (doc.updatedPinned) {
|
|
590
|
+
add(WARN, 'stamp-pinned',
|
|
591
|
+
`updated: ${doc.updated} is PINNED (updated_pinned: true) and deliberately does not track the last commit ${docCommit.date} — it records an event, not the edit`);
|
|
592
|
+
} else add(BLOCK, 'stamp-lags-doc',
|
|
593
|
+
`updated: ${doc.updated} but the document's own last commit is ${docCommit.date} (${docCommit.sha.slice(0, 8)}) — edited without touching its own stamp`);
|
|
594
|
+
} else if (deltaDays <= -1) {
|
|
595
|
+
// Typed ahead. WARN only: a stamp dated today on a change not yet committed is CORRECT, and
|
|
596
|
+
// blocking it would make the gate fire on the very act of doing the right thing.
|
|
597
|
+
add(WARN, 'stamp-ahead-of-doc', `updated: ${doc.updated} is ahead of the last commit ${docCommit.date} — expected while the change is uncommitted; a violation only if it stays`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
} else if (doc.updated && dirty) {
|
|
601
|
+
add(WARN, 'stamp-unverifiable-dirty', 'working tree modified — stamp-vs-git comparison suspended rather than guessed');
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (doc.date && addedCommit && doc.date !== addedCommit.date) {
|
|
605
|
+
add(WARN, 'created-differs-from-first-commit', `date: ${doc.date} but git says the file was added ${addedCommit.date} — the created stamp is immutable, so this is reported, never auto-corrected`);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ── governed set ──────────────────────────────────────────────────────────────────────────────
|
|
609
|
+
const governed = resolveGoverned(root, doc.governsDeclared);
|
|
610
|
+
doc.governed = governed;
|
|
611
|
+
|
|
612
|
+
for (const g of governed) {
|
|
613
|
+
if (g.type === 'tree') {
|
|
614
|
+
add(BLOCK, 'governs-directory',
|
|
615
|
+
`governs: "${g.from}" is a DIRECTORY — its tree object changes when any file under it changes, mass-expiring unrelated verifications. Use a glob (e.g. "${g.path}/*.md") so the set expands to files.`);
|
|
616
|
+
} else if (g.type === 'missing') {
|
|
617
|
+
// Severity depends on what the document CLAIMS. A Proposed ADR naming the artifact it intends
|
|
618
|
+
// to create is the `unbuilt` state working exactly as designed — blocking it would make this
|
|
619
|
+
// gate fire on the act of writing a design doc, which is the wolf-cry that gets it removed.
|
|
620
|
+
// A document that claims built/wired/verified over a path that is gone is a different thing:
|
|
621
|
+
// the claim is refuted and the digest is uncomputable.
|
|
622
|
+
const claims = doc.verifiedDigestStored || (doc.implStored && rank(doc.implStored) >= rank('built'));
|
|
623
|
+
add(claims ? BLOCK : WARN, 'governs-unresolvable',
|
|
624
|
+
`governs: "${g.from}" does not exist at HEAD and is not on disk`
|
|
625
|
+
+ (claims ? ' — but this document claims it is built or verified; the claim is refuted and no digest can be computed' : ' — not yet created, so this document derives `impl: unbuilt`'));
|
|
626
|
+
} else if (g.type === 'untracked') {
|
|
627
|
+
add(WARN, 'governs-untracked', `governs: "${g.path}" exists on disk but is not tracked by git — it can never contribute to a digest, so this document can never reach verified while it is listed`);
|
|
628
|
+
} else if (g.type === 'no-match') {
|
|
629
|
+
add(WARN, 'governs-glob-empty', `governs: "${g.from}" matched no tracked file`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (!doc.legacy && doc.governsDeclared.length === 0) {
|
|
633
|
+
add(WARN, 'no-governs', 'no `governs:` set — this document makes no machine-checkable claim about code, so it can never be verified and can never be found stale');
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ── impl (derived; the stored value is a claim to be checked, never an input) ──────────────────
|
|
637
|
+
const derived = deriveImpl(root, governed, { checkWiring,
|
|
638
|
+
callerCache: opts.callerCache, callerLookup: opts.callerLookup });
|
|
639
|
+
const digest = computeDigest(root, rel, text, governed);
|
|
640
|
+
const storedDigest = doc.verifiedDigestStored;
|
|
641
|
+
const digestMatch = digest.digest && storedDigest ? digest.digest === storedDigest : null;
|
|
642
|
+
|
|
643
|
+
let impl = derived.impl;
|
|
644
|
+
if (impl === 'wired' && storedDigest) impl = digestMatch ? 'verified' : 'verification-expired';
|
|
645
|
+
doc.impl = impl;
|
|
646
|
+
doc.implPerPath = derived.perPath;
|
|
647
|
+
doc.digest = { computed: digest.digest, stored: storedDigest ?? null, match: digestMatch, reason: digest.reason ?? null };
|
|
648
|
+
|
|
649
|
+
if (derived.unwired?.length) {
|
|
650
|
+
add(WARN, 'built-not-wired',
|
|
651
|
+
`no non-test, non-doc caller found for: ${derived.unwired.join(', ')} — built but apparently unreachable. WARNS only: hook-invoked and dynamically dispatched code is wired without a greppable caller.`);
|
|
652
|
+
}
|
|
653
|
+
if (storedDigest && digestMatch === false) {
|
|
654
|
+
add(WARN, 'verification-expired',
|
|
655
|
+
`verified_digest: ${storedDigest} no longer recomputes (now ${digest.digest ?? 'uncomputable'}) — the governed code or the document's own normative body has moved since anyone read them together`);
|
|
656
|
+
}
|
|
657
|
+
if (storedDigest && !digest.digest) {
|
|
658
|
+
add(WARN, 'verification-uncomputable', `a verified_digest is stamped but the digest cannot be recomputed: ${digest.reason}`);
|
|
659
|
+
}
|
|
660
|
+
if (doc.verifiedStamp && !storedDigest) {
|
|
661
|
+
add(BLOCK, 'verified-without-digest', 'a `verified:` date with no `verified_digest:` is a typeable stamp — exactly the class of claim this convention exists to replace');
|
|
662
|
+
}
|
|
663
|
+
if (doc.verifiedStamp && governed.length === 0) {
|
|
664
|
+
add(BLOCK, 'verified-without-governs', 'verified against an empty governed set — verifies nothing and shows green');
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ADR-0024 applied to this file's own frontmatter: a stored status must never outrank what the
|
|
668
|
+
// artifact supports. Overclaiming BLOCKS; understating WARNS (it is stale, not a lie).
|
|
669
|
+
if (doc.implStored) {
|
|
670
|
+
const storedRank = rank(doc.implStored);
|
|
671
|
+
const derivedRank = rank(impl === 'verification-expired' ? 'built' : impl);
|
|
672
|
+
if (!IMPL_LADDER.includes(doc.implStored)) {
|
|
673
|
+
add(WARN, 'impl-unknown-value', `impl: ${doc.implStored} is not one of ${IMPL_LADDER.join('|')}`);
|
|
674
|
+
} else if (storedRank > derivedRank) {
|
|
675
|
+
add(BLOCK, 'impl-overclaimed',
|
|
676
|
+
`impl: ${doc.implStored} but the artifact only supports "${impl}"${derived.missing?.length ? ` (missing: ${derived.missing.join(', ')})` : ''}${derived.unwired?.length ? ` (unwired: ${derived.unwired.join(', ')})` : ''} — a status the artifact refutes is void (ADR-0024)`);
|
|
677
|
+
} else if (storedRank < derivedRank) {
|
|
678
|
+
add(WARN, 'impl-understated', `impl: ${doc.implStored} but the artifact now supports "${impl}" — the stored value has fallen behind the code`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ── drift ─────────────────────────────────────────────────────────────────────────────────────
|
|
683
|
+
// A review records examination and findings, NOT normative agreement, acceptance, or verified
|
|
684
|
+
// implementation. Use the existing digest: frontmatter and the Currency log already exclude
|
|
685
|
+
// their own metadata, while every governed byte and normative claim remains covered unchanged.
|
|
686
|
+
// Require an explicit review row naming this digest and a governed-source referent. This checks
|
|
687
|
+
// evidence structure, never sincerity; changing a date or merely dirtying a doc proves no review.
|
|
688
|
+
const log = parseCurrencyLog(root, text);
|
|
689
|
+
const reviewedDigest = k.reviewed_digest || null;
|
|
690
|
+
const reviewMatch = Boolean(reviewedDigest && digest.digest && reviewedDigest === digest.digest);
|
|
691
|
+
const reviewRow = reviewedDigest && log.rows.find((row) => /^\d{4}-\d{2}-\d{2}$/.test(row.date)
|
|
692
|
+
&& /\breview(?:ed)?\b/i.test(row.what)
|
|
693
|
+
&& `${row.what} ${row.why}`.split(/[^a-f0-9]+/i).includes(reviewedDigest)
|
|
694
|
+
&& row.referents.some((ref) => ref.kind === 'path'
|
|
695
|
+
&& governed.some((g) => g.resolved && g.path === ref.value)));
|
|
696
|
+
const reviewed = reviewMatch && Boolean(reviewRow);
|
|
697
|
+
doc.review = { stored: reviewedDigest, computed: digest.digest, match: reviewMatch,
|
|
698
|
+
recorded: Boolean(reviewRow), current: reviewed };
|
|
699
|
+
if (reviewedDigest && !reviewed) {
|
|
700
|
+
add(WARN, 'review-unsubstantiated', !reviewMatch
|
|
701
|
+
? 'reviewed_digest does not bind the current document and governed bytes — no current review is established'
|
|
702
|
+
: 'reviewed_digest requires a dated Currency log review row naming this digest and a governed-source path — no current review is established');
|
|
703
|
+
}
|
|
704
|
+
const drift = deriveDrift(root, rel, governed, { floorSha: opts.driftFloor });
|
|
705
|
+
doc.drift = drift;
|
|
706
|
+
// A DECISION THAT IS NOT IN FORCE CANNOT DRIFT (ADR-056, 2026-07-27). Rejected / Superseded /
|
|
707
|
+
// Deprecated documents describe a path NOT taken, or one another document has since taken over.
|
|
708
|
+
// Their `governs:` set names code that was never meant to implement them, so "the governed code
|
|
709
|
+
// moved and nobody re-checked" is not a defect — there is nothing to re-check against. Blocking a
|
|
710
|
+
// push over it is a textbook false positive, and this file's own header names false positives as
|
|
711
|
+
// the DESIGNED failure mode: "a gate that cries wolf gets bypassed and a bypassed gate protects
|
|
712
|
+
// nothing." Found by ADR-046 and ADR-047 — both Rejected — sitting in the blocking set with
|
|
713
|
+
// nothing an author could ever do to clear them short of deleting the record of a rejected idea.
|
|
714
|
+
// Still REPORTED, never silent: a superseded document whose code moves is worth seeing, and the
|
|
715
|
+
// day someone un-rejects it the finding returns to BLOCK on its own.
|
|
716
|
+
const notInForce = /^(rejected|superseded|deprecated)$/i.test(statusWord || '');
|
|
717
|
+
if (drift.state === 'presumed-stale') {
|
|
718
|
+
add(notInForce || reviewed ? WARN : BLOCK, 'presumed-stale',
|
|
719
|
+
`governed code moved ${drift.commits} commit(s) (${drift.days}d) after the document's own last commit (${drift.docDate}). Paths: ${drift.paths.join(', ')}`
|
|
720
|
+
+ (reviewed ? ' — exact current bytes have a recorded review; historical drift remains visible, but does not establish missing review. This is NOT normative agreement or verification.'
|
|
721
|
+
: notInForce ? ` — reported, NOT blocked: status "${statusWord}" means this decision is not in force, so it cannot drift from code it never governed`
|
|
722
|
+
: ' — no source-bound review is recorded since it moved.'));
|
|
723
|
+
} else if (drift.state === 'lagging') {
|
|
724
|
+
add(WARN, 'lagging', `governed code moved ${drift.commits} commit(s) after the document — normal within a session; reported, not blocked`);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ── currency log ──────────────────────────────────────────────────────────────────────────────
|
|
728
|
+
doc.currencyLog = log;
|
|
729
|
+
if (!doc.legacy && !log.present) {
|
|
730
|
+
add(WARN, 'no-currency-log', 'no `## Currency log` section — the *why* of each change is unrecorded');
|
|
731
|
+
}
|
|
732
|
+
for (const row of log.rows) {
|
|
733
|
+
if (!row.referents.length) {
|
|
734
|
+
add(WARN, 'why-without-referent',
|
|
735
|
+
`currency-log row ${row.date}: the *why* carries no resolvable referent (path / ADR id / commit sha / issue / dated quote) — "${row.why.slice(0, 60)}"`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
if (doc.updated && log.rows.length && log.rows[0].date && log.rows[0].date !== doc.updated) {
|
|
739
|
+
add(WARN, 'log-head-differs-from-updated', `updated: ${doc.updated} but the newest currency-log row is dated ${log.rows[0].date}`);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return doc;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
export function evaluate(root = REPO_ROOT, opts = {}) {
|
|
746
|
+
const dirs = opts.dirs ?? DEFAULT_DIRS;
|
|
747
|
+
// This cache belongs to exactly one evaluation. Each distinct governed path is searched once,
|
|
748
|
+
// even when several ADRs name it; a later call creates a fresh map and sees current source.
|
|
749
|
+
const invocation = { ...opts, callerCache: new Map(), callerLookup: opts.callerLookup ?? findCallers };
|
|
750
|
+
const docs = (opts.files ?? listDocs(root, dirs)).map((rel) => evaluateDoc(root, rel, invocation));
|
|
751
|
+
return { root, docs };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ── --fix : backfill ONLY what git can prove ────────────────────────────────────────────────────
|
|
755
|
+
// The anti-goal is explicit and absolute: a reconstructed stamp is a fabricated number on a
|
|
756
|
+
// user-facing surface. Every value written here comes back out of `git log`, is labelled
|
|
757
|
+
// `*_source: derived-from-git`, and when git cannot answer, NOTHING is written and the gap is
|
|
758
|
+
// reported. `status:` is never written — it is a social fact no script may set. `impl:` is never
|
|
759
|
+
// written — it is derived on every read, and a stored copy is the lie this tool exists to catch.
|
|
760
|
+
export function planFix(root, doc) {
|
|
761
|
+
const changes = [];
|
|
762
|
+
const blocked = [];
|
|
763
|
+
if (!doc.tracked) { blocked.push({ code: 'no-git-history', why: 'file has no commits — no date can be derived, and none will be invented' }); return { changes, blocked }; }
|
|
764
|
+
|
|
765
|
+
if (!doc.date) {
|
|
766
|
+
if (doc.addedCommit) changes.push({ key: 'date', value: doc.addedCommit.date, source: 'derived-from-git', evidence: `git log --follow --diff-filter=A ${doc.file} → ${doc.addedCommit.sha.slice(0, 8)}` });
|
|
767
|
+
else blocked.push({ code: 'missing-created', why: 'git reports no adding commit for this path' });
|
|
768
|
+
}
|
|
769
|
+
if (!doc.updated) {
|
|
770
|
+
if (doc.dirty) blocked.push({ code: 'missing-updated', why: 'working tree is modified — the last commit date is not the date of the current contents' });
|
|
771
|
+
else if (doc.docCommit) changes.push({ key: 'updated', value: doc.docCommit.date, source: 'derived-from-git', evidence: `git log -1 ${doc.file} → ${doc.docCommit.sha.slice(0, 8)}` });
|
|
772
|
+
else blocked.push({ code: 'missing-updated', why: 'git reports no commit touching this path' });
|
|
773
|
+
} else if (!doc.updatedPinned && doc.findings.some((f) => f.code === 'stamp-lags-doc') && doc.docCommit && !doc.dirty) {
|
|
774
|
+
changes.push({ key: 'updated', value: doc.docCommit.date, source: 'derived-from-git', replaces: doc.updated, evidence: `git log -1 ${doc.file} → ${doc.docCommit.sha.slice(0, 8)}` });
|
|
775
|
+
}
|
|
776
|
+
return { changes, blocked };
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export function applyFix(root, doc, plan) {
|
|
780
|
+
if (!plan.changes.length) return { written: false, changes: [] };
|
|
781
|
+
const abs = path.join(root, doc.file);
|
|
782
|
+
const text = fs.readFileSync(abs, 'utf8');
|
|
783
|
+
const fm = parseFrontmatter(text);
|
|
784
|
+
if (!fm.present) return { written: false, changes: [], skipped: 'no frontmatter to write into' };
|
|
785
|
+
|
|
786
|
+
const lines = text.split('\n');
|
|
787
|
+
const head = lines.slice(0, fm.endLine); // '---' + keys
|
|
788
|
+
const tail = lines.slice(fm.endLine); // closing '---' onward
|
|
789
|
+
|
|
790
|
+
for (const c of plan.changes) {
|
|
791
|
+
const keyRe = new RegExp(`^${c.key}\\s*:`);
|
|
792
|
+
const srcRe = new RegExp(`^${c.key}_source\\s*:`);
|
|
793
|
+
const idx = head.findIndex((l) => keyRe.test(l));
|
|
794
|
+
const line = `${c.key}: ${c.value}`;
|
|
795
|
+
const srcLine = `${c.key}_source: ${c.source}`;
|
|
796
|
+
if (idx >= 0) head[idx] = line; else head.push(line);
|
|
797
|
+
const sIdx = head.findIndex((l) => srcRe.test(l));
|
|
798
|
+
if (sIdx >= 0) head[sIdx] = srcLine; else head.splice((idx >= 0 ? idx : head.length - 1) + 1, 0, srcLine);
|
|
799
|
+
}
|
|
800
|
+
fs.writeFileSync(abs, [...head, ...tail].join('\n'));
|
|
801
|
+
return { written: true, changes: plan.changes };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ── reporting ───────────────────────────────────────────────────────────────────────────────────
|
|
805
|
+
|
|
806
|
+
const pad = (s, n) => String(s ?? '').padEnd(n).slice(0, n);
|
|
807
|
+
|
|
808
|
+
export function renderReport({ root, docs }) {
|
|
809
|
+
const out = [];
|
|
810
|
+
out.push(`document currency — ${docs.length} document(s) under ${root}`);
|
|
811
|
+
out.push('');
|
|
812
|
+
out.push(`${pad('document', 46)} ${pad('status', 12)} ${pad('created', 11)} ${pad('updated', 11)} ${pad('impl', 21)} ${pad('drift', 15)} findings`);
|
|
813
|
+
out.push('─'.repeat(140));
|
|
814
|
+
for (const d of docs) {
|
|
815
|
+
const b = d.findings.filter((f) => f.level === BLOCK).length;
|
|
816
|
+
const w = d.findings.filter((f) => f.level === WARN).length;
|
|
817
|
+
out.push([
|
|
818
|
+
pad(d.file.replace(/^docs\//, ''), 46),
|
|
819
|
+
pad(d.status ?? '—', 12),
|
|
820
|
+
pad(d.date ?? '—', 11),
|
|
821
|
+
pad(d.updated ?? '—', 11),
|
|
822
|
+
pad(d.impl ?? 'unknown', 21),
|
|
823
|
+
pad(d.drift?.state ?? '—', 15),
|
|
824
|
+
`${b ? `${b} BLOCK` : ''}${b && w ? ' · ' : ''}${w ? `${w} warn` : ''}${!b && !w ? 'clean' : ''}`,
|
|
825
|
+
].join(' '));
|
|
826
|
+
}
|
|
827
|
+
out.push('');
|
|
828
|
+
|
|
829
|
+
const withFindings = docs.filter((d) => d.findings.length);
|
|
830
|
+
if (withFindings.length) {
|
|
831
|
+
out.push('detail');
|
|
832
|
+
out.push('─'.repeat(140));
|
|
833
|
+
for (const d of withFindings) {
|
|
834
|
+
out.push(` ${d.file}`);
|
|
835
|
+
for (const f of d.findings) out.push(` [${f.level === BLOCK ? 'BLOCK' : ' warn'}] ${f.code}: ${f.message}`);
|
|
836
|
+
out.push('');
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const blocks = docs.reduce((n, d) => n + d.findings.filter((f) => f.level === BLOCK).length, 0);
|
|
841
|
+
const warns = docs.reduce((n, d) => n + d.findings.filter((f) => f.level === WARN).length, 0);
|
|
842
|
+
const stamped = docs.filter((d) => d.updated).length;
|
|
843
|
+
out.push(`summary: ${docs.length} documents · ${stamped} carry an \`updated:\` stamp · ${docs.length - stamped} do not (absent, NOT inferred from \`date:\`)`);
|
|
844
|
+
out.push(` ${blocks} blocking finding(s) · ${warns} warning(s)`);
|
|
845
|
+
return out.join('\n');
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────────────────────────
|
|
849
|
+
|
|
850
|
+
function parseArgs(argv) {
|
|
851
|
+
const a = { mode: null, root: REPO_ROOT, dirs: null, strict: false, warnDrift: false, dryRun: false, json: false, changed: null, noWiring: false };
|
|
852
|
+
for (let i = 0; i < argv.length; i++) {
|
|
853
|
+
const v = argv[i];
|
|
854
|
+
if (v === '--report') a.mode = 'report';
|
|
855
|
+
else if (v === '--check') a.mode = 'check';
|
|
856
|
+
else if (v === '--fix') a.mode = 'fix';
|
|
857
|
+
else if (v === '--json') { a.json = true; if (!a.mode) a.mode = 'report'; }
|
|
858
|
+
else if (v === '--strict') a.strict = true;
|
|
859
|
+
else if (v === '--warn-drift') a.warnDrift = true;
|
|
860
|
+
else if (v === '--dry-run') a.dryRun = true;
|
|
861
|
+
else if (v === '--no-wiring') a.noWiring = true;
|
|
862
|
+
else if (v === '--root') a.root = path.resolve(argv[++i]);
|
|
863
|
+
else if (v === '--changed') a.changed = argv[++i];
|
|
864
|
+
else if (v === '--dir') a.dirs = argv[++i].split(',').map((s) => s.trim()).filter(Boolean);
|
|
865
|
+
}
|
|
866
|
+
if (!a.mode) a.mode = 'report';
|
|
867
|
+
return a;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Which findings actually stop a push. `--strict` promotes the judgement-shaped warnings; nothing
|
|
871
|
+
// promotes `built-not-wired`, whose false-negative rate is real and known.
|
|
872
|
+
export function blockingFindings(docs, { strict = false, warnDrift = false, scope = null } = {}) {
|
|
873
|
+
const out = [];
|
|
874
|
+
for (const d of docs) {
|
|
875
|
+
if (scope && !scope.has(d.file)) continue;
|
|
876
|
+
for (const f of d.findings) {
|
|
877
|
+
let level = f.level;
|
|
878
|
+
if (warnDrift && f.code === 'presumed-stale') level = WARN;
|
|
879
|
+
if (strict && (f.code === 'why-without-referent' || f.code === 'legacy-unstamped' || f.code === 'no-governs')) level = BLOCK;
|
|
880
|
+
if (strict && d.legacy && (f.code === 'missing-status' || f.code === 'missing-created' || f.code === 'missing-updated')) level = BLOCK;
|
|
881
|
+
if (level === BLOCK) out.push({ file: d.file, ...f });
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return out;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// A changed-scope gate must follow the Document -> Governed set relationship in both directions.
|
|
888
|
+
// Looking only for directly touched ADR filenames lets code invalidate an ADR without evaluating it.
|
|
889
|
+
export function changedDocumentScope(docs, touched) {
|
|
890
|
+
return new Set(docs
|
|
891
|
+
.filter((d) => touched.has(d.file)
|
|
892
|
+
|| (d.governed ?? []).some((g) => g.resolved && touched.has(g.path)))
|
|
893
|
+
.map((d) => d.file));
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
export function main(argv = process.argv.slice(2)) {
|
|
897
|
+
const a = parseArgs(argv);
|
|
898
|
+
if (!isGitRepo(a.root)) {
|
|
899
|
+
// FAIL OPEN, loudly. A gate that stops work because it could not read git is a gate people
|
|
900
|
+
// switch off — and every value here is git-derived, so without git there is nothing to say.
|
|
901
|
+
process.stderr.write(`[doc-currency] not a git repository: ${a.root} — nothing is derivable; passing.\n`);
|
|
902
|
+
return 0;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const dirs = a.dirs ?? DEFAULT_DIRS;
|
|
906
|
+
let touched = null;
|
|
907
|
+
let driftFloor = null;
|
|
908
|
+
if (a.changed) {
|
|
909
|
+
const diff = git(a.root, ['diff', '--name-only', `${a.changed}...HEAD`]);
|
|
910
|
+
if (!diff.ok) {
|
|
911
|
+
process.stderr.write('[doc-currency] candidate base cannot be resolved.\n');
|
|
912
|
+
return 1;
|
|
913
|
+
}
|
|
914
|
+
touched = new Set(diff.ok ? diff.out.split('\n').filter(Boolean) : []);
|
|
915
|
+
const base = git(a.root, ['merge-base', a.changed, 'HEAD']);
|
|
916
|
+
if (!base.ok || !base.out) return 1;
|
|
917
|
+
if (base.ok && base.out) driftFloor = base.out.split('\n')[0];
|
|
918
|
+
}
|
|
919
|
+
// In check mode, --changed is a candidate gate: narrow the expensive evaluation to documents
|
|
920
|
+
// that the diff can actually invalidate. Reports remain corpus-wide, while the blocking set
|
|
921
|
+
// still follows the same Document -> Governed path relationship as changedDocumentScope().
|
|
922
|
+
const files = touched && a.mode === 'check'
|
|
923
|
+
? changedDocumentCandidates(a.root, touched, dirs)
|
|
924
|
+
: undefined;
|
|
925
|
+
const result = evaluate(a.root, { dirs, checkWiring: !a.noWiring, driftFloor, files });
|
|
926
|
+
|
|
927
|
+
let scope = null;
|
|
928
|
+
if (touched) {
|
|
929
|
+
scope = changedDocumentScope(result.docs, touched);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (a.mode === 'fix') {
|
|
933
|
+
const applied = [];
|
|
934
|
+
for (const d of result.docs) {
|
|
935
|
+
const plan = planFix(a.root, d);
|
|
936
|
+
if (!plan.changes.length && !plan.blocked.length) continue;
|
|
937
|
+
for (const c of plan.changes) {
|
|
938
|
+
process.stdout.write(`${a.dryRun ? '[would fix]' : '[fixed] '} ${d.file}: ${c.key} = ${c.value} (derived-from-git; ${c.evidence})\n`);
|
|
939
|
+
}
|
|
940
|
+
for (const b of plan.blocked) {
|
|
941
|
+
process.stdout.write(`[cannot] ${d.file}: ${b.code} — ${b.why}. NOT invented.\n`);
|
|
942
|
+
}
|
|
943
|
+
if (!a.dryRun) applied.push(applyFix(a.root, d, plan));
|
|
944
|
+
}
|
|
945
|
+
process.stdout.write(`\n${a.dryRun ? 'dry run — nothing written' : `${applied.filter((x) => x.written).length} document(s) updated`}. Dates are only ever copied out of git; nothing is reconstructed.\n`);
|
|
946
|
+
return 0;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (a.json) {
|
|
950
|
+
process.stdout.write(JSON.stringify({
|
|
951
|
+
root: result.root,
|
|
952
|
+
recipe: DIGEST_RECIPE,
|
|
953
|
+
docs: result.docs.map((d) => ({
|
|
954
|
+
file: d.file, id: d.id, legacy: d.legacy, status: d.status, date: d.date, updated: d.updated,
|
|
955
|
+
implStored: d.implStored, impl: d.impl, digest: d.digest, review: d.review, drift: d.drift,
|
|
956
|
+
governs: d.governsDeclared, findings: d.findings,
|
|
957
|
+
})),
|
|
958
|
+
}, null, 2) + '\n');
|
|
959
|
+
} else {
|
|
960
|
+
process.stdout.write(renderReport(result) + '\n');
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (a.mode !== 'check') return 0;
|
|
964
|
+
|
|
965
|
+
const blocking = blockingFindings(result.docs, { strict: a.strict, warnDrift: a.warnDrift, scope });
|
|
966
|
+
if (!blocking.length) {
|
|
967
|
+
process.stderr.write('[doc-currency] no blocking currency violations.\n');
|
|
968
|
+
return 0;
|
|
969
|
+
}
|
|
970
|
+
process.stderr.write(`\n[doc-currency] ${blocking.length} BLOCKING violation(s):\n`);
|
|
971
|
+
for (const f of blocking) process.stderr.write(` ${f.file}: ${f.code} — ${f.message}\n`);
|
|
972
|
+
process.stderr.write('\nFix: `node scripts/doc-currency.mjs --fix` backfills the dates git can prove.\n'
|
|
973
|
+
+ 'Everything else is a claim only a human can make — including `status:`, which no script may set.\n');
|
|
974
|
+
return 1;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Entry-point guard. Compares REALPATHS on both sides: path.resolve() normalizes a path but does
|
|
978
|
+
// NOT follow symlinks, while import.meta.url IS symlink-resolved by Node. Through a symlink (npm bin
|
|
979
|
+
// shims, wrapper scripts, and every os.tmpdir() path on macOS) the two sides disagree, so main()
|
|
980
|
+
// never runs -- and because nothing throws, the process exits 0. A silent exit 0 is indistinguishable
|
|
981
|
+
// from "ran, found nothing", which is how prepareCorpusCandidate once reported SUCCESS with no
|
|
982
|
+
// archive on disk. Reproduced live 2026-07-27; pinned by tests/unit/entrypoint-symlink.test.mjs.
|
|
983
|
+
function isDirectInvocation() {
|
|
984
|
+
try {
|
|
985
|
+
if (!process.argv[1]) return false;
|
|
986
|
+
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
987
|
+
} catch {
|
|
988
|
+
return false;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const invokedDirectly = isDirectInvocation();
|
|
993
|
+
if (invokedDirectly) process.exit(main());
|