doxa-maps/internal/check-legend-data-contract.mjs
Doxa Maps 2c397d25ef Source comments state design history and measurements as facts, without quoting or naming anyone
Comments in library/ and internal/ that quoted a person, attributed a decision to one, used first person,
named fleet agents or carried private host names now state the same technical fact in neutral words:
poster-render (including the comments it writes into generated posters), packet-render, the poster and
research scripts, PosterWall, DataTable, the legends, the layouts and the gates. The network-policy note
states the policy itself. Behaviour is unchanged: a few printed gate messages and reason labels drop an
agent name, the poster-render integrity pin follows its comment-only edit, and series.json changes only in
its _meta and _why notes. The historical poster design snapshots (library/scripts/posters/snapshots/),
which nothing reads, are removed.
2026-10-01 00:37:17 +00:00

162 lines
9.2 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 (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')
// ── THE SHARED BRIDGE'S CALLERS MUST SUPPLY EVERY DEPENDENCY ───────────────────────────────────
// ★ WHAT A SHARED COMPOSABLE NEWLY MAKES POSSIBLE. useLegendMapBridge reaches for nothing — map,
// activeTab, countryOutline, regionIdle, mapFly, pplrInstance and mapId are all passed in. That is
// what lets a second dashboard adopt it, and it is also a new way to be wrong: a caller that omits
// one gets `undefined` at the first call, not at import, so the legend simply stops highlighting
// and nobody sees an error. Asserted on the CALL, for every caller.
//
// ⚠ AND THE CALL MUST FOLLOW ITS DEPENDENCIES. The four functions it replaces were hoisted function
// declarations, so they ran fine above `const countryOutline = ...`. A composable call is not
// hoisted; placed too early it reads undefined and the same silence follows.
{
const BRIDGE_DEPS = ['map', 'activeTab', 'countryOutline', 'regionIdle', 'mapFly', 'pplrInstance', 'mapId']
const callers = ['plugin-designs/doxa-research-map/research-map.vue',
'plugin-designs/country-posters/countries-map.vue']
for (const path of callers) {
let src
try { src = readFileSync(resolve(ROOT, path), 'utf8') } catch { fails.push(`${path} is missing — a bridge caller moved`); continue }
if (!src.includes('useLegendMapBridge')) { fails.push(`${path} no longer uses useLegendMapBridge — the twins have diverged again`); continue }
const call = /useLegendMapBridge\(\{([\s\S]{0,400}?)\}\)/.exec(src)
if (!call) { fails.push(`${path}: useLegendMapBridge is imported but never called`); continue }
for (const dep of BRIDGE_DEPS) {
if (!new RegExp(`\\b${dep}\\b`).test(call[1])) {
fails.push(`${path}: useLegendMapBridge() is missing \`${dep}\` — it reads undefined at the first call, so the legend stops working with no error`)
}
}
const at = src.indexOf('useLegendMapBridge({')
for (const dep of ['countryOutline', 'pplrInstance']) {
const decl = src.indexOf(`const ${dep} =`)
if (decl >= 0 && decl > at) {
fails.push(`${path}: useLegendMapBridge() is called before \`${dep}\` is declared — a composable call is not hoisted the way the functions it replaced were`)
}
}
}
}
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(', ')}).`)