From 7e4179667fc889a31f3f6acabbd0156b43bf4d84 Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Fri, 18 Sep 2026 23:30:35 -0400 Subject: [PATCH 01/38] fix(bulma-ui): a compound matches one simple selector, not co-occurrence Last commit's index keyed one entry per comma-separated part, so all the classes in a descendant selector counted as one set: `.hero.is-primary .tabs` answered the query `tabs.is-primary` against a stylesheet with no such rule. That is the silent direction, a dead modifier reading as live, which is a missed defect rather than a false alarm. Parts are split into simple selectors on the combinators now, and the class set has to match the query exactly, so a rule needing a third class no longer answers for two either. Order still does not matter, which is the point of the previous fix: Bulma writes both `.notification.is-primary` and `.is-primary.input`. Measured before and after rather than reasoned about, and the honest result is narrower than the finding suggests: across the fifty-seven pairs this guard actually queries, the broken index and the fixed one agree on every one. The spurious answers are real and they are in elements this file never asks about. Exact matching and order-independent matching also agree on all fifty-seven, and both correctly answer false for `.timeinput-wheel` and `.tabs`. The literal readers accepted single quotes only. A double-quoted component name failed loudly through the call count, but a double-quoted `extraUnstyled` entry read as no extras with nothing counting it, and the inline-list check is satisfied by a double-quoted array. Both styles now. Prettier is what kept this from biting, which is not a guarantee. And the measurements came out of the comments. That is the third time this file has grown point-in-time evidence and the third time a review has had to say so; the rationale survives without the numbers. --- scripts/color-tuple-css.test.mjs | 83 ++++++++++++++++---------------- 1 file changed, 42 insertions(+), 41 deletions(-) diff --git a/scripts/color-tuple-css.test.mjs b/scripts/color-tuple-css.test.mjs index a8d8b464e..8a17dd057 100644 --- a/scripts/color-tuple-css.test.mjs +++ b/scripts/color-tuple-css.test.mjs @@ -98,61 +98,62 @@ function stylesheet() { } /** - * Does the stylesheet carry this exact class, not a longer one starting with - * it? + * Does the stylesheet carry this class, or this exact compound? * - * Single classes are matched literally. A COMPOUND (`notification.is-primary`) - * is matched order-independently, because CSS does not care and Bulma does not - * either: it writes `.notification.is-primary` and `.is-primary.input`. An - * element-first-only regex called the second one absent, which is how a live - * modifier could have read as dead. + * A single class is matched literally. A COMPOUND (`notification.is-primary`) + * has to appear as one SIMPLE selector carrying exactly those classes, which + * is narrower than it sounds and deliberately so. * - * Indexed by class rather than scanned per query. Walking every selector for - * every pair took the first case from a third of a second to six, because - * this file asks the question a few thousand times. + * Order does not matter, because CSS does not care and Bulma does not either: + * it writes `.notification.is-primary` and `.is-primary.input`. Matching + * element-first only called the second absent, so a live modifier read as + * dead. + * + * But co-occurrence is not a match. Indexing per comma-separated part made + * `.hero.is-primary .tabs` answer the query `tabs.is-primary`, since all + * three classes sit in that one part, and that is the silent direction: a + * dead modifier reading as live, which is a missed defect rather than a false + * alarm. Parts are split into simple selectors on the combinators, and the + * class set has to match exactly, so a rule needing a third class does not + * answer for two. */ function shipsClass(css, cls) { - const parts = cls.split('.'); - const index = classIndex(css); - if (parts.length === 1) return index.has(cls); - // Start from the rarest class so the scan is over the shortest list. - const lists = parts.map(part => index.get(part) ?? new Set()); - if (lists.some(l => l.size === 0)) return false; - const smallest = lists.reduce((a, b) => (a.size <= b.size ? a : b)); - for (const id of smallest) { - if (lists.every(l => l.has(id))) return true; + const wanted = cls.split('.'); + const index = simpleSelectors(css); + if (wanted.length === 1) { + return index.some(classes => classes.has(cls)); } - return false; + return index.some( + classes => + classes.size === wanted.length && wanted.every(w => classes.has(w)) + ); } /** - * class name → the selector parts carrying it, as a set of part ids. + * The class sets of every simple selector in the stylesheet. * - * One pass over the stylesheet per file read. A part is one comma-separated - * selector, so a class list matching within a single part is what counts, - * rather than the whole selector list mentioning them somewhere apart. + * One pass per file read. Split rather than matched: a `[^{}]+` scan over a + * minified stylesheet of this size backtracks badly, where splitting is + * linear. */ const indexCache = new Map(); -function classIndex(css) { +function simpleSelectors(css) { if (!indexCache.has(css)) { - const index = new Map(); - let id = 0; - // Split rather than matched. `/([^{}]+)\{/g` over 800KB of minified CSS - // took six seconds for 6,483 selectors, which is backtracking rather than - // work; this is five milliseconds for the same index. Each `{` is - // preceded by a prelude, and the selector is whatever follows the last - // `}` in it. + const sets = []; for (const chunk of css.split('{')) { + // Each `{` is preceded by a prelude; the selector is whatever follows + // the last `}` in it. const prelude = chunk.slice(chunk.lastIndexOf('}') + 1); for (const part of prelude.split(',')) { - id += 1; - for (const c of part.matchAll(/\.([A-Za-z0-9_-]+)/g)) { - if (!index.has(c[1])) index.set(c[1], new Set()); - index.get(c[1]).add(id); + for (const simple of part.split(/[\s>+~]+/)) { + if (!simple.includes('.')) continue; + sets.push( + new Set([...simple.matchAll(/\.([A-Za-z0-9_-]+)/g)].map(m => m[1])) + ); } } } - indexCache.set(css, index); + indexCache.set(css, sets); } return indexCache.get(css); } @@ -179,7 +180,7 @@ function tupleFrom(path, name) { `could not find the \`${name}\` tuple in ${path}, so this guard cannot ` + 'run. Fix the pattern in the same change that moved it.' ); - const values = [...block[1].matchAll(/'([^']+)'/g)].map(m => m[1]); + const values = [...block[1].matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]); assert.ok(values.length > 0, `the \`${name}\` tuple read as empty`); return values; } @@ -198,7 +199,7 @@ function colorKeywords() { const source = codeOnly(readFileSync(COLOR_CLASSES, 'utf8')); const copies = [ ...source.matchAll(/\[\s*\.\.\.validColors\s*,([^\]]*)\]/g), - ].map(m => [...m[1].matchAll(/'([^']+)'/g)].map(x => x[1])); + ].map(m => [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map(x => x[1])); // Counted against the spellings that EXIST, not against zero. A `> 0` // guard let a copy this pattern stopped matching drop out silently while // the function claimed to read every one of them. @@ -300,7 +301,7 @@ function warningCallers() { break; } } - const named = /warnUnstyledColor\(\s*'([^']+)'/.exec(text); + const named = /warnUnstyledColor\(\s*['"]([^'"]+)['"]/.exec(text); // A call whose component is not a string literal cannot be attributed // to an element, and is counted below rather than skipped quietly. if (!named) continue; @@ -351,7 +352,7 @@ function warningCallers() { // every bracketed span in the call text let a bracketed expression // in an earlier argument contribute a value, failing with a message // naming something the library never declared. - extraUnstyled: [...(args[2] ?? '').matchAll(/'([^']+)'/g)].map( + extraUnstyled: [...(args[2] ?? '').matchAll(/['"]([^'"]+)['"]/g)].map( x => x[1] ), }); From 1ba7bdf8934188557c7d14fa853dbfe5540b31cc Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Sat, 19 Sep 2026 18:30:45 -0400 Subject: [PATCH 02/38] fix(bulma-ui): a :not() class is absent, not present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raised by CodeRabbit on #703. The class extraction read every `.name` in a simple selector, including the ones inside `:not(…)`, which name classes the element must NOT carry. So `.notification:not(.is-light)` would answer the query `notification.is-light`: a rule that explicitly excludes the pair reporting it as live, which is the silent direction this PR exists to close. Measured before changing it, and it affects nothing today: 201 simple selectors carry a functional pseudo-class and none of them also carries a colour modifier, so no answer changes. Latent, and worth fixing because it is the same shape as the defect the PR is for. `:is()` and `:where()` are deliberately left alone. Their classes are alternatives rather than requirements, so counting them makes the exact match demand too many classes and under-report, which is the safe way to be wrong; treating them properly means modelling branches, which is more parser than this file should carry. --- scripts/color-tuple-css.test.mjs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/color-tuple-css.test.mjs b/scripts/color-tuple-css.test.mjs index 8a17dd057..6550b5d90 100644 --- a/scripts/color-tuple-css.test.mjs +++ b/scripts/color-tuple-css.test.mjs @@ -147,8 +147,20 @@ function simpleSelectors(css) { for (const part of prelude.split(',')) { for (const simple of part.split(/[\s>+~]+/)) { if (!simple.includes('.')) continue; + // `:not(…)` names classes the element must NOT carry, so reading + // them as present lets `.notification:not(.is-light)` answer the + // query `notification.is-light` — a rule that explicitly excludes + // the pair reporting it live, which is the silent direction. + // Dropped before extraction. `:is()` and `:where()` are left alone: + // their classes are alternatives, so counting them makes the exact + // match demand too many and under-report, which is the safe way to + // be wrong. + const matchable = simple.replace(/:not\([^)]*\)/g, ''); + if (!matchable.includes('.')) continue; sets.push( - new Set([...simple.matchAll(/\.([A-Za-z0-9_-]+)/g)].map(m => m[1])) + new Set( + [...matchable.matchAll(/\.([A-Za-z0-9_-]+)/g)].map(m => m[1]) + ) ); } } From 6a2daeadf8cb8c509b37afa278c8da1eb2e09ce2 Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Sat, 19 Sep 2026 18:43:00 -0400 Subject: [PATCH 03/38] fix(bulma-ui): strip pseudo-class arguments before splitting, not after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `:not()` strip ran after the selector list was split on commas, and the arguments can contain commas: `.navbar-item:not(.is-active,.is-selected)` is one selector, so splitting first fragments it and leaves `is-active` looking like a class the element carries. The strip then finds no closing paren and matches nothing. Stripping before any splitting solves the ordering and the comma at once. Measured, because the obvious check does not show it: `navbar-item` with `is-active` answers true either way, since a real `.navbar-item.is-active` rule also exists. What changes is the contribution — seven exact `{navbar-item, is-active}` sets before, five after — and across the stylesheet ten compound pairs existed ONLY inside a pseudo-class argument and now correctly do not, among them `navbar-link` with `is-arrowless` and `title` with `is-spaced`. No answer this guard queries moves, so it is latent here as well. The strip now takes `:is()` and `:where()` arguments too, which the previous version deliberately kept. Dropping them makes the exact match demand fewer classes than the selector needs and under-report, which is the safe way to be wrong, and it is what lets one rule handle the comma problem for every functional pseudo-class rather than only `:not()`. `cssBackedColors()` was the one literal reader left single-quoted after four others were widened. Prettier enforces single quotes repo-wide so none of the five needed it, but five readers of the same kind of literal should fail the same way. --- scripts/color-tuple-css.test.mjs | 37 +++++++++++++++++++------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/scripts/color-tuple-css.test.mjs b/scripts/color-tuple-css.test.mjs index 6550b5d90..c6ada124f 100644 --- a/scripts/color-tuple-css.test.mjs +++ b/scripts/color-tuple-css.test.mjs @@ -143,24 +143,31 @@ function simpleSelectors(css) { for (const chunk of css.split('{')) { // Each `{` is preceded by a prelude; the selector is whatever follows // the last `}` in it. - const prelude = chunk.slice(chunk.lastIndexOf('}') + 1); + // Functional pseudo-class ARGUMENTS go before anything is split, and + // the order matters twice over. `:not(…)` names classes the element + // must not carry, so reading them as present lets + // `.notification:not(.is-light)` answer the query + // `notification.is-light` — a rule that excludes the pair reporting it + // live, which is the silent direction. And the arguments can contain + // commas: `.navbar-item:not(.is-active,.is-selected)` is one selector, + // so splitting the list first fragments it and leaves `is-active` + // looking like a class the element carries. Stripping first solves + // both. + // + // `:is()` and `:where()` lose their classes to the same strip. Those + // are alternatives rather than requirements, so dropping them makes the + // exact match demand fewer classes than the selector really needs and + // under-report, which is the safe way to be wrong. Keeping them would + // mean modelling branches, which is more parser than this file should + // carry. + const prelude = chunk + .slice(chunk.lastIndexOf('}') + 1) + .replace(/:[a-z-]+\([^()]*\)/gi, ''); for (const part of prelude.split(',')) { for (const simple of part.split(/[\s>+~]+/)) { if (!simple.includes('.')) continue; - // `:not(…)` names classes the element must NOT carry, so reading - // them as present lets `.notification:not(.is-light)` answer the - // query `notification.is-light` — a rule that explicitly excludes - // the pair reporting it live, which is the silent direction. - // Dropped before extraction. `:is()` and `:where()` are left alone: - // their classes are alternatives, so counting them makes the exact - // match demand too many and under-report, which is the safe way to - // be wrong. - const matchable = simple.replace(/:not\([^)]*\)/g, ''); - if (!matchable.includes('.')) continue; sets.push( - new Set( - [...matchable.matchAll(/\.([A-Za-z0-9_-]+)/g)].map(m => m[1]) - ) + new Set([...simple.matchAll(/\.([A-Za-z0-9_-]+)/g)].map(m => m[1])) ); } } @@ -412,7 +419,7 @@ function acceptedKeywords(component, keywords) { /** The colours `CSS_BACKED` names to the developer as ones that work. */ function cssBackedColors() { const source = readFileSync(DEPRECATIONS, 'utf8'); - const line = /const CSS_BACKED =\s*\n?\s*'([^']+)'/.exec(source); + const line = /const CSS_BACKED =\s*\n?\s*['"]([^'"]+)['"]/.exec(source); assert.ok(line, 'could not find `CSS_BACKED` in colorDeprecations.ts'); return line[1].split(',').map(v => v.trim()); } From 4e3b51793f577b66332b81456cb2ce25baa579bc Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Sat, 19 Sep 2026 18:46:00 -0400 Subject: [PATCH 04/38] fix(bulma-ui): read an escaped dot as part of the class name, not a separator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulma spells the fractional gap helpers with an escape: `.is-gap-0\.5` is one class called `is-gap-0.5`. The tokeniser stopped at the backslash and then matched the escaped dot as if it began a second class, so that selector entered the index as `{is-gap-0, 5}` — a class the stylesheet does not have, and a set one element wider than the selector it came from. Twenty-four selectors carry an escape and all twenty-four change. None of the 234 element-and-colour answers this guard asks moves, because no element name and no colour modifier is escaped, so this is latent like the last one. It is still worth fixing: the exact-size check is the whole mechanism here, and a tokeniser that invents an extra class is the one input that quietly defeats it. --- scripts/color-tuple-css.test.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/color-tuple-css.test.mjs b/scripts/color-tuple-css.test.mjs index c6ada124f..81143f8f4 100644 --- a/scripts/color-tuple-css.test.mjs +++ b/scripts/color-tuple-css.test.mjs @@ -166,8 +166,17 @@ function simpleSelectors(css) { for (const part of prelude.split(',')) { for (const simple of part.split(/[\s>+~]+/)) { if (!simple.includes('.')) continue; + // A class name may escape a character with a backslash, which is + // how Bulma spells the fractional gap helpers: `.is-gap-0\\.5` is + // ONE class called `is-gap-0.5`. Reading the escape as a separator + // splits it into `is-gap-0` and `5`, which both invents a class and + // inflates the set past the exact-size check. sets.push( - new Set([...simple.matchAll(/\.([A-Za-z0-9_-]+)/g)].map(m => m[1])) + new Set( + [...simple.matchAll(/\.((?:\\.|[A-Za-z0-9_-])+)/g)].map(m => + m[1].replace(/\\(.)/g, '$1') + ) + ) ); } } From f395e76d25762b06d088d01fd3fe2582cd3c884e Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Sat, 19 Sep 2026 19:01:31 -0400 Subject: [PATCH 05/38] fix(bulma-ui): only :not() drops its argument, and pin the direction Widening the strip from `:not()` to every functional pseudo-class was the cheap way to get the comma fix, and it bought that at the cost of the property the matcher is built on. `:not()` is a prohibition, so the rest of the selector already names everything the element carries and deleting the argument is right. Everything else ADDS a requirement, and deleting the argument shortens the class set: `.notification.is-primary:where(.is-light)` needs three classes, and a two-class set answers the two-class query `notification.is-primary` as live. That is precisely the over-reporting the exact-size rule exists to stop, so the previous commit removed the guard's own defence, and its comment claimed the opposite direction. Those selectors now leave a sentinel class behind, so the set grows instead of shrinking and the query reads dead. A false alarm is the one way this guard may be wrong. Comments are removed before the stylesheet is split. Their words were entering the index as classes, which is inert for the ones that ship today but is the same over-reporting shape: a comment quoting a compound answers for it. The matcher now has a test of its own, on selectors written for the purpose rather than on whatever Bulma happens to write. Every finding this file has taken has been a matcher bug rather than a library one, and each surfaced by measuring the built stylesheet, which can only cover the shapes already in it. Two of the four `:has()` selectors that ship come from our own SCSS, so this shape is ours to grow, and there is no `:is()` or `:where()` yet at all. Reverting each of the three behaviours separately fails the new test on that behaviour's own assertion, so none of them is carried by another. No answer moves. The six selectors that gain a sentinel are structural, the element-and-colour pairs are unchanged, and the only single-class answers that change are the words that were coming out of the comments. --- scripts/color-tuple-css.test.mjs | 139 +++++++++++++++++++++++++++++-- 1 file changed, 130 insertions(+), 9 deletions(-) diff --git a/scripts/color-tuple-css.test.mjs b/scripts/color-tuple-css.test.mjs index 81143f8f4..509d8188b 100644 --- a/scripts/color-tuple-css.test.mjs +++ b/scripts/color-tuple-css.test.mjs @@ -137,10 +137,33 @@ function shipsClass(css, cls) { * linear. */ const indexCache = new Map(); + +/** + * Stands in for a requirement the tokeniser does not model. + * + * The exact-size rule in `shipsClass` is the whole defence against a rule + * that needs a third class answering a two-class query, so anything that + * quietly shortens a class set defeats it. Where a selector demands + * something this file will not parse, one of these goes in the set instead, + * and the query stops matching. `assertUnmodelledIsUnused` holds the name to + * being absent from the stylesheet, so it cannot collide with a real class. + */ +const UNMODELLED = 'bestax-guard-unmodelled-requirement'; + function simpleSelectors(css) { if (!indexCache.has(css)) { + assert.ok( + !css.includes(UNMODELLED), + `the stylesheet contains \`${UNMODELLED}\`, which this file uses as a ` + + 'name no real class can have. Rename the sentinel.' + ); const sets = []; - for (const chunk of css.split('{')) { + // Comments go first, or their words become classes: the minireset + // banner and the trailing sourceMappingURL contribute sets like + // `{com, css}` today. None of those is queried, but a comment that + // happened to name a compound would answer for it, and that reads as a + // live modifier — the silent direction again. + for (const chunk of css.replace(/\/\*[\s\S]*?\*\//g, '').split('{')) { // Each `{` is preceded by a prelude; the selector is whatever follows // the last `}` in it. // Functional pseudo-class ARGUMENTS go before anything is split, and @@ -151,18 +174,23 @@ function simpleSelectors(css) { // live, which is the silent direction. And the arguments can contain // commas: `.navbar-item:not(.is-active,.is-selected)` is one selector, // so splitting the list first fragments it and leaves `is-active` - // looking like a class the element carries. Stripping first solves + // looking like a class the element carries. Handling them first solves // both. // - // `:is()` and `:where()` lose their classes to the same strip. Those - // are alternatives rather than requirements, so dropping them makes the - // exact match demand fewer classes than the selector really needs and - // under-report, which is the safe way to be wrong. Keeping them would - // mean modelling branches, which is more parser than this file should - // carry. + // Only `:not()` DROPS. Its argument is a prohibition, so the rest of + // the selector already says everything the element must carry. Every + // other functional pseudo-class adds a requirement, and deleting its + // argument shortens the set: `.notification.is-primary:where(.is-light)` + // would answer `notification.is-primary`, which is the same + // over-reporting the exact-size rule exists to stop. They leave an + // `UNMODELLED` behind instead, so the set grows rather than shrinks and + // the query reads dead. That direction is a false alarm, which is the + // one this guard is allowed to be wrong in. const prelude = chunk .slice(chunk.lastIndexOf('}') + 1) - .replace(/:[a-z-]+\([^()]*\)/gi, ''); + .replace(/:([a-z-]+)\(([^()]*)\)/gi, (_, name) => + name.toLowerCase() === 'not' ? '' : `.${UNMODELLED}` + ); for (const part of prelude.split(',')) { for (const simple of part.split(/[\s>+~]+/)) { if (!simple.includes('.')) continue; @@ -434,6 +462,99 @@ function cssBackedColors() { } describe('the colour tuples agree with the shipped stylesheet', () => { + // The matcher itself, on selectors written for the purpose. Every finding + // this file has taken was a matcher bug rather than a library one, and each + // was found by measuring the built stylesheet, which only covers the shapes + // Bulma happens to write today. Two of the four `:has()` selectors that ship + // come from our own SCSS, so the shapes are ours to grow. These pin the + // DIRECTION: reading a compound as live when it is not is a missed defect, + // reading it as dead when it is live is a false alarm, and the matcher is + // only ever allowed the second. + it('never reads a compound as live that the selector does not ship', () => { + const live = (css, query) => shipsClass(css, query); + + // A pseudo-class that ADDS a requirement. Deleting its argument would + // leave a two-class set answering a two-class query, when the rule needs + // three. + assert.equal( + live( + '.notification.is-primary:where(.is-light){color:red}', + 'notification.is-primary' + ), + false, + '`:where()` adds a class the element must carry, so dropping it lets a ' + + 'rule that needs three classes answer a two-class query as live.' + ); + assert.equal( + live('.title.is-spaced:has(+.subtitle){color:red}', 'title.is-spaced'), + false, + '`:has()` adds a requirement the element must satisfy, so the compound ' + + 'before it is not on its own a live rule.' + ); + assert.equal( + live( + '.notification.is-primary:nth-child(even){color:red}', + 'notification.is-primary' + ), + false, + 'a structural pseudo-class is a requirement too: the compound renders ' + + 'for some elements and not others, which is not what this guard calls ' + + 'live.' + ); + + // A pseudo-class that FORBIDS. The rest of the selector already says + // everything the element carries, so the classes inside are not its own. + assert.equal( + live('.notification:not(.is-light){color:red}', 'notification'), + true, + '`:not()` is a prohibition, so the compound outside it is still live.' + ); + assert.equal( + live( + '.navbar-item:not(.is-active,.is-selected){color:red}', + 'navbar-item.is-active' + ), + false, + 'a class the rule EXCLUDES must not read as one the element carries, ' + + 'and a multi-argument `:not()` must not fragment on its own commas.' + ); + + // The plain case still answers, or the three above would be vacuous. + assert.equal( + live('.notification.is-primary{color:red}', 'notification.is-primary'), + true, + 'a plain compound rule is exactly what this guard calls live.' + ); + + // A comment is not a selector, and an escaped dot is not a separator. + assert.equal( + live( + '/* .notification.is-primary */ .box{color:red}', + 'notification.is-primary' + ), + false, + 'a comment naming a compound would answer for it, which reads as a ' + + 'live modifier.' + ); + // `shipsClass` spells a compound with a `.`, so a class carrying a + // literal dot cannot be asked for at all. What matters is that it + // invents nothing: `.is-gap-0\\.5` is ONE class, and reading the escape + // as a separator both fabricates a compound and fabricates a shorter + // class, either of which answers a query the stylesheet never backs. + assert.equal( + live('.is-gap-0\\.5{gap:.5rem}', 'is-gap-0.5'), + false, + 'an escaped dot read as a separator turns one class into a compound ' + + 'of two, and the exact-size rule then matches it.' + ); + assert.equal( + live('.is-gap-0\\.5{gap:.5rem}', 'is-gap-0'), + false, + 'the fractional helper is not the integer one, and a stylesheet that ' + + 'ships only the first must not answer for the second.' + ); + }); + it('declares every colour whose component modifier has no CSS', () => { const css = stylesheet(); const colors = tupleFrom(HELPERS, 'validColors'); From bc056225904dceed7e44030624405c1527819b67 Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Sat, 19 Sep 2026 19:18:59 -0400 Subject: [PATCH 06/38] fix(bulma-ui): a requirement is a requirement, parentheses or not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sentinel only fired on a pseudo-class written WITH parentheses, so the same commit that asserted `:nth-child(even)` makes a compound read dead answered `:first-child` live. `:hover` and `[disabled]` were in the same position. Every one of them narrows which elements carrying those classes match, which is the thing the exact-size rule has to count, and the parenthesised half of that set was the only half being counted. A pseudo-ELEMENT is the exception and stays live. `.delete::before` styles a box generated for a delete, so the delete renders; treating it as a requirement would call it dead, which is not a conservative reading but a wrong one. Arguments now resolve to a fixed point. `[^()]*` cannot cross a parenthesis and `replace` does not re-scan its own output, so one pass over `:has(:not(.is-light))` resolved the inner call and left an empty `:has()`. The leftover is caught by the bare pass either way; what needs the loop is `:not(:is(.is-light))`, where the inner call has to resolve BEFORE the `:not` around it can drop, or a prohibition reads as a requirement. Each pass removes at least one pair of parentheses, so it ends. Attribute selectors are handled before bare pseudo-classes, because an attribute value may contain a colon. The prelude normaliser moves into a named function, since the reasoning about what may drop, what must grow, and why the order is what it is had outgrown a comment inside a loop body. Nothing moves again: no element-and-colour pair changes, and no single-class answer changes. Reverting each of the five behaviours separately fails the test on that behaviour's own assertion, so none is carried by another — the nested-argument case needed a compound query to pin at all, because a single-class query cannot tell the loop from the pass that follows it. Also drops two counts of the stylesheet that had gone back into the comments, and a reference to a function that does not exist. --- scripts/color-tuple-css.test.mjs | 156 ++++++++++++++++++++++++------- 1 file changed, 124 insertions(+), 32 deletions(-) diff --git a/scripts/color-tuple-css.test.mjs b/scripts/color-tuple-css.test.mjs index 509d8188b..edbc12f08 100644 --- a/scripts/color-tuple-css.test.mjs +++ b/scripts/color-tuple-css.test.mjs @@ -145,11 +145,65 @@ const indexCache = new Map(); * that needs a third class answering a two-class query, so anything that * quietly shortens a class set defeats it. Where a selector demands * something this file will not parse, one of these goes in the set instead, - * and the query stops matching. `assertUnmodelledIsUnused` holds the name to - * being absent from the stylesheet, so it cannot collide with a real class. + * and the query stops matching. `simpleSelectors` asserts the name is absent + * from the stylesheet before indexing, so it cannot collide with a real + * class. */ const UNMODELLED = 'bestax-guard-unmodelled-requirement'; +/** + * A selector prelude with everything that is not a class made explicit. + * + * A simple selector is only as live as its WHOLE set of requirements, and + * the exact-size rule in `shipsClass` counts classes. So anything else the + * selector demands has to end up in that count, or a rule needing more than + * the query answers it — over-reporting, and the silent direction. + * + * Three kinds of thing appear, and they are not alike: + * + * - `:not(…)` is a PROHIBITION. Its argument names classes the element must + * NOT carry, so reading them as present lets `.notification:not(.is-light)` + * answer `notification.is-light` — a rule that excludes the pair reporting + * it live. It drops, argument and all, and what remains already says + * everything the element carries. + * - A pseudo-ELEMENT does not restrict anything. `.delete::before` styles a + * box generated for a delete, so the delete is live and calling it dead + * would be a plain misreading. Those are left alone. + * - Everything else — a pseudo-class with parentheses or without, and an + * attribute selector — narrows WHICH elements carrying those classes + * match. `.notification.is-primary:where(.is-light)` and + * `.notification.is-primary:first-child` are the same shape, and treating + * only the parenthesised one as a requirement is how this file came to + * contradict itself. Both leave an `UNMODELLED` behind, so the set grows + * rather than shrinks and the query reads dead. A false alarm is the one + * way this guard may be wrong. + * + * Order carries three separate reasons. Arguments are resolved before the + * caller splits on commas, because they contain commas: + * `.navbar-item:not(.is-active,.is-selected)` is ONE selector, and splitting + * first fragments it and leaves `is-active` looking like a class the element + * carries. Attribute selectors go before bare pseudo-classes, because an + * attribute VALUE may contain a colon. And the argument pass repeats to a + * fixed point, because `[^()]*` cannot cross a parenthesis and `replace` + * does not re-scan its own output: a single pass over + * `:has(:not(.is-light))` resolves the inner call and leaves an empty + * `:has()` that matches nothing, so the set shrinks back to the size of the + * query. Each pass removes at least one pair of parentheses, so it ends. + */ +function requirements(prelude) { + let text = prelude; + let previous; + do { + previous = text; + text = text.replace(/:([a-z-]+)\(([^()]*)\)/gi, (_, name) => + name.toLowerCase() === 'not' ? '' : `.${UNMODELLED}` + ); + } while (text !== previous); + return text + .replace(/\[[^\]]*\]/g, `.${UNMODELLED}`) + .replace(/(? - name.toLowerCase() === 'not' ? '' : `.${UNMODELLED}` - ); + const prelude = requirements(chunk.slice(chunk.lastIndexOf('}') + 1)); for (const part of prelude.split(',')) { for (const simple of part.split(/[\s>+~]+/)) { if (!simple.includes('.')) continue; @@ -502,6 +531,69 @@ describe('the colour tuples agree with the shipped stylesheet', () => { 'live.' ); + // A requirement written WITHOUT parentheses is the same requirement. + // Treating only the parenthesised ones as real is how this file came to + // assert `:nth-child(even)` dead while answering `:first-child` live. + assert.equal( + live( + '.notification.is-primary:first-child{color:red}', + 'notification.is-primary' + ), + false, + 'a structural pseudo-class restricts which elements match whether or ' + + 'not it takes an argument.' + ); + assert.equal( + live( + '.notification.is-primary:hover{color:red}', + 'notification.is-primary' + ), + false, + 'a rule that applies only on hover does not show the compound renders ' + + 'on its own.' + ); + assert.equal( + live( + '.notification.is-primary[disabled]{color:red}', + 'notification.is-primary' + ), + false, + 'an attribute selector is a requirement the class set has to account ' + + 'for.' + ); + + // A pseudo-ELEMENT is not a requirement. It styles a box generated FOR + // the compound, so the compound renders. + assert.equal( + live(".delete::before{content:''}", 'delete'), + true, + 'a pseudo-element rule renders something for the element it hangs ' + + 'off, so calling that element dead would be a plain misreading.' + ); + + // Nested parentheses. One pass resolves the INNER call and leaves an + // empty `:has()` behind, which matches nothing and shrinks the set back + // to the size of the query. + assert.equal( + live( + '.notification.is-primary:has(:not(.is-light)){color:red}', + 'notification.is-primary' + ), + false, + 'a pseudo-class emptied by resolving its own nested argument still ' + + 'requires something, so it must not vanish.' + ); + assert.equal( + live( + '.notification.is-primary:not(:is(.is-light)){color:red}', + 'notification.is-primary' + ), + true, + 'a prohibition stays a prohibition however its argument is written: ' + + 'resolving the inner call has to leave a `:not()` that then DROPS, ' + + 'or the compound outside it reads dead.' + ); + // A pseudo-class that FORBIDS. The rest of the selector already says // everything the element carries, so the classes inside are not its own. assert.equal( From 64f3d7ca2e4849d157334c4a0a140e246b768a8a Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Sat, 19 Sep 2026 19:36:24 -0400 Subject: [PATCH 07/38] fix(bulma-ui): a type or ID selector is a requirement the set has to carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything a selector can say ABOUT an element was being accounted for, and the element itself was not. `a.dropdown-item.is-selected` read as two classes, which says a plain `