useLegendData returns { items, columns, title, activeFilter, setFilter,
totalCount, totalPopulation, progress, fmtPop, fmtCount }. There is no `rows`.
research-map read legend.rows in three places, and because JavaScript answers
undefined rather than complaining, each one failed in a different costume:
- provide('legendRows', legend.rows) provided undefined, and nothing in the repo
injects that key -- the comment above it described a contract no component
implements
- openPoster's fallback, legend?.rows?.value || [], could only ever be [], so on
any tab where the flat legend is empty the poster had no legend rows
- the packet's page-1 slot was rows: legend?.rows?.value || [] with NO second
source, so that poster's legend was empty unconditionally
and useMapPoster skips the legend slot entirely when the row list is empty. So the
missing legend on the exported poster had two independent causes: the renderer
painting it over the map, fixed separately, and this -- an empty array arriving
through a fallback that could never be anything else.
Both poster paths now share one builder, so they cannot drift apart again, and it
flattens children because a prayer legend is a parent plus two child tiers and
printing only the parents describes bands the map does not draw.
Two further calls went the same way: legend.refresh?.() and
legend.build?.(normalizedPeopleGroups), against methods the composable has never
defined. The optional call swallowed both, so two lines that read like steps did
nothing. `items` is a computed and re-derives on its own, so they are gone rather
than repointed.
exp-shepherd found the same misspelling in countries-map, where it threw instead
(NE-17). The check therefore guards the class rather than these lines: it reads
the returned key set from the composable and reports any consumer reading outside
it. It is NOT in gates:pre yet, because it still reports countries-map, which
belongs to another owner.
Writing it turned up two traps worth keeping. Stripping block comments with one
global regex DELETED REAL CODE -- a slash-star inside an ordinary string opens a
comment nobody wrote and the replace runs to the next real terminator -- and the
check confidently reported "no consumers at all" while both sat in the files. And
matching property reads by name flagged every t('legend.colLabel.religion'): an
i18n key spelled like a property access is not a read.
127 lines
6.9 KiB
JavaScript
127 lines
6.9 KiB
JavaScript
#!/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(', ')}).`)
|