125 lines
7.2 KiB
JavaScript
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.')
|