library-index gate: ask whether the menu drifted, not whether a file is older
Some checks are pending
build-gate / build (push) Waiting to run

`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.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 13:29:58 +00:00
parent 53fea6ee38
commit 6c60af555f
2 changed files with 91 additions and 50 deletions

View file

@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* check-library-index.mjs — the derived library menu must not fall behind library/.
* 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
@ -8,29 +8,70 @@
* 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. So the honest ancestor
* is the newest library/ source file. ★ THE ANCESTOR IS WHATEVER THE GENERATOR ACTUALLY READS —
* copying the other gate's choice would have been wrong.
* 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.
*
* NEGATIVE-TESTED: `touch library/layouts/StackLayout.vue` makes this FAIL naming that file;
* `bun run library-index` returns it to PASS.
* ⚠ 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 { readdirSync, existsSync, statSync } from 'node:fs'
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 INDEX = join(ROOT, 'documentation', 'reference-library-index.md')
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.')
@ -38,47 +79,47 @@ if (!existsSync(INDEX)) {
process.exit(1)
}
// Newest source the generator actually scans.
//
// ★ AND THAT NOW MEANS WHAT IT SAYS (fixed 2026-09-23). This walked ALL of library/, while
// generate-library-index.mjs reads exactly three subdirectories — components/, composables/ and
// utils/ (see its section() calls). MEASURED: 75 files are indexed, 173 are not — scripts/ 58,
// i18n/ 98, plus data/, layouts/, api/, constants/. So the gate watched 2.3x more than the thing it
// guards, and a change anywhere in that surplus demanded a regeneration that could not alter one
// byte of the output.
//
// It fired for real: adding a COMMENT to library/scripts/research/build-trade-languages.mjs turned
// this gate red, naming a build script as the reason the component MENU was stale. 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.
//
// Keeping the scope in step is the price of this design: if generate-library-index.mjs ever adds a
// section(), add its directory here in the same commit.
const INDEXED_DIRS = ['components', 'composables', 'utils']
let newest = 0, newestFile = null
const walk = (d) => {
for (const e of readdirSync(d, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name.startsWith('.')) continue
const p = join(d, e.name)
if (e.isDirectory()) { walk(p); continue }
if (!/\.(vue|js|mjs)$/.test(e.name)) continue
const m = statSync(p).mtimeMs
if (m > newest) { newest = m; newestFile = p.slice(ROOT.length + 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)' })
}
}
for (const d of INDEXED_DIRS) {
const p = join(LIB, d)
if (existsSync(p)) walk(p)
}
const idxMs = statSync(INDEX).mtimeMs
console.log(`LIBRARY-INDEX: index vs ${newestFile ? 'newest library source' : 'library/'}.`)
if (newest > idxMs) {
console.error(`\nLIBRARY-INDEX: FAIL — the derived menu is OLDER than library/.`)
console.error(` ✗ ${newestFile} is ${Math.round((newest - idxMs) / 1000)}s newer than the index`)
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')
console.error(' regenerated. Fix: bun run library-index (then commit the regenerated file).')
process.exit(1)
}
console.log('LIBRARY-INDEX: PASS — the derived menu is newer than every library/ source.')
console.log('LIBRARY-INDEX: PASS — the derived menu matches what library/ generates today.')

View file

@ -74,7 +74,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"check:composite": "node internal/check-composite-freshness.mjs # a COMPOSITE is a COPY taken at its own build time: fails if a nested child's source is newer than the parent's bundle. NOT in build:unlocked on purpose — there everything was just built, so it could never catch the real case (a PARTIAL build). Run it before PUBLISH.",
"check:shots": "node internal/check-shot-freshness.mjs # a README screenshot that predates the bundle it shows is a picture telling a lie — and it looks IDENTICAL to a current one. Compares each shot's PNG against the BUILT BUNDLE it renders (not raw sources: an unbuilt edit is not yet in any leaf). Fix: node internal/readme-shots.mjs",
"check:library-index": "node internal/check-library-index.mjs # the DERIVED library menu must not fall behind library/. Compares the index against the newest library SOURCE (not a bundle — this generator scans library/ directly, with no build step between). Fix: bun run library-index",
"check:library-index": "node internal/check-library-index.mjs # the DERIVED library menu must not DRIFT from library/. REGENERATES the index into a throwaway tree (the generator, unmodified, over a symlinked library/) and diffs the BYTES — it used to compare mtimes, so `touch` or a rebase turned it red on a byte-identical doc. Fix: bun run library-index",
"check:quoted-scripts": "node internal/check-quoted-scripts.mjs # documentation/README-SECTION-3-cli-commands.md QUOTES the scripts block verbatim; it had silently drifted TWICE (a stale bunx cap:sync, and build:unlocked missing check:public-hygiene + check:label-joins). package.json is what RUNS — update the DOC.",
"capability-exemplars": "node internal/generate-capability-exemplars.mjs # regenerate documentation/reference-capability-exemplars.md — the DERIVED answer to \"which bundle is the capability-N example?\", a question answered WRONGLY in three separate artifacts (a gate, the README, the /plugin-designer skill).",
"check:a11y-button-names": "node internal/check-a11y-button-names.mjs # every VISIBLE button must have an accessible name (aria-label / aria-labelledby / visible text — a `title` is a TOOLTIP and does NOT count). Measures the RENDERED DOM through shadow roots: a source grep cannot answer this (132 `<button>` hits, most with visible text). Needs a browser, so NOT in build:unlocked and NOT in CI. ALL=1 sweeps every profile.",