doxa-maps/internal/check-ported-globals-identical.mjs
Doxa Maps 32d2e4dc59 Share the ported _globals.scss rules through one module
Seven shadow-root components each carry their own copy of DX Life's
base/_globals.scss rules, because page CSS cannot reach into a shadow
root. Measured first: where the copies overlap they are byte-identical
(the Cyrillic stack 3x, strong{font-weight:500} 2x, scrollbar-gutter 2x),
so this is deduplication, not a fix.

The module exports FUNCTIONS, not strings, and the measurement forced
that: the six copies of the Cyrillic rule share one declaration but have
six different subjects (.rd-goal-card__title, .rd-h2, .rd-title,
.region-card__title, .rl-title x2). A fixed rule string would have to
pick one class and silently stop styling the other five. The caller owns
its selector; the module owns the declaration.

cyrillicHeading takes a selector LIST, not a selector. GoalCard styles
one heading class; RegionSectionCard groups two into a single rule
sharing one declaration block. A one-selector signature emits two rules
instead of one -- the same CSS to a browser, but not the same
stylesheet, and the bar here is unchanged, not equivalent.

Migrated GoalCard and RegionSectionCard. regions-dashboard's two files
port the same rules and stay with their owner; the harness records them
so that migration can run against this same baseline.

The img border-radius rule (_globals.scss:80) is documented as
deliberately NOT ported, because an invisible omission is one that gets
"fixed" later.

PROOF: internal/check-ported-globals-identical.mjs executes each
component's setup, captures the string handed to useShadowStyles and
hashes it. The emitted CSS is byte-identical across the refactor
(987d94b1cd66110d, 897d948b17b21efe, both captured from 45e31f102).

The harness's first version hashed the SOURCE of the call and was
wrong: this refactor replaces a literal with a function call, so the
source changes by construction -- a text-hash gate fails a perfect
refactor and cannot tell that from a broken one. It measured the
artifact when the property under test was the output. It failed exactly
that way on first run.

Shown capable of failing, four ways: dropping letter-spacing, a wrong
font name, 500->600, and splitting the grouped rule into two. All four
go red; restoring goes green. Components that cannot be evaluated are
reported NOT MEASURED (exit 75), never as a pass.
2026-09-28 23:26:15 +00:00

228 lines
11 KiB
JavaScript

#!/usr/bin/env node
/**
* check-ported-globals-identical — a refactor that must change NOTHING it emits.
*
* Several shadow-root components carry their own copy of DX Life's base/_globals.scss rules, because
* page CSS cannot reach into a shadow root. The copies were MEASURED as byte-identical where they
* overlap (the Cyrillic stack 3x, strong{font-weight:500} 2x, scrollbar-gutter:stable 2x), so
* extracting them into library/styles/portedGlobals.js is DEDUPLICATION, not a fix — and the proof
* obligation is therefore unusual: the emitted CSS must be IDENTICAL before and after, or the
* refactor is wrong.
*
* ★ IT EXECUTES THE COMPONENT'S SETUP AND CAPTURES THE REAL ARGUMENT. It does NOT read source text.
* The first version of this harness hashed the SOURCE of the useShadowStyles(...) call, and that was
* a defect that made the gate useless for its one job: this refactor's whole purpose is to REPLACE a
* string literal with a function call, so the source text changes by construction. A text-hash gate
* fails on a perfect refactor and cannot tell that apart from a broken one — it measures the
* ARTIFACT (how the CSS is spelled) when the property under test is the OUTPUT (what the browser
* receives). It failed exactly that way on first run, on a refactor later proven correct.
*
* So: parse the SFC, stub every import, run the <script setup> body, and keep the first argument
* handed to useShadowStyles. That string is what gets injected into the shadow root. Hash THAT.
* No browser is launched and none is needed — the CSS is assembled in plain JS at setup time.
*
* node internal/check-ported-globals-identical.mjs --write (baseline)
* node internal/check-ported-globals-identical.mjs (compare)
* node internal/check-ported-globals-identical.mjs --from <git-ref>
* evaluate the components AS OF a git ref instead of the working tree — which is how the
* pre-refactor baseline is recovered after the fact, without checking anything out.
*
* ⚠ WHITESPACE IS NORMALISED, declarations are NOT. A run of spaces or a newline between
* declarations cannot change rendering, and the concatenation seams legitimately move when a literal
* becomes a call. Selectors, properties, values and RULE GROUPING all survive normalisation — so
* splitting one grouped rule into two separate rules is still reported as a change, which is correct:
* it is the same CSS to a browser, but it is not the same stylesheet, and this gate's contract is
* "unchanged", not "equivalent".
*
* Exit 0 pass · 1 fail · 75 NOT MEASURED (a component could not be evaluated — never reported as a
* pass, because an unevaluated component proves nothing at all).
*/
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { parse } from '@vue/compiler-sfc'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '..')
const BASELINE = join(HERE, 'fixtures', 'ported-globals-baseline.json')
// The files under refactor. regions-dashboard's two are listed as OWNED ELSEWHERE: the harness
// records them too, so regions can migrate against the same baseline rather than inventing one.
// ★ `owned:false` means RECORDED, NOT GATED. regions-dashboard's two files port the same globals and
// belong to another agent; capturing their hashes lets them migrate against this same baseline
// instead of inventing one. But they must not be able to FAIL my run — a gate that goes red on a
// file its owner is mid-edit trains everyone to ignore it. They are reported, never enforced.
// Both currently evaluate only partially (they read props and coerce objects at setup time, which
// the stubs do not model); that is a gap in the harness, NOT evidence about their CSS, and it is
// reported as NOT MEASURED rather than quietly skipped.
const FILES = [
{ path: 'library/components/regions/GoalCard.vue', owned: true },
{ path: 'library/components/regions/RegionSectionCard.vue', owned: true },
{ path: 'plugin-designs/regions-dashboard/region-dashboards.vue', owned: false },
{ path: 'plugin-designs/regions-dashboard/regions-list.vue', owned: false },
]
const OWNED = new Set(FILES.filter((f) => f.owned).map((f) => f.path))
const args = process.argv.slice(2)
const fromRef = (() => {
const i = args.indexOf('--from')
return i >= 0 ? args[i + 1] : null
})()
/** Read a file from the working tree, or from a git ref when --from is given. */
function readSource(rel) {
if (!fromRef) {
const p = join(REPO, rel)
return existsSync(p) ? readFileSync(p, 'utf8') : null
}
try {
return execFileSync('git', ['show', `${fromRef}:${rel}`], { cwd: REPO, encoding: 'utf8' })
} catch {
return null
}
}
/**
* Execute a component's <script setup> far enough to capture its useShadowStyles argument.
*
* Every import is stubbed with a Proxy that returns a callable no-op for any name, EXCEPT
* useShadowStyles (captured) and portedGlobals (loaded for real — its output is the thing under
* test). Vue's own reactivity/lifecycle functions are stubbed too: nothing mounts, so onMounted
* never fires and no DOM is touched.
*
* @returns {{css: string}|{error: string}}
*/
async function emittedCss(src, rel) {
const { descriptor, errors } = parse(src, { filename: rel })
if (errors.length) return { error: `SFC parse: ${errors[0].message}` }
const block = descriptor.scriptSetup || descriptor.script
if (!block) return { error: 'no <script> block' }
let captured = null
const globals = await import('../library/styles/portedGlobals.js').catch(() => null)
// A stub that tolerates any shape a component might use it in: called, constructed, or
// property-accessed. defineProps/defineEmits are compiler macros, so they must return something
// indexable rather than undefined.
const anyFn = () => new Proxy(function () {}, {
get: (t, k) => (k === 'value' ? undefined : anyFn()),
apply: () => anyFn(),
})
const stubModule = (spec) => new Proxy({}, {
get(_t, name) {
if (name === 'useShadowStyles') return (css) => { captured = css }
if (globals && name in globals) return globals[name]
return anyFn()
},
})
// Rewrite `import ... from 'x'` into a binding off a stub, and drop the macros the SFC compiler
// would normally handle. Everything else runs as written — including the string concatenation and
// the portedGlobals calls, which is the entire point.
let code = block.content
.replace(/^\s*import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?\s*$/gm, (m, clause, spec) => {
const ns = clause.match(/^\*\s+as\s+(\w+)$/)
if (ns) return `const ${ns[1]} = __stub(${JSON.stringify(spec)});`
const named = clause.match(/\{([\s\S]*)\}/)
const def = clause.replace(/\{[\s\S]*\}/, '').replace(/,\s*$/, '').trim()
let out = ''
if (def) out += `const ${def} = __stub(${JSON.stringify(spec)});`
if (named) {
// ⚠ `import { A as B }` is NOT destructuring syntax — the rename operator there is `:`.
// Rewriting the clause verbatim produced a SyntaxError on the first real file that used it.
const bindings = named[1]
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((s) => s.replace(/^(\w+)\s+as\s+(\w+)$/, '$1: $2'))
.join(', ')
out += `const {${bindings}} = __stub(${JSON.stringify(spec)});`
}
return out || `__stub(${JSON.stringify(spec)});`
})
.replace(/^\s*import\s+['"][^'"]+['"];?\s*$/gm, '')
const fn = new Function('__stub', '__macros', `
const { defineProps, defineEmits, defineExpose, defineOptions, withDefaults } = __macros;
${code}
`)
try {
fn(stubModule, {
defineProps: anyFn(), defineEmits: anyFn(), defineExpose: anyFn(),
defineOptions: anyFn(), withDefaults: anyFn(),
})
} catch (e) {
return { error: `setup threw: ${e.message}` }
}
if (typeof captured !== 'string') return { error: 'useShadowStyles was never called with a string' }
return { css: captured.replace(/\s+/g, ' ').trim() }
}
const now = {}
const unmeasured = [] // owned files that failed to evaluate — these BLOCK
const unmeasuredOther = [] // recorded-only files — these are reported, never blocking
for (const { path: f } of FILES) {
const src = readSource(f)
if (src === null) { now[f] = null; continue }
const r = await emittedCss(src, f)
if (r.error) {
;(OWNED.has(f) ? unmeasured : unmeasuredOther).push(`${f}: ${r.error}`)
continue
}
now[f] = createHash('sha256').update(r.css).digest('hex').slice(0, 16)
}
if (args.includes('--write')) {
if (unmeasured.length) {
console.error('PORTED-GLOBALS: refusing to write a baseline with unevaluated components:')
for (const u of unmeasured) console.error(` ! ${u}`)
process.exit(75)
}
writeFileSync(BASELINE, JSON.stringify({
_why: 'Emitted-CSS hashes, captured by EXECUTING each component\'s setup and hashing the string it hands useShadowStyles. Whitespace-normalised; declarations and rule grouping are not. A ported-globals extraction must not change these.',
_source: fromRef ? `git ref ${fromRef}` : 'working tree',
hashes: now,
}, null, 2) + '\n')
console.log(`PORTED-GLOBALS: baseline written (${fromRef ? 'from ' + fromRef : 'working tree'})`)
for (const { path: f, owned } of FILES) {
if (now[f] === undefined) continue
console.log(` ${now[f] || '(absent)'} ${f}${owned ? '' : ' [recorded only — regions owns it]'}`)
}
for (const u of unmeasuredOther) console.log(` (not measured) ${u}`)
process.exit(0)
}
if (!existsSync(BASELINE)) {
console.log('PORTED-GLOBALS: SKIP — no baseline; run with --write before refactoring')
process.exit(0)
}
// ⚠ NOT MEASURED is not a pass. A component that failed to evaluate carries no evidence either way,
// and reporting it green is how a gate ends up certifying a file it never read.
if (unmeasured.length) {
console.error('PORTED-GLOBALS: NOT MEASURED (exit 75) — these components could not be evaluated, so')
console.error(' this run says NOTHING about whether the CSS changed. Not a pass, not a fail.')
for (const u of unmeasured) console.error(` ! ${u}`)
process.exit(75)
}
const base = JSON.parse(readFileSync(BASELINE, 'utf8')).hashes
const fails = []
let compared = 0
for (const { path: f } of FILES) {
if (base[f] === undefined || base[f] === null) continue
if (now[f] === undefined) continue // recorded-only and unevaluated — reported above, not enforced
compared++
if (base[f] !== now[f]) {
fails.push(`${f}\n before: ${base[f]}\n after: ${now[f]}\n the emitted CSS CHANGED — a ported-globals extraction must be byte-identical, so either the\n module's output differs from the literal it replaced, or a declaration was dropped`)
}
}
if (fails.length) {
console.error('PORTED-GLOBALS: FAIL')
for (const f of fails) console.error(` ✗ ${f}`)
process.exit(1)
}
for (const u of unmeasuredOther) console.log(`PORTED-GLOBALS: note — not measured (recorded only, not gated): ${u}`)
console.log(`PORTED-GLOBALS: PASS — the emitted CSS of ${compared} component(s) is byte-identical to the pre-refactor baseline.`)