check:selector-fields in gates:pre: a packet fetch must carry every field its selectors read
Written by the UPG-100 plugin owner. It reads each selector's fields from the evaluator itself, so it cannot encode a wrong list of zones. It fails on the fetch before cdc788fbf, which left eight zones' packets empty, and passes after it; both land in this push.
This commit is contained in:
parent
88fe7509d0
commit
29c9551d30
2 changed files with 168 additions and 1 deletions
164
internal/check-selector-fields.mjs
Normal file
164
internal/check-selector-fields.mjs
Normal file
|
|
@ -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.<field>` reads inside each `case '<type>':` 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 '<type>':` 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)
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue