/** * The static tier: collect files, group them into contexts (a page with the * stylesheets it links, or a directory of components), build the token table, * run every rule family, then apply the page's waivers. * * Ported from Nutlope/hallmark's sloplint.mjs (collectFiles, buildContext, * shareProjects; MIT, see NOTICE.md). */ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { LintContext, SharedStyles, CssFile } from './context'; import { buildTokens, isTokenRule, parseCss, stripComments, type CssRule, type Tokens } from './css'; import { docKindOf, getAttr, parseDoc, type Doc } from './html'; import { oneLine, relPath, Reporter, type Finding } from './report'; import { FAMILIES } from './rules'; import { setTwTokens, twVariants } from './tailwind'; import { applyWaivers, findWaivers, resolveWaivers, suspectedRules, type WaiverSet } from './waivers'; export interface StaticOptions { scope?: 'page' | 'component'; job?: string | null; cwd?: string; /** Receives a line for every file skipped or rule that threw; defaults to stderr. */ warn?: (line: string) => void; } export interface StaticResult { files: string[]; contexts: LintContext[]; findings: Finding[]; } /** 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; 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 { files: string[]; contexts: LintContext[]; raw: Finding[][]; /** One internal-error finding per skipped source file. */ skipped: Finding[]; } const BUILD_DIRS = new Set(['dist', 'build', 'coverage']); /** A source file past this size is a bundle or a generated artefact, not a page to read; it is skipped with a warning so the lint answers in bounded time. */ 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 = () => {}, skipped: Skipped[] = []): string[] { const files: string[] = []; const seen = new Set(); 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, kind: 'unreadable', why: 'it cannot be read' }); return; } if (seen.has(abs)) return; if (st.isDirectory()) { seen.add(abs); const base = path.basename(abs); if (base === 'node_modules' || base.startsWith('.') || (!top && BUILD_DIRS.has(base))) return; for (const e of fs.readdirSync(abs).sort()) visit(path.join(abs, e), false); return; } if (path.extname(abs).toLowerCase() !== '.css' && !docKindOf(abs)) return; seen.add(abs); 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, kind: 'oversize', why }); return; } files.push(abs); }; for (const p of paths) visit(p, true); return files; } /** * A local stylesheet reference as a path: relative to the referring file, or * for a root-relative href (`/css/site.css`, the common form on a real site) * relative to the lint root the page was found under. Schemes and * protocol-relative URLs are not local. */ export function resolveRef(href: string | null, from: string, root: string | null, bound: string | null = null): string | null { 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); const inside = (abs: string, top: string) => { if (!within(abs, top)) return false; try { return within(fs.realpathSync(abs), realDir(top)); } catch { return true; } }; if (ref.startsWith('/')) { if (!root) return null; const abs = path.join(root, ref); return inside(abs, root) ? abs : null; } const abs = path.resolve(from, ref); const top = projectRoot(from) ?? bound; return top && !inside(abs, top) ? null : abs; } export function importsOf(css: string, from: string, root: string | null = null, bound: string | null = null): string[] { const out: string[] = []; for (const m of stripComments(css).matchAll(/@import\s+(?:url\(\s*)?["']?([^"')\s;]+)["']?\s*\)?/gi)) { const ref = resolveRef(m[1], from, root, bound); if (ref) out.push(ref); } return out; } export function linksOf(doc: Doc, from: string, root: string | null = null, bound: string | null = null): string[] { const out: string[] = []; for (const t of doc.tags) { if (t.close || t.name !== 'link' || !/stylesheet/i.test(getAttr(t.attrs, 'rel') || '')) continue; const ref = resolveRef(getAttr(t.attrs, 'href'), from, root, bound); if (ref) out.push(ref); } return out; } /** The lint root a file was collected under: the longest top-level directory that contains it, else its own directory. */ const rootOf = (file: string, roots: string[]): string => roots.filter((r) => file.startsWith(r + path.sep)).sort((a, b) => b.length - a.length)[0] ?? path.dirname(file); 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, readonly skipped: Skipped[] = [], readonly bound: string | null = null) {} 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) { 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), this.bound) }; } catch (e) { const code = (e as NodeJS.ErrnoException).code; if (code === 'ENOENT') this.warn(`design-lint: skipped ${f}, not found`); else { const why = code ? `it cannot be read (${code})` : `it could not be parsed (${(e as Error).message})`; this.warn(`design-lint: skipped ${f}, ${why}`); this.skipped.push({ file: f, kind: code ? 'unreadable' : 'unparseable', why }); } } this.files.set(f, out); return out; } } /** Follow @import and from the group's files to every local .css they reach. */ function attachStylesheets(cssFiles: CssFile[], rules: CssRule[], pending: string[], inGroup: Set, cache: CssCache): void { while (pending.length) { const p = pending.shift()!; if (inGroup.has(p) || path.extname(p).toLowerCase() !== '.css') continue; inGroup.add(p); const got = cache.read(p); if (!got) continue; cssFiles.push({ file: p, raw: got.raw }); for (const rule of got.rules) rules.push(rule); pending.push(...got.imports); } } export function buildContext(key: string, cssList: string[], docList: Doc[], opts: StaticOptions, cache: CssCache): LintContext { const cssFiles: CssFile[] = []; const rules: CssRule[] = []; const inGroup = new Set([...cssList, ...docList.map((d) => d.file)]); const pending: string[] = []; for (const f of cssList) { const got = cache.read(f); cssFiles.push({ file: f, raw: got?.raw ?? '' }); if (got) { for (const rule of got.rules) rules.push(rule); pending.push(...got.imports); } } 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