mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-15 04:47:23 +01:00
Merge remote-tracking branch 'origin/main' into outside-reviews-end-with-a-verdict-line
This commit is contained in:
commit
f8914e9c2e
34 files changed
+2542
-13
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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() };
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 + '; })()';
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in new issue
Block a user