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