Gate the poster downloads the posterAction prop cannot see
check:poster-action-print asserted that PosterWall defaults to printing and that any consumer passing posterAction 'download' explains itself. Both held, and both missed the case that actually shipped: a component that never touches PosterWall and writes its own <a download> for a poster image. GiTree had a "Download SVG" button as the sibling of its own printPoster(), same makePoster() behind each, so the poster could reach disk without the print dialog ever opening. Second arm: no shipped file may set a.download to an image or document name (svg/png/webp/pdf/jpg) unless the line is reached only after a print attempt failed (if (!opened) / if (!w)), or is the prop-gated opt-in the first arm already polices, or carries a posterAction-download: reason. Data exports stay out of scope -- csv, txt and json are not posters, so the five legitimate CSV and letter exports stay green. Keying on the filename at the call site rather than on the helper matters: four of the seven download sites pass a variable, so a check written against the helper would have passed them without being able to see what they wrote. Six self-tests carry the shapes: the GiTree line fails, the print-blocked fallback passes, the gated opt-in passes, the explained exception passes, csv and txt pass. They are the proof the arm still bites now that no offender is left in the tree.
This commit is contained in:
parent
a70cf9df4f
commit
ce0417efc6
2 changed files with 51 additions and 3 deletions
|
|
@ -31,9 +31,12 @@ const walk = (d, out = []) => {
|
|||
}
|
||||
return out
|
||||
}
|
||||
const shipped = [...walk(join(ROOT, 'plugin-designs')), ...walk(join(ROOT, 'library'))]
|
||||
|
||||
// ── arm 1: a consumer passing posterAction 'download' must say why ────────────
|
||||
const USE = /(?:posterAction|<PosterDialog[^>]*\baction)\s*[=:]\s*["']download["']|:poster-action\s*=\s*["']'download'["']|poster-action\s*=\s*["']download["']/
|
||||
let optIns = 0
|
||||
for (const f of [...walk(join(ROOT, 'plugin-designs')), ...walk(join(ROOT, 'library'))]) {
|
||||
for (const f of shipped) {
|
||||
if (f.endsWith('PosterWall.vue') || f.endsWith('PosterDialog.vue')) continue
|
||||
const lines = readFileSync(f, 'utf8').split('\n')
|
||||
lines.forEach((l, i) => {
|
||||
|
|
@ -43,5 +46,50 @@ for (const f of [...walk(join(ROOT, 'plugin-designs')), ...walk(join(ROOT, 'libr
|
|||
if (!/posterAction-download:\s*\S/.test(above)) fail(`${relative(ROOT, f)}:${i + 1} opts into posterAction 'download' without a "posterAction-download: <why>" comment`)
|
||||
})
|
||||
}
|
||||
|
||||
// ── arm 2: a poster IMAGE download that never tries to print ──────────────────
|
||||
// Arm 1 only sees consumers that pass a PosterWall/PosterDialog prop. A component
|
||||
// that hand-rolls its own <a download> for an image bypasses it entirely — GiTree's
|
||||
// "Download SVG" button does, sitting right beside its own print path. Data exports
|
||||
// (csv, txt, json) are out of scope: the Driver's rule is about POSTERS.
|
||||
// Legitimate shapes, both allowed: a FALLBACK reached only after a print attempt
|
||||
// failed (`if (!opened)`), and the prop-gated opt-in arm 1 already polices.
|
||||
// The founding bug was GiTree.vue's "Download SVG" button, sibling to its own
|
||||
// printPoster(); gospel removed it in cbb0ba1df. Since no offender remains in the
|
||||
// tree, the self-tests at the foot of this file are the whole proof that this arm
|
||||
// still fires — keep them, and keep the GiTree line among them.
|
||||
const IMAGE_DL = /\.download\s*=\s*[`'"][^`'"]*\.(?:svg|png|webp|pdf|jpe?g)\b|\.download\s*=\s*`[^`]*\.(?:svg|png|webp|pdf|jpe?g)\b/i
|
||||
const GUARDED = /if\s*\(\s*!\s*(?:opened|w|win|frame|printed)\b|posterAction\s*===\s*['"]download['"]|action\s*===\s*['"]download['"]/
|
||||
for (const f of shipped) {
|
||||
const lines = readFileSync(f, 'utf8').split('\n')
|
||||
const rel = relative(ROOT, f)
|
||||
lines.forEach((l, i) => {
|
||||
if (!IMAGE_DL.test(l)) return
|
||||
const ctx = lines.slice(Math.max(0, i - 4), i + 1).join('\n')
|
||||
if (GUARDED.test(ctx)) return
|
||||
if (/posterAction-download:\s*\S/.test(ctx)) { optIns++; return }
|
||||
fail(`${rel}:${i + 1} writes a poster image to disk with no print attempt first — a poster button opens the print dialog (if it truly must save, say why in a "posterAction-download: <reason>" comment)`)
|
||||
})
|
||||
}
|
||||
// Self-tests: prove arm 2 fires on the real defect and stays quiet on the real
|
||||
// exceptions. Without these the arm's PASS would be worth nothing.
|
||||
const t = (name, src, shouldFail) => {
|
||||
const lines = src.split('\n')
|
||||
let hit = false
|
||||
lines.forEach((l, i) => {
|
||||
if (!IMAGE_DL.test(l)) return
|
||||
const ctx = lines.slice(Math.max(0, i - 4), i + 1).join('\n')
|
||||
if (GUARDED.test(ctx) || /posterAction-download:\s*\S/.test(ctx)) return
|
||||
hit = true
|
||||
})
|
||||
if (hit !== shouldFail) fail(`self-test "${name}": expected ${shouldFail ? 'FAIL' : 'pass'}, got ${hit ? 'FAIL' : 'pass'}`)
|
||||
}
|
||||
t('GiTree-shaped poster download', "a.download = `gospel-language-tree-${p.size}in.svg`", true)
|
||||
t('print-blocked fallback', "const opened = openPrintFrame(html)\nif (!opened) _download(blob, `${name}.png`)\na.download = `${name}.png`", false)
|
||||
t('gated opt-in', "if (props.posterAction === 'download')\n a.download = `${id}-poster.webp`", false)
|
||||
t('explained exception', "// posterAction-download: the print shop needs the vector file\na.download = 'map.pdf'", false)
|
||||
t('csv export', "a.download = 'gospel-internet.csv'", false)
|
||||
t('text letter', "a.download = 'letter.txt'", false)
|
||||
|
||||
if (bad) { console.log(`POSTER-ACTION-PRINT: FAIL — ${bad} problem(s).`); process.exit(1) }
|
||||
console.log(`POSTER-ACTION-PRINT: PASS — PosterWall prints by default; ${optIns} explicit download opt-in(s), each with its reason.`)
|
||||
console.log(`POSTER-ACTION-PRINT: PASS — PosterWall prints by default; ${optIns} explicit download opt-in(s), each with its reason; 0 unguarded poster-image downloads.`)
|
||||
|
|
|
|||
|
|
@ -512,7 +512,7 @@
|
|||
"check:popup-escaping": "the library's pin and region popups render hostile data (an <img onerror>, a <script>, a javascript: URL) as text, never markup: every field goes through escapeHtml/safeUrl (library/utils/html.js). Headless.",
|
||||
"check:ported-globals-identical": "the DX Life base/_globals.scss rules that shadow-root components used to copy now come from one module (library/styles/portedGlobals.js), and the CSS each component actually INJECTS must be byte-identical to the pre-refactor baseline: it runs each component's setup with stubbed imports and hashes the real useShadowStyles argument, not the source text. Deduplication with a proof that no pixel can change. Node only, no browser.",
|
||||
"check:pin-selection": "the shared source reader understands BOTH engines: mapbox-gl stores a GeoJSON source _data as the FeatureCollection, maplibre-gl wraps it as { geojson } (or { url } when remote). Reading only the Mapbox shape returned an EMPTY list on MapLibre — the keyless engine public builds use — which silently killed zoomToLegendRow camera fits on three maps and the research packet row selection, with no error because every caller no-ops on no features. Asserts both shapes, that a remote source returns [] AND warns, the serialize fallback, and that selectedPinIds agrees with the reader. Headless, no map. Exit 0 pass, 1 fail.",
|
||||
"check:poster-action-print": "a poster button opens the print dialog (Driver rule 2026-09-28: never a direct download). PosterWall's posterAction defaults to 'print', and any consumer that opts into 'download' carries a posterAction-download: comment saying why. Source-only, headless.",
|
||||
"check:poster-action-print": "a poster button opens the print dialog (Driver rule 2026-09-28: never a direct download). Two arms: PosterWall's posterAction defaults to 'print' and any consumer opting into 'download' carries a posterAction-download: comment saying why; and no shipped file hand-rolls an <a download> for a poster IMAGE (svg/png/webp/pdf) without first attempting to print, which the prop arm cannot see. Data exports (csv/txt/json) are out of scope. 6 self-tests. Source-only, headless.",
|
||||
"check:poster-attribution": "the poster footer is a LICENCE statement, so it must credit whoever actually served the tiles. posterDefaults ships '© Mapbox © OpenStreetMap', which under the fleet's MapLibre default credits a company that served nothing. Checked live 2026-09-28: the OpenFreeMap STYLE declares no attribution on either source — the credit is in the TileJSON, merged into the SOURCE object after load — so it is read from map.getSource(id), flattened from HTML, deduped, and an unreadable map falls back rather than printing a blank line. Headless; in gates:pre.",
|
||||
"check:poster-card-fits": "a poster card's print-options row must stay inside its own card. Reported by the repo agent off a 1440px screenshot: 27/27 cards overflowing by 110px, 36 neighbour intrusions. .pfs already had width:82%, so SOURCE READING SAID IT WAS FINE — flex items default to min-width:auto and refuse to shrink. Asserts BOTH no-overlap AND that the controls keep a usable box, because a row whose buttons collapsed to nothing would also \"not overflow\". Sweeps 390/768/1024/1440/1920px and asserts the row sits BELOW the preview too — measuring only left/right edges is how it passed a driver-visible regression. CARD_FIT_WIDTH overrides the list. SKIPs when nothing is served.",
|
||||
"check:poster-degrades": "with NEITHER poster nor source — the DEFAULT drop — nothing must print and the reader must be told why. check:poster-print-fallback SKIPs that case, so the behaviour a reader most often meets had no gate. MUST be served from the TREE ROOT, not public/, or assetsBase resolves outside the tree and the poster loads when it should 404.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue