mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 18:28:15 +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>
81 lines
4.9 KiB
TypeScript
Executable File
81 lines
4.9 KiB
TypeScript
Executable File
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
// gstack-design-lint — the free, deterministic design lint behind /design-html
|
|
// and /design-review. Static tier over .html .css .jsx .tsx .vue .svelte .astro
|
|
// (Tailwind class strings included); --render measures the page in gstack's own
|
|
// browser (Aside first, the browse daemon otherwise). --scope app is the
|
|
// render-only app profile: five spacing rules inside one --within section.
|
|
//
|
|
// Usage: gstack-design-lint <file-or-dir ...> [--render] [--url <http://…>]
|
|
// [--scope page|component] [--job <job>] [--json] [--rules]
|
|
// gstack-design-lint --scope app (--url <http://…> ... | --state-file <states.json>) --within <selector>
|
|
// [--viewport WxH ...] [--wait-for <selector>] [--waivers <file>] [--json] [--verbose]
|
|
//
|
|
// Prints one "LINT: <blocks|ask|polish|waived> <rule-id> <file>:<line> <evidence> | fix: <fix>"
|
|
// line per finding, then "LINT_SUMMARY: blocks=N ask=N waived=N polish=N render=<ran|skipped: reason>".
|
|
// Exit 2 on blocks, 1 on unanswered asks, 0 otherwise; 3 on a usage error; 4 when
|
|
// --url was given and no browser measured it. Under --scope app: 0 clean, 1 open,
|
|
// 4 incomplete, 3 usage, and a LINT_APP verdict line. docs/design-lint.md is the contract.
|
|
|
|
import { formatResult, lint, type LintOptions } from '../lib/design-lint/index';
|
|
import { appMain, renderUnavailable, type AppCliArgs } from '../lib/design-lint/app-cli';
|
|
import { ruleTable } from '../lib/design-lint/report';
|
|
|
|
const USAGE = 'usage: gstack-design-lint <file-or-dir ...> [--render] [--url <http://…>] [--scope page|component] [--job <job>] [--json] [--rules]\n'
|
|
+ ' gstack-design-lint --scope app (--url <http://…> ... | --state-file <states.json>) --within <selector> [--viewport WxH ...] [--wait-for <selector>] [--waivers <file>] [--json] [--verbose]';
|
|
|
|
const args = process.argv.slice(2);
|
|
const opts: LintOptions & { json: boolean; rules: boolean } = { render: false, urls: [], scope: 'page', job: null, json: false, rules: false };
|
|
const app: AppCliArgs = { paths: [], urls: [], viewports: [], verbose: false, json: false };
|
|
let appScope = false;
|
|
const appFlags: string[] = [];
|
|
const paths: string[] = [];
|
|
const take = (flag: string): string => {
|
|
const v = args[++i];
|
|
if (v === undefined) { console.error(`${flag} needs a value`); console.error(USAGE); process.exit(3); }
|
|
return v;
|
|
};
|
|
let i = 0;
|
|
for (; i < args.length; i++) {
|
|
const a = args[i];
|
|
if (a === '--render') opts.render = true;
|
|
else if (a === '--url') { opts.urls!.push(take(a)); opts.render = true; }
|
|
else if (a === '--scope') { const s = take(a); if (s === 'app') appScope = true; else if (s !== 'page' && s !== 'component') { console.error('--scope must be page, component or app'); process.exit(3); } else opts.scope = s; }
|
|
else if (a === '--job') opts.job = take(a).trim().toLowerCase();
|
|
else if (a === '--json') opts.json = true;
|
|
else if (a === '--rules') opts.rules = true;
|
|
else if (a === '--verbose') app.verbose = true;
|
|
else if (a === '--within') { appFlags.push(a); app.within = take(a); }
|
|
else if (a === '--viewport') { appFlags.push(a); app.viewports.push(take(a)); }
|
|
else if (a === '--wait-for') { appFlags.push(a); app.waitFor = take(a); }
|
|
else if (a === '--waivers') { appFlags.push(a); app.waivers = take(a); }
|
|
else if (a === '--state-file') { appFlags.push(a); app.stateFile = take(a); }
|
|
else if (a === '--help' || a === '-h') { console.log(USAGE); process.exit(0); }
|
|
else if (a.startsWith('--')) { console.error(`unknown flag ${a}`); console.error(USAGE); process.exit(3); }
|
|
else paths.push(a);
|
|
}
|
|
if (opts.rules) {
|
|
for (const r of ruleTable()) console.log(`${r.tier.padEnd(6)} ${r.id.padEnd(26)} ${r.gate.padEnd(7)} ${r.title}`);
|
|
process.exit(0);
|
|
}
|
|
if (appScope) {
|
|
const code = await appMain({ ...app, paths, urls: opts.urls!, json: opts.json }, { out: (s) => console.log(s), err: (s) => console.error(s) });
|
|
process.exit(code);
|
|
}
|
|
if (appFlags.length) {
|
|
console.error(`gstack-design-lint: APP_ONLY: ${appFlags[0]} only with --scope app | fix: add --scope app, or drop ${appFlags[0]}`);
|
|
process.exit(3);
|
|
}
|
|
if (!paths.length && !opts.urls!.length) { console.error(USAGE); process.exit(3); }
|
|
if (opts.urls!.some((u) => !/^https?:\/\//.test(u))) { console.error('--url takes an http or https address'); process.exit(3); }
|
|
|
|
const result = await lint(paths, opts).catch((e: Error) => { console.error(`gstack-design-lint: ${e.message}`); process.exit(3); });
|
|
if (!result.files.length && !opts.urls!.length) {
|
|
console.error('gstack-design-lint: no .html, .css, .jsx, .tsx, .vue, .svelte or .astro files found');
|
|
process.exit(3);
|
|
}
|
|
if (opts.json) console.log(JSON.stringify({ summary: result.summary, files: result.files.length, findings: result.findings }, null, 2));
|
|
else console.log(formatResult(result));
|
|
const missing = renderUnavailable(opts.urls!, result.summary.render);
|
|
if (missing) { console.error(missing); process.exit(4); }
|
|
process.exit(result.exitCode);
|