Merge remote-tracking branch 'origin/main' into outside-reviews-end-with-a-verdict-line

This commit is contained in:
garrytan committed 2026-11-07 20:18:05 +00:00
commit f8914e9c2e
34 files changed
+2542 -13

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
/**
* The --scope app half of bin/gstack-design-lint: validates every input before
* a browser starts (exit 3 with a code and a fix line), runs the app
* scheduler and prints its contract. Kept apart from the bin so the contract
* tests can inject a runner instead of a browser.
*/
import * as fs from 'node:fs';
import { AppUsageError, checkSelector, parseStateFile, parseViewport, parseWaiverFile, redactUrl, stateNameOf, type Viewport } from './app-input';
import { formatAppLines, lintApp, type AppOptions, type AppState } from './render-app';
import { formatFinding, formatSummary } from './report';
import { ruleMeta } from './rules/ids';
export interface AppCliArgs {
paths: string[];
urls: string[];
within?: string;
viewports: string[];
waitFor?: string;
waivers?: string;
stateFile?: string;
verbose: boolean;
json: boolean;
}
export interface Io { out: (s: string) => void; err: (s: string) => void }
export type AppDeps = Pick<AppOptions, 'engine' | 'run' | 'reach' | 'traceDir' | 'readyMs' | 'sectionWaitMs'>;
export const usageLine = (e: AppUsageError): string => `gstack-design-lint: ${e.code}: ${e.message} | fix: ${e.fix}`;
/** The stderr line for a run that was asked to measure URLs and measured nothing because no browser was available; null otherwise. */
export function renderUnavailable(urls: string[], status: string): string | null {
if (!urls.length || !/^skipped: no browser available/.test(status)) return null;
return `gstack-design-lint: RENDER_UNAVAILABLE: nothing was measured at ${urls.map(redactUrl).join(', ')} (${status.slice('skipped: '.length)}) | fix: gstack-browser-ensure, then rerun`;
}
const read = (flag: string, file: string, code: string): string => {
try { return fs.readFileSync(file, 'utf8'); } catch (e) { throw new AppUsageError(code, `${flag} ${file} could not be read: ${(e as Error).message}`, `pass a readable JSON file to ${flag}`); }
};
/** Every input settled before work: states, within, viewports, wait-for and waivers. */
export function planApp(a: AppCliArgs): { states: AppState[]; notes: string[]; within: string; viewports: Viewport[]; waivers: AppOptions['waivers'] } {
if (a.paths.length) throw new AppUsageError('APP_PATHS', `--scope app measures URLs, not files (got ${a.paths[0]})`, 'pass --url <http://…> or --state-file <states.json>, or lint files without --scope app');
if (!a.within) throw new AppUsageError('NO_WITHIN', '--scope app needs --within <selector>', 'pass --within with the selector of the section you are building');
checkSelector('--within', a.within);
if (a.waitFor !== undefined) checkSelector('--wait-for', a.waitFor);
if (a.urls.length && a.stateFile) throw new AppUsageError('BOTH_TARGETS', '--url and --state-file are two ways to name the states; pass one', 'put every URL in the state file, or drop it');
if (!a.urls.length && !a.stateFile) throw new AppUsageError('NO_TARGET', '--scope app needs --url <url> or --state-file <states.json>', 'pass the URL of each state to measure');
const bad = a.urls.find((u) => !/^https?:\/\/[^/]/.test(u));
if (bad) throw new AppUsageError('BAD_URL', `--url ${redactUrl(bad)} is not an http or https address`, 'pass the dev server URL, like http://127.0.0.1:5300/p');
const viewports = a.viewports.map(parseViewport);
const twice = a.urls.find((u, i) => a.urls.indexOf(u) !== i);
if (twice) throw new AppUsageError('DUPLICATE_STATE', `--url ${redactUrl(twice)} is given twice`, 'measure each URL once');
const used = new Set<string>();
const parsed = a.stateFile ? parseStateFile(read('--state-file', a.stateFile, 'BAD_STATE_FILE')) : {
states: a.urls.map((url) => { const base = stateNameOf(url); let name = base; for (let n = 2; used.has(name); n++) name = `${base}~${n}`; used.add(name); return { name, url }; }),
notes: [],
};
const waivers = a.waivers ? parseWaiverFile(read('--waivers', a.waivers, 'BAD_WAIVERS'), (id) => !!ruleMeta(id)?.app) : [];
return { states: parsed.states, notes: parsed.notes, within: a.within, viewports, waivers };
}
export async function appMain(a: AppCliArgs, io: Io, deps: AppDeps = {}): Promise<number> {
let plan: ReturnType<typeof planApp>;
try { plan = planApp(a); } catch (e) {
if (!(e instanceof AppUsageError)) throw e;
io.err(usageLine(e));
return 3;
}
const r = await lintApp({ states: plan.states, within: plan.within, viewports: plan.viewports, waitFor: a.waitFor, waivers: plan.waivers, ...deps });
r.app.notes.unshift(...plan.notes);
for (const line of r.stderr) io.err(line);
if (a.verbose) for (const c of r.app.states) if (c.trace) { try { io.err(`gstack-design-lint: trace ${c.trace}:\n${fs.readFileSync(c.trace, 'utf8')}`); } catch (e) { io.err(`gstack-design-lint: trace ${c.trace} could not be read: ${(e as Error).message}`); } }
const missing = renderUnavailable(plan.states.map((s) => s.url), r.summary.render);
if (missing) io.err(missing);
if (a.json) { io.out(JSON.stringify({ summary: r.summary, files: 0, findings: r.findings, app: r.app }, null, 2)); return r.exitCode; }
const lines = formatAppLines(r.app);
io.out([...r.findings.map(formatFinding), ...r.app.notes.map((n) => `LINT_APP_NOTE: ${n}`), ...lines.states, formatSummary(r.summary), lines.verdict].join('\n'));
return r.exitCode;
}
+193
View File
@@ -0,0 +1,193 @@
/**
* What `gstack-design-lint --scope app` reads before any page opens: the
* viewports, the --within selector, a --state-file and a --waivers file, plus
* the one redaction every URL goes through on its way to a person (text,
* JSON, stderr, traces). Every input error is an AppUsageError: exit 3, a
* code, and a fix line, raised before a browser starts.
*/
export class AppUsageError extends Error {
constructor(readonly code: string, message: string, readonly fix: string) {
super(message);
}
}
export interface Viewport { width: number; height: number }
export const DEFAULT_VIEWPORTS: Viewport[] = [{ width: 1440, height: 900 }, { width: 375, height: 812 }];
export const viewportLabel = (v: Viewport): string => `${v.width}x${v.height}`;
export function parseViewport(spec: string): Viewport {
const m = /^(\d{3,4})x(\d{3,4})$/.exec(spec);
const width = m ? Number(m[1]) : NaN;
const height = m ? Number(m[2]) : NaN;
if (!m || width < 320 || width > 3840 || height < 480 || height > 2400) {
throw new AppUsageError('BAD_VIEWPORT', `--viewport ${JSON.stringify(spec)} is not <width>x<height> with width 320 to 3840 and height 480 to 2400`, 'pass a size like 1440x900 or 375x812');
}
return { width, height };
}
const SECRET_PARAM = /token|key|secret|auth|session|sig|signature|password|pass|code|jwt|bearer/i;
/** Parameters with every secret-looking value replaced with REDACTED, or null when none looks secret. */
function redactParams(text: string): string | null {
const params = new URLSearchParams(text);
if (![...params.keys()].some((k) => SECRET_PARAM.test(k))) return null;
return new URLSearchParams([...params.entries()].map(([k, v]) => [k, SECRET_PARAM.test(k) ? 'REDACTED' : v])).toString();
}
/** A URL fit to print: userinfo dropped, secret-looking query and fragment parameter values replaced with REDACTED. Navigation always uses the real URL. */
export function redactUrl(raw: string): string {
let u: URL;
try { u = new URL(raw); } catch { return raw.replace(/\/\/[^/@\s]*@/, '//'); }
u.username = '';
u.password = '';
const search = redactParams(u.search.slice(1));
if (search !== null) u.search = search;
const hash = u.hash.includes('=') ? redactParams(u.hash.slice(1)) : null;
if (hash !== null) u.hash = hash;
return u.toString();
}
/** Every http(s) URL inside free text (an error message, a stack) redacted. */
export const redactText = (s: string): string => s.replace(/https?:\/\/[^\s"'<>)]+/g, (u) => redactUrl(u));
/** A state's name when only its URL is given: its path and query, redacted. */
export function stateNameOf(url: string): string {
try {
const u = new URL(redactUrl(url));
return u.pathname + u.search;
} catch {
return redactUrl(url);
}
}
const QUOTED = /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g;
/**
* Why a selector is malformed, or null, without a browser: balanced brackets,
* parentheses and quotes, a name after every . # and :, and a compound on
* both sides of every combinator and comma. Conservative: it can accept a
* selector the browser refuses (the probe then reports PROBE_ERROR), never
* the other way round.
*/
export function selectorError(sel: string): string | null {
const s = sel.trim();
if (!s) return 'the selector is empty';
if (s.length > 1000) return 'the selector is over 1000 characters';
if (/[{};]/.test(s.replace(QUOTED, ''))) return 'braces and semicolons belong to CSS rules, not a selector';
const stack: string[] = [];
let compound = false;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (c === '\\') { i++; compound = true; continue; }
if (c === '"' || c === "'") {
if (!stack.length) return `a quote outside [] or () at ${i + 1}`;
let end = i + 1;
while (end < s.length && s[end] !== c) end += s[end] === '\\' ? 2 : 1;
if (end >= s.length) return 'an unclosed quote';
i = end;
continue;
}
if (c === '[' || c === '(') { stack.push(c); compound = true; continue; }
if (c === ']' || c === ')') {
if (stack.pop() !== (c === ']' ? '[' : '(')) return `an unbalanced ${c} at ${i + 1}`;
continue;
}
if (stack.length || /\s/.test(c)) continue;
if (c === ',' || c === '>' || c === '+' || c === '~') {
if (!compound) return `nothing before ${c} at ${i + 1}`;
compound = false;
continue;
}
if (!/[\w\-.#:*|\u00a0-\uffff]/.test(c)) return `${c} cannot appear in a selector (at ${i + 1})`;
if ((c === '.' || c === '#') && !/[\w\-\\\u00a0-\uffff]/.test(s[i + 1] ?? '')) return `a ${c} with no name after it (at ${i + 1})`;
if (c === ':' && !/[\w\-:]/.test(s[i + 1] ?? '')) return `a : with no pseudo-class after it (at ${i + 1})`;
compound = true;
}
if (stack.length) return `an unclosed ${stack[stack.length - 1]}`;
if (!compound) return 'the selector ends in a combinator or a comma';
return null;
}
export function checkSelector(flag: string, sel: string): void {
const why = selectorError(sel);
if (why) throw new AppUsageError('BAD_SELECTOR', `${flag} ${JSON.stringify(sel)} is not a valid selector: ${why}`, "pass a CSS selector the page's querySelectorAll accepts, like main .access or my-panel");
}
export interface AppStateInput {
name: string;
url: string;
expect?: string;
within?: string;
}
const isLoopback = (url: string): boolean => {
try { return /^(127\.\d+\.\d+\.\d+|localhost|\[::1\])$/.test(new URL(url).hostname); } catch { return false; }
};
const STATE_KEYS = new Set(['name', 'url', 'expect', 'within']);
type Bad = (why: string) => AppUsageError;
/** The versioned top level shared by --state-file and --waivers: an object with exactly version 1 and one list. */
function versioned(text: string, list: string, bad: Bad): unknown[] {
let doc: unknown;
try { doc = JSON.parse(text); } catch (e) { throw bad(`not JSON (${(e as Error).message})`); }
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) throw bad('the top level is not an object');
const top = doc as Record<string, unknown>;
const extra = Object.keys(top).find((k) => k !== 'version' && k !== list);
if (extra) throw bad(`unknown key ${extra}`);
if (top.version !== 1) throw bad('version must be 1');
if (!Array.isArray(top[list])) throw bad(`${list} must be a list`);
return top[list] as unknown[];
}
function entry(raw: unknown, where: string, keys: Set<string>, bad: Bad): Record<string, unknown> {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw bad(`${where} is not an object`);
const unknown = Object.keys(raw).find((k) => !keys.has(k));
if (unknown) throw bad(`${where} has unknown key ${unknown}`);
return raw as Record<string, unknown>;
}
/** A --state-file: `{"version":1,"states":[{"name","url","expect"?,"within"?}]}`. Returns the states and notes (a non-loopback URL is allowed, and noted). */
export function parseStateFile(text: string): { states: AppStateInput[]; notes: string[] } {
const bad: Bad = (why) => new AppUsageError('BAD_STATE_FILE', `--state-file: ${why}`, 'write {"version":1,"states":[{"name":"owner","url":"http://127.0.0.1:5300/p?as=owner","expect":"<selector that proves the state>"}]}');
const list = versioned(text, 'states', bad);
if (!list.length) throw bad('states is empty');
const notes: string[] = [];
const names = new Set<string>();
const states = list.map((raw, i): AppStateInput => {
const st = entry(raw, `states[${i}]`, STATE_KEYS, bad);
if (typeof st.url !== 'string' || !st.url) throw bad(`states[${i}] has no url`);
if (!/^https?:\/\/[^/]/.test(st.url)) throw bad(`states[${i}].url is not an http or https address`);
for (const k of ['name', 'expect', 'within']) if (st[k] !== undefined && (typeof st[k] !== 'string' || !st[k])) throw bad(`states[${i}].${k} must be a non-empty string`);
const name = typeof st.name === 'string' ? st.name : stateNameOf(st.url);
if (names.has(name)) throw bad(`two states are named ${name}`);
names.add(name);
if (typeof st.expect === 'string') checkSelector(`states[${i}].expect`, st.expect);
if (typeof st.within === 'string') checkSelector(`states[${i}].within`, st.within);
if (!isLoopback(st.url)) notes.push(`state ${name} is not on a loopback address (${redactUrl(st.url)})`);
return { name, url: st.url, ...(typeof st.expect === 'string' ? { expect: st.expect } : {}), ...(typeof st.within === 'string' ? { within: st.within } : {}) };
});
return { states, notes };
}
export interface AppWaiver { rule: string; locator: string; reason: string }
export const APP_WAIVER_MIN_REASON = 8;
const WAIVER_KEYS = new Set(['rule', 'locator', 'reason']);
/** A --waivers file: `{"version":1,"waivers":[{"rule","locator","reason"}]}`; the rule must be an app rule, the locator a non-empty substring to match, the reason 8 characters or more. */
export function parseWaiverFile(text: string, isAppRule: (id: string) => boolean): AppWaiver[] {
const bad: Bad = (why) => new AppUsageError('BAD_WAIVERS', `--waivers: ${why}`, 'write {"version":1,"waivers":[{"rule":"small-hit-area","locator":"<part of the finding\'s locator>","reason":"<why, about this page>"}]}');
return versioned(text, 'waivers', bad).map((raw, i): AppWaiver => {
const w = entry(raw, `waivers[${i}]`, WAIVER_KEYS, bad);
if (typeof w.rule !== 'string' || !isAppRule(w.rule)) throw bad(`waivers[${i}].rule ${JSON.stringify(w.rule)} is not an app rule`);
if (typeof w.locator !== 'string' || !w.locator.trim()) throw bad(`waivers[${i}].locator must be a non-empty string`);
if (typeof w.reason !== 'string' || w.reason.trim().length < APP_WAIVER_MIN_REASON) throw bad(`waivers[${i}].reason must say why, about this page, in ${APP_WAIVER_MIN_REASON} characters or more`);
return { rule: w.rule, locator: w.locator, reason: w.reason.trim() };
});
}
+260
View File
@@ -0,0 +1,260 @@
/**
* The app profile's scheduler (gstack-design-lint --scope app): render-only,
* one page load per state, one measurement per viewport, confined to the
* --within section. Separate from render.ts on purpose: the page and
* component profiles' plan, probes and output do not change.
*
* The verdict is the machine signal, never the tier: clean when every
* planned state x viewport was measured and no app finding is open, open when
* coverage is complete and one is, incomplete (exit 4) when any state x
* viewport was not measured, whatever else it found. docs/design-lint.md
* "App profile (--scope app)" is the contract.
*/
import { randomBytes } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { pickEngine, render, renderWithBrowse, type EngineChoice, type RenderStep } from '../aside-render';
import { DEFAULT_VIEWPORTS, redactText, redactUrl, viewportLabel, type AppWaiver, type Viewport } from './app-input';
import { appInstallScript, appProbeScript, composedWaitExpression, type AppProbeFinding, type AppProbeResult, type AppSetupReport } from './render-probe-app';
import type { Runner } from './render';
import { finalize, oneLine, summarize, type Finding, type Summary } from './report';
import { ruleMeta } from './rules/ids';
export const APP_CODES = ['NO_BROWSER', 'UNREACHABLE', 'TIMEOUT', 'NOT_READY', 'STATE_MISMATCH', 'WITHIN_NO_MATCH', 'CLOSED_SHADOW', 'PROBE_ERROR', 'ASSET_MISSING'] as const;
export type AppCode = typeof APP_CODES[number];
/** One line each: what to do about a state that was not measured. Anchors: docs/design-lint.md#app-code-<code in lower case>. */
export const APP_CODE_FIX: Record<AppCode, string> = {
NO_BROWSER: 'gstack-browser-ensure, then rerun',
UNREACHABLE: 'start the server or fix the URL, then rerun',
TIMEOUT: 'the page took longer than the render budget; make it load faster or measure fewer viewports, then rerun',
NOT_READY: 'pass --wait-for <selector> that appears once the section has its data, or make the data load',
STATE_MISMATCH: "make the URL load the state it names, or fix the state's expect selector",
WITHIN_NO_MATCH: "pass the custom element's tag or a selector inside the light DOM",
CLOSED_SHADOW: 'attach the shadow root with mode "open" in development, or pass a --within outside it',
PROBE_ERROR: 'read the trace file; a page script that redefines DOM APIs breaks the probes',
ASSET_MISSING: 'fix the stylesheet path or wait for the server to serve it, then rerun',
};
export const appCodeAnchor = (code: AppCode): string => `docs/design-lint.md#app-code-${code.toLowerCase()}`;
export interface AppState { name: string; url: string; expect?: string; within?: string }
export interface AppOptions {
states: AppState[];
within: string;
viewports?: Viewport[];
/** An explicit --wait-for: the readiness selector, and no list is required before measuring. */
waitFor?: string;
waivers?: AppWaiver[];
engine?: () => EngineChoice;
run?: Runner;
/** Replaces the reachability check (tests inject one); null means reachable. */
reach?: (url: string) => Promise<string | null>;
traceDir?: string;
/** How long the probe waits for a list after the section matched; 5000 by default. */
readyMs?: number;
/** How long the engine waits for the section (or --wait-for) to match; 15000 by default. */
sectionWaitMs?: number;
}
export interface AppCoverage { name: string; url: string; viewport: string; status: 'measured' | 'incomplete'; code: AppCode | 'OK'; matched: number; detail?: string; trace?: string }
export interface AppVerdict {
verdict: 'clean' | 'open' | 'incomplete';
code: string;
codes: AppCode[];
within: string;
matched: number;
viewports: string[];
states: AppCoverage[];
open: number;
waived: number;
notes: string[];
}
export interface AppResult { findings: Finding[]; summary: Summary; app: AppVerdict; exitCode: 0 | 1 | 4; stderr: string[] }
const SECTION_WAIT_MS = 15_000;
const READY_MS = 5_000;
const RENDER_TIMEOUT_MS = 120_000;
const MAX_LOCATOR = 1000;
export const defaultTraceDir = (): string => path.join(os.tmpdir(), 'gstack-design-lint-traces');
function writeTrace(dir: string, rec: { probe: string; state: string; viewport: string; stack: string; url: string }): string {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const file = path.join(dir, `trace-${Date.now()}-${randomBytes(4).toString('hex')}.json`);
fs.writeFileSync(file, JSON.stringify(rec, null, 2) + '\n', { mode: 0o600 });
fs.chmodSync(file, 0o600);
return file;
}
async function reachable(url: string): Promise<string | null> {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(10_000), redirect: 'follow' });
return res.ok || res.status === 304 ? null : `HTTP ${res.status}`;
} catch (e) {
return (e as Error).message;
}
}
const codeOfRunError = (e: string): AppCode => (/^no browser available/i.test(e) ? 'NO_BROWSER' : /waitFor/.test(e) ? 'NOT_READY' : /timed? ?out|timeout/i.test(e) ? 'TIMEOUT' : 'UNREACHABLE');
const INCOMPLETE_FROM_PROBE = new Set<string>(['NOT_READY', 'STATE_MISMATCH', 'WITHIN_NO_MATCH', 'CLOSED_SHADOW']);
type Parsed = { value: unknown } | { error: string; stack?: string };
function parseEval(text: string | undefined): Parsed {
if (text === undefined) return { error: 'no result came back' };
try {
const v = JSON.parse(text) as unknown;
if (v && typeof v === 'object' && 'error' in v && typeof (v as { error: unknown }).error === 'string') {
const e = v as { error: string; stack?: unknown };
return { error: e.error, ...(typeof e.stack === 'string' ? { stack: e.stack } : {}) };
}
return v === null ? { error: 'empty result' } : { value: v };
} catch (e) {
return { error: (e as Error).message };
}
}
const isFinding = (f: unknown): f is AppProbeFinding => {
if (!f || typeof f !== 'object') return false;
const x = f as Record<string, unknown>;
return typeof x.rule === 'string' && !!ruleMeta(x.rule)?.app && typeof x.sentence === 'string' && x.sentence.length <= 400
&& typeof x.locator === 'string' && x.locator.length <= MAX_LOCATOR && typeof x.measured === 'number' && Number.isFinite(x.measured) && typeof x.threshold === 'number';
};
/** A probe answer of the shape glAppMeasure returns, or why not. */
function probeShape(v: unknown): { ok: AppProbeResult } | { bad: string } {
if (!v || typeof v !== 'object') return { bad: 'the answer is not an object' };
const r = v as AppProbeResult;
if (r.status !== 'measured' && r.status !== 'incomplete') return { bad: 'the answer has no status' };
if (typeof r.matched !== 'number' || !Array.isArray(r.findings)) return { bad: 'the answer has no match count or finding list' };
if (r.status === 'incomplete' && !INCOMPLETE_FROM_PROBE.has(String(r.code))) return { bad: `the answer names an unknown code (${String(r.code).slice(0, 40)})` };
if (!r.findings.every(isFinding)) return { bad: 'the page handed back a measurement the lint does not know; its scripts may be tampering with the probes' };
return { ok: r };
}
const field = (s: string): string => oneLine(redactText(s)).replace(/ \| fix: /g, ' / fix: ');
interface StateRun { coverage: AppCoverage[]; findings: Finding[]; notes: string[]; stderr: string[] }
/** One state: reachability, one render with the install, setup and one measurement per viewport, then each answer settled. */
async function lintState(state: AppState, opts: AppOptions, run: Runner, viewports: Viewport[]): Promise<StateRun> {
const out: StateRun = { coverage: [], findings: [], notes: [], stderr: [] };
const shown = redactUrl(state.url);
const within = state.within ?? opts.within;
const all = (code: AppCode, detail: string, trace?: string) => {
for (const v of viewports) out.coverage.push({ name: state.name, url: shown, viewport: viewportLabel(v), status: 'incomplete', code, matched: 0, detail: field(detail), ...(trace ? { trace } : {}) });
};
const trace = (probe: string, viewport: string, stack: string): string | undefined => {
let file: string;
try { file = writeTrace(opts.traceDir ?? defaultTraceDir(), { probe, state: state.name, viewport, stack: redactText(stack), url: shown }); } catch (e) {
out.stderr.push(`gstack-design-lint: PROBE_ERROR: the ${probe} probe threw at ${state.name} ${viewport}; no trace written (${field((e as Error).message)})`);
return undefined;
}
out.stderr.push(`gstack-design-lint: PROBE_ERROR: the ${probe} probe threw at ${state.name} ${viewport}; trace ${file}`);
return file;
};
if (/^https?:\/\//.test(state.url)) {
const err = await (opts.reach ?? reachable)(state.url);
if (err) { all('UNREACHABLE', `could not reach ${shown}: ${err}`); return out; }
}
const first = viewports[0];
const sized = (expression: string, v: Viewport, maxInline = 400_000): RenderStep => ({ kind: 'eval', expression, width: v.width, height: v.height, deviceScaleFactor: 1, mobile: false, maxInline });
const args = (v: Viewport) => JSON.stringify({ within, expect: state.expect ?? null, explicitWait: !!opts.waitFor, readyMs: opts.readyMs ?? READY_MS });
const steps: RenderStep[] = [sized(appInstallScript(), first, 10), sized(appProbeScript('P.setup(P)'), first), ...viewports.map((v) => sized(appProbeScript(`P.measure(P, ${args(v)})`), v))];
const sectionWait = opts.sectionWaitMs ?? SECTION_WAIT_MS;
const waitFor = { expression: composedWaitExpression(opts.waitFor ?? within, opts.waitFor ? null : sectionWait), timeoutMs: sectionWait + (opts.waitFor ? 0 : 1_000) };
const res = await run({ file: state.url, waitFor, steps, timeoutMs: RENDER_TIMEOUT_MS });
if (!res.ok) {
const err = res.error ?? 'unknown error';
const code = codeOfRunError(err);
all(code, code === 'NOT_READY' ? `${opts.waitFor ? `the --wait-for selector ${opts.waitFor}` : `the section ${within}`} did not match within ${sectionWait / 1000}s` : `the page did not render: ${err.slice(0, 300)}`);
return out;
}
const setup = parseEval(res.evals[1]);
if ('error' in setup) { all('PROBE_ERROR', `the setup probe failed: ${setup.error}`, trace('setup', viewportLabel(first), setup.stack ?? setup.error)); return out; }
const report = setup.value as AppSetupReport;
if (!Array.isArray(report?.lostSheets) || !Array.isArray(report?.lostFonts)) { all('PROBE_ERROR', 'the setup answer has no list of lost assets'); return out; }
if (report.lostFonts.length) out.notes.push(field(`${state.name}: fonts did not load (${report.lostFonts.slice(0, 3).join(', ')}); measured with a fallback face`));
if (report.lostSheets.length) { all('ASSET_MISSING', `stylesheets did not load: ${report.lostSheets.slice(0, 3).join(', ')}`); return out; }
viewports.forEach((v, i) => {
const label = viewportLabel(v);
const cov: AppCoverage = { name: state.name, url: shown, viewport: label, status: 'incomplete', code: 'PROBE_ERROR', matched: 0 };
out.coverage.push(cov);
const p = parseEval(res.evals[2 + i]);
if ('error' in p) { cov.detail = field(`the measure probe failed: ${p.error}`); cov.trace = trace('measure', label, p.stack ?? p.error); return; }
const shape = probeShape(p.value);
if ('bad' in shape) { cov.detail = field(shape.bad); return; }
const r = shape.ok;
cov.matched = r.matched;
if (r.status === 'incomplete') { cov.code = r.code as AppCode; cov.detail = field(r.detail ?? ''); return; }
cov.status = 'measured';
cov.code = 'OK';
for (const f of r.findings) {
const meta = ruleMeta(f.rule)!;
const locator = field(f.locator);
out.findings.push({ rule: f.rule, tier: meta.tier, grade: meta.tier, file: oneLine(shown), line: 1, evidence: field(`[${state.name} ${v.width}px] ${f.sentence} at ${locator}`), fix: meta.fix, source: 'render',
app: { state: state.name, viewport: label, locator, measured: f.measured, threshold: f.threshold } });
}
});
return out;
}
/** Waivers by rule and locator substring: a match prints at waived with its reason; a waiver that matched nothing is a waiver finding. */
function applyAppWaivers(findings: Finding[], waivers: AppWaiver[]): Finding[] {
const used = new Set<number>();
const out = findings.map((f) => {
const i = waivers.findIndex((w) => w.rule === f.rule && !!f.app && f.app.locator.includes(w.locator));
if (i < 0) return f;
used.add(i);
return { ...f, grade: 'waived' as const, waiver: oneLine(waivers[i].reason) };
});
waivers.forEach((w, i) => {
if (used.has(i)) return;
out.push({ rule: 'waiver', tier: 'ask', grade: 'ask', file: '--waivers', line: i + 1, evidence: field(`waiver on ${w.rule} at "${w.locator}" matched no finding (unused)`), fix: 'remove the waiver, or correct its locator to part of the finding\'s', source: 'render' });
});
return out;
}
export async function lintApp(opts: AppOptions): Promise<AppResult> {
const viewports = opts.viewports?.length ? opts.viewports : DEFAULT_VIEWPORTS;
let run = opts.run;
let status = 'ran';
const runs: StateRun[] = [];
if (!run) {
const choice = (opts.engine ?? pickEngine)();
if (choice.engine) run = (spec) => (choice.engine === 'browse' ? renderWithBrowse(spec, choice.bin) : render(spec));
else {
status = `skipped: ${choice.error}`;
runs.push({ findings: [], notes: [], stderr: [], coverage: opts.states.flatMap((s) => viewports.map((v): AppCoverage => ({ name: s.name, url: redactUrl(s.url), viewport: viewportLabel(v), status: 'incomplete', code: 'NO_BROWSER', matched: 0, detail: field(choice.error) }))) });
}
}
if (run) for (const s of opts.states) runs.push(await lintState(s, opts, run, viewports));
const coverage = runs.flatMap((r) => r.coverage);
const findings = finalize(applyAppWaivers(runs.flatMap((r) => r.findings), opts.waivers ?? []));
const codes = APP_CODES.filter((c) => coverage.some((x) => x.code === c));
const open = findings.filter((f) => ruleMeta(f.rule)?.app && f.grade !== 'waived').length;
const waived = findings.filter((f) => ruleMeta(f.rule)?.app && f.grade === 'waived').length;
const verdict: AppVerdict['verdict'] = codes.length || !coverage.length ? 'incomplete' : open ? 'open' : 'clean';
const app: AppVerdict = {
verdict, code: verdict === 'incomplete' ? codes[0] ?? 'NO_BROWSER' : verdict === 'open' ? 'OPEN' : 'OK', codes,
within: opts.within, matched: coverage.length ? Math.min(...coverage.map((c) => c.matched)) : 0,
viewports: viewports.map(viewportLabel), states: coverage, open, waived, notes: runs.flatMap((r) => r.notes),
};
return { findings, summary: summarize(findings, status), app, exitCode: verdict === 'clean' ? 0 : verdict === 'open' ? 1 : 4, stderr: runs.flatMap((r) => r.stderr) };
}
/** The LINT line's companions under --scope app: one LINT_APP_STATE line per state x viewport before LINT_SUMMARY, the LINT_APP verdict after it. */
export function formatAppLines(app: AppVerdict): { states: string[]; verdict: string } {
const states = app.states.map((c) => oneLine(`LINT_APP_STATE: state=${c.name} viewport=${c.viewport} status=${c.status} code=${c.code} matched=${c.matched}`
+ (c.status === 'incomplete' ? ` ${c.detail ?? ''}${c.trace ? ` (trace: ${c.trace})` : ''} | fix: ${APP_CODE_FIX[c.code as AppCode]} (${appCodeAnchor(c.code as AppCode)})` : '')));
const names = [...new Set(app.states.map((c) => c.name))];
const measured = names.filter((n) => app.states.every((c) => c.name !== n || c.status === 'measured')).length;
const verdict = oneLine(`LINT_APP: verdict=${app.verdict} code=${app.code} states=${measured}/${names.length} viewports=${app.viewports.join(',')} within=${app.within} matched=${app.matched} open=${app.open} waived=${app.waived}`);
return { states, verdict };
}
+397
View File
@@ -0,0 +1,397 @@
/**
* The app profile's in-page measurements (gstack-design-lint --scope app).
* Installed on window.__gstackLintApp by their own install step, apart from
* the page probes in render-probe.ts, which stay as they are. Each function
* is serialised with Function.prototype.toString and sees only its arguments,
* the helpers object and the DOM.
*
* Everything walks the composed tree: light DOM, every open shadow root and
* slotted content in the order it renders. Nothing reads a test hook
* (data-testid or any other attribute): a list is found by shape (siblings of
* one tag and class list, similar widths, stacked, each holding text), a
* row's name by position (the first run on its first line), a note by
* position (a text block starting below the name's line).
*
* Text positions are line boxes: a text rect widened by its half-leading
* (Chrome floors the half above the glyphs and gives the rest below), so a gap
* measured to a row edge is the padding and margins the CSS set.
*/
export interface AppProbeFinding { rule: string; sentence: string; locator: string; measured: number; threshold: number }
export interface AppProbeResult {
status: 'measured' | 'incomplete';
/** Set when incomplete: NOT_READY, STATE_MISMATCH, WITHIN_NO_MATCH or CLOSED_SHADOW. */
code?: string;
detail?: string;
/** Elements the --within selector matched in the composed tree. */
matched: number;
/** Trees searched: the document plus every open shadow root. */
roots: number;
lists: number;
findings: AppProbeFinding[];
}
export interface AppSetupReport { lostSheets: string[]; lostFonts: string[] }
export interface AppMeasureArgs { within: string; expect: string | null; explicitWait: boolean; readyMs: number }
interface Run { node: Text; el: Element; block: Element; rects: DOMRect[]; top: number; bottom: number; left: number; lineTop: number; lineBottom: number }
interface AppList { container: Element; rows: Element[]; last: Element; whole: boolean }
/** Composed-tree helpers, built once by the install step. */
export function glAppHelpers() {
const kids = (n: Node): Node[] => {
if ((n as Element).tagName === 'SLOT') { const a = (n as HTMLSlotElement).assignedNodes({ flatten: true }); return a.length ? a : Array.from(n.childNodes); }
const sr = (n as Element).shadowRoot;
return Array.from(sr ? sr.childNodes : n.childNodes);
};
const ekids = (n: Node): Element[] => kids(n).filter((c) => c.nodeType === 1) as Element[];
/** The element children that render as boxes: a display: contents child (a slot, a wrapper) is replaced by its own. */
const boxKids = (n: Node): Element[] => ekids(n).flatMap((k) => (getComputedStyle(k).display === 'contents' ? boxKids(k) : [k]));
const cparent = (n: Node): Element | null => {
const slot = (n as Element).assignedSlot;
if (slot) return slot;
const p = n.parentNode;
if (!p) return null;
return p.nodeType === 11 ? ((p as ShadowRoot).host || null) : (p as Element).nodeType === 1 ? (p as Element) : null;
};
const inside = (el: Node, root: Element): boolean => { for (let e: Node | null = el; e; e = cparent(e)) if (e === root) return true; return false; };
/** Every element under root in the composed tree, root first. */
const desc = (root: Element): Element[] => { const out: Element[] = []; const go = (e: Element) => { out.push(e); for (const k of ekids(e)) go(k); }; go(root); return out; };
const roots = (): Array<Document | ShadowRoot> => {
const out: Array<Document | ShadowRoot> = [document];
for (let i = 0; i < out.length; i++) for (const e of out[i].querySelectorAll('*')) if (e.shadowRoot) out.push(e.shadowRoot);
return out;
};
const qAll = (sel: string): Element[] => { const out: Element[] = []; for (const r of roots()) for (const e of r.querySelectorAll(sel)) out.push(e); return out; };
const shown = (el: Element): boolean => { const cs = getComputedStyle(el); return cs.display !== 'none' && cs.visibility !== 'hidden' && parseFloat(cs.opacity) !== 0; };
const vis = (el: Element): boolean => { if (!shown(el)) return false; const r = el.getBoundingClientRect(); return r.width >= 1 && r.height >= 1; };
const CONTROL = 'button, a[href], select, input, textarea, [role=button], [role=menuitem], [role=menuitemradio], [role=menuitemcheckbox], [role=tab], [role=switch], [role=option]';
const isControl = (el: Element): boolean => el.matches(CONTROL);
const blockOf = (el: Element, stop: Element): Element => {
let e: Element = el;
while (e !== stop) { const d = getComputedStyle(e).display; if (d !== 'inline' && d !== 'contents') break; const p = cparent(e); if (!p) break; e = p; }
return e;
};
/** Visible text runs under root in rendered order, text inside controls left out. */
const runsOf = (root: Element): Run[] => {
const out: Run[] = [];
const go = (n: Node, ctl: boolean): void => {
for (const c of kids(n)) {
if (c.nodeType === 3) {
if (ctl || !(c.textContent || '').trim()) continue;
const el = cparent(c);
if (!el) continue;
const rg = document.createRange(); rg.selectNodeContents(c);
const rects = Array.from(rg.getClientRects()).filter((q) => q.width > 0 && q.height > 0);
if (!rects.length) continue;
const lh = parseFloat(getComputedStyle(el).lineHeight);
const above = (q: DOMRect) => (Number.isFinite(lh) ? Math.floor((lh - q.height) / 2) : 0);
const below = (q: DOMRect) => (Number.isFinite(lh) ? lh - q.height - above(q) : 0);
out.push({ node: c as Text, el, block: blockOf(el, root), rects, top: rects[0].top, bottom: Math.max(...rects.map((q) => q.bottom)), left: rects[0].left,
lineTop: Math.min(...rects.map((q) => q.top - above(q))), lineBottom: Math.max(...rects.map((q) => q.bottom + below(q))) });
} else if (c.nodeType === 1) {
const e = c as Element;
if (/^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(e.tagName) || !shown(e)) continue;
go(e, ctl || isControl(e));
}
}
};
go(root, isControl(root));
return out;
};
const lineCount = (runs: Run[]): number => {
const tops = runs.flatMap((r) => r.rects.map((q) => q.top + q.height / 2)).sort((a, b) => a - b);
let n = 0, last = -Infinity;
for (const t of tops) { if (t - last > 4) n++; last = t; }
return n;
};
const cv = document.createElement('canvas'); cv.width = cv.height = 1;
const cx = cv.getContext('2d', { willReadFrequently: true }) as CanvasRenderingContext2D;
const rgb = (s: string): number[] => { cx.clearRect(0, 0, 1, 1); cx.fillStyle = 'rgba(0, 0, 0, 0)'; cx.fillStyle = s; cx.fillRect(0, 0, 1, 1); const d = cx.getImageData(0, 0, 1, 1).data; return [d[0] / 255, d[1] / 255, d[2] / 255, d[3] / 255]; };
const surface = (el: Element): boolean => {
const cs = getComputedStyle(el);
if (rgb(cs.backgroundColor)[3] > 0.02 || cs.backgroundImage !== 'none' || cs.boxShadow !== 'none') return true;
return (['Top', 'Right', 'Bottom', 'Left'] as const).some((s) => parseFloat(cs.getPropertyValue('border-' + s.toLowerCase() + '-width')) > 0 && !/none|hidden/.test(cs.getPropertyValue('border-' + s.toLowerCase() + '-style')) && rgb(cs.getPropertyValue('border-' + s.toLowerCase() + '-color'))[3] > 0.02);
};
/** The px of a drawn edge on one side: its border width, 0 when only a background or shadow draws it, null when nothing does. */
const drawn = (el: Element, side: 'top' | 'bottom'): number | null => {
const cs = getComputedStyle(el);
const w = parseFloat(cs.getPropertyValue('border-' + side + '-width'));
if (w > 0 && !/none|hidden/.test(cs.getPropertyValue('border-' + side + '-style')) && rgb(cs.getPropertyValue('border-' + side + '-color'))[3] > 0.02) return w;
return rgb(cs.backgroundColor)[3] > 0.02 || cs.backgroundImage !== 'none' || cs.boxShadow !== 'none' ? 0 : null;
};
const bgOf = (el: Element): number[] => {
for (let n: Element | null = el; n; n = cparent(n)) { const c = rgb(getComputedStyle(n).backgroundColor); if (c[3] >= 0.9) return c; }
return [1, 1, 1, 1];
};
const lightness = (el: Element, stop: Element): number => {
const a = rgb(getComputedStyle(el).color);
let op = 1;
for (let n: Element | null = el; n && n !== stop; n = cparent(n)) op *= parseFloat(getComputedStyle(n).opacity);
const bg = bgOf(el);
const c = [0, 1, 2].map((i) => a[i] * a[3] * op + bg[i] * (1 - a[3] * op));
const lin = c.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
const l = Math.cbrt(0.4122214708 * lin[0] + 0.5363325363 * lin[1] + 0.0514459929 * lin[2]);
const m = Math.cbrt(0.2119034982 * lin[0] + 0.6806995451 * lin[1] + 0.1073969566 * lin[2]);
const s = Math.cbrt(0.0883024619 * lin[0] + 0.2817188376 * lin[1] + 0.6299787005 * lin[2]);
return 0.2104542553 * l + 0.793617785 * m - 0.0040720403 * s;
};
const seg = (e: Element): string => {
const cls = (e.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2);
const p = e.parentElement || (e.parentNode as ShadowRoot | null);
const same = p ? Array.from(p.children).filter((x) => x.tagName === e.tagName) : [];
return e.tagName.toLowerCase() + (cls.length ? '.' + cls.join('.') : '') + (same.length > 1 ? ':nth-of-type(' + (same.indexOf(e) + 1) + ')' : '');
};
/** A shadow-piercing path to el: each host, then the steps inside el's own tree (up to eight), then its opening text, or a control's label. */
const locOf = (el: Element): string => {
const path: string[] = [];
for (let e: Element | null = el; e && path.length < 8; e = e.parentElement) { if (e === document.body || e === document.documentElement) break; path.unshift(seg(e)); }
const hosts: string[] = [];
for (let r = el.getRootNode(); r.nodeType === 11; r = (r as ShadowRoot).host.getRootNode()) hosts.unshift(seg((r as ShadowRoot).host));
const own = runsOf(el).map((r) => r.node.textContent || '').join(' ');
const words = (own.trim() ? own : el.getAttribute('aria-label') || el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 40);
return [...hosts, path.join(' > ') || seg(el)].join(' >>> ') + (words ? ' "' + words + '"' : '');
};
const px = (n: number): number => Math.round(n * 100) / 100;
/** m is under the threshold by more than layout's subpixel noise (0.05px). */
const under = (m: number, t: number): boolean => m < t - 0.05;
return { kids, ekids, boxKids, cparent, inside, desc, roots, qAll, shown, vis, isControl, runsOf, lineCount, rgb, surface, drawn, lightness, locOf, px, under };
}
export type AppHelpers = ReturnType<typeof glAppHelpers>;
/** The page before measuring: transitions off, running animations finished, fonts settled; which stylesheets and fonts did not load. */
export async function glAppSetup(P: AppProbes): Promise<AppSetupReport> {
const quiet = document.createElement('style');
quiet.textContent = '* { transition: none !important } html, body { scroll-behavior: auto !important }';
document.head.appendChild(quiet);
for (const a of document.getAnimations()) { try { a.finish(); } catch { /* an infinite animation keeps running */ } }
if (document.fonts && document.fonts.status !== 'loaded') await Promise.race([document.fonts.ready, new Promise((r) => setTimeout(r, 3000))]);
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const lostSheets: string[] = [];
for (const root of P.H.roots()) for (const l of root.querySelectorAll('link[rel~="stylesheet"][href]')) if (!(l as HTMLLinkElement).sheet) lostSheets.push((l.getAttribute('href') || '').split('?')[0].split('/').pop() || 'stylesheet');
for (const e of performance.getEntriesByType('resource') as PerformanceResourceTiming[]) if ((e.initiatorType === 'link' || e.initiatorType === 'css') && /\.css(\?|$)/.test(e.name) && e.responseStatus >= 400) lostSheets.push(e.name.split('?')[0].split('/').pop() || 'stylesheet');
const lostFonts: string[] = [];
if (document.fonts) for (const f of document.fonts) if (f.status === 'error') lostFonts.push(f.family.replace(/^["']|["']$/g, ''));
return { lostSheets: Array.from(new Set(lostSheets)), lostFonts: Array.from(new Set(lostFonts)) };
}
/** Every list under root: two or more adjacent siblings of one tag and class list (never headings, paragraphs, sections or forms), widths within 10%, stacked top to bottom, each holding text; never a table, a nav, a menu-like role or a grid of columns. */
export function glAppLists(P: AppProbes, root: Element): AppList[] {
const { boxKids, cparent, vis, runsOf } = P.H;
const out: AppList[] = [];
const banned = (e: Element): boolean => {
for (let n: Element | null = e; n; n = cparent(n)) if (/^(TABLE|THEAD|TBODY|TFOOT|TR|TD|TH|NAV)$/.test(n.tagName) || /^(navigation|grid|table|menu|menubar|listbox|tablist|tree)$/.test(n.getAttribute('role') || '')) return true;
return false;
};
const walk = (parent: Element): void => {
const kids = boxKids(parent).filter((k) => vis(k));
const keyOf = (k: Element) => k.tagName + '|' + (k.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).sort().join(' ');
const groups: Element[][] = [];
for (const k of kids) {
const g = groups[groups.length - 1];
if (g && keyOf(g[0]) === keyOf(k)) g.push(k); else groups.push([k]);
}
for (const g of groups) {
if (g.length < 2 || /^(H[1-6]|P|SECTION|MAIN|HEADER|FOOTER|ASIDE|FORM|FIELDSET)$/.test(g[0].tagName) || banned(g[0])) continue;
const boxes = g.map((e) => e.getBoundingClientRect());
const widths = boxes.map((b) => b.width);
if (Math.max(...widths) > Math.min(...widths) * 1.1) continue;
const order = g.map((e, i) => ({ e, b: boxes[i] })).sort((a, b) => a.b.top - b.b.top);
if (order.some((x, i) => i > 0 && x.b.top < order[i - 1].b.bottom - 1)) continue;
if (order.some((x) => !runsOf(x.e).length)) continue;
const rows = order.map((x) => x.e);
out.push({ container: parent, rows, last: rows[rows.length - 1], whole: kids.every((k) => rows.includes(k)) });
}
for (const k of kids) walk(k);
};
walk(root);
return out;
}
/** row-crowding and note-in-row-weight over a list of rows (bounded: a row or the list draws an edge; or laid out: every row sets two or more boxes side by side), small-hit-area over any list's. */
export function glAppRows(P: AppProbes, list: AppList, vw: number): AppProbeFinding[] {
const { runsOf, lineCount, surface, drawn, lightness, locOf, px, under, cparent, vis, desc, boxKids } = P.H;
const out: AppProbeFinding[] = [];
const rows = list.rows;
const texts = rows.map((r) => runsOf(r));
const boxes = rows.map((r) => r.getBoundingClientRect());
const cb = list.container.getBoundingClientRect();
const sideBySide = (r: Element): boolean => {
if (!/flex|grid/.test(getComputedStyle(r).display)) return false;
const bs = boxKids(r).filter((k) => vis(k)).map((k) => k.getBoundingClientRect());
return bs.some((a, i) => bs.some((b, j) => j > i && a.top < b.bottom && b.top < a.bottom && (a.right <= b.left || b.right <= a.left)));
};
const bounded = surface(list.container) || rows.some((r) => surface(r)) || rows.every(sideBySide);
const inner = (i: number, side: 'top' | 'bottom'): number | null => {
const own = drawn(rows[i], side);
if (own != null) return side === 'top' ? boxes[i].top + own : boxes[i].bottom - own;
const j = side === 'top' ? i - 1 : i + 1;
if (rows[j] && drawn(rows[j], side === 'top' ? 'bottom' : 'top') != null) return side === 'top' ? boxes[j].bottom : boxes[j].top;
const c = !rows[j] && list.whole ? drawn(list.container, side) : null;
return c == null ? null : side === 'top' ? cb.top + c : cb.bottom - c;
};
rows.forEach((row, i) => {
const runs = texts[i];
const lines = lineCount(runs);
if (bounded && lines >= 2) {
const topEdge = inner(i, 'top');
const bottomEdge = inner(i, 'bottom');
const top = topEdge == null ? Infinity : Math.min(...runs.map((r) => r.lineTop)) - topEdge;
const bottom = bottomEdge == null ? Infinity : bottomEdge - Math.max(...runs.map((r) => r.lineBottom));
const next = texts[i + 1];
const gap = px(next && next.length ? Math.min(...next.map((r) => r.lineTop)) - Math.max(...runs.map((r) => r.lineBottom)) : Infinity);
const edge = px(Math.min(top, bottom));
if (under(edge, 8)) out.push({ rule: 'row-crowding', sentence: 'a ' + lines + '-line row leaves ' + edge + 'px between its text and its ' + (top <= bottom ? 'top' : 'bottom') + ' edge (under 8px)', locator: locOf(row), measured: edge, threshold: 8 });
else if (under(gap, 16)) out.push({ rule: 'row-crowding', sentence: 'a ' + lines + '-line row leaves ' + gap + 'px between its last text line and the next row\'s text (under 16px)', locator: locOf(row), measured: gap, threshold: 16 });
}
if (bounded && runs.length) {
const first = Math.min(...runs.map((r) => r.top));
const name = runs.filter((r) => r.top <= first + 1).sort((a, b) => a.left - b.left)[0];
const blocks = new Map<Element, typeof runs>();
for (const r of runs) if (r.block !== name.block) blocks.set(r.block, [...(blocks.get(r.block) || []), r]);
for (const [block, own] of blocks) {
if (Math.min(...own.map((r) => r.top)) < name.bottom - 1) continue;
let callout = false;
for (let e: Element | null = block; e && e !== row; e = cparent(e)) if (surface(e)) { callout = true; break; }
const ICON = 'svg, img, [aria-hidden="true"]';
const iconed = !!block.querySelector(ICON) || [block.previousElementSibling, block.nextElementSibling].some((s) => !!s && vis(s) && (s.matches(ICON) || (!(s.textContent || '').trim() && !!s.querySelector('svg, img'))));
if (callout || iconed) continue;
const dl = Math.abs(lightness(own[0].el, row) - lightness(name.el, row));
const ratio = parseFloat(getComputedStyle(own[0].el).fontSize) / parseFloat(getComputedStyle(name.el).fontSize);
const noteLines = lineCount(own);
if (dl < 0.15 && ratio >= 0.9) out.push({ rule: 'note-in-row-weight', sentence: 'a note in a row reads at the name\'s weight: lightness ' + dl.toFixed(3) + ' from the name\'s (under 0.15) at ' + ratio.toFixed(2) + ' of its size (0.9 or more)', locator: locOf(block), measured: Math.round(dl * 1000) / 1000, threshold: 0.15 });
else if (vw >= 1200 && noteLines >= 3) out.push({ rule: 'note-in-row-weight', sentence: 'a note in a row runs ' + noteLines + ' lines at ' + vw + 'px (3 or more)', locator: locOf(block), measured: noteLines, threshold: 3 });
}
}
if (vw <= 480) {
const controls = desc(row).filter((c) => c !== row && c.matches('button, a[href], input:not([type=hidden]), select, [role=button], [role^=menuitem], [role=switch], [role=tab]') && vis(c));
for (const c of controls) {
if (c.tagName === 'A' && getComputedStyle(c).display === 'inline') {
const host = cparent(c);
if (host && runsOf(host).some((r) => !c.contains(r.node))) continue;
}
let b = c.getBoundingClientRect();
const labels = (c as HTMLInputElement).labels;
if (labels && labels.length) {
const all = [b, ...Array.from(labels).map((l) => l.getBoundingClientRect())];
const l = Math.min(...all.map((q) => q.left)), t = Math.min(...all.map((q) => q.top));
b = new DOMRect(l, t, Math.max(...all.map((q) => q.right)) - l, Math.max(...all.map((q) => q.bottom)) - t);
}
if (under(b.width, 32) || under(b.height, 32)) out.push({ rule: 'small-hit-area', sentence: 'a row control is ' + px(b.width) + 'x' + px(b.height) + 'px at ' + vw + 'px (under 32px)', locator: locOf(c), measured: px(Math.min(b.width, b.height)), threshold: 32 });
}
}
});
return out;
}
/** copy-flush-under-list: the first text block after the list, before the next heading, inside the measured section. */
export function glAppAfter(P: AppProbes, list: AppList, root: Element): AppProbeFinding[] {
const { boxKids, cparent, vis, runsOf, locOf, px, under } = P.H;
const HEADING = /^H[1-6]$/;
const isHeading = (e: Element) => HEADING.test(e.tagName) || e.getAttribute('role') === 'heading';
let from: Element = list.whole ? list.container : list.last;
for (let depth = 0; depth < 3; depth++) {
let parent = cparent(from);
while (parent && getComputedStyle(parent).display === 'contents' && parent !== root) parent = cparent(parent);
if (!parent) return [];
const sibs = boxKids(parent);
for (const s of sibs.slice(sibs.indexOf(from) + 1)) {
if (!vis(s)) continue;
if (isHeading(s)) return [];
const runs = runsOf(s);
if (!runs.length) continue;
const first = runs.slice().sort((a, b) => a.lineTop - b.lineTop)[0];
for (let e: Element | null = first.el; e && e !== s; e = cparent(e)) if (isHeading(e)) return [];
const gap = px(first.lineTop - from.getBoundingClientRect().bottom);
return under(gap, 12) ? [{ rule: 'copy-flush-under-list', sentence: 'the text under the list starts ' + gap + 'px below it (under 12px)', locator: locOf(first.block), measured: gap, threshold: 12 }] : [];
}
if (parent === root || isHeading(parent)) return [];
from = parent;
}
return [];
}
/** section-overflow: inside root, an element past the viewport's right edge, or a non-scrolling block wider inside than out. The innermost three. */
export function glAppOverflow(P: AppProbes, root: Element, vw: number): AppProbeFinding[] {
const { ekids, vis, locOf, px } = P.H;
const hits: Array<{ el: Element; sentence: string; m: number }> = [];
const go = (el: Element, clipped: boolean): void => {
if (el.tagName === 'svg' || /^(SCRIPT|STYLE|TEMPLATE)$/.test(el.tagName)) return;
const cs = getComputedStyle(el);
if (cs.display === 'contents') { for (const k of ekids(el)) go(k, clipped); return; }
if (!vis(el) || cs.position === 'fixed') return;
const b = el.getBoundingClientRect();
if (!clipped && b.width > 1 && b.height > 1 && b.right > vw + 1) hits.push({ el, sentence: el.tagName.toLowerCase() + ' runs ' + px(b.right - vw) + 'px past the ' + vw + 'px viewport', m: px(b.right - vw) });
else if (cs.overflowX === 'visible' && cs.display !== 'inline' && el.clientWidth > 0 && el.scrollWidth > el.clientWidth + 1) hits.push({ el, sentence: el.tagName.toLowerCase() + ' overflows its own box by ' + (el.scrollWidth - el.clientWidth) + 'px', m: el.scrollWidth - el.clientWidth });
for (const k of ekids(el)) go(k, clipped || cs.overflowX !== 'visible');
};
go(root, false);
const inner = hits.filter((h) => !hits.some((o) => o !== h && P.H.inside(o.el, h.el)));
return inner.slice(0, 3).map((h) => ({ rule: 'section-overflow', sentence: h.sentence + ' (over 1px)', locator: locOf(h.el), measured: h.m, threshold: 1 }));
}
/** One state at one viewport: wait for the section and a list (or the caller's --wait-for), check the state's expect selector, then every app rule. */
export async function glAppMeasure(P: AppProbes, a: AppMeasureArgs): Promise<AppProbeResult> {
const { qAll, inside, roots, desc } = P.H;
const t0 = Date.now();
const outermost = (els: Element[]) => els.filter((e) => !els.some((o) => o !== e && inside(e, o)));
let matches: Element[] = [];
let lists: AppList[] = [];
let expectOk = true;
let host: Element | undefined;
const closed = (scope: Element[]): Element | undefined =>
scope.flatMap((s) => desc(s)).find((e) => e.tagName.includes('-') && !e.shadowRoot && !e.children.length && !(e.textContent || '').trim() && e.getBoundingClientRect().height >= 40);
for (;;) {
matches = outermost(qAll(a.within));
lists = matches.flatMap((m) => P.lists(P, m));
expectOk = !a.expect || qAll(a.expect).length > 0;
host = matches.length && !lists.length ? closed(matches) : undefined;
if (host || (matches.length && (lists.length || a.explicitWait) && expectOk) || Date.now() - t0 > a.readyMs) break;
await new Promise((r) => setTimeout(r, 150));
}
const matched = qAll(a.within).length;
const nRoots = roots().length;
const base = { matched, roots: nRoots, lists: lists.length, findings: [] as AppProbeFinding[] };
if (!matches.length) {
const host = closed([document.body]);
if (host) return { ...base, status: 'incomplete', code: 'CLOSED_SHADOW', detail: '<' + host.tagName.toLowerCase() + '> renders a closed shadow root the lint cannot read, and nothing matched ' + a.within + ' in the document or its ' + (nRoots - 1) + ' open shadow roots' };
return { ...base, status: 'incomplete', code: 'WITHIN_NO_MATCH', detail: 'nothing matched ' + a.within + ' in the document or its ' + (nRoots - 1) + ' open shadow roots' };
}
if (host) return { ...base, status: 'incomplete', code: 'CLOSED_SHADOW', detail: '<' + host.tagName.toLowerCase() + '> inside ' + a.within + ' renders a closed shadow root the lint cannot read' };
if (!lists.length && !a.explicitWait) return { ...base, status: 'incomplete', code: 'NOT_READY', detail: 'no list of two or more rows appeared inside ' + a.within + ' within ' + Math.round(a.readyMs / 1000) + 's' };
if (!expectOk) return { ...base, status: 'incomplete', code: 'STATE_MISMATCH', detail: 'the state\'s expect selector ' + a.expect + ' matched nothing after readiness' };
const vw = innerWidth;
const findings: AppProbeFinding[] = [];
for (const l of lists) findings.push(...P.rows(P, l, vw), ...P.after(P, l, matches.find((m) => inside(l.container, m)) || l.container));
for (const m of matches) findings.push(...P.overflow(P, m, vw));
return { ...base, status: 'measured', findings };
}
const APP_FNS = { setup: glAppSetup, lists: glAppLists, rows: glAppRows, after: glAppAfter, overflow: glAppOverflow, measure: glAppMeasure };
export interface AppProbes extends AppFns { H: AppHelpers }
type AppFns = typeof APP_FNS;
export const APP_PROBE_GLOBAL = 'window.__gstackLintApp';
/** The JS that installs the app probes, as one expression. */
export function appInstallScript(): string {
const entries = Object.entries(APP_FNS).map(([k, fn]) => k + ': (' + fn.toString() + ')');
return '(' + APP_PROBE_GLOBAL + ' = { H: (' + glAppHelpers.toString() + ')(), ' + entries.join(', ') + ' }, 1)';
}
/** One app probe call as an eval expression: its JSON, or {error, stack} when it throws (the stack feeds the trace file). Written without the word await (see render-probe.ts probeScript). */
export function appProbeScript(call: string): string {
return '(() => { const P = ' + APP_PROBE_GLOBAL + '; return Promise.resolve().then(() => (' + call + ')).then((v) => JSON.stringify(v), (e) => JSON.stringify({ error: String((e && e.message) || e).slice(0, 300), stack: String((e && e.stack) || e).slice(0, 4000) })); })()';
}
/** The readiness expression handed to the engine's waitFor: a selector matched through every open shadow root. With soft, it also turns true after ms, so a selector that never matches reaches the probe and reports WITHIN_NO_MATCH instead of failing the render. */
export function composedWaitExpression(selector: string, soft: number | null): string {
const find = '(() => { const s = ' + JSON.stringify(selector) + '; const rs = [document]; try { for (let i = 0; i < rs.length; i++) { if (rs[i].querySelector(s)) return true; for (const e of rs[i].querySelectorAll("*")) if (e.shadowRoot) rs.push(e.shadowRoot); } } catch (e) { return ' + (soft != null) + '; } return false; })()';
if (soft == null) return find;
return '(() => { const w = window; w.__gstackLintAppWait = w.__gstackLintAppWait || Date.now(); return ' + find + ' || Date.now() - w.__gstackLintAppWait > ' + soft + '; })()';
}
+13 -2
View File
@@ -26,6 +26,16 @@ export interface Finding {
source: Source;
/** Set when a waiver answered the finding: the reason it recorded. */
waiver?: string;
/** An app-profile finding's measurement (--scope app): kept whole, never truncated. */
app?: AppMeasure;
}
export interface AppMeasure {
state: string;
viewport: string;
locator: string;
measured: number;
threshold: number;
}
export interface ReportOptions {
@@ -47,13 +57,14 @@ export const relPath = (file: string, cwd = process.cwd()): string =>
export class Reporter {
readonly findings: Finding[] = [];
constructor(readonly scope: 'page' | 'component' = 'page', readonly cwd = process.cwd()) {}
constructor(readonly scope: 'page' | 'component' | 'app' = 'page', readonly cwd = process.cwd()) {}
/** 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 null;
if (meta.app && this.scope !== 'app') return null;
const tier: Tier = opts.suspected && meta.tier === 'blocks' ? 'ask' : meta.tier;
const finding: Finding = {
rule, tier, grade: tier,
@@ -76,7 +87,7 @@ const GRADE_RANK: Record<Grade, number> = { blocks: 0, ask: 1, waived: 2, polish
export function finalize(findings: Finding[]): Finding[] {
const seen = new Set<string>();
const unique = findings.filter((f) => {
const k = `${f.rule}|${f.grade}|${f.file}|${f.line}|${f.evidence}`;
const k = `${f.rule}|${f.grade}|${f.file}|${f.line}|${f.evidence}` + (f.app ? `|${f.app.state}|${f.app.viewport}|${f.app.locator}` : '');
if (seen.has(k)) return false;
seen.add(k);
return true;
+8
View File
@@ -31,6 +31,8 @@ export interface RuleMeta {
pageOnly?: boolean;
/** The render tier reports this rule; the static tier never does. */
render?: boolean;
/** An app-profile rule: only `--scope app` reports it, and `--scope app` reports nothing else (render-app.ts). */
app?: boolean;
}
export const RULES: Record<string, RuleMeta> = {
@@ -170,6 +172,12 @@ export const RULES: Record<string, RuleMeta> = {
'missing-skip-link': { tier: 'ask', gate: 'craft', title: 'no skip link reaching <main>', fix: 'add a "Skip to content" link first in <body> whose #id exists on <main>', pageOnly: true },
'missing-aria-current': { tier: 'ask', gate: 'craft', title: 'route navigation without aria-current', fix: 'mark the current nav link aria-current="page"', pageOnly: true },
'empty-mount-node': { tier: 'ask', gate: 'craft', title: 'an empty mount node: the body has no text and a script renders it, so the lint read none of its content', fix: 'lint the rendered page: save document.documentElement.outerHTML to a file and lint that', pageOnly: true },
// ── App profile (--scope app only; render-app.ts and render-probe-app.ts, GST-1741) ──
'row-crowding': { tier: 'ask', gate: 'app', title: 'a list row whose text spans two or more lines sits under 8px from its row edge, or under 16px from the next row\'s text', fix: 'pad the row 8px or more top and bottom and leave 16px or more between one row\'s text and the next', render: true, app: true },
'copy-flush-under-list': { tier: 'ask', gate: 'app', title: 'the first text block after a list starts under 12px below the list', fix: 'leave 12px or more between the list and the copy under it', render: true, app: true },
'note-in-row-weight': { tier: 'ask', gate: 'app', title: 'a note inside a row at the name\'s weight (no icon, no surface of its own, lightness within 0.15 and size 0.9 or more of the name\'s), or a note of three lines or more at 1200px and wider', fix: 'mute the note (a lighter ink and a smaller size than the name), or give it an icon or a callout of its own', render: true, app: true },
'small-hit-area': { tier: 'ask', gate: 'app', title: 'a control inside a list row under 32px in either dimension at a phone width (480px or less)', fix: 'make the target 32px or more (padding with a negative margin, or an inset ::before) and keep the visible glyph its size', render: true, app: true },
'section-overflow': { tier: 'ask', gate: 'app', title: 'an element inside the measured section runs past the viewport, or a non-scrolling element overflows its own box', fix: 'let it wrap or shrink (min-width: 0, overflow-wrap: anywhere), or give it a scrolling container', render: true, app: true },
// ── The lint's own bookkeeping ──
'waiver': { tier: 'ask', gate: 'waiver', title: 'a waiver the lint refused', fix: 'fix the finding, or waive it with the rule id and a reason about this page' },
'house-style': { tier: 'polish', gate: 'waiver', title: 'more than three rules waived on one page', fix: 'lock the choices into DESIGN.md so later pages inherit them instead of re-arguing them' },