From 5b3618d0d6a37488bbc414f65e84615fec8ca67a Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Wed, 23 Sep 2026 04:19:42 +0000 Subject: [PATCH] Check that the CLI doc still quotes package.json correctly Applying last tick's lesson -- grep for the class, not the instance -- found a third surface teaching the old serve command. README-SECTION-3-cli-commands.md transcribes the package.json scripts so a reader need not open the file, and it still showed python3 -m http.server two commits after serve became serve-strict.mjs. An inheritor following the CLI reference would have started the permissive server, the one whose forgiveness hid a URL bug in two gates for a whole flight. Measured before fixing: ten quoted scripts, nine matching, exactly one drifted. I had expected build:unlocked to be stale too and it is not -- the doc truncates it rather than misquoting it, which is the right call for a seven-hundred-character line. Hand-fixing the one line would leave the next script change to rot the same way, so the doc is now checked. The comparison is loose in the three ways a human-facing doc needs: package.json's trailing hash comments are ignored, the doc's own slash-slash annotations are ignored, and truncation passes on a prefix match either way. A quoted script that does not exist in package.json is a hard failure, since that is a command someone would type and get "script not found". Completeness is deliberately not asserted; the doc is a curated subset and demanding otherwise would make it churn on every internal gate. Self-test poisons the doc in memory two ways, a changed body and a nonexistent script name, and requires both to be caught. Both are. Full build passes with the gate in the chain. --- .../README-SECTION-3-cli-commands.md | 2 +- internal/check-doc-quotes-scripts.mjs | 114 ++++++++++++++++++ package.json | 6 +- 3 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 internal/check-doc-quotes-scripts.mjs diff --git a/documentation/README-SECTION-3-cli-commands.md b/documentation/README-SECTION-3-cli-commands.md index d51c0de67..28c31f35e 100644 --- a/documentation/README-SECTION-3-cli-commands.md +++ b/documentation/README-SECTION-3-cli-commands.md @@ -27,7 +27,7 @@ The scripts these commands are grounded in (from this branch's `package.json`): "build": "bash internal/build-lock.sh", "build:unlocked": "bun run check:template-literal && bun run check:mjs-parse && bun run check:sfc-parse && 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 check:facet-cascade && bun run check:facet-one-field && bun run check:heartbeat && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:no-codename && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery", "preview": "bun x vite preview", -"serve": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public", +"serve": "node internal/serve-strict.mjs", // 404s a directory, like production "cap:sync": "./node_modules/.bin/cap sync android", "cap:verify": "test -f deployment/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 deployment/capacitor/android && ./gradlew assembleDebug", diff --git a/internal/check-doc-quotes-scripts.mjs b/internal/check-doc-quotes-scripts.mjs new file mode 100644 index 000000000..2bb07a1b7 --- /dev/null +++ b/internal/check-doc-quotes-scripts.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * check-doc-quotes-scripts.mjs — a doc that QUOTES package.json must still match it. + * + * THE DEFECT THIS CATCHES, found 2026-09-23. documentation/README-SECTION-3-cli-commands.md + * transcribes the package.json scripts verbatim so a reader can see them without opening the file. + * That is a genuinely useful doc and a copy that can never stay true on its own: `bun run serve` was + * changed from `python3 -m http.server` to `node internal/serve-strict.mjs` two commits earlier, and + * the doc still taught the old one. An inheritor following the CLI reference would have started the + * PERMISSIVE server — the one whose forgiveness hid a URL bug in two gates for a whole flight. + * + * ★ WHY A GATE AND NOT JUST THE FIX. The repo already carries + * AP-a-doc-that-QUOTES-a-config-file-silently-stops-matching-it. Hand-fixing this instance leaves + * the NEXT script change to rot the same way, silently, because nothing reads the doc. It is the + * same answer the repo reached for capability exemplars: prose that cites a checkable source can be + * checked; prose that cites memory cannot. + * + * WHAT IT ASSERTS. Every `"name": "body"` line in the doc that names a REAL package.json script must + * agree with it. Comparison is deliberately loose in three ways, because the doc is for humans: + * - a trailing `# …` comment in package.json is ignored (several scripts carry long explanations); + * - a trailing `// …` comment in the DOC is ignored (the doc annotates some entries); + * - the doc may TRUNCATE a long script, so a prefix match in either direction passes. build:unlocked + * is ~700 characters and no reader wants it in full; what matters is that the part shown is true. + * A name the doc quotes that does NOT exist in package.json is a hard failure — that is a command an + * inheritor would type and get "script not found". + * + * NOT ASSERTED: that every script is documented. The doc is a curated subset by design, and demanding + * completeness would make it churn on every internal gate added. + * + * NEGATIVE-TESTED via `--self-test`: poisons the doc in memory two ways (a changed body, and a script + * name that does not exist) and requires both to be caught. In-memory so a crash cannot leave the + * tree edited. + */ +import { readFileSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const DOC = join(ROOT, 'documentation/README-SECTION-3-cli-commands.md') +const REL = 'documentation/README-SECTION-3-cli-commands.md' + +const fail = (m) => { console.error('DOC-QUOTES-SCRIPTS: FAIL — ' + m); process.exit(1) } + +// Strip what is presentational rather than semantic: package.json's inline `#` notes and the doc's +// `//` annotations. Both are explanation, not command. +const norm = (s) => s.split(/\s+#\s/)[0].split(/\s+\/\/\s/)[0].trim().replace(/,$/, '').trim() + +function audit(docText, scripts) { + const problems = [] + const quoted = [...docText.matchAll(/^"([a-z0-9:_-]+)":\s*"((?:[^"\\]|\\.)*)"/gm)] + if (!quoted.length) { + problems.push('found 0 quoted `"name": "body"` lines in ' + REL + '.\n' + + ' A gate with nothing to assert is not passing; it is not running. If the doc was restructured,\n' + + ' update this gate in the same commit.') + return { problems, checked: 0 } + } + + let checked = 0 + for (const m of quoted) { + const name = m[1] + const shown = norm(m[2].replace(/\\"/g, '"')) + if (!(name in scripts)) { + problems.push(`${REL} quotes a script that does NOT EXIST: "${name}".\n` + + ' An inheritor typing it gets "script not found". Remove it or add it to package.json.') + continue + } + checked++ + const real = norm(scripts[name]) + // The doc may truncate a long script; a prefix match in either direction is fine. + const head = Math.min(30, shown.length, real.length) + if (head > 0 && (real.startsWith(shown.slice(0, head)) || shown.startsWith(real.slice(0, head)))) continue + problems.push(`${REL} — "${name}" no longer matches package.json.\n` + + ` doc : ${shown.slice(0, 70)}\n` + + ` real: ${real.slice(0, 70)}\n` + + ' The doc transcribes package.json so a reader need not open it, which means it rots on every\n' + + ' script change unless something checks it. Update the doc, not this gate.') + } + return { problems, checked } +} + +const scripts = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).scripts || {} + +if (process.argv.includes('--self-test')) { + const original = readFileSync(DOC, 'utf8') + if (audit(original, scripts).problems.length) { + console.error('DOC-QUOTES-SCRIPTS SELF-TEST: FAIL — the tree is already failing; fix that first.') + process.exit(1) + } + const cases = [ + ['body-drifted', (s) => s.replace(/^"serve":\s*"[^"]*"/m, '"serve": "python3 -m http.server 4173"')], + ['script-does-not-exist', (s) => s.replace(/^"dev":/m, '"dveloop":')], + ] + let bad = 0 + for (const [name, poison] of cases) { + const mutated = poison(original) + if (mutated === original) { console.error(` ${name}: POISON DID NOT APPLY — the case proves nothing.`); bad++; continue } + const caught = audit(mutated, scripts).problems.length > 0 + console.log(` ${name}: ${caught ? 'caught' : 'NOT CAUGHT'}`) + if (!caught) bad++ + } + if (bad) { console.error(`DOC-QUOTES-SCRIPTS SELF-TEST: FAIL — ${bad} case(s) not caught.`); process.exit(1) } + console.log('DOC-QUOTES-SCRIPTS SELF-TEST: PASS — both regressions caught; tree untouched (poisoned in memory).') + process.exit(0) +} + +const { problems, checked } = audit(readFileSync(DOC, 'utf8'), scripts) +if (problems.length) { + console.error(`DOC-QUOTES-SCRIPTS: FAIL — ${problems.length} problem(s).\n`) + for (const p of problems) console.error(' ✗ ' + p + '\n') + process.exit(1) +} +console.log(`DOC-QUOTES-SCRIPTS: PASS — ${checked} quoted script(s) match package.json.`) +console.log(' Truncation is allowed (build:unlocked is ~700 chars); what is SHOWN must be true.') +console.log(' Completeness is deliberately not asserted — the doc is a curated subset.') diff --git a/package.json b/package.json index 37a30ce97..d1e85133b 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "check:facet-changes-map:self-test": "node internal/check-facet-changes-map.mjs --self-test # reverts the fix IN THE LIVE PAGE and requires the gate to FAIL. SELF_TEST_MODE=colour (default, isolates the colour assertion — the reported bug) | props | both | frozen (blocks the source update, isolating the set assertion). All four modes verified caught.", "check:heartbeat": "node internal/check-heartbeat-threshold.mjs # the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).", "check:heartbeat:self-test": "node internal/check-heartbeat-threshold.mjs --self-test # THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.", - "build:unlocked": "bun run check:template-literal && bun run check:mjs-parse && bun run check:sfc-parse && bun run check:tab-radius && bun run check:synced-css && bun run check:wp-embed && 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 check:facet-cascade && bun run check:facet-one-field && bun run check:heartbeat && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:no-codename && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery", + "build:unlocked": "bun run check:template-literal && bun run check:mjs-parse && bun run check:sfc-parse && bun run check:tab-radius && bun run check:synced-css && bun run check:wp-embed && bun run check:doc-scripts && 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 check:facet-cascade && bun run check:facet-one-field && bun run check:heartbeat && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:no-codename && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery", "check:template-literal": "node internal/template-literal-check.mjs", "verify:map-paint": "node internal/verify-map-paint.mjs", "check:sri": "node internal/sri-consistency-check.mjs", @@ -118,7 +118,9 @@ "check:backlog-status": "node internal/check-backlog-status-enum.mjs", "check:mjs-parse": "node internal/check-mjs-parses.mjs", "check:synced-css:self-test": "node internal/check-synced-css-copies.mjs --self-test", - "serve:permissive": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public # LOOPBACK ONLY. This was --bind 0.0.0.0, which published the built tree on every interface of whatever machine ran a gate; measured on the build VPS it answered HTTP 200 on the box's PUBLIC IP while a browser gate was running. Every consumer (verify-browser-all, check:a11y-button-names, check:nav-reachable, smoke:share-all) already defaults to 127.0.0.1, so nothing needed the wider bind. Override with BASE_URL/BASE if you deliberately want to reach it from another host." + "serve:permissive": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public # LOOPBACK ONLY. This was --bind 0.0.0.0, which published the built tree on every interface of whatever machine ran a gate; measured on the build VPS it answered HTTP 200 on the box's PUBLIC IP while a browser gate was running. Every consumer (verify-browser-all, check:a11y-button-names, check:nav-reachable, smoke:share-all) already defaults to 127.0.0.1, so nothing needed the wider bind. Override with BASE_URL/BASE if you deliberately want to reach it from another host.", + "check:doc-scripts": "node internal/check-doc-quotes-scripts.mjs", + "check:doc-scripts:self-test": "node internal/check-doc-quotes-scripts.mjs --self-test" }, "devDependencies": { "@capacitor/android": "^7.6.9",