fix: stop treating the child combinator as a prelude boundary when pruning

removeSelectorAt walked backward to find the rule prelude and stopped at
any '>', added so the walk would not escape past the <style> open tag.
That same character is the CSS child combinator, so pruning one unused
selector from a list like '.wrap > .orphan, .orphan' cut the prelude
mid-list; when every remaining fragment equaled the flagged selector, the
whole-rule branch then deleted from the cut point and left a dangling
'.wrap >' in source. A '>' now bounds the walk only when it actually
closes a <style ...> tag; combinators are walked through.

Regression tests cover a mid-list combinator prune and the dangling-
fragment shape (verified failing on the previous code).

AI-assisted (Claude Code).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-28 18:05:41 -07:00
co-authored by Claude Code
parent 20213a6817
commit 0c18cbc9ef
2 changed files with 38 additions and 2 deletions
+11 -2
View File
@@ -535,11 +535,20 @@ function removeSelectorAt(source, start, end) {
if (braceIdx === -1) return { changed: false, selector, source };
const bodyEnd = scanBlockEnd(source, braceIdx + 1);
// Prelude spans backward from the brace to the previous } ; { or style open.
// Prelude spans backward from the brace to the previous } ; { or the end
// of the <style> open tag. A bare `>` is NOT a boundary: it is the child
// combinator, and cutting there truncates a selector list like
// `.a > .b, .c` mid-prelude. Only a `>` that closes a `<style ...>` tag
// bounds the walk.
let preludeStart = start;
for (let i = start - 1; i >= 0; i--) {
const ch = source[i];
if (ch === '}' || ch === '{' || ch === ';' || ch === '>') { preludeStart = i + 1; break; }
if (ch === '}' || ch === '{' || ch === ';') { preludeStart = i + 1; break; }
if (ch === '>') {
const styleOpen = source.lastIndexOf('<style', i);
if (styleOpen !== -1 && source.indexOf('>', styleOpen) === i) { preludeStart = i + 1; break; }
continue; // child combinator inside the prelude
}
if (i === 0) preludeStart = 0;
}
const prelude = source.slice(preludeStart, braceIdx);
+27
View File
@@ -137,6 +137,33 @@ describe('compiler-driven pruning', () => {
const { source } = pruneUnusedSelectors('<div>{broken', () => { throw new Error('nope'); });
assert.equal(source, '<div>{broken');
});
it('prunes past child combinators without truncating the selector list', () => {
// The prelude walk must not treat the child combinator as a boundary:
// cutting at the `>` of `.wrap > .item` used to rewrite the list from
// mid-prelude.
const component = `<div class="wrap"><p class="item">x</p></div>\n<style>\n .wrap > .item, .orphan { font-weight: bold; }\n .wrap { padding: 4px; }\n</style>`;
const { source, removed } = pruneUnusedSelectors(component, compile);
assert.deepEqual(removed, ['.orphan']);
assert.match(source, /\.wrap > \.item \{ font-weight: bold; \}/);
assert.match(source, /\.wrap \{ padding: 4px; \}/);
const { warnings } = compile(source, { generate: false });
assert.deepEqual(warnings.filter((w) => w.code === 'css_unused_selector'), []);
});
it('removes a fully unused combinator rule without leaving a dangling fragment', () => {
// The corruption shape: after a mid-prelude cut, every remaining fragment
// equals the flagged selector, so the whole-rule branch deleted from the
// cut point and left `.wrap >` dangling in source.
const component = `<div class="wrap"><p class="item">x</p></div>\n<style>\n .wrap > .orphan, .orphan { color: blue; }\n .wrap { padding: 4px; }\n</style>`;
const { source } = pruneUnusedSelectors(component, compile);
assert.doesNotMatch(source, /\.orphan/);
assert.doesNotMatch(source, /\.wrap >\s*\{/, 'no dangling combinator fragment');
assert.doesNotMatch(source, /\.wrap >\s*$/m, 'no dangling combinator line');
assert.match(source, /\.wrap \{ padding: 4px; \}/);
const { warnings } = compile(source, { generate: false });
assert.deepEqual(warnings.filter((w) => w.code === 'css_unused_selector'), []);
});
});
describe('postcondition scanner', () => {