mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 02:30:39 +01:00
* Hide tokens in URLs that do not parse or that hold punctuation Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Add a build receipt and a fixture preview server so a design state whose API call went unanswered is never reported finished Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Register the preview fixtures as free so editing them does not rerun every paid test Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Add /design-review --build, an opt-in loop that lints, screenshots and fixes UI while you build it Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Recommend the design build loop for UI work in the lane card, routing rules, skills guide and design-html Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Let the design review skill grow by the opt-in build mode's routing line and pointer Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Make the design build loop work when an agent follows it step by step A real run of /design-review --build turned up four problems: - Each bash block runs in a fresh shell, so later blocks lost $RUN and the helper path, and the first block never set the project slug or state root. Blocks now stand alone and later ones use the printed run path. - The lint command it printed was a bare name that is not on PATH; it now prints the full path to the bundled lint. - gstack's own browser can only save screenshots under /tmp, so the section says to save where the browser can write and copy the file into place. - The --build pointer sat before the design hard rules, which every section must follow; it now comes right after them. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Hold the design-review size ratio to the granted 1.357 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Let the free checks find the preview server's test when the server file changes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep the design build loop honest about stale API misses and pages that are not the preview A missing fixture route that was fixed before the final pass no longer blocks the receipt, an API call that arrived with no scenario now does, preflight only promises fixtures-only mutations when every page carries the preview script, and a bad path escape or a fixture with a bad status no longer stops the preview. Review-Finding: p1/specialist-batched#1 reader=state-machines Review-Finding: p1/specialist-batched#2 reader=security Review-Finding: p1/red-team#1 reader=red-team Review-Finding: p1/red-team#2 reader=red-team Review-Finding: p1/red-team#4 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Make the design build loop fail closed on any missed API call and only trust a page the preview actually serves Any API call the preview could not answer during a run now keeps the receipt incomplete, and its fix line says to add the route, restart the preview and start again from preflight. Preflight checks every local page whatever hosts were granted, asks the preview's own probe route instead of looking for a marker in the HTML, and treats a redirect as not the preview. The Vite plugin now applies to the dev server only, so built pages never carry the preview script. Review-Finding: p2v/outside-voice#3 reader=outside-voice Review-Finding: p2/red-team#1 reader=red-team Review-Finding: p2v/outside-voice#1 reader=outside-voice Review-Finding: p2v/outside-voice#2 reader=outside-voice Review-Loop-Exception: a-0aec1d2672bd Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Link the frontend preview guide from the README and note the preview check in the changelog Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Fix a test type error the CI type check caught in the preview probe test Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Fix the five free-suite failures in the design build loop Run the slug helper through the shared script launcher, move the preview server tests into the design build test that imports the product code, let the single-source check skip generated host copies, write the build-loop commands without free-text placeholders (trimmed so the skill stays under its size cap), and add the build routing line to the onboarding fixture. Review-Loop-Exception: a-b2e2abe1c646 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Document how to waive findings in the design build loop and say which requests the preview log records Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep the design build receipt from calling waived, edited or proxied runs clean The receipt now reads waivers from the lint's grade, hashes every file in a touched folder, refuses an inventory that changed after verify-start, and finds the preview's record of unanswered calls in its default state folder. The preview answers a path the app's own dev proxy would forward with a recorded 501, and refuses a state folder that is a symlink or another user's. Review-Finding: p1/red-team#1 reader=red-team Review-Finding: p1/red-team#2 reader=red-team Review-Finding: p1/red-team#3 reader=red-team Review-Finding: p1/red-team#4 reader=red-team Review-Finding: p1/specialist-batched#1 reader=testing Review-Finding: p1/specialist-batched#2 reader=security Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Give the new receipt tests the state mode the inventory type requires, and say the preview does not stop proxied WebSocket upgrades Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Stop a catch-all dev proxy, an unrecorded preview and passwords with @ from slipping past the design build checks An empty server.proxy key now gets the recorded 501 too. The preview's probe names its state directory, so the receipt reads a --state or --port 0 preview's record without --preview, and a preview that keeps no record makes the run incomplete. Text redaction drops userinfo up to the last @ and redacts percent-encoded secret parameter names. Review-Finding: s11/outside-voice#1 reader=outside-voice Review-Finding: s11/targeted#1 reader=targeted Review-Finding: s11/adversarial-native#3 reader=adversarial-native Review-Finding: s11/targeted#2 reader=targeted Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Build the userinfo redaction test's URL from parts so the credential scan does not read it as a real login Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep reading a stopped preview's default record when another origin's preview still answers The receipt now reads the union of --preview, the state directory each answering preview names, and the default gstack-preview-<port> directory for every loopback state origin that did not answer the probe. servedStates reports which origin each answer came from. Review-Finding: fc3/fix-check#1 reader=fix-check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * List the design build receipt, its library and the preview template in the project structure guide 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>
358 lines
26 KiB
TypeScript
358 lines
26 KiB
TypeScript
/**
|
|
* The receipt behind `/design-review --build` (bin/gstack-design-build). The
|
|
* skill's loop edits and looks; this module decides what the run proved, so
|
|
* "never clean on skipped or partial coverage" lives in code, not prose:
|
|
*
|
|
* - `plan` turns the state inventory into the lint's --state-file, the exact
|
|
* lint command and one screenshot path per state and viewport.
|
|
* - `preflight` checks the browser first, then that every target is loopback
|
|
* (or granted by name), then that each URL answers, before any round.
|
|
* - `verify-start` records the measured tree identity right before the
|
|
* read-only final pass; `receipt` refuses evidence older than it or a tree
|
|
* that moved since, and reads the verdict from the lint's JSON, never its
|
|
* exit status.
|
|
* - `check` recomputes the tree identity so a later edit invalidates the
|
|
* receipt.
|
|
*
|
|
* Nothing here writes to the repository: no commit, stash, checkout or reset.
|
|
*/
|
|
import { createHash } from 'node:crypto';
|
|
import { execFileSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { pickEngine, type EngineChoice } from './aside-render';
|
|
import { DEFAULT_VIEWPORTS, parseViewport, redactText, redactUrl, viewportLabel } from './design-lint/app-input';
|
|
|
|
export const MAX_ROUNDS = 3;
|
|
export const RECEIPT_VERSION = 1;
|
|
const DOC = 'docs/howto-preview-a-frontend.md';
|
|
const PREVIEW_PROBE = '/api/__gstack-preview';
|
|
|
|
export class BuildUsageError extends Error {
|
|
constructor(readonly code: string, message: string, readonly fix: string) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
export interface InventoryState { name: string; url?: string; mode: 'url' | 'screenshot'; expect?: string; within?: string; scenario?: string; steps?: string }
|
|
export interface Inventory { version: 1; within: string; viewports: string[]; fixtures?: string; touched: string[]; states: InventoryState[] }
|
|
|
|
export type Outcome = 'clean' | 'waived' | 'open' | 'incomplete' | 'unrendered' | 'stale';
|
|
|
|
/** Every code the build line can carry, with the one fix the loop is allowed to try. */
|
|
export const BUILD_FIXES: Record<string, string> = {
|
|
INSTALL: "run install.sh --for design-review from the lane's repo, then launch with its GSTACK_LAUNCH line",
|
|
NO_BROWSER: 'gstack-browser-ensure, then rerun /design-review --build',
|
|
NO_PREVIEW: `start the preview (${DOC}), then rerun`,
|
|
UNAUTHORIZED_TARGET: 'serve the app on 127.0.0.1 with the preview template, or rerun with --grant-remote <host> written by the person who owns it',
|
|
TREE_CHANGED: 'the tree moved after the final pass began; rerun the final verification pass only',
|
|
STALE_EVIDENCE: 'a lint result or screenshot predates the final pass; rerun the final verification pass only',
|
|
LINT_MISSING: 'run the LINT_CMD line from plan, then rerun receipt',
|
|
VIEWPORTS: 'run the LINT_CMD line from plan unchanged; it measures every planned viewport',
|
|
NOT_LINTED: 'a URL state is missing from the lint result; run the LINT_CMD line from plan unchanged',
|
|
PREVIEW_UNHANDLED: `add the named route to the scenario's fixture file (${DOC}#fixtures), restart the preview, then start a fresh run from preflight`,
|
|
PREVIEW_UNKNOWN_SCENARIO: `add or fix the scenario's fixture file (${DOC}#fixtures), restart the preview, then start a fresh run from preflight`,
|
|
PREVIEW_NO_SCENARIO: 'give the state URL an ?as=<scenario> query, restart the preview, then start a fresh run from preflight',
|
|
NOT_SHOT: 'take the SHOT lines from plan for the named states, then rerun receipt',
|
|
LINT_VERDICT: 'the lint result has no verdict; run the LINT_CMD line from plan unchanged',
|
|
INVENTORY_CHANGED: 'the inventory changed after verify-start; rerun the final verification pass from plan',
|
|
PREVIEW_UNRECORDED: 'start the preview template with --state <dir> (or give the plugin a stateDir), then start a fresh run from preflight',
|
|
PREVIEW_LIVE_PROXY: `serve the route under the API prefix from a fixture (${DOC}#fixtures), restart the preview, then start a fresh run from preflight`,
|
|
};
|
|
|
|
const NAME = /^[A-Za-z0-9._-]{1,64}$/;
|
|
const isLoopbackHost = (h: string): boolean => h === 'localhost' || h === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(h);
|
|
const relSafe = (p: unknown): boolean => typeof p === 'string' && p.length > 0 && !path.isAbsolute(p) && !p.split(/[\\/]/).includes('..');
|
|
|
|
export function parseInventory(text: string): Inventory {
|
|
const bad = (m: string): never => { throw new BuildUsageError('BAD_INVENTORY', m, `fix the inventory file; its format is in ${DOC}#inventory`); };
|
|
let raw: Record<string, unknown>;
|
|
try { raw = JSON.parse(text); } catch (e) { return bad(`the inventory is not JSON (${(e as Error).message})`); }
|
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return bad('the inventory is not a JSON object');
|
|
const unknown = Object.keys(raw).filter((k) => !['version', 'within', 'viewports', 'fixtures', 'touched', 'states'].includes(k));
|
|
if (unknown.length) bad(`unknown inventory key ${unknown[0]}`);
|
|
if (raw.version !== 1) bad('inventory version must be 1');
|
|
if (typeof raw.within !== 'string' || !raw.within.trim()) bad('the inventory needs within: the section selector');
|
|
const viewports = raw.viewports === undefined ? DEFAULT_VIEWPORTS.map(viewportLabel) : raw.viewports;
|
|
if (!Array.isArray(viewports) || !viewports.length) return bad('viewports must be a non-empty list like ["1440x900","375x812"]');
|
|
for (const v of viewports) parseViewport(String(v));
|
|
const touched = raw.touched ?? [];
|
|
if (!Array.isArray(touched) || !touched.every(relSafe)) bad('touched must list repo-relative paths');
|
|
if (raw.fixtures !== undefined && !relSafe(raw.fixtures)) bad('fixtures must be a repo-relative directory');
|
|
if (!Array.isArray(raw.states) || !raw.states.length) return bad('the inventory lists no states');
|
|
const seen = new Set<string>();
|
|
const states = (raw.states as Array<Record<string, unknown>>).map((s, i): InventoryState => {
|
|
if (!s || typeof s !== 'object') bad(`state ${i + 1} is not an object`);
|
|
const extra = Object.keys(s).filter((k) => !['name', 'url', 'mode', 'expect', 'within', 'scenario', 'steps'].includes(k));
|
|
if (extra.length) bad(`state ${i + 1} has unknown key ${extra[0]}`);
|
|
for (const k of ['name', 'expect', 'within', 'scenario', 'url', 'steps', 'mode']) if (s[k] !== undefined && typeof s[k] !== 'string') bad(`state ${i + 1} ${k} must be a string`);
|
|
const name = s.name as string;
|
|
if (!name || !NAME.test(name)) bad(`state ${i + 1} needs a name of letters, digits, dot, dash or underscore`);
|
|
if (seen.has(name)) bad(`state ${name} is listed twice`);
|
|
seen.add(name);
|
|
const mode = (s.mode ?? 'url') as string;
|
|
if (mode !== 'url' && mode !== 'screenshot') bad(`state ${name} mode must be url or screenshot`);
|
|
if (mode === 'url' && !/^https?:\/\//.test((s.url as string) ?? '')) bad(`state ${name} needs an http(s) url`);
|
|
if (mode === 'screenshot' && !s.steps) bad(`screenshot-only state ${name} needs steps: how to reach it by hand`);
|
|
const out: InventoryState = { name, mode: mode as InventoryState['mode'] };
|
|
for (const k of ['url', 'expect', 'within', 'scenario', 'steps'] as const) if (s[k] !== undefined) out[k] = s[k] as string;
|
|
return out;
|
|
});
|
|
if (!states.some((s) => s.mode === 'url')) bad('the inventory needs at least one url state: screenshot-only states are never linted, so a run of only those proves nothing');
|
|
return { version: 1, within: raw.within as string, viewports: viewports.map(String), ...(raw.fixtures ? { fixtures: raw.fixtures as string } : {}), touched: touched as string[], states };
|
|
}
|
|
|
|
/** The scenario a state's API calls carry: its own `scenario`, else its URL's ?as=, else none (''). */
|
|
export function scenarioOf(s: InventoryState): string {
|
|
if (s.scenario !== undefined) return s.scenario;
|
|
try { return new URL(s.url ?? '').searchParams.get('as') ?? ''; } catch { return ''; }
|
|
}
|
|
|
|
export const shotPath = (runDir: string, state: string, viewport: string): string => path.join(runDir, 'shots', `${state}-${viewport}.png`);
|
|
|
|
const quote = (s: string): string => `'${s.replace(/'/g, "'\\''")}'`;
|
|
|
|
/** The lint --state-file, the exact lint command and every screenshot path, written once per run. */
|
|
export function plan(inv: Inventory, runDir: string, waivers?: string): string[] {
|
|
fs.mkdirSync(path.join(runDir, 'shots'), { recursive: true, mode: 0o700 });
|
|
const urlStates = inv.states.filter((s) => s.mode === 'url');
|
|
const stateFile = path.join(runDir, 'states.json');
|
|
const lintStates = urlStates.map((s) => ({ name: s.name, url: s.url, ...(s.expect ? { expect: s.expect } : {}), ...(s.within ? { within: s.within } : {}) }));
|
|
fs.writeFileSync(stateFile, JSON.stringify({ version: 1, states: lintStates }, null, 2) + '\n', { mode: 0o600 });
|
|
const lint = [quote(path.resolve(import.meta.dir, '..', 'bin', 'gstack-design-lint')), '--scope', 'app', '--state-file', quote(stateFile), '--within', quote(inv.within), ...inv.viewports.flatMap((v) => ['--viewport', v]), ...(waivers ? ['--waivers', quote(waivers)] : []), '--json', '>', quote(path.join(runDir, 'lint.json'))];
|
|
return [
|
|
`DESIGN_BUILD_PLAN: states=${inv.states.length} url=${urlStates.length} screenshot_only=${inv.states.length - urlStates.length} viewports=${inv.viewports.join(',')} run=${runDir}`,
|
|
...(urlStates.length ? [`LINT_CMD: ${lint.join(' ')}`] : []),
|
|
...inv.states.flatMap((s) => inv.viewports.map((v) => `SHOT: ${s.name} ${v} ${s.mode === 'url' ? redactUrl(s.url!) : 'screenshot-only: ' + s.steps} -> ${shotPath(runDir, s.name, v)}`)),
|
|
];
|
|
}
|
|
|
|
const git = (repo: string, args: string[]): string => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, timeout: 60_000 });
|
|
|
|
function filesUnder(repo: string, rel: string): string[] {
|
|
const abs = path.join(repo, rel);
|
|
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) return [rel];
|
|
return fs.readdirSync(abs, { recursive: true, withFileTypes: true })
|
|
.filter((d) => d.isFile())
|
|
.map((d) => path.relative(repo, path.join(d.parentPath, d.name)).split(path.sep).join('/'));
|
|
}
|
|
|
|
/** The measured dirty-tree identity: HEAD, every tracked diff, the untracked files the loop touched and the fixture content. */
|
|
export function treeIdentity(repo: string, inv: Pick<Inventory, 'touched' | 'fixtures'>): string {
|
|
const h = createHash('sha256');
|
|
h.update('head\0' + git(repo, ['rev-parse', 'HEAD']).trim() + '\0');
|
|
h.update('diff\0' + git(repo, ['diff', 'HEAD', '--binary', '--no-ext-diff', '--no-textconv']) + '\0');
|
|
const files = [...new Set([...inv.touched.flatMap((t) => filesUnder(repo, t)), ...(inv.fixtures ? filesUnder(repo, inv.fixtures) : [])])].sort();
|
|
for (const rel of files) {
|
|
const abs = path.join(repo, rel);
|
|
h.update('file\0' + rel + '\0');
|
|
h.update(fs.existsSync(abs) && fs.statSync(abs).isFile() ? fs.readFileSync(abs) : Buffer.from('\0missing'));
|
|
h.update('\0');
|
|
}
|
|
return h.digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Right before the read-only final pass: record the tree it measures. The
|
|
* pass began at verify.json's own mtime, so evidence is compared on the
|
|
* filesystem's clock, not the process's.
|
|
*/
|
|
const inventoryHash = (inv: Inventory): string => createHash('sha256').update(JSON.stringify(inv)).digest('hex');
|
|
|
|
export function verifyStart(repo: string, inv: Inventory, runDir: string): string {
|
|
const tree = treeIdentity(repo, inv);
|
|
fs.mkdirSync(runDir, { recursive: true, mode: 0o700 });
|
|
fs.writeFileSync(path.join(runDir, 'verify.json'), JSON.stringify({ tree, inventory: inventoryHash(inv) }) + '\n', { mode: 0o600 });
|
|
return `VERIFY: tree=${tree.slice(0, 12)} run=${runDir}`;
|
|
}
|
|
|
|
/** Each loopback state origin where a preview answered the probe, with the state directory it names; state is null when it keeps no record. */
|
|
export async function servedStates(inv: Inventory): Promise<Array<{ origin: string; state: string | null }>> {
|
|
const origins = [...new Set(inv.states.filter((s) => s.url && isLoopbackHost(new URL(s.url).hostname)).map((s) => new URL(s.url!).origin))];
|
|
const out: Array<{ origin: string; state: string | null }> = [];
|
|
for (const origin of origins) {
|
|
try {
|
|
const res = await fetch(new URL(PREVIEW_PROBE, origin), { signal: AbortSignal.timeout(10_000), redirect: 'manual' });
|
|
if (res.status === 204 && res.headers.get('x-gstack-preview') === 'ready') out.push({ origin, state: decodeURIComponent(res.headers.get('x-gstack-preview-state') ?? '') || null });
|
|
} catch {}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export interface PreflightDeps { engine?: () => EngineChoice; reach?: (url: string) => Promise<string | null>; preview?: (url: string) => Promise<boolean> }
|
|
|
|
/** The preview template answers its probe route with 204 and x-gstack-preview: ready; a redirect, a marker in the HTML or any other answer is not the preview. */
|
|
async function previewAnswers(url: string): Promise<boolean> {
|
|
try {
|
|
const res = await fetch(new URL(PREVIEW_PROBE, url), { signal: AbortSignal.timeout(10_000), redirect: 'manual' });
|
|
return res.status === 204 && res.headers.get('x-gstack-preview') === 'ready';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function reachable(url: string): Promise<string | null> {
|
|
try {
|
|
const res = await fetch(url, { signal: AbortSignal.timeout(10_000), redirect: 'manual' });
|
|
return res.status >= 500 ? `answered ${res.status}` : null;
|
|
} catch (e) {
|
|
return (e as Error).message;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Before any round: the browser engine first, then target authorization (a
|
|
* non-loopback target needs a grant naming its host, else the run stops
|
|
* incomplete before any page opens), then each URL state answers.
|
|
*/
|
|
export async function preflight(inv: Inventory, grants: string[], deps: PreflightDeps = {}): Promise<{ lines: string[]; exit: number }> {
|
|
const engine = (deps.engine ?? (() => pickEngine()))();
|
|
if (!engine.engine) return { lines: [buildLine({ outcome: 'unrendered', code: 'NO_BROWSER', detail: redactText(engine.error) })], exit: 4 };
|
|
const withUrl = inv.states.filter((s) => s.url);
|
|
const remote = withUrl.filter((s) => { const h = new URL(s.url!).hostname; return !isLoopbackHost(h) && !grants.includes(h); });
|
|
if (remote.length) return { lines: [buildLine({ outcome: 'incomplete', code: 'UNAUTHORIZED_TARGET', detail: `${remote.map((s) => `${s.name} at ${redactUrl(s.url!)}`).join(', ')} is not on loopback and has no grant; no page was opened and no action ran` })], exit: 4 };
|
|
const down: string[] = [];
|
|
for (const s of withUrl) {
|
|
const err = await (deps.reach ?? reachable)(s.url!);
|
|
if (err) down.push(`${s.name} (${redactUrl(s.url!)}: ${redactText(err)})`);
|
|
}
|
|
if (down.length) return { lines: [buildLine({ outcome: 'unrendered', code: 'NO_PREVIEW', detail: `no answer from ${down.join(', ')}` })], exit: 4 };
|
|
const granted = withUrl.some((s) => !isLoopbackHost(new URL(s.url!).hostname));
|
|
const local = withUrl.filter((s) => isLoopbackHost(new URL(s.url!).hostname));
|
|
const previewed = (await Promise.all(local.map((s) => (deps.preview ?? previewAnswers)(s.url!)))).every(Boolean);
|
|
return { lines: [`PREFLIGHT: ok engine=${engine.engine} states=${inv.states.length} targets=${granted ? 'loopback+granted' : 'loopback'} mutations=${!previewed ? 'unknown' : granted ? 'fixtures+granted' : 'fixtures-only'}`], exit: 0 };
|
|
}
|
|
|
|
/** Map a gstack-skill-start transcript: a refused start is the build's INSTALL outcome. */
|
|
export function admit(startOutput: string): { lines: string[]; exit: number } {
|
|
if (!/^GSTACK_RESULT: skill=design-review status=refused\b/m.test(startOutput)) return { lines: ['ADMIT: ok'], exit: 0 };
|
|
const row = /^INSTALL: .*$/m.exec(startOutput)?.[0] ?? 'the preamble refused this workflow';
|
|
return { lines: [buildLine({ outcome: 'unrendered', code: 'INSTALL', detail: redactText(row) })], exit: 4 };
|
|
}
|
|
|
|
export interface Counts { planned: number; url: number; linted: number; shot: number; screenshotOnly: number; open: number; waived: number }
|
|
export interface LineInput { outcome: Outcome; code: string; detail?: string; counts?: Counts; rounds?: number; viewports?: string[]; tree?: string; receipt?: string }
|
|
|
|
/** The one DESIGN_BUILD line a READY pastes; every outcome and code prints through here. */
|
|
export function buildLine(i: LineInput): string {
|
|
const c = i.counts;
|
|
const parts = [`DESIGN_BUILD: ${i.outcome}`, `code=${i.code}`];
|
|
if (c) parts.push(`linted=${c.linted}/${c.url}`, `shot=${c.shot}/${c.planned}`, `screenshot_only=${c.screenshotOnly}`, `open=${c.open}`, `waived=${c.waived}`);
|
|
if (i.rounds !== undefined) parts.push(`rounds=${i.rounds}`);
|
|
if (i.viewports) parts.push(`viewports=${i.viewports.join(',')}`);
|
|
if (i.tree) parts.push(`tree=${i.tree.slice(0, 12)}`);
|
|
if (i.receipt) parts.push(`receipt=${i.receipt}`);
|
|
const fix = BUILD_FIXES[i.code];
|
|
return parts.join(' ') + (i.detail ? ` (${i.detail.replace(/\s+/g, ' ').replace(/ \| fix: /g, ' / fix: ')})` : '') + (fix ? ` | fix: ${fix}` : '');
|
|
}
|
|
|
|
export interface UnhandledHit { scenario: string; method: string; path: string; code: string }
|
|
|
|
/** The preview template's record of API calls it could not answer (unhandled.jsonl in its state directory). */
|
|
export function readUnhandled(previewDir: string): UnhandledHit[] {
|
|
const file = path.join(previewDir, 'unhandled.jsonl');
|
|
if (!fs.existsSync(file)) return [];
|
|
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l) as UnhandledHit);
|
|
}
|
|
|
|
interface LintFinding { rule: string; tier: string; grade: string; evidence?: string; waiver?: string; app?: { state: string; viewport: string; locator: string; measured: number; threshold: number } }
|
|
interface LintCoverage { name: string; viewport: string; status: string; code: string }
|
|
interface LintJson { findings?: LintFinding[]; app?: { verdict?: string; code?: string; codes?: string[]; viewports?: string[]; states?: LintCoverage[] } }
|
|
|
|
export interface ReceiptInput { repo: string; inv: Inventory; runDir: string; rounds: number; previewDir?: string; served?: Array<{ origin: string; state: string | null }>; waivers?: string; outRoot: string; now?: () => Date }
|
|
|
|
const mtime = (f: string): number => fs.statSync(f).mtimeMs;
|
|
|
|
function readLint(runDir: string, verifyAt: number, add: (code: string, detail: string) => void): LintJson | null {
|
|
const file = path.join(runDir, 'lint.json');
|
|
if (!fs.existsSync(file)) { add('LINT_MISSING', 'no lint.json'); return null; }
|
|
if (mtime(file) < verifyAt) add('STALE_EVIDENCE', 'lint.json predates the final pass');
|
|
try { return JSON.parse(fs.readFileSync(file, 'utf8')) as LintJson; } catch { add('LINT_MISSING', 'lint.json is not JSON'); return null; }
|
|
}
|
|
|
|
/** Decide the outcome from the evidence on disk, write the versioned receipt and return its one line. */
|
|
export function receipt(r: ReceiptInput): { line: string; file: string; exit: number; receipt: Record<string, unknown> & { states: Array<Record<string, unknown>>; open: unknown[]; waived: unknown[] } } {
|
|
if (!Number.isInteger(r.rounds) || r.rounds < 0 || r.rounds > MAX_ROUNDS) throw new BuildUsageError('ROUNDS', `--rounds ${r.rounds} is outside 0 to ${MAX_ROUNDS}`, `the loop stops editing after round ${MAX_ROUNDS}; report the open list instead of a fourth round`);
|
|
const verifyFile = path.join(r.runDir, 'verify.json');
|
|
if (!fs.existsSync(verifyFile)) throw new BuildUsageError('NO_VERIFY', `${verifyFile} is missing`, 'run verify-start right before the final verification pass');
|
|
const verify = { ...(JSON.parse(fs.readFileSync(verifyFile, 'utf8')) as { tree: string; inventory?: string }), at: mtime(verifyFile) };
|
|
const tree = treeIdentity(r.repo, r.inv);
|
|
const codes: string[] = [];
|
|
const details: string[] = [];
|
|
const add = (code: string, detail: string): void => { if (!codes.includes(code)) codes.push(code); details.push(detail); };
|
|
if (tree !== verify.tree) add('TREE_CHANGED', 'the tree changed after verify-start');
|
|
if (verify.inventory !== inventoryHash(r.inv)) add('INVENTORY_CHANGED', 'the inventory is not the one verify-start recorded');
|
|
|
|
const urlStates = r.inv.states.filter((s) => s.mode === 'url');
|
|
const lint = urlStates.length ? readLint(r.runDir, verify.at, add) : null;
|
|
const verdict = lint?.app?.verdict;
|
|
if (lint && !['clean', 'open', 'incomplete'].includes(verdict ?? '')) add('LINT_VERDICT', 'lint.json carries no app verdict');
|
|
if (lint && (lint.app?.viewports ?? []).join(',') !== r.inv.viewports.join(',')) add('VIEWPORTS', `linted ${(lint.app?.viewports ?? []).join(',') || 'none'}, planned ${r.inv.viewports.join(',')}`);
|
|
|
|
const served = r.served ?? [];
|
|
if (!r.previewDir && served.some((s) => s.state === null)) add('PREVIEW_UNRECORDED', 'a preview answered at a state origin but keeps no record of unanswered calls');
|
|
const answered = new Set(served.map((s) => s.origin));
|
|
const unanswered = urlStates.map((s) => new URL(s.url!)).filter((u) => isLoopbackHost(u.hostname) && !answered.has(u.origin));
|
|
const previewDirs = [...new Set([...(r.previewDir ? [r.previewDir] : []), ...served.flatMap((s) => (s.state ? [s.state] : [])), ...unanswered.map((u) => path.join(os.tmpdir(), `gstack-preview-${u.port || 80}`))])];
|
|
const hits = previewDirs.flatMap(readUnhandled);
|
|
const states = r.inv.states.map((s) => {
|
|
const cov = (lint?.app?.states ?? []).filter((c) => c.name === s.name);
|
|
const linted = s.mode === 'url' && r.inv.viewports.every((v) => cov.some((c) => c.viewport === v && c.status === 'measured'));
|
|
const shots = r.inv.viewports.map((v) => shotPath(r.runDir, s.name, v)).filter((f) => fs.existsSync(f));
|
|
const scenario = scenarioOf(s);
|
|
const unhandled = s.mode === 'url' ? hits.filter((h) => h.scenario === scenario || !h.scenario) : [];
|
|
if (s.mode === 'url' && lint && !linted) {
|
|
const bad = cov.find((c) => c.status !== 'measured');
|
|
add(bad?.code ?? 'NOT_LINTED', `${s.name} not linted at every viewport${bad ? ` (${bad.code})` : ''}`);
|
|
}
|
|
if (shots.length < r.inv.viewports.length) add('NOT_SHOT', `${s.name} has ${shots.length}/${r.inv.viewports.length} screenshots`);
|
|
if (shots.some((f) => mtime(f) < verify.at)) add('STALE_EVIDENCE', `a screenshot of ${s.name} predates the final pass`);
|
|
for (const h of unhandled) add(h.code, `${s.name}: ${h.method} ${redactText(h.path)} (scenario ${h.scenario || 'none'})`);
|
|
const expectPassed = s.mode === 'url' && lint ? cov.length > 0 && !cov.some((c) => c.code === 'STATE_MISMATCH') && cov.some((c) => c.status === 'measured') : null;
|
|
return {
|
|
name: s.name, mode: s.mode, ...(s.url ? { url: redactUrl(s.url) } : {}), ...(s.steps ? { steps: s.steps } : {}), scenario, linted,
|
|
viewports: cov.map((c) => ({ viewport: c.viewport, status: c.status, code: c.code })),
|
|
expect: s.expect ? { selector: s.expect, passed: expectPassed } : null,
|
|
shots: shots.map((f) => path.relative(r.runDir, f)), unhandled,
|
|
};
|
|
});
|
|
const appFindings = (lint?.findings ?? []).filter((f) => f.app);
|
|
const open = appFindings.filter((f) => f.grade !== 'waived');
|
|
const waived = appFindings.filter((f) => f.grade === 'waived');
|
|
const counts: Counts = { planned: states.length, url: urlStates.length, linted: states.filter((s) => s.linted).length, shot: states.filter((s) => s.shots.length === r.inv.viewports.length).length, screenshotOnly: states.length - urlStates.length, open: open.length, waived: waived.length };
|
|
|
|
let outcome: Outcome;
|
|
if (codes.length) outcome = 'incomplete';
|
|
else if (urlStates.length && verdict === 'open') outcome = 'open';
|
|
else if (urlStates.length && verdict !== 'clean') outcome = 'incomplete';
|
|
else outcome = waived.length ? 'waived' : 'clean';
|
|
const code = outcome === 'incomplete' ? (codes[0] ?? 'LINT_VERDICT') : outcome === 'open' ? 'OPEN' : 'OK';
|
|
|
|
const head = git(r.repo, ['rev-parse', 'HEAD']).trim();
|
|
const dir = path.join(r.outRoot, `design-build-${head.slice(0, 12)}-${tree.slice(0, 12)}`);
|
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
const file = path.join(dir, 'receipt.json');
|
|
const waivers = r.waivers ? { path: r.waivers, sha256: createHash('sha256').update(fs.readFileSync(r.waivers)).digest('hex') } : null;
|
|
const line = buildLine({ outcome, code, detail: outcome === 'incomplete' ? details.slice(0, 6).join('; ') : undefined, counts, rounds: r.rounds, viewports: r.inv.viewports, tree, receipt: file });
|
|
const rec = {
|
|
version: RECEIPT_VERSION, outcome, code, codes, head, tree, measured_tree: verify.tree, inventory: inventoryHash(r.inv), rounds: r.rounds, viewports: r.inv.viewports, within: r.inv.within,
|
|
counts, states, lint: lint ? { verdict: verdict ?? null, code: lint.app?.code ?? null, codes: lint.app?.codes ?? [] } : null,
|
|
open: open.map((f) => ({ rule: f.rule, evidence: redactText(f.evidence ?? ''), ...f.app })),
|
|
waived: waived.map((f) => ({ rule: f.rule, reason: f.waiver ?? '', ...f.app })),
|
|
waivers, touched: r.inv.touched, fixtures: r.inv.fixtures ?? null, created_at: (r.now ?? (() => new Date()))().toISOString(), line,
|
|
};
|
|
fs.writeFileSync(file, JSON.stringify(rec, null, 2) + '\n', { mode: 0o600 });
|
|
return { line, file, exit: outcome === 'clean' || outcome === 'waived' ? 0 : outcome === 'open' ? 1 : 4, receipt: rec };
|
|
}
|
|
|
|
/** A receipt stays good only while the tree it measured is the tree on disk. */
|
|
export function check(repo: string, inv: Inventory, receiptFile: string): { line: string; exit: number } {
|
|
const rec = JSON.parse(fs.readFileSync(receiptFile, 'utf8')) as { tree: string; inventory: string; line: string; outcome: Outcome; version: number };
|
|
if (rec.version !== RECEIPT_VERSION) return { line: buildLine({ outcome: 'stale', code: 'TREE_CHANGED', detail: `receipt version ${rec.version} is not ${RECEIPT_VERSION}` }), exit: 4 };
|
|
const now = treeIdentity(repo, inv);
|
|
if (rec.inventory !== inventoryHash(inv)) return { line: buildLine({ outcome: 'stale', code: 'INVENTORY_CHANGED', detail: 'the inventory is not the one the receipt measured', receipt: receiptFile }), exit: 4 };
|
|
if (now !== rec.tree) return { line: buildLine({ outcome: 'stale', code: 'TREE_CHANGED', detail: `measured ${rec.tree.slice(0, 12)}, tree is now ${now.slice(0, 12)}`, tree: now, receipt: receiptFile }), exit: 4 };
|
|
return { line: rec.line, exit: rec.outcome === 'clean' || rec.outcome === 'waived' ? 0 : rec.outcome === 'open' ? 1 : 4 };
|
|
}
|