doxa-maps/internal/check-library-index.mjs
Doxa Maps e19697673d
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Doxa Maps — the map bundler, 7 production plugins and the Experimental shelf
2026-09-25 20:07:59 +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.')