design-lint: ninth review cycle performance fixes

css.ts selectorNames reads the tag and class names a selector list mentions
once, so nowrap coverage, wrapping coverage and the roman-em check answer per
element in constant time; shortProseOf reads elements by class from an index
built once per document; font-family-count keeps a Set of family keys; the
import scan gives the optional paren its own whitespace; a JSX expression that
runs to the end of the file fails every ancestor at once; the render locator
reads class and not data-class; tooltip-focus-delay reads the delay slot of
the transition shorthand, so 200ms is a duration like 0.2s.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 12:27:57 +00:00
1 parent e501dc8589
commit 5afec19d5c
7 files changed
+57 -29

No files matched your search

+10
View File
@@ -76,6 +76,16 @@ function flatBody(src: string, s: number, e: number): string {
return out.join('');
}
/** The tag names (a token at the start of a selector or after a combinator) and class names a list of selectors mentions, read once so a rule can ask "does any selector name this element" in constant time. */
export function selectorNames(selectors: string[]): { tags: Set<string>; classes: Set<string> } {
const tags = new Set<string>(), classes = new Set<string>();
for (const sel of selectors) {
for (const m of sel.matchAll(/(?:^|[\s,>+~])([a-zA-Z]\w*)/g)) tags.add(m[1]);
for (const m of sel.matchAll(/\.([\w-]+)/g)) classes.add(m[1]);
}
return { tags, classes };
}
/** A selector longer than this is a nesting blow-up (comma lists nested in comma lists), not a stylesheet selector; past it the nested selector stands without its parent, so a sheet costs its length and not parts^depth. */
const MAX_SELECTOR = 8192;
+5 -5
View File
@@ -478,11 +478,11 @@ class JsxScanner {
if (isCls) this.inClass++;
e = this.js(v + 1, true);
if (isCls) this.inClass--;
if (e >= n) return -1;
if (e >= n) return JSX_EOF;
if (isCls) cls.v = (cls.v || []).concat(clsLits(src.slice(v + 1, e)));
else { this.paint(i, v); this.write(v, e + 1, '"' + src.slice(v + 1, e).replace(/[^\w$.\-/:#@]+/g, ' ') + '"'); }
} else if (q === '<') {
e = this.el(v) - 1;
e = this.el(v) - 1; if (e === JSX_EOF - 1) return JSX_EOF;
if (e < 0) return -1;
this.blank(i, e + 1);
} else return -1;
@@ -511,10 +511,10 @@ class JsxScanner {
const c = src[i];
if (c === '/' && src[i + 1] === '>') { this.paint(i, i + 2); if (cls.v) this.classes.push([start, cls.v]); return i + 2; }
if (c === '>') { this.paint(i, i + 1); i++; break; }
if (c === '{') { const e = this.js(i + 1, true); if (e >= n) return -1; this.blank(i, e + 1); i = e + 1; continue; }
if (c === '{') { const e = this.js(i + 1, true); if (e >= n) return JSX_EOF; this.blank(i, e + 1); i = e + 1; continue; }
if (!/[A-Za-z_$]/.test(c)) return -1;
i = this.attr(i, cls);
if (i < 0) return -1;
if (i < 0) return i;
}
if (cls.v) this.classes.push([start, cls.v]);
this.open.push(name);
@@ -526,7 +526,7 @@ class JsxScanner {
const { src, n } = this;
while (i < n) {
const c = src[i];
if (c === '{') { const e = this.js(i + 1, true); if (e >= n) return -1; i = e + 1; continue; }
if (c === '{') { const e = this.js(i + 1, true); if (e >= n) return JSX_EOF; i = e + 1; continue; }
if (c === '<') {
if (src[i + 1] === '/') {
const k = this.ws(i + 2);
+1 -1
View File
@@ -119,7 +119,7 @@ export function lineOf(src: string | null, at?: ProbeAt, lineAt: (i: number) =>
let fallback = -1;
for (const m of src.matchAll(new RegExp(`<${escapeRe(at.tag)}\\b[^>]*>`, 'gi'))) {
const pos = m.index ?? 0;
if (cls && !(/\bclass\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(m[0]) ?? ['', '']).slice(1).join(' ').split(/\s+/).includes(cls)) continue;
if (cls && !(/(?:^|\s)class\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(m[0]) ?? ['', '']).slice(1).join(' ').split(/\s+/).includes(cls)) continue;
if (attr && !m[0].includes(attr)) continue;
if (!want) { fallback = pos; break; }
if (plainText(src.slice(pos + m[0].length, pos + m[0].length + 600)).toLowerCase().startsWith(want)) { fallback = pos; break; }
+4 -5
View File
@@ -8,7 +8,7 @@
*/
import { contrastRatio, oklchOf, parseColor, type Color } from '../color';
import { mergedRules, type LintContext } from '../context';
import { CONTRAST_SLACK, isTokenRule, resolveVars, pxOf, type MergedRule, type Tokens } from '../css';
import { CONTRAST_SLACK, isTokenRule, resolveVars, pxOf, selectorNames, type MergedRule, type Tokens } from '../css';
import { ancestorsOf, getAttr, hasAttr, innerOf, stripTags, textOf, treeOf } from '../html';
import { trunc, type Finding, type Reporter } from '../report';
@@ -78,7 +78,7 @@ function iconSurfaces(ctx: LintContext): string {
}
for (const call of doc.virtuals) out.push(call.toks.join(' '));
for (const toks of doc.classAt.values()) out.push(toks.join(' '));
for (const m of doc.scripts.matchAll(/\b(?:import\s*\(?\s*|require\s*\(\s*|from\s+)['"]([^'"\n]+)['"]/g)) out.push(m[1]);
for (const m of doc.scripts.matchAll(/\b(?:import\s*(?:\(\s*)?|require\s*\(\s*|from\s+)['"]([^'"\n]+)['"]/g)) out.push(m[1]);
}
for (const f of ctx.cssFiles) for (const m of f.raw.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)|url\(\s*['"]?([^'")]+)/g)) out.push(m[1] ?? m[2]);
return out.join('\n');
@@ -193,9 +193,8 @@ function checkSurfaceInk(ctx: LintContext, r: Reporter): void {
// sloplint check49 (gates 49a and 49b)
function checkWrappingLabels(ctx: LintContext, r: Reporter): void {
const nowrapRules = ctx.rules.filter((rule) => rule.decls.some((d) => d.prop === 'white-space' && /nowrap/.test(d.value)));
const covered = (cls: string, tag: string): boolean => nowrapRules.some((rule) =>
cls.split(/\s+/).some((c) => c && rule.selector.includes('.' + c)) || new RegExp('(^|[\\s,>+~])' + tag + '\\b').test(rule.selector));
const nowrap = selectorNames(ctx.rules.filter((rule) => rule.decls.some((d) => d.prop === 'white-space' && /nowrap/.test(d.value))).map((rule) => rule.selector));
const covered = (cls: string, tag: string): boolean => nowrap.tags.has(tag) || cls.split(/\s+/).some((c) => c && nowrap.classes.has(c));
for (const doc of ctx.docs) {
for (let i = 0; i < doc.tags.length; i++) {
const t = doc.tags[i];
+4 -1
View File
@@ -162,7 +162,10 @@ function tooltipDelay(ctx: LintContext, r: Reporter): void {
const delayed = (sel: RegExp): { file: string; line: number } | null => {
for (const rule of tips) {
if (!sel.test(rule.selector)) continue;
const d = rule.decls.find((x) => /^transition(-delay)?$/.test(x.prop) && /(?<![\d.])(?:\d{3,}ms|[1-9]\d*(?:\.\d+)?s)\b/.test(x.value));
const d = rule.decls.find((x) => /^transition(-delay)?$/.test(x.prop) && x.value.split(',').some((part) => {
const ms = [...part.matchAll(/(?<![\w.-])(\d*\.?\d+)(ms|s)\b/g)].map((m) => m[2] === 'ms' ? Number(m[1]) : Number(m[1]) * 1000);
return (x.prop === 'transition-delay' ? ms[0] : ms[1]) >= 100;
}));
if (d) return { file: rule.file, line: d.line };
}
return null;
+8 -11
View File
@@ -6,7 +6,7 @@
* the render halves of 34 (horizontal-scroll) and 44d.
*/
import { COLOR_LITERAL, oklchOf, parseColor } from '../color';
import { isTokenRule, pxOf, resolveVars, type MergedRule } from '../css';
import { isTokenRule, pxOf, resolveVars, selectorNames, type MergedRule } from '../css';
import { memo, mergedRules, type LintContext } from '../context';
import { getAttr, innerOf, stripTags, textOf, treeOf, wordsOf, type Doc } from '../html';
import { trunc, type Reporter } from '../report';
@@ -240,16 +240,13 @@ function bareFrTrack(ctx: LintContext, r: Reporter): void {
// sloplint check50Wrap (gate 50)
function displayHeadUnwrapped(ctx: LintContext, r: Reporter): void {
const wrapCovered = (cls: string, tag: string) => ctx.rules.some((rule) => {
const has = rule.decls.some((d) =>
(d.prop === 'overflow-wrap' && /anywhere|break-word/.test(d.value))
|| (d.prop === 'word-break' && /break-word|break-all/.test(d.value))
|| (d.prop === 'hyphens' && /auto/.test(d.value)));
if (!has) return false;
if (/^\*|:where\(|html|body/.test(rule.selector)) return true;
return (!!cls && cls.split(/\s+/).some((c) => c && rule.selector.includes('.' + c)))
|| new RegExp(`(^|[\\s,>+~])${tag}\\b`).test(rule.selector);
});
const wrapping = ctx.rules.filter((rule) => rule.decls.some((d) =>
(d.prop === 'overflow-wrap' && /anywhere|break-word/.test(d.value))
|| (d.prop === 'word-break' && /break-word|break-all/.test(d.value))
|| (d.prop === 'hyphens' && /auto/.test(d.value)))).map((rule) => rule.selector);
const everywhere = wrapping.some((sel) => /^\*|:where\(|html|body/.test(sel));
const wrap = selectorNames(wrapping);
const wrapCovered = (cls: string, tag: string) => everywhere || wrap.tags.has(tag) || cls.split(/\s+/).some((c) => c && wrap.classes.has(c));
for (const doc of ctx.docs) {
for (let i = 0; i < doc.tags.length; i++) {
const t = doc.tags[i];
+25 -6
View File
@@ -7,8 +7,8 @@
import { BANNED_FONTS, OVERUSED_FONTS_DISPLAY } from '../../design-catalog';
import type { LintContext } from '../context';
import { fontPx, memo, mergedRules, styleFor, trackingEm, valueOf } from '../context';
import { CONTROL_TRACK_MAX_EM, GENERIC_FAMILIES, HEADING_SEL, LABEL_PX, MONO_FACE, TRACKED_EM, famKey, firstFamily, isTokenRule, pxOf, resolveVars, tokenSites, type CssDecl, type CssRule, type MergedRule } from '../css';
import { ancestorsOf, closeIndex, getAttr, innerOf, openTags, plainText, stripTags, textOf } from '../html';
import { CONTROL_TRACK_MAX_EM, GENERIC_FAMILIES, HEADING_SEL, LABEL_PX, MONO_FACE, TRACKED_EM, famKey, firstFamily, isTokenRule, pxOf, resolveVars, selectorNames, tokenSites, type CssDecl, type CssRule, type MergedRule } from '../css';
import { ancestorsOf, closeIndex, getAttr, innerOf, openTags, plainText, stripTags, textOf, type Doc } from '../html';
import type { Reporter } from '../report';
import { trunc } from '../report';
import { twMonoIsMaterial } from '../tailwind';
@@ -89,11 +89,27 @@ function shortProse(ctx: LintContext, sel: string): boolean {
return short;
}
/** The open tags of a document by class, indexed once. */
function byClass(doc: Doc): Map<string, number[]> {
const cached = doc.cache.byClass as Map<string, number[]> | undefined;
if (cached) return cached;
const index = new Map<string, number[]>();
for (let i = 0; i < doc.tags.length; i++) {
const t = doc.tags[i];
if (t.close || t.selfClosing) continue;
for (const c of (getAttr(t.attrs, 'class') || '').split(/\s+/)) if (c) { const list = index.get(c); if (list) list.push(i); else index.set(c, [i]); }
}
doc.cache.byClass = index;
return index;
}
function shortProseOf(ctx: LintContext, cls: string): boolean {
const texts: string[] = [];
for (const doc of ctx.docs) {
for (const el of openTags(doc, /./, 0, doc.tags.length, (t) => (getAttr(t.attrs, 'class') || '').split(/\s+/).includes(cls))) {
const paras = el.tag.name === 'p' ? [el.inner] : openTags(doc, /^p$/, el.idx + 1, closeIndex(doc, el.idx)).map((p) => p.inner);
for (const idx of byClass(doc).get(cls) ?? []) {
const el = innerOf(doc, idx);
if (!el) continue;
const paras = doc.tags[idx].name === 'p' ? [el.inner] : openTags(doc, /^p$/, idx + 1, closeIndex(doc, idx)).map((p) => p.inner);
for (const p of paras) texts.push(stripTags(p).replace(/\s+/g, ' ').trim());
}
}
@@ -123,6 +139,7 @@ function check25(ctx: LintContext, r: Reporter): void {
// sloplint check37 (gate 37)
function check37(ctx: LintContext, r: Reporter): void {
const families = new Map<string, { file: string; line: number }>();
const keys = new Set<string>();
for (const rule of ctx.rules) {
if (rule.selector === '@font-face') continue;
for (const d of rule.decls) {
@@ -130,7 +147,8 @@ function check37(ctx: LintContext, r: Reporter): void {
if (/^--font-/.test(d.prop) && !isTokenRule(rule)) continue;
const fam = firstFamily(resolveVars(d.value, ctx.tokens));
if (!fam || GENERIC_FAMILIES.has(fam) || fam.startsWith('var(')) continue;
if (![...families.keys()].some((k) => famKey(k) === famKey(fam))) families.set(fam, { file: rule.file, line: d.line });
const key = famKey(fam);
if (!keys.has(key)) { keys.add(key); families.set(fam, { file: rule.file, line: d.line }); }
}
}
if (families.size <= 3) return;
@@ -149,7 +167,8 @@ function check38a(ctx: LintContext, r: Reporter): void {
}
const normSel = ctx.rules.filter((rule) => /(^|[\s,>+~])(em|i)\b/.test(rule.selector)
&& rule.decls.some((d) => d.prop === 'font-style' && /normal/.test(d.value))).flatMap((rule) => rule.selector.split(',').map((x) => x.trim()));
const normalised = (level: string, classes: string[]) => normSel.some((sel) => /^(em|i)$/.test(sel) || new RegExp(`(^|[\\s,>+~])h${level}\\b`).test(sel) || classes.some((c) => sel.includes('.' + c)));
const norm = selectorNames(normSel), bareNorm = normSel.some((sel) => /^(em|i)$/.test(sel));
const normalised = (level: string, classes: string[]) => bareNorm || norm.tags.has('h' + level) || classes.some((c) => norm.classes.has(c));
for (const doc of ctx.docs) {
for (const head of openTags(doc, /^h[1-6]$/)) {
const level = head.tag.name.slice(1);