diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 0414577e1..92c088ade 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -155,6 +155,15 @@ jobs: continue-on-error: true run: bun run check:shots + # BLOCKING, unlike the three report-only steps above: this compares two files that + # are both already in the checkout, needs no network, no browser and no toolchain, + # and is fully deterministic — so a failure is always a real defect, never runner + # flake. The doc it guards QUOTES package.json verbatim and had silently drifted + # TWICE, most recently hiding check:public-hygiene (the secret scan) from the + # build chain a reader believes they are auditing. + - name: Assert the docs' quoted scripts still match package.json + run: bun run check:quoted-scripts + - name: Assert no local credential is being published run: | if [ -e public/token.local.js ]; then diff --git a/docs/README-SECTION-3-cli-commands.md b/docs/README-SECTION-3-cli-commands.md index 4787f3d55..d1fab0a78 100644 --- a/docs/README-SECTION-3-cli-commands.md +++ b/docs/README-SECTION-3-cli-commands.md @@ -25,10 +25,10 @@ The scripts these commands are grounded in (from this branch's `package.json`): // claiming to be ground truth, and this one had silently stopped being it. "dev": "bun x vite", "build": "bash internal/build-lock.sh", -"build:unlocked": "bun run check:template-literal && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run gallery", +"build:unlocked": "bun run check:template-literal && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run gallery", "preview": "bun x vite preview", "serve": "python3 -m http.server 4173 --bind 0.0.0.0 --directory public", -"cap:sync": "bunx cap sync android", +"cap:sync": "./node_modules/.bin/cap sync android", "cap:verify": "test -f capacitor/android/app/src/main/assets/public/index.html && echo \"OK webDir copied\" || (echo \"FAIL: empty assets/public -> blank white screen. Check webDir.\" && exit 1)", "apk:debug": "bun run build && bun run cap:sync && bun run cap:verify && cd capacitor/android && ./gradlew assembleDebug", "apk:install": "adb install -r capacitor/android/app/build/outputs/apk/debug/app-debug.apk", diff --git a/docs/reference.md b/docs/reference.md index 2ab6dc53a..7dcd7e3b2 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -220,6 +220,16 @@ so running it inside the build would make it unfalsifiable. Run them before publ | `bun run check:done-evidence` | a backlog card marked `done` with no bluf **and** no fields — a status word the next team cannot reconcile against the code | reads `_qa-cycle/`, which is not in any repo CI clones | | `bun run verify:gate1` | the Gate-1 chain end-to-end against LIVE data: header filter → composite props → real grid re-filter → count back up | needs network; it is an acceptance test, not a build step | +**`bun run check:quoted-scripts` is the exception that proves the rule above** — it is the one +doc-integrity gate that DOES run in CI (blocking), because it compares two files already in the +checkout: it needs no browser, no network, and no mtime. It asserts that the `scripts` block +quoted verbatim in `docs/README-SECTION-3-cli-commands.md` still matches `package.json`. That doc +carries its own hand-written plea to re-transcribe after every edit, and drifted anyway — twice. +The second drift omitted `check:public-hygiene` (the secret scan) from the build chain it +documents, which is the dangerous shape: **an omission in a quoted list is invisible**, since +there is no wrong character to notice. `package.json` is what runs, so the fix is always to update +the doc. + `bun run verify:5-caps` proves all five capability tiers mount and render. It needs a token — `MAPBOX_TOKEN=$(curl -s ) bun run verify:5-caps` — and it sets **both** `?tk=` (the top-level element) **and** `window.MAP_TOKEN` (a composite's nested children read the global, diff --git a/internal/check-quoted-scripts.mjs b/internal/check-quoted-scripts.mjs new file mode 100644 index 000000000..4192f7971 --- /dev/null +++ b/internal/check-quoted-scripts.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * check-quoted-scripts.mjs — a doc that QUOTES package.json must still match package.json. + * + * THE BUG THIS EXISTS TO PREVENT (real, twice) + * docs/README-SECTION-3-cli-commands.md reproduces the `scripts` block verbatim so a reader can + * see every command without opening package.json. It carries its own warning two lines above the + * block: "If you edit package.json, re-transcribe this block — a doc that QUOTES a config is + * claiming to be ground truth, and this one had silently stopped being it." + * + * It then silently stopped being it AGAIN. Two entries had drifted on 2026-09-21: + * - `cap:sync` still showed `bunx cap sync android` — the exact command that does not exist in + * this environment (exit 127) and that package.json had ALREADY been fixed away from. + * - `build:unlocked` was missing `check:public-hygiene` and `check:label-joins`. That is the + * worst shape of this defect: a reader auditing what the build verifies would conclude the + * SECRET-SCAN is not in the chain. An omission in a quoted list is invisible — there is no + * wrong character to notice, just a step that is not there. + * + * ★ WHY A GATE AND NOT A BETTER WARNING. The file already had the warning, written by someone who + * had just been burned, and the warning did not work — because re-transcribing by hand is exactly + * the kind of mechanical task a human or an agent skips when the edit felt unrelated. The rule was + * enforced only by remembering. This is the same failure the library-index gate was built for: + * a documented invariant with no mechanism behind it is a wish. + * + * WHY COMPARE AGAINST package.json AND NOT A SNAPSHOT + * package.json is the thing that RUNS. The doc is the copy. So the authority is unambiguous and + * the fix direction is always "update the doc" — never the reverse. + * + * WHAT IT TOLERATES DELIBERATELY + * - Trailing ` # comment` in a package.json body: several scripts carry an inline explanation + * that the doc block reasonably omits. Compared against the code half only. + * - JSON escaping (\" in the doc's fenced block) and a trailing comma. + * - Entries the doc chooses NOT to quote: this checks the ones it DOES quote, so the doc can + * stay a curated subset. It reports the count so shrinkage is visible. + * + * ★ THE MATCHER MUST NOT SILENTLY MATCH NOTHING. The first version of this regex required each + * line to END in a quote (`"$`). Nine of the ten entries end in `",` — a trailing comma — so it + * matched exactly ONE (`phone`, the last entry, the only one without a comma) and reported + * "0 drifted". That is a pass that means nothing, and it looks identical to a real clean run. + * (My first guess at the cause was "the block is indented"; it is NOT indented — checking rather + * than assuming is what found the trailing comma. A plausible explanation for a broken measurement + * is still a fabricated one until verified.) + * Hence MIN_EXPECTED below: too few matches is itself a FAILURE, not a quiet success. + * + * NEGATIVE-TESTED: reverting `cap:sync` in the doc to `bunx cap sync android` makes this FAIL + * naming that entry and showing both sides; restoring it returns PASS. Breaking the regex so it + * matches < MIN_EXPECTED also FAILs rather than passing vacuously. + */ +import { readFileSync, existsSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const PKG = join(ROOT, 'package.json') +const DOC = join(ROOT, 'docs', 'README-SECTION-3-cli-commands.md') + +// The doc quoted 10 entries when this gate was written. If the matcher suddenly finds far fewer, +// the matcher is broken, not the doc. +const MIN_EXPECTED = 6 + +if (!existsSync(DOC)) { + console.log('QUOTED-SCRIPTS: SKIP — docs/README-SECTION-3-cli-commands.md not present.') + process.exit(0) +} + +const scripts = JSON.parse(readFileSync(PKG, 'utf8')).scripts || {} +const doc = readFileSync(DOC, 'utf8') + +// Indent-tolerant: the block is inside a fenced code block and is indented. +const RE = /^[ \t]*"([a-z0-9:_-]+)":\s+"(.*?)",?[ \t]*$/gm + +const drift = [], unknown = [] +let quoted = 0 + +for (const m of doc.matchAll(RE)) { + const name = m[1] + const shown = m[2].replace(/\\"/g, '"').replace(/,$/, '').trim() + if (!(name in scripts)) { unknown.push(name); continue } + quoted++ + const real = scripts[name] + const realCode = real.split(/\s{2,}#/)[0].trim() // drop an inline ` # …` explanation + if (shown !== real.trim() && shown !== realCode) drift.push({ name, shown, real: realCode }) +} + +console.log(`QUOTED-SCRIPTS: ${quoted} quoted entr(ies) compared against package.json.`) + +if (quoted < MIN_EXPECTED) { + console.error(`\nQUOTED-SCRIPTS: FAIL — only ${quoted} entries matched (expected >= ${MIN_EXPECTED}).`) + console.error(' The MATCHER is broken, not the doc. A checker that matches almost nothing') + console.error(' reports "0 drifted" and looks exactly like a clean pass — fix the regex in') + console.error(' internal/check-quoted-scripts.mjs before trusting this result.') + process.exit(1) +} + +if (unknown.length) { + console.log(` i quoted but not in package.json (ignored): ${unknown.join(', ')}`) +} + +if (drift.length) { + console.error(`\nQUOTED-SCRIPTS: FAIL — ${drift.length} quoted script(s) no longer match package.json:`) + for (const d of drift) { + console.error(`\n ✗ ${d.name}`) + console.error(` doc : ${d.shown}`) + console.error(` real : ${d.real}`) + } + console.error('\n package.json is what RUNS; the doc is the copy — update the DOC to match.') + console.error(' An omitted step in a quoted list is invisible: there is no wrong character to') + console.error(' notice, just a check the reader believes is not in the chain.') + process.exit(1) +} +console.log('QUOTED-SCRIPTS: PASS — every quoted script matches package.json.') diff --git a/package.json b/package.json index 11f38052d..d5d11134a 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "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 \u2014 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 \u2014 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 \u2014 this generator scans library/ directly, with no build step between). Fix: bun run library-index", + "check:quoted-scripts": "node internal/check-quoted-scripts.mjs # docs/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 \u2014 update the DOC.", "shots": "node internal/readme-shots.mjs # regenerate docs/readme-images/*.png (fetches its own Mapbox token SERVER-SIDE, so the browser CORS block does not apply). Was UNREACHABLE \u2014 present but invoked by no script, which is how the 12 PNGs silently rotted a day behind the UI.", "library-index": "node internal/generate-library-index.mjs # regenerate docs/reference-library-index.md \u2014 the DERIVED menu of the 84 reusable library pieces an author can import instead of rebuilding. Was UNREACHABLE, and the index did not exist at all: its header promised 'can't drift from the code' while nothing ever ran it.", "check:done-evidence": "node internal/check-done-evidence.mjs # a card may not reach status:done with NO bluf AND NO fields \u2014 the Doxa team inherits this board and an evidence-less 'done' cannot be reconciled against the code. 10 pre-existing are baselined by id; the list should only ever SHRINK.",