countries-map.vue and research-map.vue share 1,756 lines across 60 contiguous blocks, and 78% of that is one concern: the wiring between a semantic-tree legend and the map beside it. The two files already import the same 41 library modules and the same five legend-data composables, so the data layer was never the duplication. The glue was, and glue is where a fix lands in one copy and not the other: the packet kind-split, the poster attribution reading and the packet page-1 map hand-off each shipped fixed in one twin and broken in the other. Only the four functions that are BYTE-IDENTICAL in both files move: clearCountryHighlight, _flatItemsToStlNodes, onLegendRowClick and _bridgeSelection. Measured, not assumed — applyDimFilter (185 lines against 170) and switchTab (49 against 50) genuinely differ and stay where they are. Sharing code that merely looks similar is how a refactor picks one plugin's behaviour for both. A composable rather than a component: the legend markup already lives in SemanticTreeLegend and its two shells, and a component would have forced both plugins to give up the template where their real differences live. Nothing is injected or reached for, so a second dashboard can adopt it without inheriting research-map's provide tree. Both calls sit after countryOutline and pplrInstance. The declarations they replace were hoisted and ran above those consts; a composable call is not hoisted, and placed too early it would read undefined at the first call rather than at import — the legend would stop highlighting with no error. The gate asserts both the dependency list and that ordering, for every caller. Rebuild-and-compare on both bundles: research-map 1,237,385 -> 1,237,709 bytes, country-posters 1,550,988 -> 1,551,317, and every behaviour-bearing token identical in both — regions-fill, regions-border, fill-opacity, fill-outline-color, line-color, line-width, visibility, doxa:legend-row-click, _flat_filter, iso_3166_1_alpha_3. Counted rather than set-compared, because minified output renames freely. 104 lines out of the plugins, shared lines 1,756 -> 1,702.
162 lines
9.2 KiB
JavaScript
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 (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')
|
|
|
|
// ── 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(', ')}).`)
|