From 6c60af555f4a0cac39fabf387f5e64456333d8f2 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:29:58 +0000 Subject: [PATCH] library-index gate: ask whether the menu drifted, not whether a file is older MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- internal/check-library-index.mjs | 139 ++++++++++++++++++++----------- package.json | 2 +- 2 files changed, 91 insertions(+), 50 deletions(-) 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 `