mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-12 03:30:28 +01:00
* feat(design-lint): --scope app measures one app section for row spacing, flush help copy, loud notes and small targets (GST-1741 PR 1)
Adds an opt-in, render-only app profile to gstack-design-lint: its own
scheduler (lib/design-lint/render-app.ts) and probes
(lib/design-lint/render-probe-app.ts on window.__gstackLintApp), five
app-only rules at ask (row-crowding, copy-flush-under-list,
note-in-row-weight, small-hit-area, section-overflow), --within matched
through open shadow roots, readiness and state assertions, a LINT_APP
verdict with exit 0 clean / 1 open / 4 incomplete / 3 usage, --waivers,
URL redaction everywhere and private trace files for probe errors.
Behavior change: --url with no browser now exits 4 with
RENDER_UNAVAILABLE under every scope. Page and component output is
otherwise byte-identical to e58be61 (test/design-lint-scope-guard.test.ts).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(design-lint): app profile scope, label-target and select-in-row cases (GST-1741 PR 1)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(design-lint): app rows read laid-out unboxed lists, and subpixel noise never fires a threshold (GST-1741 PR 1)
Measured against six real qm builds from the probe: the shipped access list
(flex rows, no border) slipped past row-crowding, and 8px padding read as 7.99px
and fired. Rows that set an avatar, text and control side by side now count as
a list of rows; thresholds ignore the last 0.05px.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (GST-1741 PR 1)
Redact secret-looking #fragment parameters like query ones; keep two --url states whose redacted names match as two states (name~2) and refuse only the same URL twice; a trace that cannot be written keeps the state PROBE_ERROR instead of crashing to exit 1; a section selector the browser refuses no longer holds the soft readiness wait, and a NOT_READY without --wait-for names the section.
Review-Finding: p1/specialist-batched#1 reader=security
Review-Finding: p1/specialist-batched#2 reader=testing
Review-Finding: p1/red-team#1 reader=red-team
Review-Finding: p1/red-team#2 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: a generated state name never collides with a literal one (GST-1741 PR 1)
Names for --url states are given out from the set of names already used, so /p, /p#x and /p~2 measure as three states. Review-loop exception a-7c954c6c661f.
Review-Finding: p2/outside-voice#1 reader=outside-voice
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: design-lint usage and project map name --scope app (GST-1741 PR 1)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* Describe the app section check in everyday words in the changelog
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
261 lines
15 KiB
TypeScript
261 lines
15 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|