#!/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.')