diff --git a/internal/check-legend-data-contract.mjs b/internal/check-legend-data-contract.mjs new file mode 100644 index 000000000..8bbe5328f --- /dev/null +++ b/internal/check-legend-data-contract.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +/** + * check-legend-data-contract — nobody may read a key off useLegendData that it does not return. + * + * WHY A WHOLE-CLASS CHECK AND NOT A ONE-LINE FIX. useLegendData returns + * { items, columns, title, activeFilter, setFilter, totalCount, totalPopulation, progress, fmtPop, + * fmtCount }. There is no `rows`. Two plugins independently read `legend.rows` anyway, and because + * JavaScript answers `undefined` rather than complaining, each failure wore a different costume: + * + * - countries-map: Export poster THREW (exp-shepherd's NE-17, fixed in d6916e05e) + * - research-map: provide('legendRows', legend.rows) provided undefined; the poster fallback + * `legend?.rows?.value || []` could only ever be []; and the packet's legend slot was + * `rows: legend?.rows?.value || []` with no second source, so that poster printed NO legend at + * all — useMapPoster skips the slot when the row list is empty. + * + * The empty legend was the expensive one, because it looks like a layout or data problem and is + * really a misspelt key resolving quietly to undefined. One line per plugin would have fixed the two + * known cases and left the next one to be found by a Driver looking at a blank poster. + * + * The returned key set is READ FROM THE COMPOSABLE, not restated here, so adding a key to it does + * not require editing this file — and removing one immediately names every consumer that breaks. + * + * ★ IT DOES NOT STRIP BLOCK COMMENTS, AND THAT IS DELIBERATE. The obvious way to ignore comments is + * one global regex replacing every block comment with nothing. On these files that DELETES REAL + * CODE: a slash-star sequence inside an ordinary string — a glob path, a URL — opens a comment the + * author never wrote, and the replace then runs from there to the next genuine block-comment + * terminator, taking every line between with it. Written that way, this check reported "no consumers + * at all" while both consumers sat plainly in the files, because the very lines it was looking for + * had been erased before it looked. Comments are skipped per LINE instead, which cannot swallow code + * it does not understand. + * + * (Writing that regex out in this comment closed this block early and broke the file's parse, which + * is the same trap one layer up: a comment describing a terminator must not contain one.) + * + * Headless, no browser, no dependencies. Exit 0 pass · 1 fail. + */ +import { resolve, dirname, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { readFileSync, readdirSync, statSync } from 'node:fs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const COMPOSABLE = 'library/composables/legends/useLegendData.js' +const fails = [] + +// ── what it actually returns ────────────────────────────────────────────────── +const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*)/.test(line) +/** Is column `col` of `line` inside a quoted span? Quotes toggle; a backslash escapes the next char. */ +function inString(line, col) { + let quote = null + for (let i = 0; i < col && i < line.length; i++) { + const c = line[i] + if (c === '\\') { i++; continue } + if (quote) { if (c === quote) quote = null } + else if (c === '"' || c === "'" || c === '`') quote = c + } + return quote !== null +} +const csrc = readFileSync(resolve(ROOT, COMPOSABLE), 'utf8') +// the composable's own top-level return: the LAST `return {` in the file, which is the one that +// closes useLegendData itself rather than a computed's early exit. +const lastReturn = csrc.lastIndexOf('return {') +if (lastReturn < 0) { console.error('LEGEND-DATA-CONTRACT: FAIL\n ✗ could not find useLegendData\'s return object'); process.exit(1) } +const block = csrc.slice(lastReturn + 'return {'.length, csrc.indexOf('}', lastReturn)) +const RETURNED = new Set( + block.split(',').map((t) => t.split(':')[0].trim()).filter((t) => /^[A-Za-z_$][\w$]*$/.test(t)) +) +if (RETURNED.size < 5) { console.error(`LEGEND-DATA-CONTRACT: FAIL\n ✗ parsed only ${RETURNED.size} returned keys; the parse is wrong, not the code`); process.exit(1) } +if (RETURNED.has('rows')) fails.push('useLegendData now returns `rows` — this check\'s premise changed; re-read it before trusting the result') + +// ── every consumer ──────────────────────────────────────────────────────────── +const walk = (dir, out = []) => { + for (const e of readdirSync(dir)) { + if (e === 'node_modules' || e === '.git' || e === 'public') continue + const p = resolve(dir, e) + if (statSync(p).isDirectory()) walk(p, out) + else if (/\.(vue|js|mjs)$/.test(e)) out.push(p) + } + return out +} +const files = [...walk(resolve(ROOT, 'library')), ...walk(resolve(ROOT, 'plugin-designs'))] +let consumers = 0 + +for (const file of files) { + const raw = readFileSync(file, 'utf8') + if (!/useLegendData\s*\(/.test(raw)) continue + if (file.endsWith('useLegendData.js')) continue + const src = raw + const bind = src.match(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*useLegendData\s*\(/) + if (!bind) continue + consumers++ + const name = bind[1] + const rel = relative(ROOT, file) + const useRe = new RegExp(String.raw`\b${name}\s*(?:\?\.|\.)\s*([A-Za-z_$][\w$]*)`, 'g') + const bad = new Map() + let m + while ((m = useRe.exec(src))) { + const key = m[1] + if (RETURNED.has(key)) continue + // Comments legitimately discuss the old spelling (this fix is documented in both plugins), so a + // match on a comment line is ignored — per line, never by deleting spans of the file. + const before = src.slice(0, m.index) + const line = before.split('\n').length + const lineText = src.split('\n')[line - 1] || '' + if (isCommentLine(lineText)) continue + // ★ AND NOT INSIDE A STRING. t('legend.colLabel.religion') is an i18n KEY that happens to be + // spelled like a property access on this very binding, and every such key was reported as a + // violation on the first run. A translation key is not a read. + if (inString(lineText, m.index - (before.lastIndexOf('\n') + 1))) continue + if (!bad.has(key)) bad.set(key, []) + bad.get(key).push(line) + } + for (const [key, lines] of bad) { + fails.push(`${rel}: reads \`${name}.${key}\`, which useLegendData does not return ` + + `(line${lines.length > 1 ? 's' : ''} ${lines.join(', ')})\n` + + ` it returns: ${[...RETURNED].join(', ')}\n` + + ` JavaScript answers undefined here, so this fails as a blank legend or an empty array, not as an error`) + } +} + +if (!consumers) fails.push('found no useLegendData consumers at all — the scan is broken, not the code') + +if (fails.length) { + console.error('LEGEND-DATA-CONTRACT: FAIL') + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log(`LEGEND-DATA-CONTRACT: PASS — ${consumers} consumer(s) read only keys useLegendData returns (${[...RETURNED].join(', ')}).`) diff --git a/package.json b/package.json index c412bb3dd..0c91ad29a 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,7 @@ "check:leaf-english-leaks:self-test": "node internal/check-leaf-english-leaks.mjs --self-test", "check:leaves-mount": "node internal/check-gospel-leaves-mount.mjs", "check:leaves-mount:poison": "node internal/check-gospel-leaves-mount.mjs --poison", + "check:legend-data-contract": "node internal/check-legend-data-contract.mjs", "check:legend-export-reachable": "node internal/check-legend-export-reachable.mjs", "check:legend-logical-insets": "node internal/check-legend-logical-insets.mjs", "check:legend-logical-insets:self-test": "node internal/check-legend-logical-insets.mjs --self-test", @@ -211,6 +212,7 @@ "check:one-scroll-container": "node internal/check-one-scroll-container.mjs", "check:pin-selection": "node internal/check-pin-selection.mjs", "check:poster-action-print": "node internal/check-poster-action-print.mjs", + "check:poster-attribution": "node internal/check-poster-attribution.mjs", "check:poster-card-fits": "node internal/check-poster-card-fits.mjs", "check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs", "check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs", @@ -420,6 +422,7 @@ "check:layout-key-namespaced": "two dashboards on one page must not share a saved layout. useDashboardLayout persists the four divider positions to localStorage, and until kit landing 2 the key was a single constant — invisible while there was exactly one dashboard, and SHARING THE ENGINE IS WHAT MAKES A SECOND ONE POSSIBLE, so SPEC-DASHBOARD-KIT section 8 named it as that landing's risk. The failure is quiet rather than loud: drag a divider in one dashboard, reload, and the other has moved. Asserts the shell passes no bare literal, EVALUATES the real expression with two different dashboard ids to prove the key varies (never a retyped copy of the rule), and that the pre-namespacing key is READ and never written — namespacing must not silently discard a reader's layout, and a migration that also deletes cannot be rolled back. Static; no DOM, no browser.", "check:leaf-engine-per-provider": "every leaf in the manifest at ?provider=maplibre makes ZERO requests to any *.mapbox.com host and a map leaf still draws; at its default every map leaf draws with the engine it drew with before (read from the canvas that drew). --baseline compares against another tree (e.g. main). Browser gate, @browser-slot-max-hold 2700.", "check:leaves-mount": "DID THE ELEMENT ACTUALLY MOUNT, on all five gospel leaves. Exists because a ReferenceError thrown during setup dies INSIDE the custom-element boundary: the page loads, every asset returns 200, pageerror fires zero times, and the element sits there with an empty shadow root. A healthy-looking dead page. This asks the cheap unambiguous question the rich gates are too slow to ask on every leaf — is anything there — in five steps that each name their own failure: tag defined, host present, shadowRoot attached, shadow root non-empty, visible text rendered. It does NOT trust the absence of console errors; zero errors is not the same as working. --poison for the negative test.", + "check:legend-data-contract": "nobody may read a key off useLegendData that it does not return. It returns items, never rows; two plugins read legend.rows anyway and JavaScript answered undefined, so the same mistake surfaced as a THROWN Export poster in countries-map (exp NE-17) and as a silently BLANK poster legend in research-map. The returned key set is read from the composable rather than restated. NOT in gates:pre yet: it still reports countries-map, which is another owner's file (exp's d6916e05e covers .rows but not the dead .build/.refresh calls). Run it directly.", "check:legend-export-reachable": "the Export affordance must be reachable at BOTH widths. Selects a leaf row first (the control is gated on selectedNode) and opens the mobile sheet first (it starts closed). Asserts a REAL BOX, not CSS visibility — on mobile the elements report visibility:visible at 0x0. PASSES at both widths since the mobile sheet was given :export-enabled and :tabs. SKIPs when nothing is served.", "check:legend-logical-insets": "the legend components position themselves with LOGICAL insets (inset-inline-start/end, padding-/margin-inline-*). A physical left/right does not mirror under dir=\"rtl\" while the map controls (inset-inline-end) do, so in Arabic the controls overlapped a legend that stayed left, on Simple Research, Prayer Progress and UPG-100 at once. Symmetric rules (left:50%, left:0 with right:0), auto resets and a short ALLOWED list with reasons pass.", "check:legend-logical-insets:self-test": "poisons .stl-panel back to left:8px and asserts check:legend-logical-insets notices.", @@ -467,6 +470,7 @@ "check:popup-escaping": "the library's pin and region popups render hostile data (an , a