Execution Flow
Matches docs/FLOW.md in the repository — update both together if the
pipeline changes.
scan — multi-route scan with dev-server resolution
Section titled “scan — multi-route scan with dev-server resolution”user │ npx frontend-qa scan [--url ...] [--viewport WxH] [--har] │ [--discover-routes] [--a11y] [--perf] [--visual] ▼cli ├─ parse args (commander) ├─ detectProject(cwd) → { framework, hasVite, devScript?, packageManager } ├─ resolveConfig(args, cwd) → ResolvedConfig (validated by Zod) ├─ if no --url: │ findRunningDevServer() → probe 127.0.0.1:{5173,3000,4173,8080}, reuse first hit │ else launchDevServer() → spawn detected dev script, poll guessed port, own lifecycle ▼browser.runBrowserScan ├─ startRun() → runId (ULID), startedAt ├─ if --perf: getFreePort() for Lighthouse's own CDP connection ├─ launch Playwright (chromium, headless by default, +--remote-debugging-port if --perf) ├─ new BrowserContext + Page (viewport from config, recordHar if --har) ├─ register collectors: │ console → page.on('console'), page.on('pageerror') │ network → page.on('requestfinished'), page.on('requestfailed') │ screenshot→ captured post-navigation │ timing → performance.timing snapshot │ a11y → (--a11y) inject axe-core, page.evaluate(axe.run()) per route │ perf → (--perf) lighthouse(url, { port: cdpPort, onlyCategories:['performance'] }) │ visual → (--visual) screenshot vs baseline/<route>.png via pixelmatch ├─ routeQueue = [...config.routes] (grows if --discover-routes finds new links) ├─ for each route: │ page.goto(url + route, { waitUntil: 'networkidle', timeout }) │ waitForQuiet(page) ← no new console/pageerror/request for 200ms, capped 1500ms │ collectors.onNavigate(route) │ if --discover-routes: scrape same-origin <a href>, enqueue unseen (cap 20) ├─ teardown all collectors → typed results ▼core.normalize ├─ collector.toIssues(result, ctx) → Issue[] ├─ dedup by stable id ├─ apply severity + evidence tags ▼core.report ├─ Report Zod schema validation ├─ write .local/frontend-qa/runs/<runId>/report.json ├─ move screenshots into runs/<runId>/screenshots/; network.har alongside if --har ▼cli output ├─ human-readable summary to stdout (counts by severity + collector) ├─ if the CLI launched the dev server itself, stop it (finally block) └─ exit(2) if no URL resolved; else exit(1) if issues ≥ --fail-on, else exit(0)bundle — no browser
Section titled “bundle — no browser”user │ npx frontend-qa bundle --dir dist/assets ▼cli → core.analyzeBundle(dir) ├─ walk dir recursively, collect .js/.css files + sizes ├─ flag files ≥250KB (medium) / ≥500KB (high) ▼cli output → summary to stdout, exit(1) if any high/critical issueNo Report/runId/browser involved — filesystem-only.
static — no browser
Section titled “static — no browser”user │ npx frontend-qa static [--dir src] ▼cli → core.analyzeStatic(dir) ├─ ts-morph Project loads every .ts/.tsx/.js/.jsx under dir ├─ per file: missing <img alt>, dangerouslySetInnerHTML, TODO/FIXME regex, │ unresolved relative imports (filesystem existence check) ├─ project-wide: dead PascalCase exports (zero cross-file references) ▼cli output → summary to stdout, exit(1) if any high/critical issueFindings carry source.url/source.line (and element.snippet for JSX
findings) so they can be cross-referenced against runtime issues without a
schema difference.
MCP server — stdio, external AI clients
Section titled “MCP server — stdio, external AI clients”AI client (Claude Code, etc.) │ spawns `node packages/mcp-server/dist/bin.js` (stdio transport) ▼mcp-server.bin → runStdioServer() → createServer() → registers 5 tools │ ├─ frontend.run_scan(args) │ ├─ detectProject(cwd), resolveConfig(args) │ ├─ resolveDevServerUrl(cwd, project, config) ← same helper the CLI uses │ ├─ runBrowserScan({ cwd, config, project }) ← same orchestrator the CLI uses │ └─ returns a text summary (runId, reportPath, issue counts by severity) │ ├─ frontend.get_report(runId?) → readReport() → full Report JSON (text) ├─ frontend.get_issues(runId?, minSeverity?, collector?, category?) │ → readReport() → filter → Issue[] JSON (text) ├─ frontend.get_console_errors(runId?) │ → readReport() → filter collector === 'browser.console' → Issue[] JSON (text) └─ frontend.get_screenshot(runId?, route?) → readReport() → match artifacts.screenshots by slug(route) → read PNG → return as MCP image content (base64 + mimeType)readReport()/findLatestRunId() default to the most recent run (ULID run
IDs sort lexicographically by creation time) when no runId is given. No
new analysis logic — every tool is a thin wrapper over core/browser
functions the CLI already uses.
Redaction pipeline
Section titled “Redaction pipeline”raw header/body ─► redactHeaders() / redactString() ─► Issue.metadata (safe) ─► JSONRedaction happens at the collector boundary, never later — original values never touch disk.