diff --git a/internal/check-packets-all.mjs b/internal/check-packets-all.mjs index 10a110df0..a6dcd0959 100644 --- a/internal/check-packets-all.mjs +++ b/internal/check-packets-all.mjs @@ -17,7 +17,7 @@ // SKIP (exit 0) only when the directory is genuinely absent or empty: packets are // generated on demand and a clean checkout has none. A skip says so out loud, with the // count, so "0 packets checked" can never read as "all packets passed". -import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { resolve, dirname, join } from 'node:path' // pathToFileURL, not a bare path: a dynamic import() of an absolute POSIX path works by luck on // linux and throws ERR_UNSUPPORTED_ESM_URL_SCHEME the moment anyone runs this on windows. @@ -38,9 +38,33 @@ if (!existsSync(DIR)) { process.exit(0) } -const files = readdirSync(DIR).filter((f) => f.endsWith('.html')).sort() +// ★ A STALE ARTIFACT IS NOT A DEFECT, and grading one as a defect is this gate's own recorded bug +// (BUG-PACKET-SWEEP-GRADED-AN-ACCIDENTAL-SET-OF-GITIGNORED-ARTIFACTS). The card was closed without +// the mechanism changing, and the gate went red again on 2026-09-28 over 43 packets left on disk +// from a run three days earlier — before the prayer tick-boxes existed at all, so it reported +// "0 prayer tick boxes" about a file that could not have had them. +// +// This whole directory is GITIGNORED (.gitignore:94 `library/scripts/*/out/**`), so nothing here +// ever reaches main or another agent: it is one developer's scratch. The gate already knows that an +// EMPTY directory is not a failure ("a clean checkout has none"); a directory of LEFTOVERS is the +// same fact wearing different clothes. +// +// So a packet older than the renderer that produces it is SKIPPED, loudly, rather than failed. The +// renderer's own mtime is the reference because that is what decides the shape a packet should have +// — comparing against "today" would re-fail every morning, and comparing against nothing is what +// produced this bug twice. +const RENDERER = resolve(HERE, '..', 'library/packet/packet-render.js') +const rendererMtime = existsSync(RENDERER) ? statSync(RENDERER).mtimeMs : 0 +const all = readdirSync(DIR).filter((f) => f.endsWith('.html')).sort() +const stale = all.filter((f) => statSync(join(DIR, f)).mtimeMs < rendererMtime) +const files = all.filter((f) => !stale.includes(f)) +if (stale.length) { + console.log(`PACKETS-ALL: skipping ${stale.length} packet(s) older than the renderer — regenerate`) + console.log(` them (ONLY= node library/scripts/posters/packet.mjs) or delete them; this`) + console.log(` directory is gitignored scratch, so a leftover is not a defect.`) +} if (!files.length) { - console.log(`PACKETS-ALL: SKIP — ${DIR} contains no .html packets`) + console.log(`PACKETS-ALL: SKIP — ${DIR} contains no .html packets newer than the renderer`) process.exit(0) } @@ -67,9 +91,30 @@ if (!EXPECT_FUNNEL) { console.log('PACKETS-ALL: ⚠ PACKETS_ALL_NO_FUNNEL=1 — page 2 is NOT required this run.') } +// ⚠ THE UNCONDITIONAL ABOVE STOPPED BEING TRUE, and this is the correction. The comment says every +// packet comes from packet.mjs "which always passes FUNNEL" — that held until SERIES landed +// (2026-09-28). A `country` packet DROPS the funnel page deliberately: the funnel narrows every +// people group on earth down to THIS UPG-100 list, which is not a description of a country, and a +// page of true numbers about the wrong thing is worse than no page. +// So the expectation follows the SERIES that produced the packet, read from series.json rather than +// hardcoded here — adding a series with funnel:false must not require editing this gate. +// A packet whose series cannot be determined keeps the old unconditional expectation, because the +// safe default for "I do not know" is the stricter check. +const SERIES_PATH = resolve(HERE, '..', 'library/scripts/posters/series.json') +const SERIES_DEFS = existsSync(SERIES_PATH) ? JSON.parse(readFileSync(SERIES_PATH, 'utf8')) : {} +const NO_FUNNEL_MARKERS = Object.entries(SERIES_DEFS) + .filter(([id, def]) => !id.startsWith('_') && def && def.funnel === false) + .flatMap(([, def]) => (def.networks || []).map(([head]) => head).filter(Boolean)) +/** True when this packet's own text identifies it as a series that has no funnel page. */ +function expectsFunnel(html) { + if (!NO_FUNNEL_MARKERS.length) return EXPECT_FUNNEL + return !NO_FUNNEL_MARKERS.some((m) => html.includes(m)) && EXPECT_FUNNEL +} + const failed = [] for (const f of files) { - const args = EXPECT_FUNNEL ? [gate, join(DIR, f), '--expect-funnel'] : [gate, join(DIR, f)] + const wantFunnel = expectsFunnel(readFileSync(join(DIR, f), 'utf8')) + const args = wantFunnel ? [gate, join(DIR, f), '--expect-funnel'] : [gate, join(DIR, f)] const res = spawnSync(process.execPath, args, { encoding: 'utf8' }) const ok = res.status === 0 console.log(` ${ok ? '✓' : '✗'} ${f}`)