Some checks are pending
build-gate / build (push) Waiting to run
A shared component declares the same selectors twice: once in a scoped <style> block for when it is imported as a Vue child, and once in a useShadowStyles literal for when it is mounted inside a shadow root, where scoped CSS does not reach. Both are load-bearing and the standing rule is that they stay identical. Nothing enforced it. library/components/PeopleGroupDetail.vue disagreed with itself on eleven properties, and the shadow copy is a coherent DARK theme under a light-only rule: .detail-header background white vs #16213e, .image-container #f5f5f5 vs #2d2d4e, and .people-group-name color #333 vs #fff -- white text, legible only on the dark background, so fixing the background alone would make the name invisible. Six importers; the dark hex reached four shipped bundles. It was invisible because it is a two-host bug: the component looks correct in whichever host you tested. Fourth of that shape this week. Three findings while building this, each of which had the gate reporting success about something it never compared: - a loose /<style[^>]*>/ matched the words "<style scoped>" inside a JS comment 43 characters before useShadowStyles( in SemanticTreeLegend.vue, then ran to the next real </style> and swallowed the whole shadow literal into the <style> text. Both maps read the same bytes, so that file could not fail on any of its 51 apparent shared selectors. Anchored to line start, since an SFC block starts at column 0. That also raised PeopleGroupDetail 8 -> 11. - assigning m[sel] = props let the last declaration win, discarding every property that appeared only in a base rule when a media query redeclared the selector. Rules are merged now. - comparing raw text reported comma-spacing as divergence: 5 findings for doxa-simple-map of which 2 were formatting. Compared as property maps, so the rule is about meaning. Negative-tested by change rather than by exit code, which with pre-existing failures proves nothing: fixing .detail-header takes PeopleGroupDetail 11 -> 10 and restoring it returns 11. Standalone rather than in build:unlocked, because it fails on live defects today.
175 lines
9 KiB
JavaScript
175 lines
9 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-synced-css-copies.mjs — when a component declares the same selector TWICE, both copies
|
|
* must say the same thing.
|
|
*
|
|
* WHY A COMPONENT HAS TWO COPIES AT ALL (this is not duplication by accident). A shared component
|
|
* renders in two different contexts:
|
|
* - imported as a Vue child, where its scoped `<style>` block applies;
|
|
* - mounted inside a custom element's shadow root, where scoped CSS does NOT reach it, so the
|
|
* component injects the same rules with `useShadowStyles(\`…\`)`.
|
|
* Both are load-bearing. The standing rule is that they stay identical — and nothing enforced it.
|
|
*
|
|
* THE DEFECT THIS CATCHES, found by running this audit by hand 2026-09-22:
|
|
* `library/components/PeopleGroupDetail.vue` disagreed with itself on EIGHT colour properties, and
|
|
* the shadow copy was DARK against a light `<style>` copy:
|
|
* .detail-header background style="white" shadow="#16213e"
|
|
* .stat-label color style="#666" shadow="#9ca3af"
|
|
* .description-text color style="#555" shadow="#d1d5db"
|
|
* The project rule is material LIGHT only, no dark mode. The literal is unconditional — no media
|
|
* query, no theme check — so the component renders LIGHT when imported as a Vue child and DARK when
|
|
* mounted in a shadow root. Six importers; the dark hex reached four shipped bundles.
|
|
*
|
|
* ★ WHY IT WAS INVISIBLE: it is a TWO-HOST bug. Whichever host you test, the component looks
|
|
* correct there. This is the fourth two-host defect found this week (dropdown containing-block,
|
|
* wheel scroll, grid affordances, and now CSS authoring) — the recurring shape of this codebase.
|
|
*
|
|
* WHAT IT ASSERTS. For every .vue file carrying BOTH a `<style>` block and a `useShadowStyles`
|
|
* literal, every selector present in both must declare the same VALUE for every property present
|
|
* in both. Compared as a property map, so `display: flex;` and `display:flex` are equal — the rule
|
|
* is about meaning, not formatting, and a whitespace-sensitive check would drown real findings in
|
|
* noise (measured: 20 "divergences" in one file, of which 8 were real).
|
|
*
|
|
* A property in only ONE copy is allowed: the two contexts legitimately need different rules (a
|
|
* shadow copy often adds `:host` sizing a scoped block cannot express). Only a CONFLICT — both
|
|
* copies naming the same property with different values — is a defect.
|
|
*
|
|
* SCOPE IS DERIVED, never listed: it walks library/ and plugin-designs/ for the pattern. A
|
|
* hand-maintained file list is how check:template-literal silently missed 36 files (see its header
|
|
* and AP-a-gate-that-is-right-about-the-rule-and-wrong-about-the-files).
|
|
*
|
|
* NEGATIVE-TESTED: changing one colour in either copy of a synced file fails by name.
|
|
*/
|
|
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
import { resolve, dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
|
|
function vuesUnder(root) {
|
|
const abs = join(ROOT, root)
|
|
if (!existsSync(abs)) return []
|
|
const out = []
|
|
const walk = (d) => {
|
|
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
if (e.name === 'node_modules' || e.name.startsWith('.')) continue
|
|
const p = join(d, e.name)
|
|
if (e.isDirectory()) walk(p)
|
|
else if (e.name.endsWith('.vue')) out.push(p)
|
|
}
|
|
}
|
|
walk(abs)
|
|
return out
|
|
}
|
|
|
|
const stripComments = (t) => t.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
|
|
// A rule body -> { property: value }. Formatting-insensitive on purpose.
|
|
const propsOf = (body) => {
|
|
const o = {}
|
|
for (const decl of stripComments(body).split(';')) {
|
|
const i = decl.indexOf(':')
|
|
if (i < 0) continue
|
|
const k = decl.slice(0, i).trim()
|
|
if (!k || k.startsWith('-')) continue
|
|
// Normalise COMMA SPACING too, not just run-length. A minified shadow copy writes
|
|
// `color .15s,border-color .15s` where the <style> block writes `color .15s, border-color .15s`
|
|
// — identical meaning, and reporting it buries the real findings. First run of this gate
|
|
// produced 5 conflicts for doxa-simple-map of which 2 were pure comma spacing; a gate whose
|
|
// output is 40% noise is one people learn to skim.
|
|
o[k] = decl.slice(i + 1).trim().replace(/\s+/g, ' ').replace(/\s*,\s*/g, ',')
|
|
}
|
|
return o
|
|
}
|
|
|
|
// Class-selector rules only. Pseudo-element and media-query variants are distinct rules and would
|
|
// produce false "divergences" if folded together — an early hand-check reported 12 copies / 5
|
|
// distinct for .vtl-nav purely because ::-webkit-scrollbar variants were swept in.
|
|
// ★ MERGE every rule for a selector; do NOT let the last one win.
|
|
// A selector is routinely declared more than once — a base rule plus media-query overrides. The
|
|
// first version of this gate assigned `m[sel] = props`, so only the LAST declaration survived and
|
|
// every property that appeared solely in the base rule became invisible.
|
|
//
|
|
// MEASURED: .stl-titlebar in SemanticTreeLegend.vue has TWO rules in each copy — a base rule
|
|
// carrying `gap: 6px` and a media-query rule carrying only `border-color`. Injecting `gap: 99px`
|
|
// into the shadow copy produced NO finding, because both maps had already discarded the base rule.
|
|
// A gate silently blind to base rules would have passed this file forever.
|
|
//
|
|
// Merging is the honest model here: the question is "do the two copies ever disagree about this
|
|
// property", and a property declared in a base rule is just as real as one in an override. Later
|
|
// declarations win within a copy, matching CSS cascade order, so a value genuinely overridden in
|
|
// both copies still compares equal.
|
|
const rulesOf = (css) => {
|
|
const m = {}
|
|
for (const r of stripComments(css).matchAll(/(^|[}\s;])(\.[a-z][\w-]*)\s*\{([^}]*)\}/gi)) {
|
|
m[r[2]] = Object.assign(m[r[2]] || {}, propsOf(r[3]))
|
|
}
|
|
return m
|
|
}
|
|
|
|
const files = [...vuesUnder('library'), ...vuesUnder('plugin-designs')]
|
|
const offenders = []
|
|
let pairs = 0
|
|
let sharedTotal = 0
|
|
|
|
for (const f of files) {
|
|
const src = readFileSync(f, 'utf8')
|
|
const i = src.indexOf('useShadowStyles(`')
|
|
if (i < 0 || !/<style/.test(src)) continue
|
|
|
|
const end = src.indexOf('`', i + 'useShadowStyles(`'.length)
|
|
if (end < 0) continue
|
|
const shadowCss = src.slice(i, end)
|
|
|
|
// ★ TAKE <style> BLOCKS FROM THE TOP LEVEL ONLY — an SFC block starts at column 0.
|
|
// A loose /<style[^>]*>/ matches the word inside a JS comment too. SemanticTreeLegend.vue has
|
|
// `// CSS — verbatim from PPLR's <style scoped>, injected into shadow root.` 43 characters BEFORE
|
|
// useShadowStyles(, so the regex opened a "style block" there and ran to the next real </style>,
|
|
// SWALLOWING THE WHOLE SHADOW LITERAL into the <style> text. Both maps then read the same bytes,
|
|
// so the two copies could never disagree and no injected conflict was ever detected — a gate that
|
|
// structurally could not fail on its largest file (51 shared selectors).
|
|
// Anchoring to line start also excludes the literal itself, which is what makes the comparison
|
|
// a comparison rather than a tautology.
|
|
const styleCss = [...src.matchAll(/^<style[^>]*>([\s\S]*?)^<\/style>/gm)].map((m) => m[1]).join('\n')
|
|
|
|
const A = rulesOf(styleCss)
|
|
const B = rulesOf(shadowCss)
|
|
const shared = Object.keys(A).filter((k) => k in B)
|
|
if (!shared.length) continue
|
|
pairs++
|
|
sharedTotal += shared.length
|
|
|
|
const conflicts = []
|
|
for (const sel of shared) {
|
|
for (const p of Object.keys(A[sel])) {
|
|
if (p in B[sel] && A[sel][p] !== B[sel][p]) {
|
|
conflicts.push({ sel, p, a: A[sel][p], b: B[sel][p] })
|
|
}
|
|
}
|
|
}
|
|
if (conflicts.length) offenders.push({ file: f.replace(ROOT + '/', ''), conflicts })
|
|
}
|
|
|
|
if (offenders.length) {
|
|
console.error(`SYNCED-CSS: FAIL — ${offenders.length} file(s) disagree with themselves.\n`)
|
|
for (const o of offenders) {
|
|
console.error(` ${o.file} — ${o.conflicts.length} conflicting propert(ies):`)
|
|
for (const c of o.conflicts.slice(0, 10)) {
|
|
console.error(` ${c.sel} { ${c.p} } <style>="${c.a}" shadow="${c.b}"`)
|
|
}
|
|
if (o.conflicts.length > 10) console.error(` …and ${o.conflicts.length - 10} more`)
|
|
console.error('')
|
|
}
|
|
console.error(' The two copies exist because the component renders BOTH as an imported Vue child')
|
|
console.error(' (scoped <style> applies) and inside a shadow root (only the injected literal')
|
|
console.error(' reaches it). They are meant to be identical. A conflict means the component looks')
|
|
console.error(' correct in whichever host you tested and wrong in the other — which is exactly')
|
|
console.error(' how a dark-mode shadow copy shipped in four bundles under a light-only rule.')
|
|
process.exit(1)
|
|
}
|
|
|
|
console.log(`SYNCED-CSS: PASS — ${pairs} file(s) carry both a <style> block and a shadow literal;`)
|
|
console.log(` ${sharedTotal} shared selector(s) agree on every property declared in both.`)
|
|
console.log(' A property present in only one copy is allowed — the two contexts legitimately need')
|
|
console.log(' different rules. Only a CONFLICT, where both name the same property differently, is')
|
|
console.log(' a defect. Compared as property maps, so formatting differences are not reported.')
|