mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-10-28 03:52:53 +08:00
147 lines
6.9 KiB
JavaScript
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);
|
|
}
|