design-lint: ninth review cycle fixes

nestSelector stops expanding a comma list nested in a comma list past an
8 KB selector, resolveVars never substitutes a token over MAX_RESOLVED and
stops growing a value past it, a stylesheet or <style> block the lint could
not parse (or a linked sheet over the size cap) is an internal-error finding
through the Skipped list, which now carries a kind; stripTags takes a visible
character limit so the emoji and label reads do not pay for an element that
runs to the end of the file; assets.ts records every quoted emoji finding
and component.ts splices the one at its line; columnCount drops named lines
inside repeat(); doc.text is built from the stray-blanked source;
Reporter.report returns the finding; the 1280 probes read their viewport
from the page; openTags loses its unused limit; the CHANGELOG hero-fold note
matches what the probe measures.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 12:19:29 +00:00
1 parent d4bb11356f
commit b101f64951
10 files changed
+64 -51

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@
- **`bin/gstack-design-lint <file-or-dir ...> [--render] [--url] [--scope page|component] [--job <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 `<link>` and `@import`, resolves CSS variables, converts colours to OKLCH and computes contrast, and prints one line per finding: `LINT: <blocks|ask|polish|waived> <rule-id> <file>:<line> <evidence> | fix: <fix>`, then `LINT_SUMMARY: blocks=N ask=N waived=N polish=N render=<ran|skipped: reason>`. 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.** `<!-- gstack-lint-disable <rule-id>: <reason> -->` (or the `/* */` and `//` comment forms; `impeccable-disable <rule>: <reason>` 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: <reason>` 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: <reason>` 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.
+10 -4
View File
@@ -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<string> = new Set()): string {
if (!value || depth > 5 || value.length > MAX_RESOLVED || !value.includes('var(')) return value;
const seen = new Set<string>();
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;
+10 -8
View File
@@ -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 `<!` or `<?` declaration with 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(/&quot;|&#0?34;/g, '"').replace(/&#0?39;|&apos;/g, "'")
.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, '>')
@@ -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);
+7 -7
View File
@@ -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;
}
+7 -4
View File
@@ -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;
}
}
+11 -11
View File
@@ -20,13 +20,13 @@ function checkEmojiIcons(ctx: LintContext, r: Reporter): void {
for (const doc of ctx.docs) {
const lines = new Set<number>();
let n = 0;
const icons = (doc.cache.emojiIcons ??= new Map<string, Finding>()) as Map<string, Finding>;
const flag = (line: number, what: string): boolean => {
if (lines.has(line) || n >= 8) return false;
const quoted = (doc.cache.emojiQuoted ??= new Map<string, Finding[]>()) as Map<string, Finding[]>;
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');
+3 -3
View File
@@ -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<string, Finding> | 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<string, Finding[]> | 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' });
}
}
+1 -1
View File
@@ -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;
+13 -11
View File
@@ -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<string, CssRead | null>();
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 <style> block in ${doc.file}, it could not be parsed (${(e as Error).message})`); continue; }
try { for (const rule of parseCss(st.css, doc.file, st.lineOffset)) rules.push(rule); } catch (e) { cache.warn(`design-lint: skipped a <style> block in ${doc.file}, it could not be parsed (${(e as Error).message})`); cache.skipped.push({ file: doc.file, kind: 'unparseable', why: `a <style> block 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));
@@ -211,7 +212,7 @@ function linkedCss(page: Doc, cache: CssCache): Set<string> {
* 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[] = [], skipped: Skipped[] = []): LintContext[] {
const cache = new CssCache(roots, warn);
const cache = new CssCache(roots, warn, skipped);
const groups = new Map<string, { css: string[]; pages: Doc[]; parts: Doc[] }>();
for (const f of files) {
const dir = path.dirname(f);
@@ -222,9 +223,10 @@ export function groupContexts(files: string[], opts: StaticOptions, warn: (l: st
const doc = parseDoc(f, fs.readFileSync(f, 'utf8'));
(doc.kind === 'html' ? g.pages : g.parts).push(doc);
} catch (e) {
const why = (e as NodeJS.ErrnoException).code ? `it cannot be read (${(e as NodeJS.ErrnoException).code})` : `it could not be parsed (${(e as Error).message})`;
const kind = (e as NodeJS.ErrnoException).code ? 'unreadable' : 'unparseable';
const why = kind === 'unreadable' ? `it cannot be read (${(e as NodeJS.ErrnoException).code})` : `it could not be parsed (${(e as Error).message})`;
warn(`design-lint: skipped ${f}, ${why}`);
skipped.push({ file: f, why });
skipped.push({ file: f, kind, why });
}
}
const linkedBy = new Map<Doc, Set<string>>();
@@ -336,7 +338,7 @@ export function lintStaticRaw(paths: string[], opts: StaticOptions = {}): RawSta
for (const doc of ctx.docs) doc.cache = {};
}
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: /cannot be read/.test(s.why) ? 'check the path' : /parsed/.test(s.why) ? 'fix the markup the parser stopped on' : 'split the file, or lint the page that links it' });
for (const s of new Map(skippedFiles.map((s) => [s.file + '|' + s.why, s])).values()) skippedReporter.report('internal-error', s.file, 1, `skipped: ${s.why}; a page the lint could not read is not a pass`, { fix: s.kind === 'unreadable' ? 'check the path' : s.kind === 'unparseable' ? 'fix the markup the parser stopped on' : 'split the file, or lint the page that links it' });
return { files, contexts, raw, skipped: skippedReporter.findings };
}
+1 -1
View File
@@ -373,7 +373,7 @@ export function twTokensOf(doc: Doc, t: Tag): string[] {
if (q === '"' || q === "'") { const e = tag.indexOf(q, i + 1); val = tag.slice(i + 1, e < 0 ? tag.length : e); }
else if (q === '{') { val = tag.slice(i + 1, braceEnd(tag, i)); dyn = true; }
else val = (/^[^\s>]*/.exec(tag.slice(i)) || [''])[0];
for (const t of dyn ? clsLits(val) : val.includes('{') ? twInterp(val) : val.split(/\s+/)) out.push(t);
for (const tok of dyn ? clsLits(val) : val.includes('{') ? twInterp(val) : val.split(/\s+/)) out.push(tok);
}
for (const d of tag.matchAll(/(?:^|\s)class:(?!list\b)([\w-]+)/g)) out.push(d[1]);
return out.filter(Boolean);