doxa-maps/internal/check-library-index.mjs
doxa-maps-88655c70 6c60af555f
Some checks are pending
build-gate / build (push) Waiting to run
library-index gate: ask whether the menu drifted, not whether a file is older
`touch library/components/legends/SemanticTreeLegend.vue` — zero content
change, nothing in git status — turned this gate red. So did a git checkout, a
rebase, and any editor that rewrites a file on save. It cried wolf twice in one
build session while the regenerated doc was byte-identical both times, and the
only available response was to run the generator and commit nothing.

An mtime answers a different question than the gate's own name asks.

This is the SECOND time this exact class was fixed here. The first was a SCOPE
error: the gate walked 2.3x more of library/ than the generator reads, so a
comment in a build script turned it red and named that script as the reason the
component MENU was stale. Narrowing the scope fixed that instance, but the
mechanism was still mtime, so the class survived inside the narrowed scope. The
lesson recorded then applied again word for word — a gate that fails for a
change it can prove is irrelevant teaches the reader to regenerate on reflex,
which is how a staleness check stops being read.

It now regenerates into a throwaway tree and diffs the bytes.
generate-library-index.mjs hardcodes its output path and derives ROOT from its
own location, so it cannot be told to write elsewhere — but copying the SCRIPT
into a temp tree whose library/ is a SYMLINK to the real one relocates its ROOT
without modifying it. The generator runs unmodified against real sources, writes
somewhere disposable, and an interrupted run leaves nothing behind. Verified
byte-identical to a real `bun run library-index` before this was written, and
the generator was confirmed deterministic first — a content diff would be
useless otherwise.

Comparing output also makes the scope self-enforcing: a change outside
components/, composables/ and utils/ cannot alter one byte of the menu, so the
INDEXED_DIRS list the mtime walk needed is gone.

Mutation-tested both ways, because a gate that never fires is worse than a noisy
one. Deleting a catalogued row fails with the line number and expected content.
Adding a real new library component fails — that is the drift this exists to
catch. Touching 40 library files, the rebase case, passes. Wave 34/34.

Swept the five other internal/*.mjs that reference mtime: only
check-shot-freshness also decides on one, and there it is correct — a PNG cannot
be cheaply regenerated and diffed, and a rebuilt bundle is meaningfully newer.
Left alone deliberately.
2026-09-24 13:29:58 +00:00

125 lines
7.2 KiB
JavaScript

#!/usr/bin/env node
/**
* check-library-index.mjs — the derived library menu must not DRIFT from library/.
*
* THE BUG THIS EXISTS TO PREVENT (real, 2026-09-21)
* internal/generate-library-index.mjs opens with: "The index is DERIVED, never hand-edited — so it
* can't drift from the code." That guarantee had NO MECHANISM behind it: the script was invoked by
* no package.json entry, and documentation/reference-library-index.md DID NOT EXIST AT ALL. A documented
* invariant enforced only by a human remembering is a wish, not an invariant.
*
* ★ IT ASKS THE REAL QUESTION NOW: "HAS THE MENU DRIFTED?", NOT "IS THIS FILE OLDER?"
* This compared MTIMES until 2026-09-24, and an mtime is a proxy that answers a different question
* than the one the gate's own name asks. MEASURED: `touch library/components/legends/
* SemanticTreeLegend.vue` — zero content change — turned it RED. So did a `git checkout`, a rebase,
* and any editor that rewrites a file on save. It cried wolf TWICE in a single build session while
* the regenerated doc was byte-identical both times, and the only available response was to run the
* generator and commit nothing.
*
* That is the SECOND time this exact class was fixed here. The first (2026-09-23, note below) was a
* SCOPE error — the gate walked 2.3x more of library/ than the generator reads, so adding a comment
* to a build script turned it red and named that script as the reason the component MENU was stale.
* Narrowing the scope fixed that instance; the MECHANISM was still mtime, so the class survived
* inside the narrowed scope. The lesson recorded then is the one that applies now, verbatim:
* "A gate that fails for a change it can prove is irrelevant teaches the reader to regenerate on
* reflex, which is how a staleness check stops being read."
*
* HOW: regenerate into a THROWAWAY TREE and diff the bytes. generate-library-index.mjs resolves its
* ROOT from its own location (`join(fileURLToPath(new URL('.', import.meta.url)), '..')`) and
* hardcodes its output path, so it cannot be told to write elsewhere — but copying the SCRIPT into a
* temp tree whose `library/` is a SYMLINK to the real one relocates its ROOT without touching it.
* The generator runs completely unmodified, reads the real sources, and writes somewhere disposable.
* Nothing in the working tree is stashed or mutated, so an interrupted run leaves no mess.
* VERIFIED byte-identical to a real `bun run library-index` before this gate was written.
*
* PRECONDITION, tested before relying on it: the generator is DETERMINISTIC — two runs a second
* apart produce identical bytes (no timestamps, no directory-order dependence). A content diff would
* be useless otherwise, and would have been a worse gate than the mtime one it replaces.
*
* WHY RAW SOURCE IS THE RIGHT ANCESTOR HERE (and was NOT for the screenshots)
* check-shot-freshness compares a PNG against the BUILT BUNDLE, because a screenshot renders a
* built leaf and an unbuilt edit is not yet visible in one. This index is different: it is
* generated by SCANNING library/ directly, with no build step in between. ★ THE ANCESTOR IS
* WHATEVER THE GENERATOR ACTUALLY READS — copying the other gate's choice would have been wrong.
*
* ⚠ SCOPE NOTE, KEPT FROM THE MTIME VERSION because it still explains the generator: it reads
* exactly three subdirectories — components/, composables/ and utils/ (see its section() calls).
* 75 files are indexed, 173 are not — scripts/ 58, i18n/ 98, plus data/, layouts/, api/, constants/.
* Comparing OUTPUT rather than input mtimes makes that scope self-enforcing: a change outside those
* three directories cannot alter one byte of the generated menu, so this gate can no longer fire on
* one. The INDEXED_DIRS list is gone with the mtime walk that needed it.
*
* NEGATIVE-TESTED: deleting a `- **Name**` row from the index makes this FAIL naming that row;
* `touch`ing any library file does NOT (it is not drift); `bun run library-index` returns it to PASS.
*/
import { existsSync, readFileSync, mkdtempSync, mkdirSync, copyFileSync, symlinkSync, rmSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { resolve, dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const REL = join('documentation', 'reference-library-index.md')
const INDEX = join(ROOT, REL)
const LIB = join(ROOT, 'library')
const GENERATOR = join(ROOT, 'internal', 'generate-library-index.mjs')
if (!existsSync(LIB)) {
console.log('LIBRARY-INDEX: SKIP — no library/ directory.')
process.exit(0)
}
if (!existsSync(GENERATOR)) {
console.log('LIBRARY-INDEX: SKIP — internal/generate-library-index.mjs is not present.')
process.exit(0)
}
if (!existsSync(INDEX)) {
console.error('LIBRARY-INDEX: FAIL — documentation/reference-library-index.md does not exist.')
console.error(' It is the menu of reusable pieces an author reads INSTEAD of rebuilding them.')
console.error(' Fix: bun run library-index')
process.exit(1)
}
// Regenerate into a throwaway tree: real script, real sources (symlinked), disposable output.
let fresh = null
let tmp = null
try {
tmp = mkdtempSync(join(tmpdir(), 'libidx-'))
mkdirSync(join(tmp, 'internal'))
mkdirSync(join(tmp, 'documentation'))
copyFileSync(GENERATOR, join(tmp, 'internal', 'generate-library-index.mjs'))
symlinkSync(LIB, join(tmp, 'library'), 'dir')
execFileSync(process.execPath, [join(tmp, 'internal', 'generate-library-index.mjs')], { stdio: 'pipe' })
fresh = readFileSync(join(tmp, REL), 'utf8')
} catch (e) {
// SKIP, never fail, if the harness itself could not run — a gate that goes red because a
// temp directory was unwritable reports a product problem that is not one.
console.log(`LIBRARY-INDEX: SKIP — could not regenerate for comparison (${String(e.message || e).slice(0, 90)}).`)
process.exit(0)
} finally {
if (tmp) { try { rmSync(tmp, { recursive: true, force: true }) } catch { /* best effort */ } }
}
const onDisk = readFileSync(INDEX, 'utf8')
const rows = (s) => s.split('\n').filter((l) => l.startsWith('- **'))
console.log(`LIBRARY-INDEX: ${rows(onDisk).length} catalogued item(s) vs a fresh regeneration.`)
if (onDisk !== fresh) {
const a = onDisk.split('\n')
const b = fresh.split('\n')
const diffs = []
for (let i = 0; i < Math.max(a.length, b.length) && diffs.length < 8; i++) {
if (a[i] !== b[i]) diffs.push({ line: i + 1, file: a[i] ?? '(end of file)', real: b[i] ?? '(end of file)' })
}
console.error('\nLIBRARY-INDEX: FAIL — the derived menu no longer matches library/.')
for (const d of diffs) {
console.error(` line ${d.line}`)
console.error(` file: ${d.file}`)
console.error(` real: ${d.real}`)
}
const onlyCount = Math.abs(rows(onDisk).length - rows(fresh).length)
if (onlyCount) console.error(` (${onlyCount} catalogued row(s) differ in number)`)
console.error('\n The file says "DERIVED — cannot drift from the code"; that is only true if it is')
console.error(' regenerated. Fix: bun run library-index (then commit the regenerated file).')
process.exit(1)
}
console.log('LIBRARY-INDEX: PASS — the derived menu matches what library/ generates today.')