doxa-maps/internal/check-legend-data-contract.mjs

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 whoever next opened 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(', ')}).`)