argusqa-os 9.7.5 → 9.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp-server.js CHANGED
@@ -29,14 +29,22 @@ import { createRequire } from 'module';
29
29
  import { createMcpClient } from './utils/mcp-client.js';
30
30
  import { childLogger } from './utils/logger.js';
31
31
  import { parseListPagesResponse } from './utils/mcp-parsers.js';
32
- import { crawlRouteCheap, runCrawl } from './orchestration/crawl-and-report.js';
32
+ import { crawlRouteWithDepth, runCrawl } from './orchestration/crawl-and-report.js';
33
+ import { resolveAuditDepth, selectAnalyzers } from './utils/audit-depth.js';
33
34
  import { runComparison } from './orchestration/env-comparison.js';
34
35
  import { WatchSession } from './orchestration/watch-mode.js';
35
36
  import { CdpBrowserAdapter } from './adapters/browser.js';
36
37
  import { getFigmaFrame } from './adapters/figma.js';
37
38
  import { analyzeDesignFidelity } from './utils/design-fidelity-analyzer.js';
38
39
  import { analyzeVisualRegression } from './utils/visual-diff-analyzer.js';
39
- import { fetchPrFiles, mapFilesToRoutes } from './utils/pr-diff-analyzer.js';
40
+ import { fetchPrFiles, mapFilesToRoutesDeep } from './utils/pr-diff-analyzer.js';
41
+ import { resolveTargetUrl } from './utils/deploy-preview.js';
42
+ import { mapWithConcurrency, auditRouteWithRetry, routeResilienceFromEnv } from './utils/parallel-crawler.js';
43
+ import { reportPrValidation } from './utils/github-reporter.js';
44
+ import { getCurrentBranch } from './utils/baseline-manager.js';
45
+ import {
46
+ decidePrBlock, resolvePrBaselineFile, loadPrBaseline, savePrBaseline, tagFindingNovelty, severityTally,
47
+ } from './utils/pr-baseline.js';
40
48
 
41
49
  const logger = childLogger('mcp-server');
42
50
 
@@ -158,7 +166,7 @@ const TOOLS = [
158
166
  },
159
167
  {
160
168
  name: 'argus_pr_validate',
161
- description: 'Runs a targeted Argus audit on the routes affected by a GitHub pull request. Fetches the PR diff, maps changed files to routes in your target config using path-slug heuristics (infrastructure changes trigger a full audit; targeted otherwise), and audits only those routes — faster than a full scan and focused on what the PR actually touched. Returns { findings, affectedRoutes, changedFiles, perRoute, summary, blocked, blockOn }. Use in CI to gate merges: check blocked:true or pipe findings to an AI verdict step. Requires Chrome on --remote-debugging-port=9222. GITHUB_TOKEN env var recommended for private repos.',
169
+ description: 'Runs a targeted Argus audit on the routes affected by a GitHub pull request. Fetches the PR diff, maps changed files to routes in your target config using path-slug heuristics (infrastructure changes trigger a full audit; targeted otherwise) — or, when ARGUS_SOURCE_DIR points at the checked-out app source, framework-aware import-graph mapping that narrows a changed component or stylesheet to only the routes whose pages import it (Next.js + monorepo-aware, conservative-fallback on any ambiguity) — and audits only those routes — faster than a full scan and focused on what the PR actually touched. The audit target is resolved per-PR: an explicit targetUrl, else the PR\'s deploy-preview URL (ARGUS_PREVIEW_URL or opt-in GitHub-Deployments auto-detection), else TARGET_DEV_URL. Routes are audited with bounded concurrency (ARGUS_CONCURRENCY) and each route audit is timeout-bounded (ARGUS_ROUTE_TIMEOUT_MS) so a hung audit blocks rather than silently passing. Returns { findings, affectedRoutes, changedFiles, perRoute, summary, blocked, blockOn, baseline, reporting }. Blocking is baseline-aware: it gates on the findings the PR introduces vs a stored per-branch baseline (reports/baselines/<base-branch>.json, restored via actions/cache), failing safe to absolute counts when no baseline is available. When GITHUB_TOKEN and a resolvable PR are present it also posts/updates an Argus PR comment (surfacing new/persisting/resolved counts) and a GitHub Check Run (the same reporting the CI Action produces) — best-effort, never alters the block decision. Use in CI to gate merges: check blocked:true or pipe findings to an AI verdict step. Requires Chrome on --remote-debugging-port=9222. GITHUB_TOKEN env var recommended for private repos.',
162
170
  inputSchema: {
163
171
  type: 'object',
164
172
  properties: {
@@ -188,7 +196,11 @@ async function withMcp(fn) {
188
196
 
189
197
  // ── Tool handlers ─────────────────────────────────────────────────────────────
190
198
 
191
- async function handleAudit({ url, critical = false, cache = false }) {
199
+ // `analyzers` is an INTERNAL-only argument (not in the public argus_audit schema): the
200
+ // PR-validate path (handlePrValidate) passes the depth-policy-selected expensive analyzer
201
+ // names (D2) so the same handler runs them on the affected route. The public tool is always
202
+ // called with no `analyzers` → [] → crawlRouteWithDepth returns the cheap pass unchanged.
203
+ async function handleAudit({ url, critical = false, cache = false, analyzers = [] }) {
192
204
  if (cache && auditCache.has(url)) {
193
205
  const { result, ts } = auditCache.get(url);
194
206
  // Refresh recency on read so eviction is true LRU, not insertion-order FIFO.
@@ -199,7 +211,7 @@ async function handleAudit({ url, critical = false, cache = false }) {
199
211
  return withMcp(async (mcp) => {
200
212
  const parsed = new URL(url);
201
213
  const route = { path: parsed.pathname + parsed.search + parsed.hash, name: 'audit', critical };
202
- const raw = await crawlRouteCheap(route, parsed.origin, mcp);
214
+ const raw = await crawlRouteWithDepth(route, parsed.origin, mcp, analyzers);
203
215
  const findings = Array.isArray(raw.errors) ? raw.errors : [];
204
216
  const result = {
205
217
  findings,
@@ -397,44 +409,132 @@ async function handleDesignAudit({ url, figmaFrameUrl }) {
397
409
  });
398
410
  }
399
411
 
412
+ /**
413
+ * argus_pr_validate — the MCP-tool PR-validate path.
414
+ *
415
+ * INTENTIONAL DIVERGENCE from the CLI (src/cli/pr-validate.js): this path audits the dev's
416
+ * own config/targets.js routes (dev convenience), whereas the CLI audits a routes-file (CI
417
+ * safety + speed). That ROUTE-SOURCE divergence is by design (PR_VALIDATOR plan A4/E4).
418
+ * Everything downstream of the route list is SHARED so the two paths agree (E4 — CLI↔MCP parity):
419
+ * - AUDIT DEPTH does NOT diverge: both paths run the same crawlRouteCheap pass by default and
420
+ * share ONE opt-in depth policy (ARGUS_PR_AUDIT_DEPTH → selectAnalyzers, D2). (Earlier comments
421
+ * here claimed this path ran the "full" audit; it has always called handleAudit = argus_audit =
422
+ * the cheap pass, never handleAuditFull — corrected in D2.)
423
+ * - The BLOCK DECISION is the shared decidePrBlock, fed a summary built by the shared severityTally,
424
+ * so for the same findings + baseline + blockOn the two paths reach the IDENTICAL blocked/reason.
425
+ * decidePrBlock owns the none|warning|critical matrix AND normalizes blockOn casing internally, so
426
+ * this path may pass the raw `blockOn` arg without re-normalizing and still agree with the CLI.
427
+ * - Both paths report through the SAME shared helper — reportPrValidation — so a reviewer sees an
428
+ * identical PR comment + Check Run.
429
+ */
400
430
  async function handlePrValidate({ prUrl, targetUrl, githubToken, blockOn } = {}) {
401
431
  if (!prUrl) throw new Error('argus_pr_validate: prUrl is required');
402
432
 
403
433
  const { routes } = await import('./config/targets.js');
404
434
  const token = githubToken ?? process.env.GITHUB_TOKEN;
405
- const base = targetUrl ?? process.env.TARGET_DEV_URL ?? 'http://localhost:3000';
435
+ // D3 — resolve the audit target: an explicit `targetUrl` arg wins (raw); else a per-PR
436
+ // deploy preview (ARGUS_PREVIEW_URL / opt-in GitHub-Deployments auto-detection); else
437
+ // TARGET_DEV_URL. Mirrors the CLI; default (no arg, no preview env) → byte-identical.
438
+ const headSha = process.env.ARGUS_PR_HEAD_SHA || process.env.GITHUB_SHA;
439
+ const { url: base } = await resolveTargetUrl({ env: process.env, explicitTarget: targetUrl, prUrl, headSha, token });
406
440
  const policy = blockOn ?? process.env.ARGUS_BLOCK_ON ?? 'critical';
407
441
 
408
- const changedFiles = await fetchPrFiles(prUrl, token);
409
- const affectedRoutes = mapFilesToRoutes(changedFiles, routes ?? []);
442
+ // prFiles carries { filename, status, patch } per file; changedFiles is the filename-only
443
+ // view kept as a string[] in the tool response. mapFilesToRoutesDeep accepts either shape.
444
+ // ARGUS_SOURCE_DIR (opt-in) enables C1 framework-aware mapping (changed component → only the
445
+ // routes whose pages import it); unset → conservative slug heuristic. Mirrors the CLI path.
446
+ const prFiles = await fetchPrFiles(prUrl, token);
447
+ const changedFiles = prFiles.map(f => f.filename);
448
+ const affectedRoutes = mapFilesToRoutesDeep(prFiles, routes ?? [], { sourceDir: process.env.ARGUS_SOURCE_DIR });
410
449
 
411
- const allFindings = [];
412
- const perRoute = [];
450
+ const allFindings = [];
451
+ const perRoute = [];
452
+ const routeFindings = []; // [{ path, findings }] — feeds the baseline-aware diff (B1)
413
453
 
414
454
  // Preserve any path prefix in the target URL (e.g. http://host/app) — new URL()
415
455
  // with a leading-slash path would drop it. Mirrors src/cli/pr-validate.js.
416
456
  const baseUrl = String(base).replace(/\/$/, '');
417
- for (const route of affectedRoutes) {
457
+
458
+ // Selective analyzer depth (D2) — the SAME shared policy the CLI uses (audit-depth.js), so
459
+ // the two paths run identical depth. Default 'cheap' → no expensive analyzers (byte-identical
460
+ // to the prior loop). Computed once per PR off ARGUS_PR_AUDIT_DEPTH + the changed file types.
461
+ const auditDepth = resolveAuditDepth(process.env.ARGUS_PR_AUDIT_DEPTH);
462
+ const depthAnalyzers = selectAnalyzers({ depth: auditDepth, changedFiles });
463
+ if (depthAnalyzers.length > 0) {
464
+ logger.info(`[ARGUS] D2: audit depth ${auditDepth} → expensive analyzers: ${depthAnalyzers.join(', ')}`);
465
+ }
466
+
467
+ // Audit affected routes with bounded concurrency (ARGUS_CONCURRENCY; default 1 = sequential,
468
+ // byte-identical to the prior loop). handleAudit opens its OWN MCP client per call (withMcp), so
469
+ // routes are already connection-isolated — concurrency just caps how many run at once.
470
+ // mapWithConcurrency returns results in route order, so the baseline diff + block decision are
471
+ // identical to a sequential run. Mirrors the CLI path + the orchestrator's parallel crawling.
472
+ const rawConcurrency = parseInt(process.env.ARGUS_CONCURRENCY ?? '1', 10);
473
+ const concurrency = Math.min(10, Math.max(1, Number.isNaN(rawConcurrency) ? 1 : rawConcurrency));
474
+ // Per-route timeout + retry (D4) — the SAME shared policy the CLI uses (routeResilienceFromEnv),
475
+ // so the two paths cannot diverge on the bound. A timed-out audit throws; mapWithConcurrency
476
+ // re-throws the first error, so handlePrValidate fails loud (a structured tool error) rather than
477
+ // returning a false-PASS result — the MCP path has no all-routes-failed guard, so fail-loud IS the
478
+ // safe behaviour here. Default ARGUS_ROUTE_TIMEOUT_MS=120000 / ARGUS_ROUTE_RETRIES=0.
479
+ const { timeoutMs: routeTimeoutMs, retries: routeRetries } = routeResilienceFromEnv();
480
+ const auditResults = await mapWithConcurrency(affectedRoutes, concurrency, async (route) => {
418
481
  const routePath = String(route.path ?? '/').startsWith('/') ? route.path : `/${route.path}`;
419
- const url = `${baseUrl}${routePath}`;
420
- const res = await handleAudit({ url, critical: route.critical ?? false });
482
+ const url = `${baseUrl}${routePath}`;
483
+ const res = await auditRouteWithRetry(
484
+ () => handleAudit({ url, critical: route.critical ?? false, analyzers: depthAnalyzers }),
485
+ { timeoutMs: routeTimeoutMs, retries: routeRetries, label: `Route audit ${routePath}` },
486
+ );
421
487
  const data = JSON.parse(res.content[0].text);
422
- allFindings.push(...(data.findings ?? []));
423
- perRoute.push({ route: route.path, ...data.summary });
488
+ return { route, findings: data.findings ?? [], summary: data.summary };
489
+ });
490
+ for (const { route, findings, summary } of auditResults) {
491
+ allFindings.push(...findings);
492
+ perRoute.push({ route: route.path, ...summary });
493
+ routeFindings.push({ path: route.path, findings });
424
494
  }
425
495
 
426
- const summary = {
427
- critical: allFindings.filter(f => f.severity === 'critical').length,
428
- warning: allFindings.filter(f => f.severity === 'warning').length,
429
- info: allFindings.filter(f => f.severity === 'info').length,
430
- };
496
+ // Aggregate (absolute) severity summary that feeds the block decision — built via the shared
497
+ // severityTally so this path and the CLI (src/cli/pr-validate.js) construct the decidePrBlock
498
+ // `summary` input IDENTICALLY (PR_VALIDATOR plan E4 CLI↔MCP parity).
499
+ const summary = severityTally(allFindings);
500
+
501
+ // B1 — baseline-aware merge-block decision via the SAME shared helper the CLI uses
502
+ // (decidePrBlock), so the two PR-validate paths cannot diverge on the block semantics.
503
+ // Diff the head findings against the stored base-branch baseline (GITHUB_BASE_REF, restored
504
+ // via actions/cache) and gate on the findings this PR introduces; fail safe to absolute
505
+ // blocking when no baseline is resolvable.
506
+ const outputDir = process.env.REPORT_OUTPUT_DIR || './reports';
507
+ const baselineFile = resolvePrBaselineFile({ outputDir });
508
+ const baseline = baselineFile ? loadPrBaseline(baselineFile) : null;
509
+ const decision = decidePrBlock({ routeFindings, summary, blockOn: policy, baseline });
510
+ const blocked = decision.blocked;
511
+
512
+ // B2: tag each finding new-vs-persisting off the same baseline (shared objects in allFindings),
513
+ // so the PR comment surfaces only the findings this PR introduced — parity with the CLI path.
514
+ tagFindingNovelty(routeFindings, baseline);
515
+
516
+ const baselineInfo = decision.baselineAvailable
517
+ ? {
518
+ available: true,
519
+ newCritical: decision.newSummary.critical,
520
+ newWarning: decision.newSummary.warning,
521
+ newInfo: decision.newSummary.info,
522
+ persisting: decision.persistingCount,
523
+ resolved: decision.resolvedCount,
524
+ }
525
+ : { available: false, note: decision.note };
431
526
 
432
- const blocked =
433
- policy === 'critical' ? summary.critical > 0 :
434
- policy === 'warning' ? summary.critical + summary.warning > 0 :
435
- false;
527
+ // Optionally update this branch's baseline (ARGUS_UPDATE_BASELINE) — default off → no write.
528
+ if (/^(1|true|yes|on)$/i.test(process.env.ARGUS_UPDATE_BASELINE || '')) {
529
+ try {
530
+ const writeFile = resolvePrBaselineFile({ outputDir, baseRef: getCurrentBranch() });
531
+ if (writeFile) savePrBaseline(writeFile, routeFindings);
532
+ } catch (baseErr) {
533
+ logger.warn(`[ARGUS] B1: argus_pr_validate baseline write failed — ${baseErr.message}`);
534
+ }
535
+ }
436
536
 
437
- return { content: [{ type: 'text', text: JSON.stringify({
537
+ const result = {
438
538
  prUrl,
439
539
  targetUrl: base,
440
540
  affectedRoutes: affectedRoutes.map(r => r.path),
@@ -444,7 +544,23 @@ async function handlePrValidate({ prUrl, targetUrl, githubToken, blockOn } = {})
444
544
  summary,
445
545
  blocked,
446
546
  blockOn: policy,
447
- }, null, 2) }] };
547
+ baseline: baselineInfo,
548
+ };
549
+
550
+ // A4 — report through the SAME shared helper the CLI uses. Best-effort + fully isolated:
551
+ // reporting runs AFTER the block decision and is appended to the response, so a missing
552
+ // GITHUB_TOKEN, an unresolvable PR, or a GitHub API error can never change `blocked` or
553
+ // throw out of the tool. Env-gated on GITHUB_TOKEN exactly like the CLI (reporting uses the
554
+ // env token, not the per-call githubToken arg). The token never rides into the result.
555
+ let reporting;
556
+ try {
557
+ reporting = await reportPrValidation(result, { prUrl });
558
+ } catch (err) {
559
+ logger.warn(`[ARGUS] A4: argus_pr_validate PR reporting failed — ${err.message}`);
560
+ reporting = { posted: false, checked: false, skipped: true, reason: `reporting failed: ${err.message}` };
561
+ }
562
+
563
+ return { content: [{ type: 'text', text: JSON.stringify({ ...result, reporting }, null, 2) }] };
448
564
  }
449
565
 
450
566
  async function handleLastReport() {
@@ -11,6 +11,6 @@
11
11
  * continue to import from this file unchanged.
12
12
  */
13
13
 
14
- export { runCrawl, crawlRouteCheap, crawlRouteExpensive } from './orchestrator.js';
14
+ export { runCrawl, crawlRouteCheap, crawlRouteExpensive, crawlRouteWithDepth, checkHttpsRequired } from './orchestrator.js';
15
15
  export { processReport, deduplicateFindings, rebuildSummary } from './report-processor.js';
16
16
  export { dispatchAll } from './dispatcher.js';
@@ -28,6 +28,7 @@ import { parseConsoleMsgResponse } from '.
28
28
  import { CdpBrowserAdapter } from '../adapters/browser.js';
29
29
  import { getFigmaFrame } from '../adapters/figma.js';
30
30
  import { chunkArray } from '../utils/parallel-crawler.js';
31
+ import { runDepthAnalyzers } from '../utils/audit-depth.js';
31
32
  import { validateApiContracts } from '../utils/contract-validator.js';
32
33
  import { checkLighthouse } from '../utils/lighthouse-checker.js';
33
34
  import { parseIssues } from '../utils/issues-analyzer.js';
@@ -370,6 +371,33 @@ function analyzeNetworkPerformance(perfEntries, pageUrl) {
370
371
  return bugs;
371
372
  }
372
373
 
374
+ /**
375
+ * HTTPS-enforcement rule (single source of truth, exported so it can be verified
376
+ * directly — the harness can only crawl localhost, which is excluded, so the
377
+ * positive-trigger path has no live fixture).
378
+ *
379
+ * Returns a `security_no_https` finding for an http:// page on a non-loopback host,
380
+ * or null otherwise (https, or any localhost/127.x/::1 address).
381
+ *
382
+ * @param {string} url
383
+ * @returns {{type:string,message:string,severity:string,url:string}|null}
384
+ */
385
+ export function checkHttpsRequired(url) {
386
+ try {
387
+ const parsed = new URL(url);
388
+ const isLocalhost = /^(localhost|127\.|::1)/.test(parsed.hostname);
389
+ if (parsed.protocol === 'http:' && !isLocalhost) {
390
+ return {
391
+ type: 'security_no_https',
392
+ message: `Page served over HTTP — enforce HTTPS via server redirect or HSTS`,
393
+ severity: 'warning',
394
+ url,
395
+ };
396
+ }
397
+ } catch { /* URL parse failure */ }
398
+ return null;
399
+ }
400
+
373
401
  // ── Cheap Crawl (called ×2 for flakiness detection) ───────────────────────────
374
402
 
375
403
  /**
@@ -680,19 +708,9 @@ export async function crawlRouteCheap(route, baseUrl, mcp) {
680
708
  logger.warn(`[ARGUS] Issues analysis skipped for ${url}: ${err.message}`);
681
709
  }
682
710
 
683
- // 9f. HTTPS enforcement check
684
- try {
685
- const parsed = new URL(url);
686
- const isLocalhost = /^(localhost|127\.|::1)/.test(parsed.hostname);
687
- if (parsed.protocol === 'http:' && !isLocalhost) {
688
- result.errors.push({
689
- type: 'security_no_https',
690
- message: `Page served over HTTP — enforce HTTPS via server redirect or HSTS`,
691
- severity: 'warning',
692
- url,
693
- });
694
- }
695
- } catch { /* URL parse failure */ }
711
+ // 9f. HTTPS enforcement check (shared rule — see checkHttpsRequired above)
712
+ const httpsFinding = checkHttpsRequired(url);
713
+ if (httpsFinding) result.errors.push(httpsFinding);
696
714
 
697
715
  // 10. Deduplicate within this cheap run
698
716
  result.errors = deduplicateErrors(result.errors);
@@ -836,6 +854,39 @@ export async function crawlRouteExpensive(route, baseUrl, mcp) {
836
854
  return errors;
837
855
  }
838
856
 
857
+ // ── Selective-Depth Crawl (D2 — PR Validator) ──────────────────────────────────
858
+
859
+ /**
860
+ * Crawl one route at a SELECTABLE depth for the PR Validator (D2).
861
+ *
862
+ * Runs the cheap pass (crawlRouteCheap) and then a SELECTED SUBSET of the registered
863
+ * expensive analyzers — the names chosen by the shared depth policy (audit-depth.js
864
+ * selectAnalyzers, off ARGUS_PR_AUDIT_DEPTH + the PR's changed file types). With an empty
865
+ * selection it returns the crawlRouteCheap result UNCHANGED, so the default ('cheap') tier
866
+ * is byte-identical to the prior PR-validate behaviour. Each expensive analyzer is isolated
867
+ * (runDepthAnalyzers try/catch), so deepening only ever ADDS findings — it can never turn a
868
+ * real failure into a PASS. Same shape as crawlRouteCheap ({ ...result, errors }).
869
+ *
870
+ * @param {object} route
871
+ * @param {string} baseUrl
872
+ * @param {object} mcp
873
+ * @param {string[]} [analyzerNames] registry expensive-analyzer names to also run
874
+ */
875
+ export async function crawlRouteWithDepth(route, baseUrl, mcp, analyzerNames = []) {
876
+ const result = await crawlRouteCheap(route, baseUrl, mcp);
877
+ const wanted = Array.isArray(analyzerNames) ? analyzerNames : [];
878
+ if (wanted.length === 0) return result;
879
+
880
+ const browser = new CdpBrowserAdapter(mcp);
881
+ const url = `${baseUrl}${route.path}`;
882
+ const extra = await runDepthAnalyzers(getExpensive(), browser, url, route, wanted);
883
+ if (extra.length > 0) {
884
+ result.errors.push(...extra);
885
+ result.errors = deduplicateErrors(result.errors);
886
+ }
887
+ return result;
888
+ }
889
+
839
890
  // ── Per-Route Crawl Coordinator ────────────────────────────────────────────────
840
891
 
841
892
  async function crawlAndAnalyzeRoute(route, targetBaseUrl, mcp, sessionFile) {
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Argus PR-Validator — selective analyzer depth policy (D2).
3
+ *
4
+ * Both PR-validate paths (the CLI `src/cli/pr-validate.js` and the MCP tool
5
+ * `handlePrValidate` in `src/mcp-server.js`) run `crawlRouteCheap` on each affected route
6
+ * by default. This module is the SINGLE, documented, deterministic policy that decides
7
+ * which — if any — registered EXPENSIVE analyzers also run on those routes, based on an
8
+ * opt-in depth tier and the PR's changed file types. Shared by both paths so they can
9
+ * never diverge on depth.
10
+ *
11
+ * Tiers (ARGUS_PR_AUDIT_DEPTH):
12
+ * cheap (default) → no expensive analyzers → byte-identical to the prior behaviour.
13
+ * standard → a file-type-selected subset of expensive analyzers (this module's
14
+ * STANDARD_POLICY) — the "selective" tier; a PR that only touches
15
+ * non-UI files (docs/config) degrades to cheap.
16
+ * deep → every registered expensive analyzer (ALL_EXPENSIVE_ANALYZERS),
17
+ * including Lighthouse + memory.
18
+ *
19
+ * Safety: depth can only ADD findings on a route, never drop one — a failing analyzer is
20
+ * isolated (try/catch) and skipped — so a deeper audit can never turn a real failure into a
21
+ * PASS. The merge-block decision (decidePrBlock) is untouched by this module.
22
+ *
23
+ * Purity: `resolveAuditDepth` / `selectAnalyzers` are pure (no I/O, no Chrome).
24
+ * `runDepthAnalyzers` is dependency-injected (the analyzer list + a browser are passed in),
25
+ * so the whole module is Chrome-free testable and stdout-clean (logs to stderr via Pino) —
26
+ * safe to import from the JSON-RPC MCP server.
27
+ */
28
+
29
+ import { childLogger } from './logger.js';
30
+
31
+ const logger = childLogger('audit-depth');
32
+
33
+ /** Valid depth tiers, cheapest → deepest. The unset/invalid fallback is the cheapest. */
34
+ export const AUDIT_DEPTHS = ['cheap', 'standard', 'deep'];
35
+
36
+ /**
37
+ * The full catalog of registry expensive-analyzer `name`s D2 can run, in registration
38
+ * order (lighthouse self-registers first via its named import in orchestrator.js, then the
39
+ * side-effect imports). A drift-guard test asserts this set equals the live registry
40
+ * (`getExpensive()`), so a renamed/added/removed analyzer fails LOUDLY here instead of
41
+ * silently never running (the recurring "Argus mis-reads its own state" bug class).
42
+ */
43
+ export const ALL_EXPENSIVE_ANALYZERS = [
44
+ 'lighthouse', 'css', 'responsive', 'memory', 'hover', 'snapshot', 'keyboard',
45
+ 'theme', 'design-fidelity', 'web-vitals', 'visual', 'a11y-deep', 'har-recorder',
46
+ 'motion', 'font', 'form',
47
+ ];
48
+
49
+ /**
50
+ * The documented file-type → analyzer policy for the `standard` tier. Each changed file
51
+ * contributes the analyzers of EVERY rule whose `test` matches its name; the route set runs
52
+ * the UNION (deduped, in registry order). A file matching no rule contributes nothing.
53
+ *
54
+ * Deliberately EXCLUDED from `standard` (reserved for `deep`): `lighthouse` (slow, up to the
55
+ * Lighthouse timeout), `memory` (GC-dependent / flaky), `design-fidelity` (inert without a
56
+ * route `figmaFrameUrl`), `har-recorder` (needs a committed HAR baseline). These add little
57
+ * PR signal per file type and would slow the per-PR gate.
58
+ */
59
+ export const STANDARD_POLICY = [
60
+ // Stylesheets → layout/overflow, theming, motion, visual + contrast (a11y) regressions.
61
+ { label: 'stylesheet', test: /\.(css|scss|sass|less|styl)$/i,
62
+ analyzers: ['css', 'responsive', 'theme', 'motion', 'visual', 'a11y-deep'] },
63
+ // Component / markup source → a11y tree, focus order, hover state, vitals, forms.
64
+ { label: 'component', test: /\.(jsx?|tsx?|mjs|cjs|vue|svelte|astro|mdx|html?)$/i,
65
+ analyzers: ['a11y-deep', 'snapshot', 'keyboard', 'hover', 'web-vitals', 'form'] },
66
+ // Raster/vector images → visual regression.
67
+ { label: 'image', test: /\.(png|jpe?g|gif|webp|avif|svg|ico)$/i,
68
+ analyzers: ['visual'] },
69
+ // Web fonts → font-loading (FOIT/FOUT/fallback) regression.
70
+ { label: 'font', test: /\.(woff2?|ttf|otf|eot)$/i,
71
+ analyzers: ['font'] },
72
+ ];
73
+
74
+ /**
75
+ * Normalize a raw depth value (env string) to a valid tier. Anything unrecognized →
76
+ * 'cheap' (fail-safe to the cheapest, byte-identical tier — a misconfigured value must
77
+ * never silently deepen or, worse, skip the audit).
78
+ *
79
+ * @param {string|undefined|null} raw
80
+ * @returns {'cheap'|'standard'|'deep'}
81
+ */
82
+ export function resolveAuditDepth(raw) {
83
+ const v = String(raw ?? '').toLowerCase().trim();
84
+ return AUDIT_DEPTHS.includes(v) ? v : 'cheap';
85
+ }
86
+
87
+ /**
88
+ * The depth policy: which registered expensive-analyzer names to run on each affected
89
+ * route, given the resolved tier + the PR's changed files. Returns a deduped list in
90
+ * registry (ALL_EXPENSIVE_ANALYZERS) order. `cheap` → []; `deep` → all; `standard` → the
91
+ * union of STANDARD_POLICY rules over the changed files.
92
+ *
93
+ * @param {{depth?: string, changedFiles?: string[]}} [opts]
94
+ * @returns {string[]}
95
+ */
96
+ export function selectAnalyzers({ depth = 'cheap', changedFiles = [] } = {}) {
97
+ const tier = resolveAuditDepth(depth);
98
+ if (tier === 'cheap') return [];
99
+ if (tier === 'deep') return [...ALL_EXPENSIVE_ANALYZERS];
100
+
101
+ // standard — union of the file-type rules over the changed files.
102
+ const selected = new Set();
103
+ for (const file of Array.isArray(changedFiles) ? changedFiles : []) {
104
+ const name = String(file ?? '');
105
+ for (const rule of STANDARD_POLICY) {
106
+ if (rule.test.test(name)) {
107
+ for (const a of rule.analyzers) selected.add(a);
108
+ }
109
+ }
110
+ }
111
+ // Emit in registry order for determinism (and so dedup is stable).
112
+ return ALL_EXPENSIVE_ANALYZERS.filter(a => selected.has(a));
113
+ }
114
+
115
+ /**
116
+ * Run a SELECTED SUBSET of expensive analyzers against an already-navigated page.
117
+ * Dependency-injected: `expensiveAnalyzers` is the registry list (`getExpensive()`) and
118
+ * `browser` is the live adapter — both passed in, so this is Chrome-free testable. Only
119
+ * analyzers whose `name` is in `wantedNames` run; each runs in its own try/catch so one
120
+ * failing analyzer never aborts the route (and never drops a finding — depth is additive
121
+ * only, which is why it can never turn a real failure into a PASS).
122
+ *
123
+ * @param {Array<{name: string, analyze: Function}>} expensiveAnalyzers
124
+ * @param {object} browser CdpBrowserAdapter (or a stub in tests)
125
+ * @param {string} url
126
+ * @param {object} route
127
+ * @param {string[]} [wantedNames]
128
+ * @returns {Promise<Array<object>>} collected findings
129
+ */
130
+ export async function runDepthAnalyzers(expensiveAnalyzers, browser, url, route, wantedNames = []) {
131
+ const wanted = new Set(Array.isArray(wantedNames) ? wantedNames : []);
132
+ const findings = [];
133
+ if (wanted.size === 0) return findings;
134
+
135
+ for (const entry of Array.isArray(expensiveAnalyzers) ? expensiveAnalyzers : []) {
136
+ if (!entry || !wanted.has(entry.name) || typeof entry.analyze !== 'function') continue;
137
+ try {
138
+ const raw = await entry.analyze(browser, url, route);
139
+ // Analyzers return either findings[] or { findings, screenshots } (responsive).
140
+ const out = Array.isArray(raw) ? raw
141
+ : (raw && Array.isArray(raw.findings) ? raw.findings : []);
142
+ findings.push(...out);
143
+ } catch (err) {
144
+ logger.warn(`[ARGUS] D2: expensive analyzer "${entry.name}" skipped for ${url}: ${err.message}`);
145
+ }
146
+ }
147
+ return findings;
148
+ }