diff --git a/internal/check-selector-fields.mjs b/internal/check-selector-fields.mjs new file mode 100644 index 000000000..4b13bbc67 --- /dev/null +++ b/internal/check-selector-fields.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +/** + * check-selector-fields — every field a zone SELECTOR reads must be in every FETCH that evaluates it. + * + * THE DEFECT THIS CATCHES, measured 2026-09-28. The UPG-100 packet fetch requested + * slug,name,country_code,population,religion,image_url while SIX of the 27 printable zones select on + * wagf fields it never asked for (west-africa / east-africa / central-africa via wagf_block, europe via + * wagf_region, southern-africa-islands and oceania-indonesia via unions of them). evaluate() matched + * NOTHING, so those packets printed EMPTY — while the SAME selectors resolved correctly on the map, + * whose fetch had carried both fields all along. One file, two fetches, two field sets, nothing + * comparing them. + * + * ★ oceania-indonesia IS WHY THIS IS A GATE AND NOT A NOTE. Its union still matched country_code IDN, + * so it did not print empty — it printed a PLAUSIBLE packet silently missing every Oceania group. An + * empty packet is caught on page one; a packet quietly 40% short is not. Same asymmetry gospel measured + * the same day joining admin-to-list.json: 514 of 1009 rows matched and THREE whole zones vanished while + * every surviving zone still looked populated. A result that returns HALF looks like a smaller dataset. + * + * ★ FIELDS ARE READ FROM THE EVALUATOR, NOT FROM A TABLE IN THIS FILE. A hardcoded type→field map is a + * second source of truth and drifts silently the day a case is edited. This parses selector-evaluator.js + * and collects the `r.` reads inside each `case '':` block, so a new field in a case is + * picked up without touching this gate. It is also how the non-obvious ones are caught: deaf_predicate + * reads wagf_region AND slug AND name; affinity_bloc reads rop1. Neither is guessable from the name. + * + * ⚠ LIMIT, stated so a green run is not over-read: it matches `const fields = '…'` string literals. A + * fetch that builds its field list dynamically is NOT checked, and is reported as UNCHECKED rather than + * passed — absence of evidence is not evidence. + * + * --self-test plants a wagf_block selector against a fetch missing the field. Exit 0 clean · 1 findings. + */ +import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs' +import { join, relative, resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +// ── the evaluator's own field reads, per selector type ────────────────────────────────────────── +export function fieldsByType(evaluatorSrc) { + const out = new Map() + // Split on `case '':` and read each block up to the next case. + const re = /case\s+'([a-z_]+)'\s*:\s*\{/g + const marks = [] + let m + while ((m = re.exec(evaluatorSrc))) marks.push({ type: m[1], at: m.index }) + for (let i = 0; i < marks.length; i++) { + const body = evaluatorSrc.slice(marks[i].at, i + 1 < marks.length ? marks[i + 1].at : evaluatorSrc.length) + const fields = new Set() + for (const f of body.matchAll(/\br\.([a-zA-Z_][\w]*)/g)) fields.add(f[1]) + out.set(marks[i].type, fields) + } + return out +} + +/** Every field a selector tree needs, following union/nested selectors. */ +export function fieldsForSelector(sel, byType, acc = new Set()) { + if (!sel || typeof sel !== 'object') return acc + const t = sel.type + if (t && byType.has(t)) for (const f of byType.get(t)) acc.add(f) + for (const child of sel.selectors || []) fieldsForSelector(child, byType, acc) + return acc +} + +/** `const fields = 'a,b,c'` / `const _fields = '…'` occurrences, with their line. */ +export function fetchFieldLists(src) { + const out = [] + for (const m of src.matchAll(/const\s+_?fields\s*=\s*'([^']*)'/g)) { + out.push({ line: src.slice(0, m.index).split('\n').length, fields: new Set(m[1].split(',').map((s) => s.trim()).filter(Boolean)) }) + } + // A dynamically-built list is reported, never silently passed. ⚠ The lookahead must reject a + // literal that is merely INDENTED differently — an earlier version matched the same + // lines it had already parsed and reported them as dynamic, which would have + // trained a reader to ignore the ⊘ marker. Anchor on the assignment actually not being a quote. + for (const m of src.matchAll(/const\s+_?fields\s*=\s*([^'\s])/g)) { + out.push({ line: src.slice(0, m.index).split('\n').length, dynamic: true }) + } + return out +} + +// ── SELF-TEST ─────────────────────────────────────────────────────────────────────────────────── +const EVAL_FIXTURE = ` + switch (selector.type) { + case 'country_code': { for (const r of rows) { if (r.country_code === want) {} } } + case 'wagf_block': { for (const r of rows) { if (r.wagf_block === want) {} } } + case 'deaf_predicate': { for (const r of rows) { if (containsDeaf(r.slug) || containsDeaf(r.name)) { if (regions.has(r.wagf_region)) {} } } } + }` +const CASES = [ + ['reads wagf_block from the evaluator', () => fieldsByType(EVAL_FIXTURE).get('wagf_block').has('wagf_block'), true], + ['deaf_predicate reads 3 fields, not 1', () => fieldsByType(EVAL_FIXTURE).get('deaf_predicate').size, 3], + ['union follows its children', () => { + const t = fieldsByType(EVAL_FIXTURE) + return [...fieldsForSelector({ type: 'union', selectors: [{ type: 'wagf_block' }, { type: 'country_code' }] }, t)].sort().join(',') + }, 'country_code,wagf_block'], + ['BAD a fetch missing wagf_block is caught', () => { + const t = fieldsByType(EVAL_FIXTURE) + const need = fieldsForSelector({ type: 'wagf_block' }, t) + const have = fetchFieldLists("const fields = 'slug,name,country_code';")[0].fields + return [...need].filter((f) => !have.has(f)).join(',') + }, 'wagf_block'], + ['GOOD a fetch carrying it passes', () => { + const t = fieldsByType(EVAL_FIXTURE) + const need = fieldsForSelector({ type: 'wagf_block' }, t) + const have = fetchFieldLists("const fields = 'slug,wagf_block';")[0].fields + return [...need].filter((f) => !have.has(f)).length + }, 0], + ['a DYNAMIC field list is flagged, not passed', () => fetchFieldLists('const fields = buildFields();')[0].dynamic, true], +] + +if (process.argv.includes('--self-test')) { + let ok = 0 + for (const [name, run, want] of CASES) { + let got + try { got = run() } catch (e) { got = `ERR ${e.message}` } + const pass = got === want + console.log(` ${pass ? '✓' : '✗'} ${name} -> ${JSON.stringify(got)} (want ${JSON.stringify(want)})`) + if (pass) ok++ + } + console.log(ok === CASES.length + ? `SELECTOR-FIELDS SELF-TEST: PASS — ${ok}/${CASES.length}` + : `SELECTOR-FIELDS SELF-TEST: FAIL — ${ok}/${CASES.length}`) + process.exit(ok === CASES.length ? 0 : 1) +} + +// ── RUN ───────────────────────────────────────────────────────────────────────────────────────── +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const DESIGNS = join(ROOT, 'plugin-designs') +let bad = 0, unchecked = 0 + +for (const bundle of existsSync(DESIGNS) ? readdirSync(DESIGNS) : []) { + const dir = join(DESIGNS, bundle) + if (!statSync(dir).isDirectory()) continue + const evalPath = join(dir, 'data/selector-evaluator.js') + const clustersPath = join(dir, 'data/clusters.json') + if (!existsSync(evalPath) || !existsSync(clustersPath)) continue + + const byType = fieldsByType(readFileSync(evalPath, 'utf8')) + const c = JSON.parse(readFileSync(clustersPath, 'utf8')) + const zones = ['lists', 'deaf_lists', 'asia_lists'].flatMap((k) => c[k] || []) + + const need = new Map() // field -> the zones that need it + for (const z of zones) for (const f of fieldsForSelector(z.selector, byType)) { + if (!need.has(f)) need.set(f, []) + need.get(f).push(z.id) + } + + // Every .vue in the bundle that both fetches AND evaluates. + for (const file of readdirSync(dir).filter((f) => f.endsWith('.vue'))) { + const src = readFileSync(join(dir, file), 'utf8') + if (!/selector-evaluator|evaluate\(/.test(src)) continue + for (const fetchDef of fetchFieldLists(src)) { + const where = `${relative(ROOT, join(dir, file))}:${fetchDef.line}` + if (fetchDef.dynamic) { console.log(` ⊘ ${where} field list is built dynamically — UNCHECKED, not passed`); unchecked++; continue } + const missing = [...need.keys()].filter((f) => !fetchDef.fields.has(f)) + for (const f of missing) { + console.log(` ✗ ${where} does not request '${f}', needed by: ${need.get(f).slice(0, 4).join(', ')}${need.get(f).length > 4 ? ` (+${need.get(f).length - 4})` : ''}`) + bad++ + } + } + } +} + +if (bad) { + console.log(`SELECTOR-FIELDS: FAIL — ${bad} fetch/selector mismatch(es). A zone whose selector field is absent resolves to NOBODY, or worse, to HALF.`) + process.exit(1) +} +console.log(`SELECTOR-FIELDS: PASS — every selector's fields are requested by every fetch that evaluates them${unchecked ? `, ${unchecked} dynamic list(s) unchecked` : ''}.`) +process.exit(0) diff --git a/package.json b/package.json index 026a4b4c3..b5b22adcb 100644 --- a/package.json +++ b/package.json @@ -184,6 +184,8 @@ "check:packet-render": "node internal/check-packet-render.mjs", "check:packet-render:expect-funnel": "node internal/check-packet-render.mjs --expect-funnel", "check:series-isolation": "node internal/check-series-isolation.mjs", + "check:selector-fields": "node internal/check-selector-fields.mjs", + "check:selector-fields:self-test": "node internal/check-selector-fields.mjs --self-test", "check:packet-strings-parity": "node internal/check-packet-strings-parity.mjs", "check:packets-all": "node internal/check-packets-all.mjs", "check:parse": "node internal/inline-parse-check.mjs", @@ -265,7 +267,7 @@ "fix:sri": "node internal/fix-sri.mjs", "gallery": "node internal/generate-gallery.mjs", "gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived 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:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:frozen-labels", - "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:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection", + "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:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields", "gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs", "library-index": "node internal/generate-library-index.mjs", @@ -443,6 +445,7 @@ "check:religion-label-not-value": "the library's two RELIGION_FAMILIES sites (useLegendData, useSearch) show religionFamilyLabel() to the reader, while the value a filter or search matches on stays the canonical English family name: a translated label must never become a wire value.", "check:scrim-viewport": "a full-screen SCRIM must cover the VIEWPORT, not just the content pane. A position:fixed;inset:0 scrim is PANE-relative wherever an ancestor sets contain/transform — VerticalTabsLayout sets contain:layout on .vtl-content — so a scrim dimmed 1024x793 @ (256,107) at a 1280x900 viewport and left the 256px rail CLICKABLE behind a modal. A source grep cannot see it: the declaration is correct, the ANCESTOR is the variable. Needs `bun run dev`; browser gate, not in build:unlocked.", "check:scrim-viewport:self-test": "reverts each scrim to plain inset:0 and requires EVERY case to FAIL — a gate that cannot fail is worse than no gate, and this one measures a property that looks fine in a screenshot either way.", + "check:selector-fields": "every UPG-100 selector's fields, read FROM the evaluator (not a table in this file), must be requested by every fetch that evaluates it. A missing field matches nothing and prints an EMPTY packet with no error: eight zones did, until the fetch carried wagf_region/wagf_block. Headless; in gates:pre. :self-test has 6 cases.", "check:setup-order": "a line's POSITION in a setup block is not the MOMENT it runs, and two P0s from the Driver's console came from that gap. ARM A: `watch(..., { immediate: true })` runs during setup, so anything it reaches must be declared ABOVE it - research-map.vue threw `Cannot access '_lastFitKey' before initialization` and blanked the whole map, because the watcher called a HOISTED function 350 lines down whose own constants were still in their temporal dead zone. A hoisted function promises that its NAME resolves early, not that its BODY can run early, and that asymmetry is invisible from both ends. ARM B: a lifecycle registrar closes over Vue's current instance, which exists only while setup is on the stack, so `onMounted(async () => { await init(); onBeforeUnmount(clean) })` drops the registration silently - DashboardMapPanel.vue leaked a ResizeObserver on document.body plus a window listener, warning only, feature working, and research-map.vue had a second copy this gate found. The fix is never 'move the line': declare the handle at setup scope and tear it down in a hook registered before the first await. Its own false positives are the interesting part - it went 6 hits -> 1 over three rounds, and every one it dropped was prose or shadowing read as code: `onBeforeUnmount` inside a console.error STRING, `table` inside `new Error(`table not found (${r.status})`)` in four files, and a `const [pins, byCountry]` destructured INSIDE the function, shadowing a computed 220 lines away. So it blanks comments, string text and template text (keeping ${...}, which is code) and subtracts every name a span declares for itself. Self-tests on both shipped defects, both fixes, and all three decoys; exits 3 if any arm goes blind.", "check:sfc-parse": "every .vue script block must parse as the BUILD parses it — catches the LIVE-CSS backtick that check:template-literal cannot see (it reads CSS comments only). 0 false positives on 83 files, where 3 lookahead heuristics scored 98 and 136.", "check:shadow-css-parses": "the CSS we assemble as JS STRINGS must parse as CSS — brace balance and declarations stranded outside any rule. check:sfc-parse proves only that the JS parses, so it passed a stylesheet with 9 orphaned declarations and braces at 12 open / 21 close (REGLIB batch 1); nothing else in the tree validates CSS syntax. Reads every useShadowStyles() argument off the @babel/parser AST the build already uses, because three hand-rolled lexers each produced a confident FALSE RED: a /* CSS comment */ read as a declaration, an apostrophe in a .vue prose header opening a phantom string literal, and a regex literal containing a quote. 9 self-tests run inline on every invocation, including the exact batch-1 damage, so it cannot silently stop being able to fail.",