From 5afec19d5c7e16d4ab9abab118cdd17fcb8da357 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 12:27:57 +0000 Subject: [PATCH] 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> --- lib/design-lint/css.ts | 10 ++++++++++ lib/design-lint/html.ts | 10 +++++----- lib/design-lint/render.ts | 2 +- lib/design-lint/rules/assets.ts | 9 ++++----- lib/design-lint/rules/motion.ts | 5 ++++- lib/design-lint/rules/structure.ts | 19 ++++++++---------- lib/design-lint/rules/type.ts | 31 ++++++++++++++++++++++++------ 7 files changed, 57 insertions(+), 29 deletions(-) diff --git a/lib/design-lint/css.ts b/lib/design-lint/css.ts index be6ff2ce9..4b38f3fe3 100644 --- a/lib/design-lint/css.ts +++ b/lib/design-lint/css.ts @@ -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; classes: Set } { + const tags = new Set(), classes = new Set(); + 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; diff --git a/lib/design-lint/html.ts b/lib/design-lint/html.ts index fdb9a7ed7..44cfc4339 100644 --- a/lib/design-lint/html.ts +++ b/lib/design-lint/html.ts @@ -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); diff --git a/lib/design-lint/render.ts b/lib/design-lint/render.ts index 005e7dcdf..85e83538d 100644 --- a/lib/design-lint/render.ts +++ b/lib/design-lint/render.ts @@ -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; } diff --git a/lib/design-lint/rules/assets.ts b/lib/design-lint/rules/assets.ts index 262f32d05..a3fb1d828 100644 --- a/lib/design-lint/rules/assets.ts +++ b/lib/design-lint/rules/assets.ts @@ -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]; diff --git a/lib/design-lint/rules/motion.ts b/lib/design-lint/rules/motion.ts index f2e8721ed..1c1e8b0b4 100644 --- a/lib/design-lint/rules/motion.ts +++ b/lib/design-lint/rules/motion.ts @@ -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) && /(? /^transition(-delay)?$/.test(x.prop) && x.value.split(',').some((part) => { + const ms = [...part.matchAll(/(? 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; diff --git a/lib/design-lint/rules/structure.ts b/lib/design-lint/rules/structure.ts index ad4f21fc5..f4a5dac42 100644 --- a/lib/design-lint/rules/structure.ts +++ b/lib/design-lint/rules/structure.ts @@ -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]; diff --git a/lib/design-lint/rules/type.ts b/lib/design-lint/rules/type.ts index 7ca422eea..2500d6186 100644 --- a/lib/design-lint/rules/type.ts +++ b/lib/design-lint/rules/type.ts @@ -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 { + const cached = doc.cache.byClass as Map | undefined; + if (cached) return cached; + const index = new Map(); + 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(); + const keys = new Set(); 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);