diff --git a/internal/check-library-index.mjs b/internal/check-library-index.mjs index 08424039e..a62e2af4d 100644 --- a/internal/check-library-index.mjs +++ b/internal/check-library-index.mjs @@ -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.') diff --git a/package.json b/package.json index d4ce59108..7e3f73c97 100644 --- a/package.json +++ b/package.json @@ -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 `