doxa-maps/plugin-designs/research-dashboard/scripts/check-legend-mirrors.mjs
Doxa Maps f294159b64
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source 2b826795
2026-09-27 06:32:22 +00:00

356 lines
25 KiB
JavaScript

#!/usr/bin/env node
/**
* check-legend-mirrors.mjs — the legends must MIRROR, and their labels must not become values.
*
* rega's Arabic survey on main 52eeb0db: the research-dashboard legends sat on the wrong edge with
* ENGLISH text — 70 of 85 letters Latin — in Big Picture and in the Research Gallery. Two faults
* that look like one, and they fail in opposite directions if you fix them carelessly.
*
* ── 1. A PHYSICAL INSET DOES NOT MIRROR ──────────────────────────────────────────────────────
* `right:8px` pins a box to the physical right edge in every locale, so in Arabic it stays where an
* English reader expects it while everything around it flips. The logical property is the fix, and
* `inset-inline-start:auto` / `inset-inline-end:auto` is part of the pair rather than decoration:
* it cancels a physical inset inherited from a control container, which otherwise stretches the box
* across the map once the other edge moves.
* Two surfaces are asserted because they are the two the survey named, and one of them — the
* research-map geocoder column — exists ONLY to share an edge with the legend beside it. A shared
* edge that one of the pair honours and the other does not is worse than no alignment: it reads as
* a broken layout rather than as a choice.
*
* ── 2. THE LABEL IS PROJECTED; THE VALUE IS NOT ──────────────────────────────────────────────
* The English in that legend was NOT chrome. `s.key` is a facet VALUE, and for the religion donut
* it is a family name DERIVED in English by library/colors/religion.js — the same table
* people-groups-grid re-derives per row so the two ends byte-match. So the obvious fix, translating
* the strings, is the dangerous one: it would work in English and match nothing in Arabic,
* SILENTLY, because an empty intersection reads as "no results". That is the language-link defect
* one facet over, and it cost a card to learn the first time.
* So this gate asserts BOTH halves: that the visible text is projected, AND that the projection did
* not leak into the value — data-key still carries the raw key, and RELIGION_FAMILIES is still the
* English table every consumer compares against.
*
* NEGATIVE-TESTED: a physical inset on either surface fails 1; binding data-key to the projection,
* or dropping the projection from the visible text, fails 2; translating a RELIGION_FAMILIES value
* fails 2 naming the link it would break.
*/
import { readFileSync, existsSync, readdirSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const TAG = 'LEGEND-MIRRORS'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const fails = []
const notes = []
/* Every .vue/.js under the two source roots — the importer sweep needs a file list, and building it
* here keeps arm 5 from trusting a hardcoded list of "the files that could import this". */
function sources(dir = null, acc = []) {
const base = dir || ROOT
let entries = []
try { entries = readdirSync(base, { withFileTypes: true }) } catch (_) { return acc }
for (const e of entries) {
const full = join(base, e.name)
const rel = full.slice(ROOT.length + 1)
if (/(^|\/)(node_modules|dist|public|\.git)(\/|$)/.test(rel)) continue
if (dir === null && !/^(plugin-designs|library)$/.test(e.name)) continue
if (e.isDirectory()) sources(full, acc)
else if (/\.(vue|js|mjs)$/.test(e.name)) acc.push(rel)
}
return acc
}
const read = (rel) => { const p = join(ROOT, rel); return existsSync(p) ? readFileSync(p, 'utf8') : null }
// ── 1. logical insets on the two surfaces the survey named ───────────────────────────────────
const SURFACES = [
{ rel: 'plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue',
sel: '.rd-legend-map', want: 'inset-inline-end', what: 'the map legend' },
{ rel: 'plugin-designs/doxa-research-map/research-map.vue',
sel: '.mapboxgl-ctrl-top-left', want: 'inset-inline-start',
what: 'the geocoder column, which exists to share an edge with the legend below it' },
]
for (const { rel, sel, want, what } of SURFACES) {
const src = read(rel)
if (!src) { fails.push(`1 ${rel} is missing — this gate is blind to ${what}.`); continue }
const rule = new RegExp(`\\${sel}\\s*\\{([^}]*)\\}`).exec(src)
if (!rule) { fails.push(`1 ${rel}: no ${sel} rule found — it moved or was renamed, and this gate cannot see ${what}.`); continue }
const body = rule[1]
const physical = [...body.matchAll(/(^|[;{\s])(left|right)\s*:/g)].map((m) => m[2])
if (physical.length) {
fails.push(`1 ${rel}: ${sel} uses the PHYSICAL inset(s) ${[...new Set(physical)].join(', ')} — ${what} will not mirror under ar, so it lands on the edge an English reader expects while everything around it flips.`)
}
if (!body.includes(want)) {
fails.push(`1 ${rel}: ${sel} has no ${want} — ${what} has nothing anchoring it to the reading direction.`)
}
const other = want === 'inset-inline-end' ? 'inset-inline-start' : 'inset-inline-end'
if (!new RegExp(`${other}\\s*:\\s*auto`).test(body)) {
fails.push(`1 ${rel}: ${sel} sets ${want} without ${other}:auto. The pair is what cancels a physical inset inherited from a control container; without it the box stretches across the map once the anchored edge moves.`)
}
}
if (!fails.some((f) => f.startsWith('1'))) notes.push(`insets: ${SURFACES.length} surface(s) anchored to the reading direction, both halves of the pair present`)
// ── 2a. the visible text is projected, the wire is not ───────────────────────────────────────
{
const rel = 'plugin-designs/research-dashboard/src/components/dashboards/DashboardChartsRail.vue'
const src = read(rel)
if (!src) fails.push(`2 ${rel} is missing.`)
else {
const li = /<span v-for="s in chart\.geometry"[\s\S]*?<\/span>\n/.exec(src)
if (!li) fails.push(`2 ${rel}: the legend item markup moved — this gate is blind to what it renders.`)
else {
const m = li[0]
if (!/class="rd-lt">\{\{ legendLabel\(/.test(m)) {
fails.push(`2 ${rel}: the legend's visible text is not projected through legendLabel() — a derived English facet value renders untranslated in all eleven locales.`)
}
// ★ EVERY data-key IN THE FILE, not just this one. A first version checked only the legend
// item, and a mutation test aimed at it silently hit a DIFFERENT :data-key — the donut
// segment — and the gate stayed green while a projection sat on a wire. There are three
// (segment, legend item, bar), all of them values the bus compares, and a check that guards
// one of three is a check whose own negative test can miss.
const keyBinds = [...src.matchAll(/:data-(?:key|facet)="([^"]*)"/g)]
if (keyBinds.length < 3) fails.push(`2 ${rel}: expected at least 3 :data-key/:data-facet bindings (segment, legend item, bar) and found ${keyBinds.length} — the markup moved and this gate is guarding less than it thinks.`)
for (const b of keyBinds) {
if (/legendLabel\(|religionFamilyLabel\(|tStatic\(/.test(b[1])) {
fails.push(`2 ${rel}: a PROJECTION is bound to a wire attribute (${b[0]}). The value is what the bus and the grid compare; a localized one works in English and matches NOTHING in Arabic, silently, because an empty intersection reads as "no results". Labels are for readers; values cross boundaries.`)
}
}
if (!/:data-key="s\.key"/.test(m)) {
fails.push(`2 ${rel}: the legend item's data-key no longer carries the RAW key.`)
}
if (!fails.some((f) => f.startsWith('2'))) notes.push('charts legend: text projected, data-key still the canonical value')
}
}
}
// ── 2b. the table itself was not translated ──────────────────────────────────────────────────
{
const rel = 'library/colors/religion.js'
const src = read(rel)
if (!src) fails.push(`2 ${rel} is missing.`)
else {
const tbl = /export const RELIGION_FAMILIES = \{([\s\S]*?)\}/.exec(src)
if (!tbl) fails.push(`2 ${rel}: RELIGION_FAMILIES not found.`)
else {
const values = [...tbl[1].matchAll(/'[A-Z]':\s*'([^']+)'/g)].map((m) => m[1])
const nonAscii = values.filter((v) => /[^\x20-\x7E]/.test(v))
if (nonAscii.length) {
fails.push(`2 ${rel}: RELIGION_FAMILIES has been TRANSLATED (${nonAscii.join(', ')}). These are VALUES: the dashboard puts them on the selection bus and people-groups-grid re-derives them per row. Localized, the link works in English and matches nothing elsewhere. Translate religionFamilyLabel's catalogue entries instead.`)
}
if (!/export function religionFamilyLabel/.test(src)) {
fails.push(`2 ${rel}: religionFamilyLabel() is gone, so there is no way to show a family name to a reader without translating the value.`)
}
if (!fails.some((f) => f.includes('RELIGION_FAMILIES has been'))) notes.push(`religion table: ${values.length} values still canonical English, projection available`)
}
}
}
// ── 2c. the projection has something to project ──────────────────────────────────────────────
{
const LOCALES = ['ar', 'de', 'en', 'es', 'fr', 'hi', 'it', 'pt', 'ro', 'ru', 'zh']
const en = read('library/i18n/locales/en/religion.json')
if (!en) fails.push('2 library/i18n/locales/en/religion.json is missing — religionFamilyLabel() would fall back to English everywhere, which is the defect it exists to fix.')
else {
const names = Object.keys(JSON.parse(en).family || {})
if (!names.length) fails.push('2 en/religion.json has no `family` entries.')
for (const loc of LOCALES) {
const j = read(`library/i18n/locales/${loc}/religion.json`)
if (!j) { fails.push(`2 ${loc}/religion.json is missing.`); continue }
const missing = names.filter((n) => !(JSON.parse(j).family || {})[n])
if (missing.length) fails.push(`2 ${loc}/religion.json is missing ${missing.length} family name(s): ${missing.join(', ')}`)
}
if (!fails.some((f) => f.includes('religion.json'))) notes.push(`catalogue: ${names.length} family names in all ${LOCALES.length} locales`)
}
}
// ── 3. THE INVERTED DEPENDENCY, AND THE NO-LEGEND FALLBACK (kit landing 4b) ───────────────────
// The rail and the chips used to IMPORT useTreeLegendColors / useTreeLegendLabels — the DOXA
// palettes and the People code tables, which are research-specific and stay in plugin-designs.
// That import is what stopped the rail from being able to move into the kit at all: a library
// component importing a plugin composable is what check:no-cross-plugin-imports refuses. The shell
// now supplies both as dashboardConfig.legend, so the panels declare what they need and the research
// plugin keeps owning its colours.
//
// ★ WHAT THIS ARM DOES AND DOES NOT DO, stated because the difference matters. It drives the
// FALLBACK EXPRESSIONS lifted out of the SFC — so "no legend yields a neutral swatch and a raw
// value" is measured, not assumed — and it asserts the imports are gone. It does NOT mount the
// component: mounting an SFC here needs the Vite/no-op-renderer harness check:composites-mount
// carries, and a no-op renderer cannot show a colour anyway, so a mount would prove only "it did
// not throw" at the cost of a dev server in a gate that otherwise runs in milliseconds. The
// rendered result is rega's, on the NEEDS-EYES row for this landing.
{
const RAIL = 'library/components/dashboards/DashboardFacetRail.vue' // moved by kit landing 4c-3
const CHIP = 'library/components/dashboards/DashboardFilterChips.vue' // moved by kit landing 4c-2
for (const [rel, what] of [[RAIL, 'the facet rail'], [CHIP, 'the chip bar']]) {
const src = read(rel)
if (!src) { fails.push(`3 ${rel} is missing.`); continue }
const code = src.split('\n').filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n')
if (/from '\.\.\/\.\.\/composables\/useTreeLegend(Colors|Labels)\.js'/.test(code)) {
fails.push(`3 ${what} still imports a useTreeLegend* composable. Those are the research palettes; a kit component importing them is the library-to-plugin dependency that stops this file from moving at all.`)
}
if (!/legend/.test(code)) fails.push(`3 ${what} never reads cfg.legend, so the shell has nothing to hand it.`)
}
// Drive the rail's two fallbacks with NO legend at all.
const railSrc = read(RAIL) || ''
const neutral = /const KIT_NEUTRAL_SWATCH = '(#[0-9a-fA-F]{3,8})'/.exec(railSrc)
const cFor = /const colorFor = (\(\.\.\.a\)[^\n]*)/.exec(railSrc)
const lFor = /const legendLabelFor = (\([^\n]*)/.exec(railSrc)
if (!neutral || !cFor || !lFor) {
fails.push('3 could not lift the rail\'s no-legend fallbacks (KIT_NEUTRAL_SWATCH, colorFor, legendLabelFor) — a consumer that supplies no legend would get whatever this gate can no longer see.')
} else {
const legend = { value: null }
const KIT_NEUTRAL_SWATCH = neutral[1]
const colorFor = new Function('legend', 'KIT_NEUTRAL_SWATCH', `return ${cFor[1]}`)(legend, KIT_NEUTRAL_SWATCH)
const legendLabelFor = new Function('legend', `return ${lFor[1]}`)(legend)
if (colorFor('religion', 'religion', 'M', '') !== KIT_NEUTRAL_SWATCH) {
fails.push('3 with no legend the rail does not fall back to its neutral swatch — an unconfigured dashboard should look PLAIN, not coloured by accident.')
}
if (legendLabelFor('affinity_bloc', 'A012') !== 'A012') {
fails.push('3 with no legend the rail does not pass the raw value through as its label — a consumer with no code tables must see the code, not blank.')
}
// And the research palette's own NEUTRAL must NOT be imported to get that value.
// ★ COMMENT-STRIPPED, and this is the FIFTH time I have written this hole. The comment beside
// KIT_NEUTRAL_SWATCH explains the decision by NAMING the research NEUTRAL, so testing the raw
// source reported a coupling that does not exist — my own documentation failing my own check.
// I had already fixed this shape four times in other gates and still wrote the raw-source
// version here, which says the fix belongs in a habit rather than in each gate: every text
// search over source strips comments first, without thinking about it.
const railCode = railSrc.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
if (/NEUTRAL/.test(railCode.replace(/KIT_NEUTRAL_SWATCH/g, ''))) {
fails.push('3 the rail references the research palette\'s NEUTRAL. Coupling the kit\'s neutral to the research one re-creates the dependency this landing removes; they happen to match today and must stay independent.')
}
if (!fails.some((f) => f.startsWith('3'))) notes.push(`no-legend fallback: neutral ${KIT_NEUTRAL_SWATCH} and raw values, with the research imports gone`)
}
}
// ── 4. THE ROLLUP BUCKET: SENTINEL KEY, TRANSLATED LABEL (kit landing 4c-1) ───────────────────
// aggregate()'s topN tail becomes `{ key: 'Other', other: true }`, and that English word was
// rendered directly in both rails, in every locale. check:i18n-keys-exist could not see it because
// there was no T() call to inspect — the string was never a lookup, it was a KEY being used as a
// LABEL. Found while assessing 4c, and fixed before the module moved, because unfixed it would have
// been exported to every future dashboard the moment this became kit code.
// Both halves are asserted, and they pull in opposite directions: the KEY must stay English (the
// rail tests it to make the row inert, and the charts rail refuses to filter on it — a localized key
// would make the bucket clickable in Arabic on a value no row carries), while the LABEL must not be.
{
const AGG = 'library/composables/useFacetAggregation.js'
const src = read(AGG)
if (!src) notes.push(`${AGG}: not present yet (kit landing 4c-1 not applied) — skipped`)
else {
const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
const k = /export const OTHER_KEY = '([^']*)'/.exec(code)
if (!k) fails.push(`4 ${AGG}: no OTHER_KEY export — the sentinel and its label have no single owner, which is how they drift.`)
else if (k[1] !== 'Other') fails.push(`4 OTHER_KEY is ${JSON.stringify(k[1])}. It is a SENTINEL: the rail tests it to make the bucket inert and the charts rail refuses to filter on it, so changing it silently makes the bucket clickable on a value no row carries.`)
if (!/export const otherLabel = \(\) =>[\s\S]{0,120}tStatic\('dashboard\.other'\)/.test(code)) {
fails.push(`4 ${AGG}: otherLabel() does not read dashboard.other, so the bucket renders English in every locale.`)
}
// the catalogue has to hold it, in every locale
const LOCALES = ['ar', 'de', 'en', 'es', 'fr', 'hi', 'it', 'pt', 'ro', 'ru', 'zh']
const missing = LOCALES.filter((l) => { const j = read(`library/i18n/locales/${l}/dashboard.json`); return !j || !JSON.parse(j).other })
if (missing.length) fails.push(`4 dashboard.other is missing in ${missing.length} locale(s): ${missing.join(', ')}`)
// and the render sites must project rather than print the key
const rail = read('library/components/dashboards/DashboardFacetRail.vue') || '' // moved by kit landing 4c-3
if (!/label: d\.other \? otherLabel\(\) : d\.key/.test(rail)) {
fails.push('4 the facet rail does not project the bucket label — it renders the sentinel, so every locale reads "Other".')
}
if (!/facet, value: d\.key/.test(rail)) {
fails.push('4 the facet rail no longer carries d.key as the row VALUE. The value is what inertness is tested against; projecting it would make the bucket filterable.')
}
if (!fails.some((f) => f.startsWith('4'))) notes.push(`rollup bucket: key stays ${JSON.stringify(k[1])}, label reads dashboard.other in all ${LOCALES.length} locales`)
}
}
// ── 5. THE MOVED CHIP BAR: THE FILE IS ACTUALLY GONE, AND ITS DEFAULT RESOLVER IS DRIVEN ──────
// Kit landing 4c-2 moves DashboardFilterChips.vue into library/components/dashboards/. Two things
// can go wrong in a "move" and only one of them is visible by reading the new file:
// (a) the old copy survives, so the tree holds two chip bars that drift apart — the failure this
// repo has already paid for with useNeighborT/usePplrT at Jaccard 0.82;
// (b) the kit's UNCONFIGURED behaviour is wrong, which nobody notices because every dashboard in
// this tree supplies a resolver. go-int is the consumer that will not, at first.
// So the arm asserts the move is complete and then LIFTS the default out of the source and RUNS it,
// the same way arm 3 drives the rail's fallbacks. Not a mount: a no-op renderer cannot show a chip,
// and the rendered result is rega's.
{
const NEW = 'library/components/dashboards/DashboardFilterChips.vue'
const OLD = 'plugin-designs/research-dashboard/src/components/dashboards/DashboardFilterChips.vue'
const BARREL = 'plugin-designs/research-dashboard/src/components/dashboards/index.js'
const src = read(NEW)
if (!src) {
fails.push(`5 ${NEW} is missing — the kit component the barrel now points at does not exist.`)
} else {
if (read(OLD)) fails.push(`5 ${OLD} still exists. A move that leaves the original behind is a COPY, and two chip bars over one filterSet drift apart the moment one is edited.`)
// ★ THIS ASSERTION INVERTED WHEN THE SHIM WINDOW CLOSED, and the inversion is the point rather
// than an edit. While the one-cycle shim existed, the thing that could break was an importer of
// the barrel failing to resolve, so the arm REQUIRED the re-export. Now that the shim is retired
// the risk is the opposite and larger: a re-export creeping back makes the plugin barrel look
// like the owner of a library component, which is exactly how check:filter-multivalue ended up
// reading a shim instead of the panel it was written to guard, and passing vacuously.
// A one-cycle shim that nobody retires is permanent, so the gate now holds the window closed.
const barrel = read(BARREL) || ''
const barrelCode = barrel.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
for (const moved of ['DashboardFilterChips', 'DashboardFacetRail', 'DashboardBreadcrumb']) {
if (new RegExp(`export[^\\n]*${moved}`).test(barrelCode)) {
fails.push(`5 ${BARREL} re-exports ${moved}, which lives in library/components/dashboards/. The one-cycle shim is retired: importers read '@map/components/dashboards/${moved}.vue' directly, and a barrel re-export makes the plugin look like the owner of a kit component.`)
}
// And nobody may still be reading it through the barrel or from the path it left.
for (const rel of sources()) {
const src = read(rel); if (!src) continue
const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
// ★ ONE IMPORT STATEMENT AT A TIME. The first version used `import … ${moved} …
// [\s\S]{0,200}? from '…'`, and that span CROSSED INTO THE NEXT STATEMENT: after the fix,
// research-dashboard.vue reads `import DashboardFacetRail from '@map/…'` immediately followed
// by a braced import ending `from './src/components/dashboards'`, so the regex started at the
// CORRECT line and found the barrel path two statements later. It reported the file it had
// just been fixed in. Third unbounded-span false positive I have written today (the others
// walked a brace match past a callback-less watch() and read a chip label's own name as
// prose), so the shape is the lesson: never let a span cross a statement boundary — bound it
// with a negative lookahead for the next statement keyword.
const IMPORTS = /\bimport\b(?:(?!\bimport\b)[\s\S])*?\bfrom\s*'([^']*)'/g
let stmt
while ((stmt = IMPORTS.exec(code))) {
const spec = stmt[0]
const path = stmt[1]
if (!new RegExp(`\\b${moved}\\b`).test(spec)) continue
const viaBarrel = /components\/dashboards$/.test(path)
const viaOldPath = new RegExp(`plugin-designs[^']*${moved}\\.vue$`).test(path)
if (viaBarrel || viaOldPath) {
fails.push(`5 ${rel} still imports ${moved} from '${path}'. It moved to library/components/dashboards/ — import it from '@map/components/dashboards/${moved}.vue'.`)
}
}
}
}
// Comments first: this file's own notes name cfg.legend and useTreeLegendLabels.
const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
if (/from '\.\.\//.test(code) || /useTreeLegend/.test(code)) {
fails.push('5 the kit chip bar imports something relative or research-specific. A library component reaching back into plugin-designs is what check:no-cross-plugin-imports refuses, and it is the one thing 4b removed to make this move possible.')
}
for (const dead of ['dataStore', 'searchSourceId']) {
if (new RegExp(`inject\\(\\s*'${dead}'`).test(code)) {
fails.push(`5 the kit chip bar still injects '${dead}', which existed only to build useTreeLegendLabels. An unused inject is a claim about the host: a new dashboard would provide it before finding out it is never read.`)
}
}
const lf = /const labelFor = (\([^\n]*)/.exec(src)
if (!lf) {
fails.push('5 could not lift `const labelFor` out of the kit chip bar, so its unconfigured behaviour is whatever this gate can no longer see. Keep it one expression.')
} else {
const make = (cfg) => new Function('cfg', `return ${lf[1]}`)(cfg)
const bare = make({})
if (bare('affinity_bloc', 'A012') !== 'A012') {
fails.push('5 with NO resolver the kit chip bar does not pass a scalar value through — a dashboard that has not wired its code tables must see the code, not blank.')
}
if (bare('religion', ['Islam', 'Hinduism']) !== 'Islam, Hinduism') {
fails.push(`5 with NO resolver a MULTI-VALUE filter (spec D1) renders as ${JSON.stringify(String(bare('religion', ['Islam', 'Hinduism'])))} instead of a joined list. Configured and unconfigured must differ in whether CODES RESOLVE, never in punctuation — a comma-jammed "A012,A013" reads as a rendering bug rather than as missing configuration.`)
}
const wired = make({ legend: { labelFor: () => 'SUPPLIED' } })
if (wired('affinity_bloc', 'A012') !== 'SUPPLIED') {
fails.push('5 a supplied cfg.legend.labelFor does not win over the default, so a dashboard that configures its labels would still show raw codes.')
}
if (!fails.some((f) => f.startsWith('5'))) notes.push('kit chip bar: moved with no copy left behind, no dead injects, and its no-resolver default passes scalars through and joins lists')
}
}
}
for (const n of notes) console.log(' · ' + n)
if (fails.length) {
console.error(`\n${TAG}: FAIL`)
for (const f of fails) console.error(' ✗ ' + f)
process.exit(1)
}
console.log(`${TAG}: PASS — the legends mirror, and their labels never became their values.`)