design-lint: fourth review cycle fixes

Commented-out markup is dead for every rule (Doc.src is the comment-blanked text; craft copy, the module-script check and the border+shadow scan read it). Headings, buttons, links, labels, selects, forms and an open <p> close the way the browser closes them, a tree nests at most 512 deep, and the heading, footer-column, sign-up-button and span readers walk the shared pairing instead of lazy regexes, with each element's text computed once (textOf). Tailwind frames and wireframe slots answer from treeOf. resolveVars stops on a cycle and caps its size; every parseCss at-rule branch honours the depth guard and an inline <style> that fails to parse is skipped with a warning. cwd and lint roots are read through their real paths, a skipped source file is one internal-error finding, relative refs stay inside the project, a linked stylesheet over 2 MB is skipped. overused-font and banned-font match a catalog stem to a family name's first words; width: min()/clamp() caps a container; a box-shadow focus ring beside a border-colour change is not input-state-shift; Meet and triad headings below the hero are suspected; the stylesheet-waiver key goes through oneLine; pxOf reads leading-dot lengths; check67 precomputes selector bases; the render tier reports a wrong-shaped answer per probe and keeps every measured finding, flags a bad grade as tampering, and never repeats the kept width when merging horizontal-scroll.

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 09:19:33 +00:00
1 parent 9bb348df77
commit f5134ae2db
15 files changed
+203 -138

No files matched your search

+15 -9
View File
@@ -140,15 +140,15 @@ export function parseCss(source: string, file: string, lineOffset = 0): CssRule[
if (/^@(media|supports|layer|container|scope)/i.test(selector)) {
const q = /^@media/i.test(selector) ? selector.replace(/^@media/i, '').trim() : media;
if (parent) rules.push(makeRule(parent, blockStart, blockEnd, q, keyframes));
walk(blockStart, blockEnd, q, keyframes, parent, depthLeft - 1);
if (depthLeft > 0) walk(blockStart, blockEnd, q, keyframes, parent, depthLeft - 1);
} else if (/^@(-webkit-)?keyframes/i.test(selector)) {
const name = selector.replace(/^@(-webkit-)?keyframes/i, '').trim();
walk(blockStart, blockEnd, media, name || 'anon', null, depthLeft - 1);
if (depthLeft > 0) walk(blockStart, blockEnd, media, name || 'anon', null, depthLeft - 1);
} else if (/^@font-face/i.test(selector)) {
rules.push(makeRule('@font-face', blockStart, blockEnd, media, null));
} else if (/^@theme\b/i.test(selector)) {
rules.push(makeRule('@theme', blockStart, blockEnd, media, null));
walk(blockStart, blockEnd, media, keyframes, null, depthLeft - 1);
if (depthLeft > 0) walk(blockStart, blockEnd, media, keyframes, null, depthLeft - 1);
}
} else {
const sel = parent && !keyframes ? nestSelector(selector, parent) : selector;
@@ -183,16 +183,22 @@ export function buildTokens(rules: CssRule[]): Tokens {
}
/** Substitute var() references from the tokens (a local scope first), honouring fallbacks, five levels deep. */
export function resolveVars(value: string, tokens: Tokens, local: Tokens | null = null, depth = 0): string {
if (!value || depth > 5 || !value.includes('var(')) return value;
/** A resolved value longer than this is a token that refers to itself, not a stylesheet value; expansion stops there. */
const MAX_RESOLVED = 8192;
/** value with every var() replaced by its token (or fallback), five levels deep; a token that re-enters its own chain, or a value past MAX_RESOLVED, is left as it stands, so a cyclic stylesheet costs its size and not its powers. */
export function resolveVars(value: string, tokens: Tokens, local: Tokens | null = null, depth = 0, chain: ReadonlySet<string> = new Set()): string {
if (!value || depth > 5 || value.length > MAX_RESOLVED || !value.includes('var(')) return value;
const seen = new Set<string>();
const out = value.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*(?:\([^()]*\)[^()]*)*))?\)/g, (m0, name: string, fb: string | undefined) => {
if (chain.has(name)) return m0;
const v = local && local[name] != null ? local[name] : tokens[name];
if (v != null) return v;
if (v != null) { seen.add(name); return v; }
if (fb != null) return fb.trim();
return m0;
});
if (out === value) return out;
return resolveVars(out, tokens, local, depth + 1);
if (out === value || out.length > MAX_RESOLVED) return out.length > MAX_RESOLVED ? value : out;
return resolveVars(out, tokens, local, depth + 1, new Set([...chain, ...seen]));
}
/** A family's identity ignores spaces, hyphens and underscores, so "GeistSans" and "Geist Sans" are one face. */
@@ -204,7 +210,7 @@ export function firstFamily(value: string): string {
/** px, rem or em lengths to px at a 16px root; anything else is null. */
export function pxOf(value: string): number | null {
const m = /^(-?\d+(?:\.\d+)?)(px|rem|em)$/.exec(value.trim());
const m = /^(-?\d*\.?\d+)(px|rem|em)$/.exec(value.trim());
if (!m) return null;
const n = parseFloat(m[1]);
return m[2] === 'px' ? n : n * 16;
+42 -9
View File
@@ -35,7 +35,7 @@ export interface ClassCall { pos: number; name: string; toks: string[] }
export interface Doc {
file: string;
kind: DocKind;
/** The text the tags were scanned from: the raw HTML, or the pseudo-HTML of a component. */
/** The text the tags were scanned from: the HTML, or the pseudo-HTML of a component, with comments blanked (positions kept), so no rule reads markup the browser never renders. */
src: string;
/** The original file text. */
raw: string;
@@ -203,7 +203,7 @@ export function parseHtml(file: string, src: string, kind: DocKind = 'html'): Do
.replace(/&hellip;|&#8230;/g, '\u2026').replace(/&mdash;|&#8212;|&#x2014;/gi, '\u2014').replace(/&ndash;|&#8211;|&#x2013;/gi, '\u2013')
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&nbsp;/g, ' ').replace(/[ \t]+/g, ' ');
return { file, kind, src, raw: src, masked, styles, scripts, tags, comps, text, lineOf, classAt: new Map(), virtuals: [], cache: {} };
return { file, kind, src: live, raw: src, masked, styles, scripts, tags, comps, text, lineOf, classAt: new Map(), virtuals: [], cache: {} };
}
/** The index of the brace that closes the one at j (strings skipped), or the string's length. */
@@ -554,13 +554,13 @@ export function classCalls(src: string, sites: number[]): ClassCall[] {
/** Positions of class-helper calls inside <script> blocks (and Astro frontmatter). */
export function scriptSites(src: string, kind: DocKind): number[] {
const ranges: Array<[number, number]> = [];
for (const m of src.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)) ranges.push([m.index! + m[0].length - m[1].length - 9, m.index! + m[0].length - 9]);
const fm = kind === 'astro' ? FRONTMATTER.exec(src) : null;
const live = blankRanges(src, spansOf(asciiLower(src), '<!--', '-->', false).map((s): [number, number] => [s.start, s.end]));
const ranges = spansOf(asciiLower(live), '<script', '</script', true).map((s): [number, number] => [s.bodyStart, s.bodyEnd]);
const fm = kind === 'astro' ? FRONTMATTER.exec(live) : null;
if (fm) ranges.push([0, fm[0].length]);
const sites: number[] = [];
for (const [a, b] of ranges) {
for (const m of src.slice(a, b).matchAll(CLASS_FN_CALL)) sites.push(a + m.index!);
for (const m of live.slice(a, b).matchAll(CLASS_FN_CALL)) sites.push(a + m.index!);
}
return sites;
}
@@ -603,8 +603,13 @@ export function hasAttr(attrStr: string, name: string): boolean {
return new RegExp(`(?:^|\\s)${name}(?=[\\s=/]|$)`, 'i').test(attrStr);
}
/** Elements whose end tag is optional: a new one closes the open one, so a list of unclosed <li> does not read as nested. */
const IMPLICIT_CLOSE = new Set(['li', 'p', 'dt', 'dd', 'option', 'tr', 'td', 'th']);
/** Elements a browser closes when the same element opens again (optional end tags, and the elements that cannot nest in themselves), so a list of unclosed <li> or a run of unclosed <h2> does not read as nested. */
const IMPLICIT_CLOSE = new Set(['li', 'p', 'dt', 'dd', 'option', 'tr', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'button', 'a', 'form', 'label', 'select']);
/** A heading start tag closes any open heading, and a block start tag closes an open <p>, as the browser does. */
const HEADING = /^h[1-6]$/;
const CLOSES_P = new Set(['address', 'article', 'aside', 'blockquote', 'details', 'dialog', 'div', 'dl', 'fieldset', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'main', 'menu', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'ul']);
/** Past this depth an open tag is read as empty: a real page nests tens deep, and a file that nests thousands deep must cost its length, not its square. */
const MAX_DEPTH = 512;
export interface Tree {
/** For an open tag, the index of the tag that ends it: its own close tag, the sibling that implicitly closed it, or the close tag of an ancestor; -1 when it runs to the end of the document. */
@@ -640,9 +645,13 @@ export function treeOf(doc: Doc): Tree {
stack.length = depth;
continue;
}
if (IMPLICIT_CLOSE.has(t.name) && stack.length && doc.tags[stack[stack.length - 1]].name === t.name) close[stack.pop()!] = i;
if (stack.length) {
const top = doc.tags[stack[stack.length - 1]].name;
if ((IMPLICIT_CLOSE.has(t.name) && top === t.name) || (HEADING.test(t.name) && HEADING.test(top)) || (top === 'p' && CLOSES_P.has(t.name))) close[stack.pop()!] = i;
}
parent[i] = stack.length ? stack[stack.length - 1] : -1;
if (t.selfClosing) continue;
if (stack.length >= MAX_DEPTH) { close[i] = i; continue; }
let opens = openByName.get(t.name);
if (!opens) openByName.set(t.name, opens = []);
opens.push({ idx: i, depth: stack.length });
@@ -706,6 +715,30 @@ export function innerOf(doc: Doc, idx: number): { inner: string; endPos: number
return { inner: doc.src.slice(open.end, endPos), endPos };
}
/** The visible text of the element opened at idx, computed once per element however many rules read it; '' for a close or void tag. */
export function textOf(doc: Doc, idx: number): string {
const cache = (doc.cache.text ??= new Map<number, string>()) as Map<number, string>;
const hit = cache.get(idx);
if (hit != null) return hit;
const text = plainText(innerOf(doc, idx)?.inner || '');
cache.set(idx, text);
return text;
}
export interface Element { idx: number; tag: Tag; inner: string }
/** The elements opened by a tag named by `name` between tag indices from and to, each with its inner HTML from the shared pairing; a tag walk, so an unclosed element costs its own extent and nothing scans to the end of the file for it. */
export function openTags(doc: Doc, name: RegExp, from = 0, to = doc.tags.length, pick: (t: Tag) => boolean = () => true, limit = Infinity): Element[] {
const out: Element[] = [];
for (let i = from; i < to && out.length < limit; i++) {
const t = doc.tags[i];
if (t.close || t.selfClosing || !name.test(t.name) || !pick(t)) continue;
const inner = innerOf(doc, i);
if (inner) out.push({ idx: i, tag: t, inner: inner.inner });
}
return out;
}
/** The index of the tag that ends the element opened at idx: its close tag, or the tag that implicitly closed it (or the last tag when nothing does). */
export function closeIndex(doc: Doc, idx: number): number {
const c = treeOf(doc).close[idx];
+4 -3
View File
@@ -13,7 +13,7 @@
import { docKindOf } from './html';
import { lintRender, type RenderOptions } from './render';
import { exitCodeOf, finalize, formatFinding, formatSummary, oneLine, relPath, summarize, type Finding, type Summary } from './report';
import { lintStaticRaw, settleContext, type StaticOptions } from './static';
import { lintStaticRaw, realDir, settleContext, type StaticOptions } from './static';
export interface LintOptions extends StaticOptions {
render?: boolean;
@@ -38,13 +38,13 @@ export interface LintResult {
* every measurement is in.
*/
export async function lint(paths: string[], opts: LintOptions = {}): Promise<LintResult> {
const cwd = opts.cwd ?? process.cwd();
const cwd = realDir(opts.cwd ?? process.cwd());
const stat = lintStaticRaw(paths, opts);
let render = 'skipped: not requested';
const byPage = new Map<string, Finding[]>();
if (opts.render || opts.urls?.length) {
const pages = stat.files.filter((f) => docKindOf(f) === 'html');
const r = await lintRender([...pages, ...(opts.urls ?? [])], { cwd: opts.cwd, scope: opts.scope, engine: opts.engine, run: opts.run });
const r = await lintRender([...pages, ...(opts.urls ?? [])], { cwd, scope: opts.scope, engine: opts.engine, run: opts.run });
render = r.status;
for (const f of r.findings) byPage.set(f.file, [...(byPage.get(f.file) ?? []), f]);
}
@@ -56,6 +56,7 @@ export async function lint(paths: string[], opts: LintOptions = {}): Promise<Lin
findings.push(...settleContext(ctx, [...stat.raw[i], ...measured], cwd));
});
for (const rest of byPage.values()) findings.push(...rest);
findings.push(...stat.skipped);
const final = finalize(findings);
const summary = summarize(final, render);
return { files: stat.files, findings: final, summary, exitCode: exitCodeOf(summary) };
+10 -6
View File
@@ -106,7 +106,7 @@ function parsed<T>(text: string | undefined): Parsed<T> {
/** The source line of the element a finding names: its tag, first class, a distinguishing attribute and its opening text. Best effort; 1 when nothing matches. */
export function lineOf(src: string | null, at?: ProbeAt): number {
if (!src || !at) return 1;
if (!src || !at || typeof at.tag !== 'string' || typeof at.text !== 'string') return 1;
const cls = at.cls ? new RegExp(`class=["'](?:[^"']*\\s)?${escapeRe(at.cls)}(?:\\s[^"']*)?["']`, 'i') : null;
const want = at.text.toLowerCase();
let fallback = -1;
@@ -146,7 +146,7 @@ async function lintPage(target: string, r: Reporter, run: Runner, timeoutMs: num
if (!res.ok) { report({ rule: 'render', grade: 'fail', evidence: `the page did not render: ${res.error ?? 'unknown error'}` }, null); return; }
const setup = parsed<SetupReport>(res.evals[opening.length - 1]);
if ('error' in setup) report({ rule: 'render', grade: 'fail', evidence: `the page could not be prepared for measurement: ${setup.error}` }, null);
if ('error' in setup || !Array.isArray(setup.value?.lost)) report({ rule: 'render', grade: 'fail', evidence: `the page could not be prepared for measurement: ${'error' in setup ? setup.error : 'the setup answer has no list of lost assets'}` }, null);
else if (setup.value.lost.length) report({ rule: 'render', grade: 'warn', evidence: `fonts or stylesheets did not load (${setup.value.lost.slice(0, 2).join(', ')}): the measurements show a fallback face`, fix: 'fix the path or wait for the host, then measure again; never sign off a fallback face' }, null);
const errs = parsed<string[]>(res.stdout.match(/^PAGE_ERRORS=(.+)$/m)?.[1]);
const uncaught = 'value' in errs && Array.isArray(errs.value) ? errs.value.find((e) => e.startsWith('uncaught: ')) : undefined;
@@ -157,9 +157,12 @@ async function lintPage(target: string, r: Reporter, run: Runner, timeoutMs: num
const seen = new Set<string>();
plan.forEach((m, i) => {
const p = parsed<ProbeFinding[] | RingProbe[]>(res.evals[opening.length + i]);
if ('error' in p) { report({ rule: 'render', grade: 'fail', evidence: `the ${m.call.replace(/^P\.(\w+).*/, '$1')} measurement at ${m.label} failed: ${p.error}` }, null); return; }
if (m.call === 'P.focus(P)') { rings = p.value as RingProbe[]; return; }
for (const f of p.value as ProbeFinding[]) {
const list = 'error' in p ? null : (p.value as unknown);
const bad = 'error' in p ? p.error : !Array.isArray(list) ? 'the answer is not a list' : (list as unknown[]).some((f) => !f || typeof f !== 'object') ? 'the list holds a non-object entry' : null;
if (bad != null) { report({ rule: 'render', grade: 'fail', evidence: `the ${m.call.replace(/^P\.(\w+).*/, '$1')} measurement at ${m.label} failed: ${bad}` }, null); return; }
if (m.call === 'P.focus(P)') { rings = (list as RingProbe[]).filter((x) => typeof x.key === 'string'); return; }
for (const f of list as ProbeFinding[]) {
if (typeof f.rule !== 'string' || typeof f.evidence !== 'string' || (f.grade !== 'fail' && f.grade !== 'warn') || !ruleMeta(f.rule)) { report({ rule: String(f.rule ?? '?'), grade: 'malformed' as ProbeFinding['grade'], evidence: '' }, null); continue; }
const key = f.rule + '|' + f.evidence;
if (seen.has(key)) continue;
seen.add(key);
@@ -170,7 +173,8 @@ async function lintPage(target: string, r: Reporter, run: Runner, timeoutMs: num
if (scrolls.length > 1) {
const keep = scrolls.find((f) => f.grade === 'fail') ?? scrolls[0];
const rest = scrolls.filter((f) => f !== keep);
keep.evidence += ` (also at ${rest.map((f) => f.label.replace(/px$/, '')).join(', ')}px)`;
const widths = [...new Set(rest.map((f) => f.label.replace(/px$/, '')))].filter((w) => w !== keep.label.replace(/px$/, ''));
if (widths.length) keep.evidence += ` (also at ${widths.join(', ')}px)`;
for (const f of rest) found.splice(found.indexOf(f), 1);
}
const bare = rings.filter((x) => x.bare);
+2 -2
View File
@@ -9,7 +9,7 @@
import { contrastRatio, oklchOf, parseColor, type Color } from '../color';
import { mergedRules, type LintContext } from '../context';
import { isTokenRule, resolveVars, pxOf, type MergedRule, type Tokens } from '../css';
import { ancestorsOf, getAttr, hasAttr, innerOf, plainText, treeOf } from '../html';
import { ancestorsOf, getAttr, hasAttr, innerOf, plainText, textOf, treeOf } from '../html';
import { trunc, type Reporter } from '../report';
const EMOJI = /(?![©®™‼⁉←-⇿])\p{Extended_Pictographic}/u;
@@ -40,7 +40,7 @@ function checkEmojiIcons(ctx: LintContext, r: Reporter): void {
continue;
}
if (!/^(h[1-6]|button|a|label|th|summary|option|legend|dt)$/.test(t.name) || /<(p|div|ul|ol|table|section)\b/i.test(inner.inner)) continue;
const text = plainText(inner.inner);
const text = textOf(doc, i);
const at = inner.inner.search(GLYPH);
if (!text || at < 0) continue;
flag(doc.lineOf(t.end + at), 'an emoji in <' + t.name + '> "' + trunc(text, 32) + '"');
+10 -9
View File
@@ -9,7 +9,7 @@
*/
import { styleFor, type LintContext } from '../context';
import { resolveVars } from '../css';
import { ancestorsOf, getAttr, innerOf, maskedProse, plainText, treeOf, type Doc, type Tag } from '../html';
import { ancestorsOf, getAttr, innerOf, maskedProse, plainText, textOf, treeOf, type Doc, type Tag } from '../html';
import { trunc, type Reporter } from '../report';
@@ -34,13 +34,14 @@ function checkHeroCopy(ctx: LintContext, r: Reporter): void {
for (const doc of ctx.docs) {
const prose = maskedProse(doc);
const lines = new Set<number>();
const tell = (rule: string, idx: number, what: string, text: string): void => {
const hero = heroRange(doc);
const tell = (rule: string, idx: number, what: string, text: string, heading = false): void => {
const line = doc.lineOf(idx);
if (lines.has(line)) return;
lines.add(line);
r.report(rule, doc.file, line, `${what}: "${trunc(text, 40)}"`);
const inHero = !heading || (hero != null && idx >= hero[0] && idx < hero[1]);
r.report(rule, doc.file, line, `${what}: "${trunc(text, 40)}"`, inHero ? {} : { suspected: true, fix: 'a heading in the product\'s own voice; below the hero this is a suspicion, so a waiver can answer it' });
};
const hero = heroRange(doc);
for (const [re, what] of HERO_TELLS) {
const all = [...prose.matchAll(new RegExp(re.source, re.flags + 'g'))];
const inHero = all.find((m) => hero && m.index! >= hero[0] && m.index! < hero[1]);
@@ -58,14 +59,14 @@ function checkHeroCopy(ctx: LintContext, r: Reporter): void {
const t = doc.tags[i];
if (t.close) continue;
if (t.name === 'a') {
const label = plainText(innerOf(doc, i)?.inner || '').replace(/[\u2192\u203a\u00bb]|->/g, '').trim();
const label = textOf(doc, i).replace(/[\u2192\u203a\u00bb]|->/g, '').trim();
if (STOCK_LINK.test(label)) stock.push({ t, label });
continue;
}
if (!/^h[1-6]$/.test(t.name)) continue;
const text = plainText(innerOf(doc, i)?.inner || '');
if (/^Meet\s+[A-Z][\w-]*[.!]?$/.test(text)) tell('generic-hero-copy', t.pos, 'the "Meet <Name>" heading', text);
else if (/^[A-Z][a-z]+\.\s+[A-Z][a-z]+\.\s+[A-Z][a-z]+\.$/.test(text)) tell('generic-hero-copy', t.pos, 'the three-word triad heading', text);
const text = textOf(doc, i);
if (/^Meet\s+[A-Z][\w-]*[.!]?$/.test(text)) tell('generic-hero-copy', t.pos, 'the "Meet <Name>" heading', text, true);
else if (/^[A-Z][a-z]+\.\s+[A-Z][a-z]+\.\s+[A-Z][a-z]+\.$/.test(text)) tell('generic-hero-copy', t.pos, 'the three-word triad heading', text, true);
}
if (stock.length >= 2) {
r.report('generic-cta-copy', doc.file, stock[0].t.line, `the stock link label "${stock[0].label}" on ${stock.length} links`, { fix: 'say where each link goes: the thing it opens, in its own noun' });
@@ -145,7 +146,7 @@ function checkHeroActions(ctx: LintContext, r: Reporter): void {
const cls = getAttr(t.attrs, 'class') || '';
if (t.name === 'a' && !/btn|cta|button/i.test(cls)) continue;
if (ancestorsOf(doc, i).includes('nav')) continue;
controls.push({ t, cls, label: plainText(innerOf(doc, i)?.inner || '').trim() });
controls.push({ t, cls, label: textOf(doc, i).trim() });
}
if (controls.length < 2) continue;
const firstFilled = isFilled(ctx, controls[0]);
+16 -31
View File
@@ -7,7 +7,7 @@
*/
import type { LintContext } from '../context';
import { lineIndex } from '../css';
import { getAttr, innerOf, maskedProse, plainText, treeOf, type Doc, type Tag } from '../html';
import { closeIndex, getAttr, innerOf, maskedProse, openTags, plainText, textOf, treeOf, type Doc, type Tag } from '../html';
import { trunc, type Reporter } from '../report';
import { runChromeCopy } from './chrome-copy';
import { runEyebrow } from './chrome-eyebrow';
@@ -43,13 +43,10 @@ function checkFooterShape(ctx: LintContext, r: Reporter): void {
d.prop === 'grid-template-columns'
&& (/repeat\(\s*4\s*,/.test(d.value) || /^(1fr|minmax\([^)]*\))(\s+(1fr|minmax\([^)]*\))){3,}$/.test(d.value.trim()))));
let linkGroups = 0;
const colRe = /<(ul|div|p|nav)\b[^>]*class\s*=\s*"[^"]*(col|column|links|group)[^"]*"[^>]*>([\s\S]*?)<\/\1>/gi;
let cm: RegExpExecArray | null;
while ((cm = colRe.exec(inner.inner))) {
if ((cm[3].match(/<a\b/gi) || []).length >= 3) linkGroups++;
}
for (const um of inner.inner.matchAll(/<ul\b[^>]*>([\s\S]*?)<\/ul>/gi)) {
if ((um[1].match(/<a\b/gi) || []).length >= 3) linkGroups++;
const end = closeIndex(doc, idx);
for (const el of openTags(doc, /^(ul|div|p|nav)$/, idx + 1, end)) {
const column = el.tag.name === 'ul' || /col|column|links|group/.test(getAttr(el.tag.attrs, 'class') || '');
if (column && openTags(doc, /^a$/, el.idx + 1, closeIndex(doc, el.idx)).length >= 3) linkGroups++;
}
if (uls >= 4 || (fourEqualCol && linkGroups >= 3)) {
r.report('template-footer', doc.file, doc.tags[idx].line, 'the reflex footer shape: ' + (uls >= 4 ? uls + ' link columns' : 'four equal columns of links'));
@@ -142,7 +139,7 @@ function checkTemplatePunctuation(ctx: LintContext, r: Reporter): void {
if (TICKER.test(cls) || tickerRanges.some(([a, b]) => t.pos > a && t.pos < b)) continue;
const inner = innerOf(doc, i);
if (!inner || /<(p|div|li|section|article|ul|ol|table)\b/i.test(inner.inner)) continue;
const text = plainText(inner.inner);
const text = textOf(doc, i);
if (!text) continue;
const dots = (text.match(/\s[·•]\s/g) || []).length;
if (dots >= 2 && !dotLines.has(t.line)) {
@@ -164,31 +161,18 @@ function checkTemplatePunctuation(ctx: LintContext, r: Reporter): void {
const cls = getAttr(t.attrs, 'class') || '';
if (!/(^|\s)(dot|status-dot|indicator|bullet|pip|blip)(\s|$|__|-)/i.test(cls)) continue;
if (/aria-live|role\s*=\s*"status"/i.test(t.attrs)) continue;
const inner = innerOf(doc, i);
if (inner && plainText(inner.inner)) continue;
if (textOf(doc, i)) continue;
dotCount++;
if (dotCount === 3) r.report('template-punctuation', doc.file, t.line, 'decorative status dots (' + cls.trim() + ') repeated across the page', { fix: 'delete them; a dot means live state, and only where it is one' });
}
}
}
/** The tag index of the nearest open ancestor of the tag at from, or -1. */
function parentIndex(doc: Doc, from: number): number {
let depth = 0;
for (let k = from - 1; k >= 0; k--) {
const t = doc.tags[k];
if (t.selfClosing) continue;
if (t.close) depth++;
else if (depth === 0) return k;
else depth--;
}
return -1;
}
// sloplint check68 (gate 68); the no-slot-script half (F18) is render-only and not ported
function checkWireframeSlots(ctx: LintContext, r: Reporter): void {
for (const doc of ctx.docs) {
if (doc.kind !== 'html') continue;
const { parent } = treeOf(doc);
const slots: Array<{ i: number; t: Tag }> = [];
for (let i = 0; i < doc.tags.length; i++) {
const t = doc.tags[i];
@@ -197,17 +181,17 @@ function checkWireframeSlots(ctx: LintContext, r: Reporter): void {
if (inner && /<img\b[^>]*\bsrc\s*=\s*["'][^"']+["']/i.test(inner.inner)) continue;
if (inner && (inner.inner.match(/<[a-z][\w-]*\b[^>]*>\s*[^<\s]/gi) || []).length >= 6) continue;
let hiddenUp = false;
for (let k = parentIndex(doc, i); k >= 0 && !hiddenUp; k = parentIndex(doc, k)) if (/(?:^|\s)hidden(?:\s|=|$)/.test(doc.tags[k].attrs)) hiddenUp = true;
for (let k = parent[i]; k >= 0 && !hiddenUp; k = parent[k]) if (/(?:^|\s)hidden(?:\s|=|$)/.test(doc.tags[k].attrs)) hiddenUp = true;
if (hiddenUp) continue;
slots.push({ i, t });
}
if (slots.length > 5) r.report('wireframe-slots', doc.file, slots[5].t.line, slots.length + ' empty slots on one page', { fix: 'two to four slots carry a page; set the rest in type, with names and facts in a list or table' });
const byRun = new Map<number, number>();
for (const sl of slots) {
const parent = parentIndex(doc, sl.i);
const gp = parent >= 0 ? parentIndex(doc, parent) : -1;
const ggp = gp >= 0 ? parentIndex(doc, gp) : -1;
for (const key of new Set([parent, gp, ggp])) { if (key < 0) continue; byRun.set(key, (byRun.get(key) || 0) + 1); }
const p = parent[sl.i];
const gp = p >= 0 ? parent[p] : -1;
const ggp = gp >= 0 ? parent[gp] : -1;
for (const key of new Set([p, gp, ggp])) { if (key < 0) continue; byRun.set(key, (byRun.get(key) || 0) + 1); }
}
for (const [key, n] of byRun) {
if (n < 4) continue;
@@ -287,8 +271,9 @@ function isEmailSignup(doc: Doc, idx: number): boolean {
const inner = innerOf(doc, idx);
if (!inner) return false;
const html = inner.inner;
const buttons = [...html.matchAll(/<button\b[^>]*>([\s\S]*?)<\/button>|<input\b([^>]*\btype\s*=\s*["']submit["'][^>]*)>/gi)]
.map((m) => (m[1] != null ? plainText(m[1]) : getAttr(m[2], 'value') || '')).join(' ');
const end = closeIndex(doc, idx);
const buttons = [...openTags(doc, /^button$/, idx + 1, end).map((b) => plainText(b.inner)),
...doc.tags.slice(idx + 1, end).filter((t) => !t.close && t.name === 'input' && /^submit$/i.test(getAttr(t.attrs, 'type') || '')).map((t) => getAttr(t.attrs, 'value') || '')].join(' ');
if (ASKS.test(buttons) && !LIST_WORDS.test(buttons)) return false;
const fields: string[] = [];
for (const m of html.matchAll(/<(input|textarea|select)\b([^>]*)>/gi)) {
+1 -1
View File
@@ -17,7 +17,7 @@ const STATIC_OWNS = new Set(['transition-all', 'vh-height']);
export function run(ctx: LintContext, r: Reporter): void {
for (const doc of ctx.docs) {
if (doc.kind !== 'html') continue;
for (const f of checkCraft(doc.raw)) {
for (const f of checkCraft(doc.src)) {
if (STATIC_OWNS.has(f.id) || !ruleMeta(f.id)) continue;
const title = ruleMeta(f.id)!.title;
r.report(f.id, doc.file, 1, f.count === 1 ? title : `${title}, ${f.count} times`, { fix: f.fix, source: 'copy' });
+5 -5
View File
@@ -6,7 +6,7 @@
*/
import { mergedRules, type LintContext } from '../context';
import { HEADING_SEL, isTokenRule, lineIndex, pxOf, resolveVars, type MergedRule } from '../css';
import { getAttr, maskedProse } from '../html';
import { getAttr, maskedProse, openTags, plainText } from '../html';
import { trunc, type Reporter } from '../report';
import { twVariants } from '../tailwind';
@@ -269,10 +269,10 @@ function checkHeadingLevels(ctx: LintContext, r: Reporter): void {
for (const doc of ctx.docs) {
if (doc.kind !== 'html') continue;
let prev = 0;
for (const m of doc.src.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi)) {
const level = Number(m[1]);
for (const head of openTags(doc, /^h[1-6]$/)) {
const level = Number(head.tag.name.slice(1));
if (prev && level > prev + 1) {
r.report('skipped-heading', doc.file, doc.lineOf(m.index!), 'h' + prev + ' is followed by h' + level + ' ("' + trunc(m[2].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(), 32) + '")', { fix: 'step the levels one at a time; size a head with CSS, never with its level' });
r.report('skipped-heading', doc.file, head.tag.line, 'h' + prev + ' is followed by h' + level + ' ("' + trunc(plainText(head.inner), 32) + '")', { fix: 'step the levels one at a time; size a head with CSS, never with its level' });
break;
}
prev = level;
@@ -316,7 +316,7 @@ function checkHairlineUnderShadow(ctx: LintContext, r: Reporter): void {
if (edge) { r.report('hairline-under-shadow', rule.file, sh.line, 'a hairline border under a wide soft shadow on ' + rule.selector); return; }
}
for (const doc of ctx.docs) {
const m = /class(?:Name)?\s*=\s*["'][^"']*(?<![\w:-])border(?![\w-])[^"']*(?<![\w:-])shadow-(?:xl|2xl)(?![\w-])|class(?:Name)?\s*=\s*["'][^"']*(?<![\w:-])shadow-(?:xl|2xl)(?![\w-])[^"']*(?<![\w:-])border(?![\w-])/.exec(doc.raw);
const m = /class(?:Name)?\s*=\s*["'][^"']*(?<![\w:-])border(?![\w-])[^"']*(?<![\w:-])shadow-(?:xl|2xl)(?![\w-])|class(?:Name)?\s*=\s*["'][^"']*(?<![\w:-])shadow-(?:xl|2xl)(?![\w-])[^"']*(?<![\w:-])border(?![\w-])/.exec(doc.src);
if (m) { r.report('hairline-under-shadow', doc.file, doc.lineOf(m.index), 'a hairline border under a wide soft shadow (border with shadow-xl or shadow-2xl)'); return; }
}
}
+1 -1
View File
@@ -284,7 +284,7 @@ function inputStates(ctx: LintContext, r: Reporter): void {
const w = widthOf(d.value);
if (w != null && baseW != null && w !== baseW) r.report('input-state-shift', rule.file, d.line, `the border goes from ${baseW}px to ${w}px on ${rule.selector}, so the field shifts`, { fix: 'keep the border width constant; move the state to outline' });
}
if (/:focus/.test(rule.selector) && rule.decls.some((d) => /^border(-color)?$/.test(d.prop)) && !rule.decls.some((d) => /^outline/.test(d.prop))) {
if (/:focus/.test(rule.selector) && rule.decls.some((d) => /^border(-color)?$/.test(d.prop)) && !rule.decls.some((d) => /^outline/.test(d.prop) || (d.prop === 'box-shadow' && !/^none\b/i.test(d.value.trim())))) {
r.report('input-state-shift', rule.file, rule.line, `the focus state is drawn with border on ${rule.selector}`, { fix: 'a focus ring with outline, not border' });
}
}
+7 -6
View File
@@ -8,7 +8,7 @@
import { COLOR_LITERAL, oklchOf, parseColor } from '../color';
import { isTokenRule, pxOf, resolveVars, type MergedRule } from '../css';
import { memo, mergedRules, type LintContext } from '../context';
import { getAttr, innerOf, plainText, treeOf, wordsOf, type Doc } from '../html';
import { getAttr, innerOf, plainText, textOf, treeOf, wordsOf, type Doc } from '../html';
import { trunc, type Reporter } from '../report';
import { twGroup } from '../tailwind';
import { heroIndexOf } from './chrome-copy';
@@ -167,7 +167,7 @@ function rawSpacing(ctx: LintContext, r: Reporter): void {
for (const d of rule.decls) {
if (!SPACING_PROP.test(d.prop)) continue;
const raw = d.value.replace(/var\([^)]*\)/g, ' ');
for (const m of raw.matchAll(/(?<![\w.-])(-?\d+(?:\.\d+)?)(px|rem)\b/g)) {
for (const m of raw.matchAll(/(?<![\w.-])(-?\d*\.?\d+)(px|rem)\b/g)) {
const px = Math.abs(pxOf(m[1] + m[2]) ?? 0);
const token = spaceVals.get(px);
if (!px || !token) continue;
@@ -183,8 +183,8 @@ function rawSpacing(ctx: LintContext, r: Reporter): void {
/** An HTML file that is only an app shell: no styles, a module script, no local stylesheet. */
const isShell = (d: Doc): boolean => d.kind === 'html' && !d.styles.length
&& /<script\b[^>]*type\s*=\s*["']module["'][^>]*\bsrc\s*=\s*["'][^"']+\.(?:tsx?|jsx?|mjs|vue|svelte)["']/i.test(d.raw)
&& ![...d.raw.matchAll(/<link\b[^>]*>/gi)].some((m) => /rel\s*=\s*["']stylesheet/i.test(m[0]) && !/href\s*=\s*["']https?:/i.test(m[0]));
&& /<script\b[^>]*type\s*=\s*["']module["'][^>]*\bsrc\s*=\s*["'][^"']+\.(?:tsx?|jsx?|mjs|vue|svelte)["']/i.test(d.src)
&& ![...d.src.matchAll(/<link\b[^>]*>/gi)].some((m) => /rel\s*=\s*["']stylesheet/i.test(m[0]) && !/href\s*=\s*["']https?:/i.test(m[0]));
// sloplint check34, static half (gate 34); the render tier owns horizontal-scroll
function overflowX(ctx: LintContext, r: Reporter): void {
@@ -310,7 +310,7 @@ function hiddenWordCount(doc: Doc): number {
if (!hiddenish) continue;
const inner = innerOf(doc, i);
if (!inner) continue;
words += countWords(plainText(inner.inner));
words += countWords(textOf(doc, i));
hiddenEnd = inner.endPos;
}
return words;
@@ -366,7 +366,8 @@ function columnCount(value: string): number | null {
/** Whether some container is capped: a max-width paired with auto margins, a max-width of 900px or more, or a Tailwind container or max-w utility. */
function containerCapped(ctx: LintContext): boolean {
for (const m of mergedRules(ctx)) {
const cap = m.decls.find((d) => d.prop === 'max-width' || d.prop === 'max-inline-size');
const cap = m.decls.find((d) => d.prop === 'max-width' || d.prop === 'max-inline-size')
?? m.decls.find((d) => (d.prop === 'width' || d.prop === 'inline-size') && /^(min|clamp)\(/.test(resolveVars(d.value, ctx.tokens).trim()) && /\d(px|rem|em|ch)\b/.test(d.value));
if (!cap || /^(none|100%|100vw)$/.test(cap.value.trim())) continue;
const px = pxOf(resolveVars(cap.value, ctx.tokens));
const centred = m.decls.some((d) => /^margin(-inline|-left|-right)?$/.test(d.prop) && /\bauto\b/.test(d.value));
+44 -29
View File
@@ -8,7 +8,7 @@ import { BANNED_FONTS, OVERUSED_FONTS_DISPLAY } from '../../design-catalog';
import type { LintContext } from '../context';
import { fontPx, mergedRules, styleFor, trackingEm, valueOf } from '../context';
import { GENERIC_FAMILIES, HEADING_SEL, LABEL_PX, MONO_FACE, TRACKED_EM, famKey, firstFamily, isTokenRule, pxOf, resolveVars, type CssDecl, type CssRule, type MergedRule } from '../css';
import { ancestorsOf, getAttr, innerOf, plainText } from '../html';
import { ancestorsOf, closeIndex, getAttr, innerOf, openTags, plainText, stripTags, textOf } from '../html';
import type { Reporter } from '../report';
import { trunc } from '../report';
@@ -25,13 +25,18 @@ function tokenSite(ctx: LintContext, prop: string): { file: string; line: number
}
/** gstack's own catalog of training-data display faces, plus the system stacks sloplint names. */
const OVERUSED = [...OVERUSED_FONTS_DISPLAY, 'system-ui', '-apple-system', 'helvetica neue', 'segoe ui'].map((f) => famKey(f.toLowerCase()));
const OVERUSED = [...OVERUSED_FONTS_DISPLAY, 'system-ui', '-apple-system', 'helvetica neue', 'segoe ui'].map((f) => f.toLowerCase());
/** gstack's catalog of faces banned in any role (Papyrus, Comic Sans, Impact and kin). */
const BANNED = BANNED_FONTS.map((f) => famKey(f.toLowerCase()));
const BANNED_WORDS = BANNED_FONTS.map((f) => f.toLowerCase());
/** The catalog names "Comic Sans" and "Brush Script"; a declaration says "Comic Sans MS" or "Brush Script MT". */
const isBanned = (fam: string): boolean => { const k = famKey(fam); return BANNED.some((b) => k === b || k.startsWith(b)); };
/** The catalog names a family by its stem ("Comic Sans", "Cormorant", "DM Serif"); a declaration says "Comic Sans MS", "Cormorant Garamond" or "DM Serif Display". A stem matches the whole name or its first words, never a longer word ("Inter" is not "Interstate"). */
function namesFace(fam: string, stems: readonly string[]): boolean {
const k = famKey(fam);
const words = fam.toLowerCase().replace(/[\s_-]+/g, ' ').trim();
return stems.some((s) => k === famKey(s) || words.startsWith(s + ' '));
}
const isBanned = (fam: string): boolean => namesFace(fam, BANNED_WORDS);
// gstack's own: a banned face anywhere, display or body (lib/design-catalog.ts BANNED_FONTS)
function bannedFont(ctx: LintContext, r: Reporter): void {
@@ -56,7 +61,7 @@ function bannedFont(ctx: LintContext, r: Reporter): void {
function check1(ctx: LintContext, r: Reporter): void {
const seen = new Set<string>();
const flag = (fam: string, file: string, line: number, where: string) => {
if (!OVERUSED.includes(famKey(fam)) || seen.has(fam + where)) return;
if (!namesFace(fam, OVERUSED) || seen.has(fam + where)) return;
seen.add(fam + where);
r.report('overused-font', file, line, `"${fam}" as the display face (${where})`);
};
@@ -77,8 +82,9 @@ function shortProse(ctx: LintContext, sel: string): boolean {
if (!cls) return false;
const texts: string[] = [];
for (const doc of ctx.docs) {
for (const m of doc.src.matchAll(new RegExp(`<(\\w+)[^>]*class="[^"]*\\b${cls}\\b[^"]*"[^>]*>([\\s\\S]*?)</\\1>`, 'g'))) {
for (const p of (/^p$/i.test(m[1]) ? [m[2]] : m[2].match(/<p\b[^>]*>[\s\S]*?<\/p>/g) || [])) texts.push(p.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim());
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 p of paras) texts.push(stripTags(p).replace(/\s+/g, ' ').trim());
}
}
return texts.length > 0 && texts.every((t) => t.length <= 200 && (t.match(/[.!?](?:\s|$)/g) || []).length <= 2);
@@ -133,13 +139,14 @@ function check38a(ctx: LintContext, r: Reporter): void {
&& 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)));
for (const doc of ctx.docs) {
for (const m of doc.src.matchAll(/<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi)) {
const classes = ((/class\s*=\s*["']([^"']*)["']/i.exec(m[2]) || [])[1] || '').split(/\s+/).filter(Boolean);
const tag = /<(em|i)\b[^>]*>([\s\S]*?)<\/\1\s*>/i.exec(m[3]);
if (!tag || normalised(m[1], classes)) continue;
const italic = plainText(tag[2]);
if (!italic || italic.length >= 0.8 * plainText(m[3]).length) continue;
r.report('italic-word-in-heading', doc.file, doc.lineOf(m.index!), `an <${tag[1].toLowerCase()}> inside an otherwise roman <h${m[1]}>`);
for (const head of openTags(doc, /^h[1-6]$/)) {
const level = head.tag.name.slice(1);
const classes = (getAttr(head.tag.attrs, 'class') || '').split(/\s+/).filter(Boolean);
const tag = openTags(doc, /^(em|i)$/, head.idx + 1, closeIndex(doc, head.idx), () => true, 1)[0];
if (!tag || normalised(level, classes)) continue;
const italic = textOf(doc, tag.idx);
if (!italic || italic.length >= 0.8 * textOf(doc, head.idx).length) continue;
r.report('italic-word-in-heading', doc.file, head.tag.line, `an <${tag.tag.name}> inside an otherwise roman <h${level}>`);
}
}
}
@@ -167,14 +174,15 @@ function accentedHeads(ctx: LintContext): CssRule[] {
if (t.close || !/^h[1-3]$/.test(t.name)) continue;
const inner = innerOf(doc, i);
if (!inner) continue;
const headLen = plainText(inner.inner).replace(/\s+/g, '').length || 1;
const spans = [...inner.inner.matchAll(/<(span|em|strong|b|i|mark)\b[^>]*\bclass\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/\1>/gi)];
const cover = (cls: string) => spans.filter((x) => x[2].split(/\s+/).includes(cls)).reduce((n, x) => n + plainText(x[3]).replace(/\s+/g, '').length, 0) / headLen;
const headLen = textOf(doc, i).replace(/\s+/g, '').length || 1;
const spans = openTags(doc, /^(span|em|strong|b|i|mark)$/, i + 1, closeIndex(doc, i), (s) => !!(getAttr(s.attrs, 'class') || '').trim())
.map((el) => ({ classes: getAttr(el.tag.attrs, 'class') || '', inner: el.inner }));
const cover = (cls: string) => spans.filter((x) => x.classes.split(/\s+/).includes(cls)).reduce((n, x) => n + plainText(x.inner).replace(/\s+/g, '').length, 0) / headLen;
const headDecls = styleFor(ctx, getAttr(t.attrs, 'class'), t.name, true);
const own = (prop: string) => valueOf(ctx, headDecls, prop) ?? (prop === 'color' ? resolveVars('var(--color-ink)', ctx.tokens) : null);
for (const m of spans) {
if (!/[A-Za-z0-9]/.test(plainText(m[3]))) continue;
for (const cls of m[2].split(/\s+/).filter(Boolean)) {
if (!/[A-Za-z0-9]/.test(plainText(m.inner))) continue;
for (const cls of m.classes.split(/\s+/).filter(Boolean)) {
if (cover(cls) >= 0.8) continue;
for (const rule of ctx.rules) {
if (rule.keyframes || isTokenRule(rule) || seen.has(rule.selector)) continue;
@@ -271,19 +279,25 @@ function exempt67(sel: string): boolean {
});
}
/** The merged rules that can pass a value down, each with its selector parts' bases computed once, so a stylesheet of many label rules is walked without re-splitting every selector per lookup. */
interface Ancestors67 { rules: MergedRule[]; bases: string[][] }
function ancestors67(bySel: MergedRule[]): Ancestors67 {
const rules = bySel.filter((o) => !/:hover|:focus|:active/.test(o.selector) && !(o.media && /max-width/i.test(o.media)));
return { rules, bases: rules.map((o) => o.selector.split(',').map(baseSelector)) };
}
// sloplint inherited67 (gate 67): the nearest ancestor selector's value for a property
function inherited67(bySel: MergedRule[], sel: string, prop: string): string | null {
function inherited67(up: Ancestors67, sel: string, prop: string): string | null {
const base = baseSelector(sel);
let best: string | null = null, bestLen = 0;
for (const o of bySel) {
if (/:hover|:focus|:active/.test(o.selector) || (o.media && /max-width/i.test(o.media))) continue;
for (const part of o.selector.split(',')) {
const ob = baseSelector(part);
up.rules.forEach((o, i) => {
for (const ob of up.bases[i]) {
if (!ob || ob === base || ob.length <= bestLen || !base.startsWith(ob) || !/[\s>+~]/.test(base[ob.length] || '')) continue;
const d = o.decls.filter((x) => x.prop === prop).pop();
if (d) { best = d.value; bestLen = ob.length; }
}
}
});
return best;
}
@@ -293,7 +307,7 @@ const isSmallCaps67 = (prop: string, v: string): boolean => (/^font-variant(-cap
interface Caps67 { upper: boolean; smallCaps: boolean; em: number | null; px: number | null }
/** What a rule says about case and tracking, its own declarations first and the nearest ancestor's where it is silent. */
function caps67(ctx: LintContext, bySel: MergedRule[], rule: MergedRule): Caps67 | null {
function caps67(ctx: LintContext, bySel: Ancestors67, rule: MergedRule): Caps67 | null {
const isUpper = (v: string | null) => v != null && /uppercase/.test(resolveVars(v, ctx.tokens));
const ownTT = rule.decls.filter((d) => d.prop === 'text-transform').pop();
const ownUpper = ownTT ? isUpper(ownTT.value) : null;
@@ -321,9 +335,10 @@ function check67(ctx: LintContext, r: Reporter): void {
|| rule.decls.some((d) => (d.prop === 'font-family' || d.prop === 'font') && /--font-display/.test(d.value)) || (fontSizePxOf(rule, ctx) || 0) >= 28;
const upperDisplay = bySel.some((rule) => displayish(rule) && !LABEL67.test(rule.selector) && rule.decls.some((d) => d.prop === 'text-transform' && /uppercase/.test(d.value)));
const seen = new Set<string>();
const up = ancestors67(bySel);
for (const rule of bySel) {
if (isTokenRule(rule) || exempt67(rule.selector) || /::first-letter\s*(,|$)/.test(rule.selector)) continue;
const caps = caps67(ctx, bySel, rule);
const caps = caps67(ctx, up, rule);
if (!caps) continue;
const { upper, smallCaps, em, px } = caps;
const tracked = em != null && em > TRACKED_EM;
@@ -373,7 +388,7 @@ function check67Html(ctx: LintContext, r: Reporter): void {
if (ancestorsOf(doc, i).some((n) => /^(svg|pre|code|abbr|kbd|samp)$/.test(n))) continue;
const inner = innerOf(doc, i);
if (!inner || /<(p|div|ul|ol|h[1-6]|table|section|article|svg|img|abbr|code|kbd)\b/i.test(inner.inner)) continue;
const text = plainText(inner.inner);
const text = textOf(doc, i);
const letters = text.replace(/[^A-Za-z]/g, '');
if (letters.length < 5 || capsRatio(text) < 0.95 || /^[A-Z]{2,5}$/.test(text) || /^\(?\s*(CC0|CC BY(-SA|-NC|-ND)*( [0-9.]+)?|PD|GFDL)\b/.test(text)) continue;
const decls = styleFor(ctx, cls, t.name);
+39 -19
View File
@@ -13,9 +13,8 @@ import { buildTokens, isTokenRule, parseCss, stripComments, type CssRule, type T
import { docKindOf, getAttr, parseDoc, type Doc } from './html';
import { Reporter, type Finding } from './report';
import { FAMILIES } from './rules';
import { twVariants } from './tailwind';
import { setTwTokens, twVariants } from './tailwind';
import { applyWaivers, findWaivers, resolveWaivers, suspectedRules, type WaiverSet } from './waivers';
import { setTwTokens } from './tailwind';
export interface StaticOptions {
scope?: 'page' | 'component';
@@ -32,10 +31,15 @@ export interface StaticResult {
}
/** The static tier before waivers: every context with the findings its rule families reported, so a caller can add the render tier's findings and settle the page's waivers once over both. */
/** A source file the lint did not read, and why; it is reported, because a page the lint could not read is not a pass. */
export interface Skipped { file: string; why: string }
export interface RawStaticResult {
files: string[];
contexts: LintContext[];
raw: Finding[][];
/** One internal-error finding per skipped source file. */
skipped: Finding[];
}
const BUILD_DIRS = new Set(['dist', 'build', 'coverage']);
@@ -44,13 +48,13 @@ const BUILD_DIRS = new Set(['dist', 'build', 'coverage']);
export const MAX_FILE_BYTES = 2 * 1024 * 1024;
/** Every .css page and component file under the paths; node_modules, dot folders and (below the top) build output are skipped, a link cycle is walked once, and a file over MAX_FILE_BYTES is skipped with a warning. */
export function collectFiles(paths: string[], warn: (l: string) => void = () => {}): string[] {
export function collectFiles(paths: string[], warn: (l: string) => void = () => {}, skipped: Skipped[] = []): string[] {
const files: string[] = [];
const seen = new Set<string>();
const visit = (p: string, top: boolean) => {
let abs = path.resolve(p);
let st: fs.Stats;
try { abs = fs.realpathSync(abs); st = fs.statSync(abs); } catch { warn(`design-lint: cannot read ${p}`); return; }
try { abs = fs.realpathSync(abs); st = fs.statSync(abs); } catch { warn(`design-lint: cannot read ${p}`); if (top) skipped.push({ file: abs, why: 'it cannot be read' }); return; }
if (seen.has(abs)) return;
if (st.isDirectory()) {
seen.add(abs);
@@ -61,7 +65,12 @@ export function collectFiles(paths: string[], warn: (l: string) => void = () =>
}
if (path.extname(abs).toLowerCase() !== '.css' && !docKindOf(abs)) return;
seen.add(abs);
if (st.size > MAX_FILE_BYTES) { warn(`design-lint: skipped ${p} (${Math.round(st.size / 1024 / 1024)} MB, over the ${MAX_FILE_BYTES / 1024 / 1024} MB limit)`); return; }
if (st.size > MAX_FILE_BYTES) {
const why = `${Math.round(st.size / 1024 / 1024)} MB, over the ${MAX_FILE_BYTES / 1024 / 1024} MB limit`;
warn(`design-lint: skipped ${p} (${why})`);
skipped.push({ file: abs, why });
return;
}
files.push(abs);
};
for (const p of paths) visit(p, true);
@@ -78,12 +87,15 @@ export function resolveRef(href: string | null, from: string, root: string | nul
if (!href || /^(?:[a-z]+:|\/\/)/i.test(href)) return null;
const ref = href.split(/[?#]/)[0];
if (!ref) return null;
const within = (abs: string, top: string) => abs === top || abs.startsWith(top + path.sep);
if (ref.startsWith('/')) {
if (!root) return null;
const abs = path.join(root, ref);
return abs === root || abs.startsWith(root + path.sep) ? abs : null;
return within(abs, root) ? abs : null;
}
return path.resolve(from, ref);
const abs = path.resolve(from, ref);
const project = projectRoot(from);
return project && !within(abs, project) ? null : abs;
}
export function importsOf(css: string, from: string, root: string | null = null): string[] {
@@ -114,11 +126,13 @@ interface CssRead { raw: string; rules: CssRule[]; imports: string[] }
/** Stylesheets read once per run: a bundle shared by forty pages is parsed once, not forty times. */
class CssCache {
private readonly files = new Map<string, CssRead | null>();
constructor(readonly roots: string[], private readonly warn: (l: string) => void) {}
constructor(readonly roots: string[], readonly warn: (l: string) => void) {}
read(f: string): CssRead | null {
if (this.files.has(f)) return this.files.get(f)!;
let out: CssRead | null = null;
try {
const size = fs.statSync(f).size;
if (size > MAX_FILE_BYTES) { this.warn(`design-lint: skipped ${f} (${Math.round(size / 1024 / 1024)} MB, over the ${MAX_FILE_BYTES / 1024 / 1024} MB limit)`); this.files.set(f, null); return null; }
const raw = fs.readFileSync(f, 'utf8');
out = { raw, rules: parseCss(raw, f), imports: importsOf(raw, path.dirname(f), rootOf(f, this.roots)) };
} catch (e) {
@@ -156,7 +170,7 @@ export function buildContext(key: string, cssList: string[], docList: Doc[], opt
for (const doc of docList) {
const root = rootOf(doc.file, cache.roots);
for (const st of doc.styles) {
rules.push(...parseCss(st.css, doc.file, st.lineOffset));
try { rules.push(...parseCss(st.css, doc.file, st.lineOffset)); } catch (e) { cache.warn(`design-lint: skipped a <style> block in ${doc.file}, it could not be parsed (${(e as Error).message})`); continue; }
pending.push(...importsOf(st.css, path.dirname(doc.file), root));
}
pending.push(...linksOf(doc, path.dirname(doc.file), root));
@@ -192,7 +206,7 @@ function linkedCss(page: Doc, cache: CssCache): Set<string> {
* components and unlinked CSS. A stylesheet some page links, from any
* directory, belongs to that page and is not linted again on its own.
*/
export function groupContexts(files: string[], opts: StaticOptions, warn: (l: string) => void, roots: string[] = []): LintContext[] {
export function groupContexts(files: string[], opts: StaticOptions, warn: (l: string) => void, roots: string[] = [], skipped: Skipped[] = []): LintContext[] {
const cache = new CssCache(roots, warn);
const groups = new Map<string, { css: string[]; pages: Doc[]; parts: Doc[] }>();
for (const f of files) {
@@ -203,7 +217,7 @@ export function groupContexts(files: string[], opts: StaticOptions, warn: (l: st
try {
const doc = parseDoc(f, fs.readFileSync(f, 'utf8'));
(doc.kind === 'html' ? g.pages : g.parts).push(doc);
} catch (e) { warn(`design-lint: skipped ${f}, it could not be parsed (${(e as Error).message})`); }
} catch (e) { warn(`design-lint: skipped ${f}, it could not be parsed (${(e as Error).message})`); skipped.push({ file: f, why: `it could not be parsed (${(e as Error).message})` }); }
}
const linkedBy = new Map<Doc, Set<string>>();
const owned = new Set<string>();
@@ -285,10 +299,11 @@ export function settleContext(ctx: LintContext, findings: Finding[], cwd: string
/** Run every rule family over every context, without waivers. */
export function lintStaticRaw(paths: string[], opts: StaticOptions = {}): RawStaticResult {
const warn = opts.warn ?? ((l: string) => console.error(l));
const cwd = opts.cwd ?? process.cwd();
const files = collectFiles(paths, warn);
const roots = paths.map((p) => path.resolve(p)).map((p) => { try { return fs.statSync(p).isDirectory() ? p : path.dirname(p); } catch { return null; } }).filter((p): p is string => p !== null);
const contexts = groupContexts(files, opts, warn, roots);
const cwd = realDir(opts.cwd ?? process.cwd());
const skippedFiles: Skipped[] = [];
const files = collectFiles(paths, warn, skippedFiles);
const roots = paths.map((p) => { try { const real = fs.realpathSync(p); return fs.statSync(real).isDirectory() ? real : path.dirname(real); } catch { return null; } }).filter((p): p is string => p !== null);
const contexts = groupContexts(files, opts, warn, roots, skippedFiles);
shareProjects(contexts);
const raw: Finding[][] = [];
for (const ctx of contexts) {
@@ -301,11 +316,16 @@ export function lintStaticRaw(paths: string[], opts: StaticOptions = {}): RawSta
}
raw.push(reporter.findings);
}
return { files, contexts, raw };
const skippedReporter = new Reporter(opts.scope ?? 'page', cwd);
for (const s of skippedFiles) skippedReporter.report('internal-error', s.file, 1, `skipped: ${s.why}; a page the lint could not read is not a pass`, { fix: 'split the file, or lint the page that links it' });
return { files, contexts, raw, skipped: skippedReporter.findings };
}
/** A directory's real path, so a cwd or lint root reached through a symlink still matches the files collected under it. */
export const realDir = (p: string): string => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
export function lintStatic(paths: string[], opts: StaticOptions = {}): StaticResult {
const cwd = opts.cwd ?? process.cwd();
const { files, contexts, raw } = lintStaticRaw(paths, opts);
return { files, contexts, findings: contexts.flatMap((ctx, i) => settleContext(ctx, raw[i], cwd)) };
const cwd = realDir(opts.cwd ?? process.cwd());
const { files, contexts, raw, skipped } = lintStaticRaw(paths, opts);
return { files, contexts, findings: [...contexts.flatMap((ctx, i) => settleContext(ctx, raw[i], cwd)), ...skipped] };
}
+6 -7
View File
@@ -13,7 +13,7 @@ import type { LintContext } from './context';
import { memo } from './context';
import { bannedGradient, COLOR_LITERAL, oklchOf, parseColor, type Color } from './color';
import { GENERIC_FAMILIES, LABEL_PX, MONO_FACE, TRACKED_EM, firstFamily, pxOf, resolveVars, splitTop, type Tokens } from './css';
import { braceEnd, closeIndex, clsLits, directChildren, getAttr, innerOf, plainText, type Doc, type Tag } from './html';
import { braceEnd, closeIndex, clsLits, directChildren, getAttr, innerOf, plainText, treeOf, type Doc, type Tag } from './html';
import { trunc } from './report';
export const TW_SIZE: Record<string, number> = { xs: 12, sm: 14, base: 16, lg: 18, xl: 20, '2xl': 24, '3xl': 30, '4xl': 36, '5xl': 48, '6xl': 60, '7xl': 72, '8xl': 96, '9xl': 128 };
@@ -307,7 +307,6 @@ function twReadSurface(ctx: LintContext, b: string, tok: string, p: TwParsed, f:
if (/^rounded(?:-|$)/.test(b)) { if (b !== 'rounded-none') f.rounded = true; return; }
if (b === 'grid-cols-3') { f.cols3 = true; return; }
if (/^p[xytrblse]?-/.test(b)) { f.padded = true; return; }
if (b.startsWith('caret-')) return;
}
/** Read one class string into its facts; the variants it uses land in the shared set as well as on the info. */
@@ -463,16 +462,16 @@ export function twGroup(ctx: LintContext): TwGroup {
if (/^h[12]$/.test(it.tag) && it.info.upper) tw.upperDisplay = true;
};
for (const doc of ctx.docs) {
const stack: TwFrame[] = [];
const { parent } = treeOf(doc);
const frames: TwFrame[] = [];
for (let i = 0; i < doc.tags.length; i++) {
const t = doc.tags[i];
if (t.close) { stack.pop(); continue; }
const parent = stack.length ? stack[stack.length - 1] : TW_ROOT;
if (t.close) continue;
const toks = twTokensOf(doc, t);
const info = toks.length ? twInfo(ctx, toks, tw.variants) : null;
const frame = twFrame(parent, t, info, toks);
const frame = twFrame(parent[i] >= 0 ? frames[parent[i]] : TW_ROOT, t, info, toks);
frames[i] = frame;
if (info) add({ file: doc.file, line: t.line, idx: i, tag: t.name, info, doc, comp: null, virtual: false, attrs: t.attrs, toks, frame, text: null });
if (!t.selfClosing) stack.push(frame);
}
for (let c = 0; c < doc.comps.length; c++) {
const t = doc.comps[c];
+1 -1
View File
@@ -133,7 +133,7 @@ export function resolveWaivers(claims: WaiverClaim[], pageText: string, cwd = pr
const words = c.reason.toLowerCase().match(/[a-z]{4,}/g) || [];
const sheet = /\.css$/i.test(c.file);
if (pageWords.size && !sheet && !words.some((w) => pageWords.has(w))) { refuse(c, 'reason shares no word with the page'); continue; }
accepted.set(c.rule, { reason: c.reason, sheet: sheet ? relPath(c.file, cwd) : null });
accepted.set(c.rule, { reason: c.reason, sheet: sheet ? oneLine(relPath(c.file, cwd)) : null });
}
if (accepted.size > WAIVER_MAX) {
const first = claims[0];