diff --git a/CHANGELOG.md b/CHANGELOG.md index c2792afd9..1690cf755 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - **`bin/gstack-design-lint [--render] [--url] [--scope page|component] [--job ] [--json] [--rules]`**: reads `.html`, `.css`, `.jsx`, `.tsx`, `.vue`, `.svelte` and `.astro` (Tailwind class strings included, `cn()`/`clsx()`/`cva()` calls too), links each page to the stylesheets it reaches through `` and `@import`, resolves CSS variables, converts colours to OKLCH and computes contrast, and prints one line per finding: `LINT: : | fix: `, then `LINT_SUMMARY: blocks=N ask=N waived=N polish=N render=`. Exit 2 on blocks, 1 on an unanswered ask, 0 otherwise. `docs/design-lint.md` is the contract and lists every rule. - **Three tiers in gstack's words, no fourth scale.** `blocks` (hallmark's Floor: contrast, focus rings, reduced motion, a card in a card, a purple-blue gradient, content hidden until JavaScript runs; never waivable), `ask` (hallmark's Reflex: three equal cards, Inter as the display, the centred-stack hero; waivable on the record) and `polish` (hallmark's Finish: straight quotes, a lonely last grid item; never blocks). Blocking and waived counts are never summed, so `blocks=0` is always a true sentence about the Floor. Three house rules hallmark holds at Floor stay at ask here: the eyebrow above a heading, the 80-word screen and the dash. - **Waivers on the record.** `` (or the `/* */` and `//` comment forms; `impeccable-disable : ` is an alias) answers an ask or polish finding when the reason is about this page: 24 characters or more, not boilerplate, not reused on another waiver, sharing a word with the page's own text. A waiver of a blocking rule is refused and the rule still fires; more than three waivers on one page is reported as a house style that belongs in DESIGN.md. -- **The render tier** (`--render`, or `--url` for a dev server) runs on the same engine `gstack-render` picks, Aside first and gstack's own headless browser otherwise, with nothing to install: the hero fits at 1280x800 (headline, sentence, action and visual), about 80 words per 900px window, contrast against the painted pixels, focus rings found by focusing every control, headings crowded against the section above, text painted under another element, horizontal scroll between 320 and 1920, wrapping button labels, a hero offering more than two actions, section gaps under 120px or all alike and more than nine sections, thin sections, dead bands, drawn illustrations, empty picture slots, counting marks and photographs covering more than a third of the page. Without a browser the summary says `render=skipped: ` and the static tier still runs. +- **The render tier** (`--render`, or `--url` for a dev server) runs on the same engine `gstack-render` picks, Aside first and gstack's own headless browser otherwise, with nothing to install: the hero fits at 1280x800 (the headline's bottom and the first action; the lede and the visual are not measured), about 80 words per 900px window, contrast against the painted pixels, focus rings found by focusing every control, headings crowded against the section above, text painted under another element, horizontal scroll between 320 and 1920, wrapping button labels, a hero offering more than two actions, section gaps under 120px or all alike and more than nine sections, thin sections, dead bands, drawn illustrations, empty picture slots, counting marks and photographs covering more than a third of the page. Without a browser the summary says `render=skipped: ` and the static tier still runs. - `lib/aside-render.ts`: an eval step takes an optional viewport (`width`, `height`, `deviceScaleFactor`, `mobile`), set before the eval and cleared after it the way the screenshot step already does, and a spec's `file` may be an http(s) URL, opened as it is with no loopback server. Both engines take the size; `deviceScaleFactor` and `mobile` on an eval step reach Aside only (the browse daemon's `viewport` takes a size alone). Nothing else in the file changes. - **The copy tier**: `gstack-design-craft`'s content-realism and ship-floor tells (placeholder names, lorem, buzzwords, round numbers, `[Needs proof]`, dead links, images without alt, the missing title, description, skip link and `aria-current`) report under the same contract and ids, so one command prints one list. `bin/gstack-design-craft` itself is unchanged until Phase 2 swaps the call. - Fixtures under `test/fixtures/design-lint/` (hallmark's `slopped.html`, eyebrow and Tailwind pass/fail pairs and the waiver page, MIT-attributed, plus a clean page every rule family must leave alone); `licenses/MIT-hallmark.txt`; a NOTICE.md entry. diff --git a/lib/design-lint/css.ts b/lib/design-lint/css.ts index abbe657f4..be6ff2ce9 100644 --- a/lib/design-lint/css.ts +++ b/lib/design-lint/css.ts @@ -76,10 +76,15 @@ function flatBody(src: string, s: number, e: number): string { return out.join(''); } +/** 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; + /** Expand a nested selector against its parent: `&` is replaced, a bare selector is descendant-joined. */ function nestSelector(sel: string, parent: string): string { const p = parent.includes(',') ? `:is(${parent})` : parent; - return splitTop(sel).map((part) => part.includes('&') ? part.trim().replace(/&/g, p) : `${p} ${part.trim()}`).join(', '); + const parts = splitTop(sel).map((part) => part.trim()); + if (sel.length + p.length * parts.length > MAX_SELECTOR) return parts.map((part) => part.replace(/&/g, '').trim()).filter(Boolean).join(', ') || p; + return parts.map((part) => part.includes('&') ? part.replace(/&/g, p) : `${p} ${part}`).join(', '); } /** Faces that read as monospace, whether they arrive as a token, a utility or a declaration; one list so every check classifies a face the same way. */ @@ -215,11 +220,12 @@ const MAX_RESOLVED = 8192; export function resolveVars(value: string, tokens: Tokens, local: Tokens | null = null, depth = 0, chain: ReadonlySet = new Set()): string { if (!value || depth > 5 || value.length > MAX_RESOLVED || !value.includes('var(')) return value; const seen = new Set(); + let grown = 0; const out = value.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*(?:\([^()]*\)[^()]*)*))?\)/g, (m0, name: string, fb: string | undefined) => { - if (chain.has(name)) return m0; + if (chain.has(name) || grown > MAX_RESOLVED) return m0; const v = local && local[name] != null ? local[name] : tokens[name]; - if (v != null) { seen.add(name); return v; } - if (fb != null) return fb.trim(); + if (v != null && v.length <= MAX_RESOLVED) { seen.add(name); grown += v.length; return v; } + if (v == null && fb != null) return fb.trim(); return m0; }); if (out === value || out.length > MAX_RESOLVED) return out.length > MAX_RESOLVED ? value : out; diff --git a/lib/design-lint/html.ts b/lib/design-lint/html.ts index 6cc80ef73..fdb9a7ed7 100644 --- a/lib/design-lint/html.ts +++ b/lib/design-lint/html.ts @@ -27,7 +27,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 HTML, or the pseudo-HTML of a component, with comments blanked and every stray `<` (a tag start with no `>`) spaced out, positions kept, so no rule reads markup the browser never renders and no `[^>]*>` scan over it runs past its tag. */ + /** The text the tags were scanned from: the HTML, or the pseudo-HTML of a component, with comments blanked and every `<` that starts no tag (a tag start with no `>`, `<1`, `a < b`; a `` ahead stays) spaced out, positions kept, so no rule reads markup the browser never renders and no `[^>]*>` scan over it runs past its tag. */ src: string; /** The original file text. */ raw: string; @@ -111,16 +111,18 @@ function blankRanges(s: string, ranges: Array<[number, number]>, cut = false): s return out + s.slice(at); } -/** s with every `<…>` run replaced by one space (or padded to spaces with keep set), in one linear pass; a `<` with no `>` after it is text. */ -export function stripTags(s: string, keep = false): string { - let out = '', i = 0; +/** s with every `<…>` run replaced by one space (or padded to spaces with keep set), in one linear pass; a `<` with no `>` after it is text. With a limit, the read stops once more than that many non-space characters are out, so a caller that only needs to know whether the text is short does not pay for an element that runs to the end of the file. */ +export function stripTags(s: string, keep = false, limit = Infinity): string { + let out = '', i = 0, seen = 0; for (;;) { const lt = s.indexOf('<', i); if (lt < 0) break; const gt = s.indexOf('>', lt + 1); if (gt < 0) break; - out += s.slice(i, lt) + (keep ? pad(s.slice(lt, gt + 1)) : ' '); + const text = s.slice(i, lt); + out += text + (keep ? pad(s.slice(lt, gt + 1)) : ' '); i = gt + 1; + if (limit < Infinity && (seen += (text.match(/\S/g) || []).length) > limit) return out; } return out + s.slice(i); } @@ -206,7 +208,7 @@ export function parseHtml(file: string, src: string, kind: DocKind = 'html'): Do const src2 = blankStrays(live, strays); const masked = blankRanges(src2, bodies); const whole = [...scriptSpans, ...styleSpans].map((s): [number, number] => [s.start, s.end]).sort((a, b) => a[0] - b[0]); - const text = stripTags(blankRanges(live, whole, true)) + const text = stripTags(blankRanges(src2, whole, true)) .replace(/"|�?34;/g, '"').replace(/�?39;|'/g, "'") .replace(/…|…/g, '\u2026').replace(/—|—|—/gi, '\u2014').replace(/–|–|–/gi, '\u2013') .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') @@ -757,9 +759,9 @@ export function textOf(doc: Doc, idx: number): string { 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[] { +export function openTags(doc: Doc, name: RegExp, from = 0, to = doc.tags.length, pick: (t: Tag) => boolean = () => true): Element[] { const out: Element[] = []; - for (let i = from; i < to && out.length < limit; i++) { + for (let i = from; i < to; i++) { const t = doc.tags[i]; if (t.close || t.selfClosing || !name.test(t.name) || !pick(t)) continue; const inner = innerOf(doc, i); diff --git a/lib/design-lint/render-probe.ts b/lib/design-lint/render-probe.ts index 50a22407c..dd76e5b70 100644 --- a/lib/design-lint/render-probe.ts +++ b/lib/design-lint/render-probe.ts @@ -192,11 +192,11 @@ export function glLayout(P: Probes, checkFold: boolean): ProbeFinding[] { const hr = h1.getBoundingClientRect(); const scope = h1.closest('section, header, main, div') || document.body; const cta = Array.from(scope.querySelectorAll('a, button')).find((c) => vis(c) && (c.textContent || '').trim()); - const fold = 800; - if (hr.bottom > fold || hr.top < 0) out.push({ rule: 'hero-fold', grade: 'fail', evidence: 'the headline crosses the 1280x800 fold (its bottom is at ' + Math.round(hr.bottom) + 'px)', fix: 'pull the display clamp down so the headline fits the fold', at: at(h1) }); + const fold = innerHeight, view = innerWidth + 'x' + innerHeight; + if (hr.bottom > fold || hr.top < 0) out.push({ rule: 'hero-fold', grade: 'fail', evidence: 'the headline crosses the ' + view + ' fold (its bottom is at ' + Math.round(hr.bottom) + 'px)', fix: 'pull the display clamp down so the headline fits the fold', at: at(h1) }); else if (cta) { const cr = cta.getBoundingClientRect(); - if (cr.bottom > fold) out.push({ rule: 'hero-fold', grade: 'fail', evidence: 'the primary action sits below the 1280x800 fold (its bottom is at ' + Math.round(cr.bottom) + 'px)', fix: 'trim the hero padding so the action fits the fold', at: at(cta) }); + if (cr.bottom > fold) out.push({ rule: 'hero-fold', grade: 'fail', evidence: 'the primary action sits below the ' + view + ' fold (its bottom is at ' + Math.round(cr.bottom) + 'px)', fix: 'trim the hero padding so the action fits the fold', at: at(cta) }); } } } @@ -293,7 +293,7 @@ export function glAccent(P: Probes): ProbeFinding[] { const targets = ['--color-accent', '--color-accent-2', '--accent', '--accent-2'].map((t) => toRgb(cs0.getPropertyValue(t))).filter((c): c is number[] => !!c); if (!targets.length) return out; const near = (str: string) => { const p = toRgb(str); return !!p && p[3] >= 0.5 && targets.some((t) => Math.abs(t[0] - p[0]) + Math.abs(t[1] - p[1]) + Math.abs(t[2] - p[2]) <= 30); }; - const CW = 16, COLS = Math.ceil(innerWidth / CW), ROWS = Math.ceil(800 / CW); + const CW = 16, COLS = Math.ceil(innerWidth / CW), ROWS = Math.ceil(innerHeight / CW), view = innerWidth + 'x' + innerHeight; const grid = new Uint8Array(COLS * ROWS); let walked = 0; for (const el of document.querySelectorAll('body, body *')) { @@ -305,7 +305,7 @@ export function glAccent(P: Probes): ProbeFinding[] { if (!isBg && !isTxt) continue; const r = el.getBoundingClientRect(); const x0 = Math.max(0, Math.floor(r.left / CW)), x1 = Math.min(COLS - 1, Math.floor((Math.min(r.right, innerWidth) - 1) / CW)); - const y0 = Math.max(0, Math.floor(r.top / CW)), y1 = Math.min(ROWS - 1, Math.floor((Math.min(r.bottom, 800) - 1) / CW)); + const y0 = Math.max(0, Math.floor(r.top / CW)), y1 = Math.min(ROWS - 1, Math.floor((Math.min(r.bottom, innerHeight) - 1) / CW)); const turned = cs.transform !== 'none' && !/^matrix\(1, 0, 0, 1, /.test(cs.transform); for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) { if (turned) { const hit = document.elementFromPoint(x * CW + CW / 2, y * CW + CW / 2); if (!hit || !(hit === el || el.contains(hit))) continue; } @@ -315,8 +315,8 @@ export function glAccent(P: Probes): ProbeFinding[] { let filled = 0; for (const c of grid) filled += c; const pct = (100 * filled) / (COLS * ROWS); - if (pct > 8) out.push({ rule: 'accent-footprint', grade: 'fail', evidence: 'the accent paints about ' + pct.toFixed(1) + '% of the 1280x800 viewport (over 8%)', fix: 'retreat the accent to 5% or less: large colour belongs to the paper and field tokens' }); - else if (pct > 5) out.push({ rule: 'accent-footprint', grade: 'warn', evidence: 'the accent paints about ' + pct.toFixed(1) + '% of the 1280x800 viewport', fix: 'retreat the accent to 5% or less, or keep the surface on the record' }); + if (pct > 8) out.push({ rule: 'accent-footprint', grade: 'fail', evidence: 'the accent paints about ' + pct.toFixed(1) + '% of the ' + view + ' viewport (over 8%)', fix: 'retreat the accent to 5% or less: large colour belongs to the paper and field tokens' }); + else if (pct > 5) out.push({ rule: 'accent-footprint', grade: 'warn', evidence: 'the accent paints about ' + pct.toFixed(1) + '% of the ' + view + ' viewport', fix: 'retreat the accent to 5% or less, or keep the surface on the record' }); return out; } diff --git a/lib/design-lint/report.ts b/lib/design-lint/report.ts index 2e997f332..59c12ace3 100644 --- a/lib/design-lint/report.ts +++ b/lib/design-lint/report.ts @@ -49,18 +49,21 @@ export class Reporter { constructor(readonly scope: 'page' | 'component' = 'page', readonly cwd = process.cwd()) {} - report(rule: string, file: string, line: number | null | undefined, evidence: string, opts: ReportOptions = {}): void { + /** Records one finding and returns it; null when the rule is page-only and the scope is component. */ + report(rule: string, file: string, line: number | null | undefined, evidence: string, opts: ReportOptions = {}): Finding | null { const meta = ruleMeta(rule); if (!meta) throw new Error(`design-lint: unknown rule id ${rule}`); - if (this.scope === 'component' && meta.pageOnly) return; + if (this.scope === 'component' && meta.pageOnly) return null; const tier: Tier = opts.suspected && meta.tier === 'blocks' ? 'ask' : meta.tier; - this.findings.push({ + const finding: Finding = { rule, tier, grade: tier, file: oneLine(relPath(file, this.cwd)), line: line || 1, evidence: trunc(field(evidence)), fix: trunc(field(opts.fix ?? meta.fix), 240), source: opts.source ?? (meta.render ? 'render' : 'static'), - }); + }; + this.findings.push(finding); + return finding; } } diff --git a/lib/design-lint/rules/assets.ts b/lib/design-lint/rules/assets.ts index 5385c00f2..262f32d05 100644 --- a/lib/design-lint/rules/assets.ts +++ b/lib/design-lint/rules/assets.ts @@ -20,13 +20,13 @@ function checkEmojiIcons(ctx: LintContext, r: Reporter): void { for (const doc of ctx.docs) { const lines = new Set(); let n = 0; - const icons = (doc.cache.emojiIcons ??= new Map()) as Map; - const flag = (line: number, what: string): boolean => { - if (lines.has(line) || n >= 8) return false; + const quoted = (doc.cache.emojiQuoted ??= new Map()) as Map; + const flag = (line: number, what: string, key?: string): void => { + if (lines.has(line) || n >= 8) return; lines.add(line); n++; - r.report('emoji-decoration', doc.file, line, what); - return true; + const found = r.report('emoji-decoration', doc.file, line, what); + if (key && found) { const list = quoted.get(key); if (list) list.push(found); else quoted.set(key, [found]); } }; for (let i = 0; i < doc.tags.length; i++) { const t = doc.tags[i]; @@ -35,9 +35,9 @@ function checkEmojiIcons(ctx: LintContext, r: Reporter): void { const iconish = /icon|card|step|tier|feature|value|prop|badge/i.test(cls); const inner = innerOf(doc, i); if (!inner) continue; - const content = stripTags(inner.inner).replace(/\s+/g, ' ').trim(); + const content = stripTags(inner.inner, false, 4).replace(/\s+/g, ' ').trim(); if (content && content.length <= 4 && EMOJI.test(content) && (iconish || !/<[a-z]/.test(inner.inner))) { - if (flag(t.line, 'an emoji icon "' + content + '" in <' + t.name + ' class="' + trunc(cls, 28) + '">')) icons.set(content, r.findings[r.findings.length - 1]); + flag(t.line, 'an emoji icon "' + content + '" in <' + t.name + ' class="' + trunc(cls, 28) + '">', content); lines.add(doc.lineOf(t.end + inner.inner.search(EMOJI))); continue; } @@ -45,12 +45,12 @@ function checkEmojiIcons(ctx: LintContext, r: Reporter): void { 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) + '"'); + flag(doc.lineOf(t.end + at), 'an emoji in <' + t.name + '> "' + trunc(text, 32) + '"', trunc(text, 32)); } const raw = doc.raw; const body = doc.masked; - for (const m of body.matchAll(/>\s*((?:\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F)[^<>{}\n]{0,40})/gu)) flag(doc.lineOf(m.index! + m[0].length - m[1].length), 'text opens with an emoji: "' + trunc(m[1].trim(), 32) + '"'); - for (const m of body.matchAll(/placeholder\s*=\s*["']([^"']*)["']/g)) if (GLYPH.test(m[1])) flag(doc.lineOf(m.index!), 'an emoji in a placeholder "' + trunc(m[1], 28) + '"'); + for (const m of body.matchAll(/>\s*((?:\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F)[^<>{}\n]{0,40})/gu)) flag(doc.lineOf(m.index! + m[0].length - m[1].length), 'text opens with an emoji: "' + trunc(m[1].trim(), 32) + '"', trunc(m[1].trim(), 32)); + for (const m of body.matchAll(/placeholder\s*=\s*["']([^"']*)["']/g)) if (GLYPH.test(m[1])) flag(doc.lineOf(m.index!), 'an emoji in a placeholder "' + trunc(m[1], 28) + '"', trunc(m[1], 28)); if (doc.kind !== 'html') for (const m of raw.matchAll(/(["'`])\s*((?:(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F?)\u200D?){1,3})\s*\1/gu)) flag(doc.lineOf(m.index!), 'an emoji passed as an icon: ' + m[2]); } } @@ -207,7 +207,7 @@ function checkWrappingLabels(ctx: LintContext, r: Reporter): void { if (!button && !link) continue; const inner = innerOf(doc, i); if (!inner) continue; - const label = stripTags(inner.inner).replace(/\s+/g, ' ').trim(); + const label = stripTags(inner.inner, false, 40).replace(/\s+/g, ' ').trim(); if (label.length <= 24 || covered(cls, t.name)) continue; if (button) r.report('button-label-wraps', doc.file, t.line, 'the long label "' + trunc(label, 36) + '" on <' + t.name + '> can wrap, and nothing sets nowrap', { suspected: true }); else r.report('link-wraps', doc.file, t.line, 'the long link "' + trunc(label, 36) + '" can wrap, and nothing sets nowrap'); diff --git a/lib/design-lint/rules/component.ts b/lib/design-lint/rules/component.ts index c9b0ceae8..0d3127c8c 100644 --- a/lib/design-lint/rules/component.ts +++ b/lib/design-lint/rules/component.ts @@ -103,9 +103,9 @@ function checkComponentText(ctx: LintContext, r: Reporter): void { if (/(?:link|button|btn|cta)$/i.test(t.name) && /(→|->|›|»|⟶)\s*$/.test(text)) r.report('template-punctuation', doc.file, t.line, `arrow glued to the label "${trunc(text, 40)}" (<${t.name}>)`, { fix: 'a verb as the label; the link already says it goes somewhere' }); if ((text.match(/\s[·•]\s/g) || []).length >= 2) r.report('template-punctuation', doc.file, t.line, `middle-dot meta string "${trunc(text, 50)}"`, { fix: 'line breaks, columns or hairlines; one dot in a byline at most' }); if (text.length <= 4 && EMOJI_ICON.test(text)) { - const icon = (doc.cache.emojiIcons as Map | undefined)?.get(text); - const outer = icon ? r.findings.indexOf(icon) : -1; - if (outer >= 0) r.findings.splice(outer, 1); + const recorded = (doc.cache.emojiQuoted as Map | undefined)?.get(text) ?? []; + const icon = recorded.find((f) => f.line === t.line) ?? recorded[0]; + if (icon) { recorded.splice(recorded.indexOf(icon), 1); const outer = r.findings.indexOf(icon); if (outer >= 0) r.findings.splice(outer, 1); } r.report('emoji-decoration', doc.file, t.line, `emoji icon "${text}" in <${t.name}>`, { fix: 'one icon library or custom SVG' }); } } diff --git a/lib/design-lint/rules/structure.ts b/lib/design-lint/rules/structure.ts index a5675af7b..ad4f21fc5 100644 --- a/lib/design-lint/rules/structure.ts +++ b/lib/design-lint/rules/structure.ts @@ -353,7 +353,7 @@ function columnCount(value: string): number | null { if (/auto-fi(t|ll)|var\(/.test(value)) return null; let repeated = 0; const v = value.replace(/repeat\(\s*(\d+)\s*,\s*((?:[^()]|\([^()]*\))*)\)/g, (_, count: string, tracks: string) => { - repeated += parseInt(count, 10) * tracks.replace(/\([^()]*\)/g, 'X').trim().split(/\s+/).length; + repeated += parseInt(count, 10) * tracks.replace(/\([^()]*\)/g, 'X').replace(/\[[^\]]*\]/g, ' ').trim().split(/\s+/).filter(Boolean).length; return ' '; }); if (/repeat\(/.test(v)) return null; diff --git a/lib/design-lint/static.ts b/lib/design-lint/static.ts index b6a722431..6ff310957 100644 --- a/lib/design-lint/static.ts +++ b/lib/design-lint/static.ts @@ -31,7 +31,7 @@ export interface StaticResult { } /** 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 Skipped { file: string; kind: 'unreadable' | 'unparseable' | 'oversize'; why: string } /** 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. */ export interface RawStaticResult { @@ -54,7 +54,7 @@ export function collectFiles(paths: string[], warn: (l: string) => void = () => 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}`); if (top) skipped.push({ file: abs, why: 'it cannot be read' }); return; } + try { abs = fs.realpathSync(abs); st = fs.statSync(abs); } catch { warn(`design-lint: cannot read ${p}`); if (top) skipped.push({ file: abs, kind: 'unreadable', why: 'it cannot be read' }); return; } if (seen.has(abs)) return; if (st.isDirectory()) { seen.add(abs); @@ -68,7 +68,7 @@ export function collectFiles(paths: string[], warn: (l: string) => void = () => 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 }); + skipped.push({ file: abs, kind: 'oversize', why }); return; } files.push(abs); @@ -130,17 +130,18 @@ 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(); - constructor(readonly roots: string[], readonly warn: (l: string) => void) {} + constructor(readonly roots: string[], readonly warn: (l: string) => void, readonly skipped: Skipped[] = []) {} 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; } + if (size > MAX_FILE_BYTES) { const why = `${Math.round(size / 1024 / 1024)} MB, over the ${MAX_FILE_BYTES / 1024 / 1024} MB limit`; this.warn(`design-lint: skipped ${f} (${why})`); this.skipped.push({ file: f, kind: 'oversize', why }); 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) { - this.warn((e as NodeJS.ErrnoException).code === 'ENOENT' ? `design-lint: skipped ${f}, not found` : `design-lint: skipped ${f}, it could not be parsed (${(e as Error).message})`); + if ((e as NodeJS.ErrnoException).code === 'ENOENT') this.warn(`design-lint: skipped ${f}, not found`); + else { const why = `it could not be parsed (${(e as Error).message})`; this.warn(`design-lint: skipped ${f}, ${why}`); this.skipped.push({ file: f, kind: 'unparseable', why }); } } this.files.set(f, out); return out; @@ -174,7 +175,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) { - try { for (const rule of parseCss(st.css, doc.file, st.lineOffset)) rules.push(rule); } catch (e) { cache.warn(`design-lint: skipped a