Project instructions that no longer describe the repo. A command nobody can run, a check the enforcement table leaves out, a row saying pre-commit for a gate the hook never runs, a hard rule and the table naming different commands, and a CLAUDE.md that has drifted from AGENTS.md.
Add "Project instructions check" from ntent to this repo.
Fetch https://ntent.app/r/f/check-rules-doc as plain text. Write it verbatim to scripts/check-rules-doc.mjs. Then make exactly these edits and no others: Point CONFIG.rulebook, CONFIG.tableHeading and CONFIG.hook at this repo’s own if they are not AGENTS.md, a "What enforces what" section and scripts/git-hooks/pre-commit. It needs AGENTS.md. If this repo does not have it, stop and tell me.
Then check it: Name `pnpm ship:check` in AGENTS.md with no such script in package.json and the commit is refused, naming the command and the line. Add a scripts/check-*.mjs the table never mentions and it names the file. While AGENTS.md is still the unfilled template it says so and lets the commit through, rather than passing it or blocking setup.Reads https://ntent.app/r/f/check-rules-doc
Read the code
check-rules-doc.mjsscripts/check-rules-doc.mjs
781 lines
#!/usr/bin/env node
/**
* Rulebook gate: does AGENTS.md still describe the repo it governs?
*
* Every other check here asks whether the CODE obeys a rule. This one asks
* whether the RULE is still true. AGENTS.md names the commands, says which
* hard rule each one enforces and which of them block a commit, and it is read
* by every agent on every turn without anyone re-checking it. Nothing else
* notices when it stops being true, because a rulebook that is wrong reads
* exactly like one that is right.
*
* In an earlier repo it had drifted three ways at once. Four scripts that ran
* on every commit were named nowhere in the gate table, while the file said
* "every probe is mandatory" and then listed eight of the ten. `pnpm lint` sat
* in the table described as enforcing five hard rules, and ran nowhere at all
* for weeks. And a reshuffle left a second rules file without the link that
* made every tool read the same rules. A by-hand audit found all of it, and
* only because somebody happened to run one. This is that audit, so the next
* one is not luck.
*
* A RULEBOOK THAT NAMES THE CONTROLS IS ITSELF A CONTROL, and an incomplete
* list of them reads exactly like a complete one.
*
* Seven findings, each about something the file claims:
*
* GHOST-COMMAND a `pnpm x`, `yarn x`, `bun run x`, `npm run x` or `node scripts/x.mjs` in the
* file that nothing defines, or a script that runs a file
* that is not there. The first person who gets "command
* not found" stops believing the rest of the file.
* UNLISTED-CHECK a scripts/check-*.mjs or verify-*.mjs that the "What
* enforces what" section never names, by file or through
* a script that runs it.
* HOOK-NOT-IN-TABLE a gate the pre-commit hook runs whose row does not say
* pre-commit.
* TABLE-NOT-IN-HOOK a row that says pre-commit for a command the hook never
* runs. The worse direction of the two: a gate that runs
* undocumented is untidy, a gate documented and not
* running is a control people believe in.
* RULE-TABLE-DRIFT a hard rule's "Enforced by" line and the table's
* Enforces column disagreeing, in either direction, or
* the table citing a rule number that does not exist.
* CLAUDE-MD-SPLIT a CLAUDE.md that neither imports AGENTS.md nor is a link
* to it, so two tools read two rulebooks.
* DEAD-LINK a link to a heading, or a file, that is not there.
*
* HOW "THE HOOK RUNS IT" IS DECIDED. Each `run_gate` call in the hook is one
* gate, and the scripts/ files it names are what it runs. A gate counts only
* when its file is on disk, because the hook skips a checker this repo has not
* installed. A row is matched to a gate by file (through the package.json
* script behind it), by the gate having the script's name, or by the hook
* calling the script by name. A comment in the hook is not evidence: prose
* about a gate is not a call to it.
*
* EXIT CODES. 0 clean, 1 findings (with --check), 2 cannot check. No AGENTS.md,
* no package.json, no "What enforces what" table, or an AGENTS.md that is still
* the unfilled template are all exit 2. The template case matters most: it
* describes an example repo, not this one, so holding this repo to it would
* refuse the very first commit after setup. It says so, and says how far off
* the template is, rather than passing.
*
* WHAT THIS DOES NOT PROVE. That a row's prose is accurate: that is a reading,
* and the table can be wrong in every way a sentence can while this passes.
* That a command works: `pnpm x` counts as defined if any package.json in the
* repo defines it, so a script that lives only in a workspace package passes
* even when the doc says to run it from the root. The CI column: nothing here
* reads CI config, so the hook is evidence for the pre-commit claim and silent
* about the rest. And typecheck and lint name no file of their own, so a
* typecheck gate the table leaves out is not reported; a row that claims one is
* still checked.
*
* Usage:
* node scripts/check-rules-doc.mjs report, exit 0
* node scripts/check-rules-doc.mjs --check exit 1 on any finding
*/
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'
import { basename, join, posix, relative, sep } from 'node:path'
// ---------------------------------------------------------------- CONFIG ---
const CONFIG = {
// The rulebook, and the other name some tools read it under.
rulebook: 'AGENTS.md',
alias: 'CLAUDE.md',
// The section holding the enforcement table, and the one holding the
// numbered rules. Matched against heading text, ignoring case.
tableHeading: /^what enforces what$/i,
rulesHeading: /^hard rules$/i,
hook: 'scripts/git-hooks/pre-commit',
// A check script, by the naming every checker in this playbook uses. Tests
// are not checks.
checkDir: 'scripts',
checkFile: /^(check|verify)-[\w.-]+\.(mjs|cjs|js|sh)$/,
notCheckFile: /\.test\.|\.probe\./,
// Text that only the unfilled template has. Its banner, and its title.
templateMarkers: [/THIS IS A TEMPLATE/, /^# <Project name>\s*$/m],
// How deep to look for workspace package.json files.
manifestDepth: 3,
ignoreDirs: ['node_modules', '.git', 'dist', 'build', 'out', '.next', '.turbo', 'coverage'],
// pnpm's own commands. `pnpm install` is a claim about pnpm, not about this
// repo's scripts. `doctor` and `check` are deliberately missing: in this
// playbook they are the repo's scripts, and that is what the doc means.
pnpmBuiltins: new Set([
'add', 'approve-builds', 'audit', 'bin', 'config', 'create', 'dedupe', 'deploy', 'dlx',
'env', 'exec', 'fetch', 'help', 'i', 'import', 'init', 'install', 'install-test', 'it',
'licenses', 'link', 'list', 'ln', 'ls', 'outdated', 'pack', 'patch', 'patch-commit',
'prune', 'publish', 'rb', 'rebuild', 'remove', 'rm', 'root', 'self-update', 'server',
'setup', 'store', 'un', 'uninstall', 'unlink', 'up', 'update', 'upgrade', 'why',
]),
// The same for yarn (classic and berry) and bun. `bun test` is bun's own
// runner and `bun x` is bunx, neither a script of this repo's.
otherBuiltins: {
yarn: new Set([
'add', 'audit', 'autoclean', 'bin', 'cache', 'config', 'constraints', 'create', 'dedupe', 'dlx',
'exec', 'explain', 'global', 'help', 'import', 'info', 'init', 'install', 'licenses', 'link', 'list',
'login', 'logout', 'node', 'npm', 'outdated', 'owner', 'pack', 'patch', 'plugin', 'publish', 'rebuild',
'remove', 'self-update', 'set', 'tag', 'team', 'unlink', 'unplug', 'up', 'upgrade', 'upgrade-interactive',
'version', 'versions', 'why', 'workspace', 'workspaces',
]),
bun: new Set([
'add', 'audit', 'build', 'completions', 'create', 'exec', 'info', 'init', 'install', 'i', 'link', 'outdated',
'patch', 'pm', 'publish', 'remove', 'rm', 'repl', 'test', 'unlink', 'update', 'upgrade', 'why', 'x',
]),
},
}
const strict = process.argv.includes('--check')
const root = process.cwd()
const cannotCheck = (lines) => {
console.log(`check-rules-doc: ${lines[0]}`)
for (const line of lines.slice(1)) console.log(` ${line}`)
process.exit(2)
}
// ------------------------------------------------------------ THE REPO ---
const read = (rel) => readFileSync(join(root, rel), 'utf8')
const toPosix = (p) => p.split(sep).join('/')
/** Every package.json that could define a script, root first. */
function manifests() {
const found = []
const walk = (dir, depth) => {
const file = join(dir, 'package.json')
if (existsSync(file)) {
try {
const scripts = JSON.parse(readFileSync(file, 'utf8')).scripts ?? {}
found.push({ dir: toPosix(relative(root, dir)) || '.', scripts })
} catch {
// A manifest that does not parse defines nothing. The package manager
// will say so louder than this could.
}
}
if (depth >= CONFIG.manifestDepth) return
let entries = []
try {
entries = readdirSync(dir)
} catch {
return
}
for (const entry of entries) {
if (CONFIG.ignoreDirs.includes(entry) || entry.startsWith('.')) continue
const full = join(dir, entry)
if (statSync(full).isDirectory()) walk(full, depth + 1)
}
}
walk(root, 0)
return found
}
/** Repo files a shell command names, as repo-relative paths. */
function filesIn(command, fromDir = '.') {
const out = []
for (const m of command.matchAll(/(?:^|[\s"'=])(?:\.\/)?([\w@.-]+(?:\/[\w@.-]+)+\.(?:mjs|cjs|js|ts|sh))(?=$|[\s"';&|)])/g)) {
if (m[1].includes('node_modules/')) continue
out.push(posix.normalize(posix.join(fromDir, m[1])))
}
return out
}
/**
* A command as the doc writes it, reduced to what it claims exists: a script
* by name, or a file. Null for anything this cannot hold to account, such as
* `pnpm install`, `pnpm -r lint` or `npx eslint`.
*/
function parseCommand(text) {
const words = text.trim().replace(/^\$\s+/, '').split(/\s+/)
const [tool, a, b] = words
const name = (n) => (n && /^[a-z0-9][\w:.-]*$/i.test(n) ? n : null)
const script = (n) => name(n) && { kind: 'script', name: n, pm: tool }
if (tool === 'pnpm') {
if (!a || a.startsWith('-')) return null
if (a === 'run' || a === 'run-script') return script(b)
if (CONFIG.pnpmBuiltins.has(a)) return null
return script(a)
}
if (tool === 'npm') {
if (a === 'run' || a === 'run-script') return script(b)
if (a === 'test' || a === 'start') return { kind: 'script', name: a, pm: tool }
return null
}
// yarn and bun run a script by its bare name (`yarn dev`, `bun dev`) as well
// as through `run`, so a word that is not the tool's own command is a script.
// `npx`, `bunx` and `yarn dlx` run a package, which no script defines, so
// they are never read as a claim about this repo.
if (tool === 'yarn' || tool === 'bun') {
if (!a || a.startsWith('-')) return null
if (a === 'run') return script(b)
if (CONFIG.otherBuiltins[tool].has(a)) return null
return script(a)
}
if (['node', 'sh', 'bash', 'tsx'].includes(tool) || /^\.\//.test(tool)) {
const path = words.map((w) => w.replace(/^\.\//, '')).find((w) => /^[\w@.-]+(\/[\w@.-]+)*\.(mjs|cjs|js|ts|sh)$/.test(w))
return path ? { kind: 'file', path, tool: tool.startsWith('./') ? null : tool } : null
}
return null
}
const keyOf = (cmd) => (cmd.kind === 'script' ? `${cmd.pm ?? 'pnpm'} ${cmd.name}` : cmd.tool ? `${cmd.tool} ${cmd.path}` : `./${cmd.path}`)
// ------------------------------------------------------------- THE DOC ---
/**
* The doc as lines, with what matters pulled out of each: headings, the code
* spans outside fences, and the lines inside fences. A fenced line counts as a
* command only from its first word, so a directory listing in a code block is
* not read as one.
*/
function parseDoc(text) {
const lines = text.split('\n')
const out = []
let fence = null
lines.forEach((raw, i) => {
const line = { n: i + 1, raw, fenced: false, heading: null, spans: [] }
const f = raw.match(/^\s*(```|~~~)/)
if (f) {
fence = fence ? (raw.trim().startsWith(fence) ? null : fence) : f[1]
line.fenced = true
out.push(line)
return
}
if (fence) {
line.fenced = true
out.push(line)
return
}
const h = raw.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/)
if (h) line.heading = { level: h[1].length, text: h[2] }
line.spans = [...raw.matchAll(/`([^`\n]+)`/g)].map((m) => m[1])
out.push(line)
})
return out
}
/** The lines of the first section whose heading matches, or null. */
function section(doc, pattern) {
const start = doc.findIndex((l) => l.heading && pattern.test(stripHeading(l.heading.text)))
if (start === -1) return null
const level = doc[start].heading.level
let end = start + 1
while (end < doc.length && !(doc[end].heading && doc[end].heading.level <= level)) end++
return doc.slice(start, end)
}
const stripHeading = (text) => text.replace(/\s*\{#[\w-]+\}\s*$/, '').replace(/[`*_]/g, '').trim()
/** The anchor a heading gets: an explicit {#id}, otherwise GitHub's slug. */
function anchorOf(text) {
const explicit = text.match(/\{#([\w-]+)\}\s*$/)
if (explicit) return explicit[1]
return stripHeading(text)
.toLowerCase()
.replace(/[^\p{L}\p{N}\s_-]/gu, '')
.replace(/\s/g, '-')
}
/** Every command the doc names, with the lines it is named on. */
function commandsIn(lines) {
const found = new Map()
const add = (cmd, n) => {
if (!cmd) return
const key = keyOf(cmd)
if (!found.has(key)) found.set(key, { ...cmd, key, lines: [] })
if (!found.get(key).lines.includes(n)) found.get(key).lines.push(n)
}
for (const line of lines) {
if (line.fenced) add(parseCommand(line.raw), line.n)
else for (const span of line.spans) add(parseCommand(span), line.n)
}
return found
}
/** Rule numbers in a cell: "Rule 1", "Rules 2, 3, 4, 8", "Rules 1–5 and 8". */
function ruleNumbers(text) {
const out = new Set()
for (const m of text.matchAll(/\brules?\s+(\d+(?:\s*(?:[–—-]|,|&|\band\b|,\s*and\b)\s*\d+)*)/gi)) {
for (const part of m[1].matchAll(/(\d+)(?:\s*[–—-]\s*(\d+))?/g)) {
const from = Number(part[1])
const to = part[2] ? Number(part[2]) : from
for (let n = from; n <= to && n - from < 100; n++) out.add(n)
}
}
return out
}
/**
* The rows of every table in the section whose first cell is a command. The
* header, where there is one, says which column is Enforces and which is Runs;
* a table with no header row, like the template's list of non-gates, is read
* as the same three columns.
*/
function tableRows(lines) {
const rows = []
let cols = { enforces: 1, runs: 2 }
for (const line of lines) {
if (line.fenced || !line.raw.trim().startsWith('|')) {
cols = { enforces: 1, runs: 2 }
continue
}
const cells = line.raw.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim())
if (cells.every((c) => /^:?-+:?$/.test(c))) continue
const first = cells[0].match(/`([^`]+)`/)
if (!first) {
const at = (re) => cells.findIndex((c) => re.test(c))
if (at(/^enforces$/i) !== -1) cols.enforces = at(/^enforces$/i)
if (at(/^runs$/i) !== -1) cols.runs = at(/^runs$/i)
continue
}
const cmd = parseCommand(first[1])
rows.push({
n: line.n,
text: first[1],
cmd,
key: cmd ? keyOf(cmd) : null,
enforces: cells[cols.enforces] ?? '',
runs: cells[cols.runs] ?? '',
})
}
return rows
}
/** Numbered hard rules, each with the commands its "Enforced by" line names. */
function hardRules(lines) {
const rules = new Map()
let current = null
let inEnforced = false
for (const line of lines) {
if (line.fenced) continue
const item = line.raw.match(/^(\d+)\.\s/)
if (item) {
current = { n: Number(item[1]), line: line.n, enforcedBy: null, commands: new Set() }
rules.set(current.n, current)
inEnforced = false
}
if (!current) continue
if (!line.raw.trim()) {
inEnforced = false
continue
}
const at = line.raw.search(/enforced by:/i)
if (at !== -1) {
inEnforced = true
current.enforcedBy = line.n
}
if (inEnforced) {
const from = at === -1 ? line.raw : line.raw.slice(at)
for (const m of from.matchAll(/`([^`\n]+)`/g)) {
const cmd = parseCommand(m[1])
if (cmd) current.commands.add(keyOf(cmd))
}
}
}
return rules
}
// ------------------------------------------------------------ THE HOOK ---
/**
* The gates the hook runs, one per `run_gate` call.
*
* run_gate <name> <path-regex> <command> <fix-hint> [required-file ...]
*
* The scripts a gate runs are the ones its command and its required files
* name. Whether it runs HERE is whether every required file exists, because
* that is the test the hook applies: the contract gate needs contract.json as
* well as its checker, and a repo with the checker and no contract runs no
* contract gate. A required file spelled as a shell variable cannot be looked
* up from here and is left out of that test.
*
* Comment lines are dropped first. The hook explains itself at length, and a
* gate named in a comment is not a gate that runs.
*/
function hookGates(text) {
const code = text
.replace(/\\\n/g, ' ')
.split('\n')
.filter((l) => !l.trimStart().startsWith('#'))
.join('\n')
const unquote = (arg) => arg.replace(/^(["'])([\s\S]*)\1$/, '$2')
const gates = []
for (const m of code.matchAll(/^\s*run_gate\s+(.*)$/gm)) {
const args = (m[1].match(/'[^']*'|"[^"]*"|[^\s'"]+/g) ?? []).map(unquote)
const [name, , command = '', , ...required] = args
const needs = required.filter((r) => !r.includes('$'))
const files = [...new Set([...filesIn(command), ...needs.filter((r) => filesIn(r).length)])]
gates.push({ name, files, needs })
}
// A hand-written hook with no run_gate: every script it calls is a gate.
if (!gates.length) {
for (const file of new Set(filesIn(code))) gates.push({ name: basename(file), files: [file], needs: [file] })
}
return { gates, code }
}
// ------------------------------------------------------------------ MAIN ---
function main() {
if (!existsSync(join(root, CONFIG.rulebook))) {
const alias = existsSync(join(root, CONFIG.alias))
cannotCheck([
`no ${CONFIG.rulebook} at ${rel(root) || 'the repo root'}. Nothing was checked.`,
alias
? `There is a ${CONFIG.alias}. Move its rules into ${CONFIG.rulebook} and point ${CONFIG.alias} at it with an @${CONFIG.rulebook} import, or set CONFIG.rulebook.`
: `Write one from the constitution template, or set CONFIG.rulebook if the rules live elsewhere.`,
])
}
if (!existsSync(join(root, 'package.json'))) {
cannotCheck([
'no package.json, so no command the rulebook names can be looked up. Nothing was checked.',
])
}
const text = read(CONFIG.rulebook)
const doc = parseDoc(text)
const pkgs = manifests()
/** Where a script is defined, and every file it runs, following `pnpm y` inside it. */
const resolve = (cmd) => {
if (cmd.kind === 'file') {
return { defined: existsSync(join(root, cmd.path)), files: [cmd.path], missing: [], where: [] }
}
const where = pkgs.filter((p) => cmd.name in p.scripts)
const files = new Set()
const missing = new Set()
const seen = new Set()
const follow = (pkg, script, depth) => {
if (seen.has(`${pkg.dir}:${script}`) || depth > 3) return
seen.add(`${pkg.dir}:${script}`)
const body = pkg.scripts[script]
for (const f of filesIn(body, pkg.dir)) {
files.add(f)
if (depth === 0 && !existsSync(join(root, f))) missing.add(f)
}
for (const m of body.matchAll(/\b(?:(?:pnpm|yarn|bun)(?:\s+run)?|npm\s+run)\s+([a-z0-9][\w:.-]*)/gi)) {
if (m[1] in pkg.scripts) follow(pkg, m[1], depth + 1)
}
}
for (const pkg of where) follow(pkg, cmd.name, 0)
return { defined: where.length > 0, files: [...files], missing: [...missing], where }
}
// The unfilled template describes an example repo. Say how far off it is, in
// one list, and stop: every finding below would be the template's, not this
// repo's.
const marker = CONFIG.templateMarkers.find((re) => re.test(text))
if (marker) {
const absent = [...commandsIn(doc).values()].filter((c) => !resolve(c).defined).map((c) => c.key)
cannotCheck([
`${CONFIG.rulebook} is still the unfilled template, so it describes an example repo`,
`rather than this one. Nothing was checked against it.`,
`Fill it in: replace every <angle bracket>, delete the sections marked for tiers above`,
`yours, and cut any row whose command this repo does not have.`,
...(absent.length
? [`${absent.length} of the commands it names do not exist here yet: ${absent.join(', ')}.`]
: []),
])
}
const tableLines = section(doc, CONFIG.tableHeading)
const rows = tableLines ? tableRows(tableLines) : []
if (!rows.length) {
cannotCheck([
tableLines
? `the "What enforces what" section in ${CONFIG.rulebook} has no table rows naming a command.`
: `${CONFIG.rulebook} has no "What enforces what" section.`,
'That table is what says which command holds up which rule, and it is most of what this',
'check reads, so nothing was checked. Write it, one row per command, or set',
'CONFIG.tableHeading if yours has another name.',
])
}
const findings = []
const notChecked = []
const add = (kind, subject, detail, fix) => findings.push({ kind, subject, detail, fix })
const at = (lines) => `${CONFIG.rulebook}:${lines.join(', :')}`
// GHOST-COMMAND. Everything the doc tells somebody to run.
const commands = commandsIn(doc)
const ghosts = new Set()
for (const cmd of commands.values()) {
const r = resolve(cmd)
if (!r.defined) {
ghosts.add(cmd.key)
add(
'GHOST-COMMAND',
cmd.key,
cmd.kind === 'script'
? `${at(cmd.lines)}. No package.json in this repo has a script called ${cmd.name}.`
: `${at(cmd.lines)}. There is no ${cmd.path}.`,
cmd.kind === 'script'
? 'add the script, or cut the line. A command nobody can run is how the rest of the file stops being believed.'
: 'restore the file, or cut the line.',
)
} else if (r.missing.length) {
ghosts.add(cmd.key)
add(
'GHOST-COMMAND',
cmd.key,
`${at(cmd.lines)}. The script runs ${r.missing.join(', ')}, which is not there.`,
'point the script at the file that replaced it, or cut the line and the script.',
)
}
}
// UNLISTED-CHECK. Every check script on disk, named somewhere in the section.
const sectionText = tableLines.map((l) => l.raw).join('\n')
const sectionFiles = new Set()
for (const cmd of commandsIn(tableLines).values()) for (const f of resolve(cmd).files) sectionFiles.add(f)
let checkFiles = []
try {
checkFiles = readdirSync(join(root, CONFIG.checkDir))
.filter((f) => CONFIG.checkFile.test(f) && !CONFIG.notCheckFile.test(f))
.map((f) => `${CONFIG.checkDir}/${f}`)
} catch {
notChecked.push(`check scripts: no ${CONFIG.checkDir}/ directory`)
}
const unlisted = new Set()
for (const file of checkFiles) {
if (sectionFiles.has(file) || sectionText.includes(basename(file))) continue
unlisted.add(file)
add(
'UNLISTED-CHECK',
file,
`It is a check, and "What enforces what" never names it, by file or through a script that runs it.`,
'add a row saying what it enforces and when it runs. If it enforces nothing any more, delete it.',
)
}
// HOOK-NOT-IN-TABLE and TABLE-NOT-IN-HOOK. The hook is the only hard
// evidence for the pre-commit column, so it is compared both ways.
const resolvedRows = rows.map((row) => ({ ...row, files: row.cmd ? resolve(row.cmd).files : [] }))
const saysPreCommit = (row) => /pre-?commit/i.test(row.runs)
let hookCount = 0
if (!existsSync(join(root, CONFIG.hook))) {
notChecked.push(`the pre-commit column: no ${CONFIG.hook}`)
} else {
const hook = hookGates(read(CONFIG.hook))
const here = (f) => existsSync(join(root, f))
const live = hook.gates.filter((g) => g.files.length && g.files.every(here) && g.needs.every(here))
hookCount = live.length
for (const gate of live) {
if (gate.files.every((f) => unlisted.has(f))) continue // already reported
const matching = resolvedRows.filter(
(r) => r.files.some((f) => gate.files.includes(f)) || (r.cmd?.kind === 'script' && r.cmd.name === gate.name),
)
if (matching.some(saysPreCommit)) continue
const file = gate.files.find((f) => CONFIG.checkFile.test(basename(f))) ?? gate.files[0]
add(
'HOOK-NOT-IN-TABLE',
file,
matching.length
? `pre-commit runs it as the "${gate.name}" gate, and the ${matching.map((r) => `\`${r.text}\``).join(', ')} row says it runs "${matching[0].runs}".`
: `pre-commit runs it as the "${gate.name}" gate, and no row in the table does.`,
matching.length
? 'add pre-commit to that row’s Runs column. If the hook runs it in another mode, with a flag the row’s script does not pass, give that mode a row of its own.'
: 'add a row for it, with pre-commit in the Runs column.',
)
}
// A gate with the row's name counts, which is how typecheck and lint are
// found: neither has a file of its own, and the hook names both gates
// after the scripts they stand in for.
for (const row of resolvedRows) {
if (!saysPreCommit(row) || !row.cmd || ghosts.has(row.key)) continue
const isRow = (g) => g.files.some((f) => row.files.includes(f)) || (row.cmd.kind === 'script' && g.name === row.cmd.name)
const runs = (g) => g.files.every(here) && g.needs.every(here)
const byGate = hook.gates.some((g) => isRow(g) && runs(g))
const byName =
row.cmd.kind === 'script'
? new RegExp(`\\b(?:(?:pnpm|yarn|bun)(?:\\s+-s)?(?:\\s+run)?|npm\\s+run)\\s+${escape(row.cmd.name)}(?![\\w:.-])`).test(hook.code)
: hook.code.includes(row.cmd.path)
if (byGate || byName) continue
const dormant = hook.gates.find(isRow)
const absent = dormant ? [...dormant.files, ...dormant.needs].filter((f) => !here(f)) : []
add(
'TABLE-NOT-IN-HOOK',
row.key,
dormant
? `${at([row.n])}. The row says it runs at pre-commit. The hook's "${dormant.name}" gate would run it, but that gate needs ${[...new Set(absent)].join(', ')}, which is not here, so it is skipped on every commit.`
: `${at([row.n])}. The row says it runs at pre-commit. ${CONFIG.hook} never runs ${
row.files.length ? row.files.join(', ') : 'it, by name or by the file behind it'
}.`,
dormant
? `add ${[...new Set(absent)].join(', ')}, or take pre-commit out of the row until it exists.`
: 'wire it into the hook with run_gate, or take pre-commit out of the row. A gate people believe runs and does not is worse than no gate.',
)
}
}
// RULE-TABLE-DRIFT. The two places the file says what enforces a rule.
const rulesLines = section(doc, CONFIG.rulesHeading)
const rules = rulesLines ? hardRules(rulesLines) : new Map()
const cited = resolvedRows.filter((r) => ruleNumbers(r.enforces).size)
if (!rules.size) {
if (cited.length) {
const numbers = [...new Set(cited.flatMap((r) => [...ruleNumbers(r.enforces)]))].sort((a, b) => a - b)
add(
'RULE-TABLE-DRIFT',
`rules ${numbers.join(', ')}`,
`The table cites them, and ${CONFIG.rulebook} has no numbered list under a "Hard rules" heading.`,
'number the rules under "## Hard rules", or take the numbers out of the table.',
)
} else {
notChecked.push('rules against the table: no numbered "Hard rules" section')
}
} else {
const rowsFor = (key) => resolvedRows.filter((r) => r.key === key)
for (const rule of rules.values()) {
for (const key of rule.commands) {
if (ghosts.has(key)) continue
const matching = rowsFor(key)
if (!matching.length) {
add(
'RULE-TABLE-DRIFT',
`rule ${rule.n}`,
`${at([rule.enforcedBy])}. It says \`${key}\` enforces it, and the table has no row for \`${key}\`.`,
'add the row, or correct the rule’s Enforced by line.',
)
} else if (!matching.some((r) => ruleNumbers(r.enforces).has(rule.n))) {
add(
'RULE-TABLE-DRIFT',
`rule ${rule.n}`,
`${at([rule.enforcedBy])}. It says \`${key}\` enforces it. The table’s row for it (${at([matching[0].n])}) says "${matching[0].enforces}".`,
`add rule ${rule.n} to that row, or take \`${key}\` off the rule. One of the two is wrong.`,
)
}
}
}
for (const row of cited) {
for (const n of ruleNumbers(row.enforces)) {
const rule = rules.get(n)
if (!rule) {
add(
'RULE-TABLE-DRIFT',
`rule ${n}`,
`${at([row.n])}. The \`${row.text}\` row cites it, and there is no hard rule ${n}.`,
'renumber the row to match the rules, or restore the rule.',
)
} else if (row.key && !rule.commands.has(row.key)) {
add(
'RULE-TABLE-DRIFT',
`rule ${n}`,
`${at([row.n])}. The table says \`${row.text}\` enforces it. Rule ${n} (${at([rule.line])}) ${
rule.enforcedBy ? 'names other things on its Enforced by line' : 'has no Enforced by line'
}.`,
`name \`${row.text}\` on rule ${n}’s Enforced by line, or take ${n} out of the row.`,
)
}
}
}
}
// CLAUDE-MD-SPLIT. One rulebook, whichever name a tool reads.
const aliasPath = join(root, CONFIG.alias)
if (existsSync(aliasPath)) {
const same = realpathSync(aliasPath) === realpathSync(join(root, CONFIG.rulebook))
const imports = !same && new RegExp(`^\\s*@${escape(CONFIG.rulebook)}\\s*$`, 'm').test(readFileSync(aliasPath, 'utf8'))
if (!same && !imports) {
const link = lstatSync(aliasPath).isSymbolicLink()
const copy = !link && readFileSync(aliasPath, 'utf8') === text
add(
'CLAUDE-MD-SPLIT',
CONFIG.alias,
link
? `It is a link, but to something other than ${CONFIG.rulebook}.`
: copy
? `It is a copy of ${CONFIG.rulebook}. Identical today, and the first edit to either one splits the rules in two.`
: `It neither imports ${CONFIG.rulebook} nor links to it, so a tool reading ${CONFIG.alias} gets different rules.`,
`put a line reading @${CONFIG.rulebook} at the top of ${CONFIG.alias}, above anything only one tool needs.`,
)
}
}
// DEAD-LINK. Cheap, and a pointer to nowhere is a claim like any other.
const anchors = (lines) => new Set(lines.filter((l) => l.heading).map((l) => anchorOf(l.heading.text)))
const own = anchors(doc)
for (const line of doc) {
if (line.fenced) continue
const prose = line.raw.replace(/`[^`\n]*`/g, '')
for (const m of prose.matchAll(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) {
const target = m[1]
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('<')) continue
const [path, fragment] = target.split('#')
if (!path) {
if (!own.has(fragment)) {
add('DEAD-LINK', target, `${at([line.n])}. No heading in ${CONFIG.rulebook} has that anchor.`, 'point it at the heading as it is now named, or cut the link.')
}
continue
}
const file = path.startsWith('/') ? path.slice(1) : posix.join(posix.dirname(CONFIG.rulebook), path)
if (!existsSync(join(root, file))) {
add('DEAD-LINK', target, `${at([line.n])}. There is no ${file}.`, 'point it at where the file is now, or cut the link.')
} else if (fragment && /\.md$/i.test(file) && statSync(join(root, file)).isFile()) {
if (!anchors(parseDoc(read(file))).has(fragment)) {
add('DEAD-LINK', target, `${at([line.n])}. ${file} has no heading with that anchor.`, 'point it at the heading as it is now named, or cut the link.')
}
}
}
}
// -------------------------------------------------------------- REPORT ---
if (!findings.length) {
const preCommit = hookCount ? ` ${hookCount} pre-commit gate(s), each with a row that says so.` : ''
console.log(
`check-rules-doc: ok. ${CONFIG.rulebook} names ${commands.size} command(s), all defined. ` +
`${checkFiles.length} check script(s), all in the table.${preCommit}` +
(rules.size ? ` ${rules.size} hard rule(s), each agreeing with the table.` : ''),
)
if (notChecked.length) console.log(` not checked: ${notChecked.join('; ')}.`)
process.exit(0)
}
// Findings with the same fix print together, and the fix prints once after
// them. Thirteen copies of "add the script, or cut the line" is how a report
// gets skimmed past the one finding that is different.
const order = [...new Set(findings.map((f) => `${f.kind}\n${f.fix}`))]
console.log(`\ncheck-rules-doc: ${findings.length} finding(s). ${CONFIG.rulebook} no longer describes this repo.\n`)
for (const key of order) {
const run = findings.filter((f) => `${f.kind}\n${f.fix}` === key)
for (const f of run) {
console.log(` ${f.kind} ${f.subject}`)
for (const line of wrap(f.detail, 72)) console.log(` ${line}`)
}
const fix = run.length > 1 ? `for ${run.length === 2 ? 'both' : 'each'}, ${run[0].fix}` : run[0].fix
wrap(fix, 67).forEach((line, i) => console.log(` ${i ? ' ' : 'fix: '}${line}`))
console.log()
}
if (notChecked.length) console.log(` not checked: ${notChecked.join('; ')}.\n`)
console.log(` ${CONFIG.rulebook} names the controls and says which of them block a commit. When`)
console.log(' that list is wrong it still reads as complete. Fix the file or the repo,')
console.log(' whichever is wrong. Not this check.\n')
process.exit(strict ? 1 : 0)
}
function escape(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function wrap(text, width) {
const lines = []
let line = ''
for (const word of String(text).split(/\s+/)) {
if (line && line.length + word.length + 1 > width) {
lines.push(line)
line = word
} else {
line = line ? `${line} ${word}` : word
}
}
if (line) lines.push(line)
return lines
}
function rel(f) {
return toPosix(relative(process.cwd(), f))
}
main()
How to tell it works: Name `pnpm ship:check` in AGENTS.md with no such script in package.json and the commit is refused, naming the command and the line. Add a scripts/check-*.mjs the table never mentions and it names the file. While AGENTS.md is still the unfilled template it says so and lets the commit through, rather than passing it or blocking setup.