Files
small-packages/luci-theme-footstrap/tools/css-dup.mjs
T
2026-09-20 01:22:33 +08:00

147 lines
6.9 KiB
JavaScript

#!/usr/bin/env node
/* Structural duplicate detector: the SAME declaration body under DIFFERENT guards.
*
* No linter does this. stylelint catches one selector twice in a file, or one property twice in a
* block; neither sees two rules with different selectors, under mutually exclusive guards, carrying
* an identical declaration set. To a cascade-aware tool both are required — only one ever matches —
* so it is findable only structurally, and it is exactly the shape that drifts: this theme had the
* same bar under a media query and under an attribute selector, 55 of ~75 declarations identical.
*
* No budget, deliberately: a number nobody defends lets the next unexplained copy in for free the
* moment somebody raises it. Every duplicated body is folded into one rule or PINNED with
* `@mirror`, which tools/mirror.mjs then holds byte-identical. The pin is not ceremony: THIS
* detector matches only IDENTICAL bodies, so it goes quiet the moment two copies diverge —
* exactly when it should shout. */
import { readFileSync } from 'node:fs';
import * as csstree from 'css-tree';
import { buildCss } from './lib/css.mjs';
/* Not a CLI flag: `--min N` would put this gate's own threshold on the command line, in a tool whose
* header rejects "a number nobody defends".
*
* The defence, measured on the shipped sheet: at 3 the gate reports 5 bodies, all pinned. Dropping
* it to 2 adds five more, every one a coincidence rather than a shared decision — two unrelated
* rules both taking the base type size, three pairs both saying "become a block". Pinning those
* with @mirror would assert that changing one means changing the other, which is false, and a pin
* that lies is worse than the duplication it describes. */
const MIN_DECLS = 3;
/* --dev: the pin is a COMMENT, and the squeeze strips comments */
const css = readFileSync(buildCss({ dev: true }), 'utf8');
/* css-tree drops comments from the AST, so the pin is collected on the side and matched back by
* LINE. */
const mirrorAt = new Map(); /* line -> "group/role" */
const ast = csstree.parse(css, {
positions: true,
onComment(value, loc) {
const m = value.match(/@mirror\s+([A-Za-z0-9_-]+\/[A-Za-z0-9_-]+)/);
if (m) mirrorAt.set(loc.start.line, m[1]);
},
});
/* the pin sits INSIDE the braces: a comment on some line between the block's first and last */
function mirrorFor(node) {
const a = node.block?.loc?.start.line, b = node.block?.loc?.end.line;
if (!a) return null;
for (const [line, name] of mirrorAt)
if (line >= a && line <= b) return name;
return null;
}
/* A rule's "guard" = the chain of at-rules it sits inside (media/container/supports). Same guard
* + same body = a plain duplicate (stylelint's job); DIFFERENT guards is what we hunt. */
const rules = [];
const stack = [];
csstree.walk(ast, {
enter(node) {
if (node.type === 'Atrule' && node.prelude && ['media', 'container', 'supports', 'layer'].includes(node.name))
stack.push(`@${node.name} ${csstree.generate(node.prelude)}`);
if (node.type !== 'Rule' || node.block?.type !== 'Block') return;
const decls = [];
const mirror = mirrorFor(node);
for (const d of node.block.children) {
if (d.type !== 'Declaration') continue;
decls.push(`${d.property}:${csstree.generate(d.value).replace(/\s+/g, ' ').trim()}${d.important ? '!' : ''}`);
}
if (decls.length < MIN_DECLS) return;
rules.push({
guard: stack.filter(g => !g.startsWith('@layer')).join(' & ') || '(none)',
selector: csstree.generate(node.prelude).replace(/\s+/g, ' ').trim(),
line: node.loc?.start.line ?? 0,
key: decls.slice().sort().join('; '),
n: decls.length,
mirror,
});
},
leave(node) {
if (node.type === 'Atrule' && node.prelude && ['media', 'container', 'supports', 'layer'].includes(node.name))
stack.pop();
},
});
/* group by identical body, keep only groups spanning >1 DISTINCT guard */
const byBody = new Map();
for (const r of rules) {
if (!byBody.has(r.key)) byBody.set(r.key, []);
byBody.get(r.key).push(r);
}
const findings = [];
for (const [key, group] of byBody) {
if (group.length < 2) continue;
const guards = new Set(group.map(r => r.guard));
if (guards.size < 2) continue; /* same guard = plain dup, stylelint owns it */
findings.push({ key, n: group[0].n, group });
}
findings.sort((a, b) => b.n * b.group.length - a.n * a.group.length);
const wasted = findings.reduce((s, f) => s + f.n * (f.group.length - 1), 0);
/* accepted only if every copy carries the SAME @mirror pin */
const unpinned = findings.filter(f => {
const pins = f.group.map(r => r.mirror);
return pins.some(p => !p) || new Set(pins).size !== 1;
});
for (const f of findings) {
const pins = new Set(f.group.map(r => r.mirror));
const tag = (pins.size === 1 && !pins.has(null)) ? `pinned @mirror ${[...pins][0]}` : 'UNPINNED';
console.log(`\n--- ${f.n} decls x ${f.group.length} occurrences [${tag}]`);
for (const r of f.group)
console.log(` L${String(r.line).padEnd(6)} guard=${r.guard.padEnd(42)} ${r.selector}`);
console.log(` decls: ${f.key}`);
}
console.log(`\n${findings.length} duplicated declaration bodies across differing guards `
+ `(>= ${MIN_DECLS} decls); ~${wasted} redundant declarations. `
+ `${unpinned.length ? `${unpinned.length} UNPINNED.` : 'all pinned.'}`);
/* "0 duplicated bodies, all pinned" is what a broken build prints too: buildCss() returning a
* near-empty sheet leaves `rules` near-empty, `findings` empty, `unpinned` empty, and this gate
* has no budget on FINDINGS by design (see the header) — but it never had a floor on how much CSS
* it actually walked, either, so a build that produced almost nothing read as "nothing is
* duplicated" rather than as "nothing was checked". MEASURED is the current count of qualifying
* (>= MIN_DECLS decls) rules (434); the floor sits far under it. */
const MEASURED_RULES = 434;
const RULES_FLOOR = 100;
if (rules.length < RULES_FLOOR) {
console.error(`\nFAIL: only ${rules.length} rule(s) with >= ${MIN_DECLS} declarations were found `
+ `in the built sheet (last real run: ${MEASURED_RULES}) — the build produced far less CSS `
+ `than the theme ships. This is not a clean sweep; it is a sweep over almost nothing.`);
process.exit(1);
}
if (unpinned.length) {
console.error(`\nFAIL: ${unpinned.length} duplicated declaration body/bodies are not pinned.`);
console.error('Fold them into one rule. If the guards genuinely cannot be merged in CSS (a');
console.error('media query vs an attribute selector, a class vs a container query, two');
console.error('@container thresholds), then this duplication is forced on you — say so, by');
console.error('wrapping the declarations of EVERY copy in:');
console.error(' /* @mirror <group>/<role> */ …declarations… /* @endmirror */');
console.error('tools/mirror.mjs then holds the copies byte-identical, so they cannot drift.');
console.error('There is no budget: a number nobody defends lets the next copy in for free.');
process.exit(1);
}