opencode discovers only AGENTS.md, Claude Code only CLAUDE.md — so with the content living in CLAUDE.md, every doc here was invisible to opencode. Swaps all five pairs (root, src/apps, src/databases, themes, plugins): AGENTS.md now holds the full text verbatim, CLAUDE.md is a short pointer. docs-check's permission-count regex now reads root AGENTS.md instead of CLAUDE.md; the workspace-level officer-dev/CLAUDE.md check is untouched, out of scope.
170 lines
6.3 KiB
TypeScript
170 lines
6.3 KiB
TypeScript
/*
|
|
Does the documentation still match the code?
|
|
|
|
── Why a script and not a habit ──
|
|
|
|
"Update the docs before a deploy" is a step that gets skipped at two in the morning, and its absence is
|
|
invisible. Every piece of drift found on 2026-08-22 was MECHANICAL — a count, a route name, a process
|
|
list — so it is checkable, and a check that exits non-zero cannot be quietly not done.
|
|
|
|
What it will not do is read prose and judge it. It looks only at claims with a machine-checkable
|
|
counterpart, which is a small fraction of the documentation and the fraction that rots fastest, because a
|
|
number is exactly the thing nobody re-derives while editing a sentence around it.
|
|
|
|
Real examples this was written from, both live at the time:
|
|
|
|
platform/CLAUDE.md "27 entries as of 2026-08-13" → the registry held 18
|
|
officer-dev/CLAUDE.md "Officer is twenty PM2 entries" → there were 13
|
|
Spotlight's settings list 9 rows against 21 sections, and one route that does not exist
|
|
|
|
── The rule for adding a check ──
|
|
|
|
A check must compare a documented claim against a value DERIVED AT RUN TIME, never against a second
|
|
hard-coded copy. A check with the answer written into it is one more place to drift, and it will drift in
|
|
the same edit that misses the doc.
|
|
*/
|
|
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import { join, resolve } from 'node:path';
|
|
|
|
type Finding = { file: string; claim: string; documented: string; actual: string };
|
|
type CheckResult = { findings: Finding[]; skipped?: string };
|
|
|
|
const ROOT = resolve(import.meta.dir, '..');
|
|
const WORKSPACE = resolve(ROOT, '..');
|
|
|
|
const read = (path: string): string | null => (existsSync(path) ? readFileSync(path, 'utf-8') : null);
|
|
|
|
/** Spelled-out numbers appear in prose more often than digits, and both need catching. */
|
|
const WORDS: Record<string, number> = {
|
|
ten: 10,
|
|
eleven: 11,
|
|
twelve: 12,
|
|
thirteen: 13,
|
|
fourteen: 14,
|
|
fifteen: 15,
|
|
sixteen: 16,
|
|
seventeen: 17,
|
|
eighteen: 18,
|
|
nineteen: 19,
|
|
twenty: 20,
|
|
'twenty-one': 21,
|
|
'twenty-two': 22,
|
|
'twenty-three': 23,
|
|
'twenty-four': 24,
|
|
'twenty-five': 25,
|
|
};
|
|
const asNumber = (raw: string): number | null => {
|
|
const digits = Number(raw);
|
|
if (Number.isFinite(digits)) return digits;
|
|
return WORDS[raw.toLowerCase()] ?? null;
|
|
};
|
|
|
|
/**
|
|
* A documented count, found by a pattern with one capture group, compared to the live value.
|
|
*
|
|
* Returns a finding per MISMATCH, not per occurrence: a doc stating the right number twice is fine.
|
|
*/
|
|
function checkCount(file: string, pattern: RegExp, actual: number, claim: string): CheckResult {
|
|
const body = read(file);
|
|
if (!body) return { findings: [], skipped: `${file} not found` };
|
|
const findings: Finding[] = [];
|
|
for (const match of body.matchAll(pattern)) {
|
|
const stated = asNumber(match[1] ?? '');
|
|
if (stated === null || stated === actual) continue;
|
|
findings.push({ file, claim, documented: String(stated), actual: String(actual) });
|
|
}
|
|
return { findings };
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const all: Finding[] = [];
|
|
const skipped: string[] = [];
|
|
const record = (r: CheckResult) => {
|
|
all.push(...r.findings);
|
|
if (r.skipped) skipped.push(r.skipped);
|
|
};
|
|
|
|
// 1. How many permissions the registry holds. The number appears in prose and is never recomputed.
|
|
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
|
|
record(
|
|
checkCount(
|
|
join(ROOT, 'AGENTS.md'),
|
|
/([0-9]+|[a-z-]+) entries as of/gi,
|
|
PERMISSIONS.length,
|
|
'permission registry entries',
|
|
),
|
|
);
|
|
|
|
// 2. How many PM2 entries Officer is. Documented as going stale "every time a sidecar lands", which is an
|
|
// admission that it needs a check rather than a caveat.
|
|
const pm2 = Bun.spawnSync([process.env.SHELL || '/bin/sh', '-ic', 'pm2 jlist'], { stdout: 'pipe', stderr: 'pipe' });
|
|
if (pm2.exitCode === 0) {
|
|
try {
|
|
const list = JSON.parse(pm2.stdout.toString()) as Array<{ name: string }>;
|
|
const officer = list.filter((p) => p.name === 'officer' || p.name.startsWith('officer-')).length;
|
|
record(
|
|
checkCount(
|
|
join(WORKSPACE, 'CLAUDE.md'),
|
|
/Officer is ([a-z-]+|[0-9]+) PM2 entries/gi,
|
|
officer,
|
|
'officer PM2 entries',
|
|
),
|
|
);
|
|
} catch {
|
|
skipped.push('pm2 jlist did not parse');
|
|
}
|
|
} else {
|
|
// Honest rather than silent: a machine with no pm2 cannot check this, and saying so is the point.
|
|
skipped.push('pm2 unavailable — PM2 entry count unchecked');
|
|
}
|
|
|
|
// 3. Every settings path Spotlight offers must exist as a declared section. This is the drift that shipped
|
|
// a dead link to /settings/users, and it is the class the other two cannot catch: a name, not a count.
|
|
const { SPOTLIGHT_SETTINGS_PATHS } = await import('./docs-check-settings');
|
|
const declared = await SPOTLIGHT_SETTINGS_PATHS();
|
|
for (const bad of declared.missing) {
|
|
all.push({
|
|
file: 'Spotlight/useSpotlightItems.ts',
|
|
claim: 'settings route offered by Spotlight',
|
|
documented: bad,
|
|
actual: 'no screen declares that section',
|
|
});
|
|
}
|
|
|
|
// 4. The generated platform skill must match its generator. A stale SKILL.md is worse than a stale doc:
|
|
// an agent quotes it to a user with no way to tell it is out of date.
|
|
const { renderPlatformSkill, SKILL_PATH } = await import('./gen-platform-skill');
|
|
const expected = await renderPlatformSkill();
|
|
const onDisk = read(SKILL_PATH);
|
|
if (onDisk === null) {
|
|
all.push({
|
|
file: 'capabilities/skills/platform/SKILL.md',
|
|
claim: 'generated platform skill',
|
|
documented: 'missing',
|
|
actual: 'run `bun run skills:platform`',
|
|
});
|
|
} else if (onDisk !== expected) {
|
|
all.push({
|
|
file: 'capabilities/skills/platform/SKILL.md',
|
|
claim: 'generated platform skill is stale',
|
|
documented: `${onDisk.split('\n').length} lines on disk`,
|
|
actual: `${expected.split('\n').length} lines from the generator — run \`bun run skills:platform\``,
|
|
});
|
|
}
|
|
|
|
for (const note of skipped) console.log(` skipped: ${note}`);
|
|
if (all.length === 0) {
|
|
console.log('docs:check — every checkable claim matches the code');
|
|
return;
|
|
}
|
|
console.log(`\ndocs:check — ${all.length} claim(s) no longer match the code:\n`);
|
|
for (const f of all) {
|
|
console.log(` ${f.file}`);
|
|
console.log(` ${f.claim}: says ${f.documented}, actually ${f.actual}\n`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
await main();
|