diff --git a/documentation/README-SECTION-3-cli-commands.md b/documentation/README-SECTION-3-cli-commands.md index 062a053ec..770c1427d 100644 --- a/documentation/README-SECTION-3-cli-commands.md +++ b/documentation/README-SECTION-3-cli-commands.md @@ -22,7 +22,11 @@ The scripts these commands are grounded in (from this branch's `package.json`): // BUILD_LOCK_WAIT=600 to QUEUE rather than collide. Note the lock serializes but does NOT // dedupe: N queued builds = N full rebuilds. // ★ THE PIPELINE RUNS IN PHASES, NOT AS ONE `&&` CHAIN (2026-09-23). Measured on the build -// box: the 29 gates cost 84.5s chained, but 21 of them finish under 700ms — they were +// box AS IT WAS THAT DAY — the numbers below are a DATED BENCHMARK, not a running total. +// The gate set has grown since (36 steps as of 2026-09-24: 7 pre + 29 post); the timings +// and the "31 in, 31 out" count are kept verbatim because they are the EVIDENCE that the +// phase change preserved the step set. Restating them as current would destroy that. +// Then: the 29 gates cost 84.5s chained, but 21 of them finish under 700ms — they were // queueing behind check:mjs-parse (26.9s) and check:parse (18.7s), not working. The 22 vite // builds cost 129.0s and were never ordered at all: vite.config.js gives each bundle its own // outDir with emptyOutDir:false, so they only ever needed to not overwrite each other. @@ -36,13 +40,16 @@ The scripts these commands are grounded in (from this branch's `package.json`): // `&&` BETWEEN phases is load-bearing and stays: a source gate still blocks the build, and // the build still blocks every gate that reads its output. Within a phase the runner reports // ALL failures instead of stopping at the first, so three broken gates cost one cycle, not three. +// ⚠ check:quoted-scripts IS ITSELF IN gates:post, so adding or removing ANY gate changes the +// very line this doc quotes — the update is self-referential and takes two passes: wire the +// gate, then re-transcribe. That is the mechanism working, not a bug. // 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. "dev": "bun x vite", "build": "bash internal/build-lock.sh", "build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery", "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:wp-embed check:doc-scripts", -"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token", +"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts", "build:bundles": "node internal/run-parallel.mjs --bundles", // the 22 vite builds, 4 at a time "preview": "bun x vite preview", "serve": "node internal/serve-strict.mjs", // 404s a directory, like production diff --git a/internal/check-no-orphan-gates.mjs b/internal/check-no-orphan-gates.mjs index 21fcfbed0..9499add44 100644 --- a/internal/check-no-orphan-gates.mjs +++ b/internal/check-no-orphan-gates.mjs @@ -107,6 +107,39 @@ if (orphans.length) { process.exit(1) } +// ── SECOND, DISTINCT QUESTION: does it RUN? ──────────────────────────────── +// Reachable and wired are not the same property, and conflating them is how 70 of +// 100 check:* scripts came to sit outside every gate wave without anyone noticing — +// including this gate itself. A script entry means a human CAN run it; membership in +// gates:pre/gates:post means it runs on every build. +// +// This REPORTS rather than fails, deliberately. Most unwired gates are correctly +// hand-run: they need a served tree (check:mobile, check:shots), a device, a token, +// or an argument (check:packet-render takes a path). Failing the build on those +// would be wrong and the gate would be disabled within a day. +// +// ⚠ AND SOME MUST NEVER BE WIRED, which "it passes when I run it" cannot tell you. +// check:settled asserts the tree is QUIET — no builder running, no recent churn — so +// it fails inside a build BY DESIGN. Measured 2026-09-24: it passed twice standalone, +// was wired on that evidence, and failed the very next build (29/30). The test for +// wave membership is not "does it pass" but "is its assertion compatible with running +// mid-build". What it must not do is +// stay silent — a gate that could run and does not is indistinguishable from one +// that is broken, which is the exact failure verify-shell-scroll had. +const pkg = JSON.parse(pkgRaw) +const waves = `${pkg.scripts['gates:pre'] || ''} ${pkg.scripts['gates:post'] || ''}` +const checks = Object.keys(pkg.scripts).filter((k) => /^(check|verify|smoke):/.test(k) + && !k.endsWith(':self-test')) +const unwired = checks.filter((k) => !waves.includes(k)) +console.log(` wave membership : ${checks.length - unwired.length}/${checks.length} ` + + `check/verify script(s) run in gates:pre or gates:post`) +if (unwired.length) { + console.log(` ${unwired.length} run only by hand — expected for gates needing a served tree, a`) + console.log(' device, a token or an argument. If one of these needs no precondition, wire it:') + for (const k of unwired.slice(0, 8)) console.log(` · ${k}`) + if (unwired.length > 8) console.log(` …and ${unwired.length - 8} more`) +} + console.log(`NO-ORPHAN-GATES: PASS — ${checked} gate-shaped file(s); all reachable (${tagged} tagged @one-shot).`) console.log(' Reachable means named in package.json or imported by something that is. The @one-shot') console.log(' tag exists so a dated diagnostic can be KEPT as a reproduction recipe without either') diff --git a/internal/check-packet-render.mjs b/internal/check-packet-render.mjs index a43e56bff..0c5d67b2b 100644 --- a/internal/check-packet-render.mjs +++ b/internal/check-packet-render.mjs @@ -58,6 +58,39 @@ const lang = await page.evaluate(() => document.documentElement.lang || 'en') // the bug it is, and no other assertion here would notice. const RTL = new Set(['ar', 'he', 'fa', 'ur', 'ps', 'sd', 'yi']) const dir = await page.evaluate(() => document.documentElement.dir || 'ltr') +// ── AND THE TEXT MUST ACTUALLY BE LAID OUT THAT WAY ───────────────────────── +// dir="rtl" is a DECLARATION, not a layout. It can be set correctly on +// while an inner element overrides it, or while the sheet keeps its LTR flow — +// and every other assertion here would still pass, because an Arabic packet with +// readable glyphs in LTR order looks like a styling quirk rather than the bug it +// is. So measure where the painted text actually sits. +// +// ⚠ MEASURE THE INLINE TEXT RUN, NOT THE BLOCK. A first attempt compared each +// element's own box to its parent's and got IDENTICAL numbers for Arabic and +// English — it was measuring container geometry, which does not move when +// direction flips. A Range over the text node gives the painted extent, which +// does: in ar the run pins to the RIGHT edge (rightGap 0, slack on the left), in +// en/hi to the LEFT. Centred text is skipped because it proves nothing either +// way, and so is text that fills its box — no room to tell. +const layout = await page.evaluate(() => { + const out = { pinnedRight: 0, pinnedLeft: 0, sampled: 0, rtlComputed: 0 } + for (const el of document.querySelectorAll('.sheet *')) { + if (el.childElementCount) continue + if ((el.textContent || '').trim().length < 8) continue + const cs = getComputedStyle(el) + if (cs.textAlign === 'center') continue + const box = el.getBoundingClientRect() + const rng = document.createRange(); rng.selectNodeContents(el) + const tb = rng.getBoundingClientRect() + if (tb.width < 20 || box.width < 50 || box.width - tb.width < 12) continue + if (cs.direction === 'rtl') out.rtlComputed += 1 + if (Math.round(box.right - tb.right) <= 1) out.pinnedRight += 1 + else if (Math.round(tb.left - box.left) <= 1) out.pinnedLeft += 1 + out.sampled += 1 + if (out.sampled >= 25) break + } + return out +}) // SCRIPT FONT. Some languages need a face the system stack does not carry — Hindi // renders as tofu boxes in Georgia/Palatino/serif. When the packet declares one, // it must actually LOAD; a declared-but-failed @font-face is silent, and the page @@ -240,6 +273,20 @@ if (fontStatus.declared && fontStatus.loaded !== fontStatus.declared) { } if (RTL.has(lang) && dir !== 'rtl') fails.push(`lang="${lang}" is right-to-left but dir="${dir}"`) if (!RTL.has(lang) && dir === 'rtl') fails.push(`lang="${lang}" is not RTL but dir="rtl"`) +// The attribute is necessary; this is the part that proves it took effect. +if (layout.sampled >= 5) { + const wantRight = RTL.has(lang) + const pinned = wantRight ? layout.pinnedRight : layout.pinnedLeft + if (pinned < Math.ceil(layout.sampled * 0.8)) { + fails.push(`lang="${lang}" should paint text to the ${wantRight ? 'RIGHT' : 'LEFT'}, but only ` + + `${pinned}/${layout.sampled} sampled runs do (right ${layout.pinnedRight}, left ${layout.pinnedLeft}) — ` + + `dir="${dir}" is declared and not taking effect`) + } + if (RTL.has(lang) && layout.rtlComputed < layout.sampled) { + fails.push(`${layout.sampled - layout.rtlComputed}/${layout.sampled} sampled elements compute ` + + 'direction:ltr inside an RTL packet — something overrides the document direction') + } +} if (translated === 0) { fails.push(`lang="${lang}" but no string differs from English — ` + 'the language block is missing, mis-keyed, or the renderer ignored it') @@ -373,6 +420,8 @@ console.log(` page size : ${boxes[0] ? boxes[0].join(' x ') : '?'} pt` console.log(` layout : ${r.cards} cards / ${PER_PAGE} per page ` + `= ${expectedSheets} sheets expected, ${r.sheets} rendered`) console.log(` script font : ${fontStatus.declared ? fontStatus.names.join(', ') : 'system stack'}`) +console.log(` text layout : ${layout.pinnedRight} right / ${layout.pinnedLeft} left of ` + + `${layout.sampled} sampled run(s)${layout.sampled < 5 ? ' (too few to assert)' : ''}`) console.log(` language : ${lang} (dir=${dir}) (${r.rhythms}/${r.rhythmsTotal} rhythms in ${lang}` + `${translated === null ? '' : `, ${translated} strings translated`})`) diff --git a/internal/check-poster-prints.mjs b/internal/check-poster-prints.mjs index c3f15377a..e5962470e 100644 --- a/internal/check-poster-prints.mjs +++ b/internal/check-poster-prints.mjs @@ -128,7 +128,14 @@ const r = await pg.evaluate(async ({ src, frame, packetRender, cardRender, qr, s out.packetSheets = pdoc.querySelectorAll('.sheet').length out.packetCards = pdoc.querySelectorAll('.card').length out.packetWheel = pdoc.querySelectorAll('svg path[fill^="hsl"]').length - out.packetMark5 = /Gadarenes/.test(pdoc.body.innerText) + // ⚠ NOT /Gadarenes/. That is an ENGLISH proper noun from the World English Bible + // text, so this assertion would go RED on a CORRECTLY TRANSLATED packet and passes + // today only because point4_passage is still English in all ten non-English blocks + // (DECIDE-MARK5-SCRIPTURE-IS-ENGLISH-IN-EVERY-TRANSLATED-PACKET). The check and the + // bug were locked together — the same fix already applied in check-packet-render + // and check-packet-browser. Assert the passage BLOCK and its length, which holds in + // any language. + out.packetMark5Len = (pdoc.querySelector('.p4-passage') || { textContent: '' }).textContent.trim().length // ── without a provider: must FAIL HONESTLY, not throw ──────────────────── const noProv = usePosterPrints({ label: (i) => i.label }) @@ -155,13 +162,19 @@ if (!/no records provider/i.test(r.noProvMsg)) { if (r.packetSheets !== 4) fails.push(`the packet printed ${r.packetSheets} sheets, expected 4`) if (r.packetCards !== 2) fails.push(`the packet printed ${r.packetCards} people cards, expected 2`) if (r.packetWheel !== 12) fails.push(`the prayer wheel has ${r.packetWheel} segments, expected 12`) -if (!r.packetMark5) fails.push('the packet is missing Mark 5:1-20 in full') +// The English passage is 2,141 chars; 1,200 is a floor no truncation or placeholder +// survives, with room for languages that render the same text more compactly. +if (r.packetMark5Len < 1200) { + fails.push(r.packetMark5Len === 0 + ? 'the packet is missing Mark 5:1-20 in full (no .p4-passage block)' + : `Mark 5:1-20 looks truncated: ${r.packetMark5Len} chars`) +} if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`) console.log(` list print : ${r.listRows} rows, checkbox=${r.listHasCheckbox}`) console.log(` without provider : phase=${r.noProvPhase} "${r.noProvMsg.slice(0, 60)}"`) console.log(` packet : ${r.packetSheets} sheets, ${r.packetCards} cards, ` - + `${r.packetWheel} wheel segments, Mark5=${r.packetMark5}`) + + `${r.packetWheel} wheel segments, Mark5=${r.packetMark5Len} chars`) console.log(` page errors : ${errs.length}`) if (fails.length) { diff --git a/package.json b/package.json index 52f776d73..eb0c8eca7 100644 --- a/package.json +++ b/package.json @@ -141,7 +141,7 @@ "check:doc-scripts:self-test": "node internal/check-doc-quotes-scripts.mjs --self-test", "check:legend-source": "node internal/check-legend-matches-source.mjs # the numbers the maps SHOW must match the numbers the live SOURCE has. Every other gate checks INTERNAL consistency — a page can agree with itself perfectly while its legend has stopped describing reality. Offline SKIPs (exit 0); only a reachable endpoint that disagrees fails.", "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:wp-embed check:doc-scripts", - "gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity", + "gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts", "build:bundles": "node internal/run-parallel.mjs --bundles", "check:maplibre-drops": "node internal/check-maplibre-layer-drops.mjs", "check:tabs-localized": "node internal/check-tabs-localized.mjs # a chosen locale must CHANGE the rendered tab text. check:i18n-parity compares catalogs to each OTHER, so it cannot tell a shipped translation from a dead one; tabs.json is reached only via a DYNAMIC key (SemanticTreeLegend.vue:40), invisible to any grep. Renders it and reads the result.",