argusqa-os 9.7.6 → 9.8.1

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, checkHttpsRequired } 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';
@@ -853,6 +854,39 @@ export async function crawlRouteExpensive(route, baseUrl, mcp) {
853
854
  return errors;
854
855
  }
855
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
+
856
890
  // ── Per-Route Crawl Coordinator ────────────────────────────────────────────────
857
891
 
858
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
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Deploy-preview URL auto-detection (PR Validator D3).
3
+ *
4
+ * Resolves the audit TARGET URL for a PR run, preferring a per-PR deploy preview
5
+ * (Vercel / Netlify / any GitHub Deployment) over the static TARGET_DEV_URL, and ALWAYS
6
+ * degrading gracefully to TARGET_DEV_URL when no preview is found or any detection step
7
+ * fails. Detection never throws and never blocks the run — a wrong/failed/missing preview
8
+ * must never silently audit the wrong app, so only a SUCCESS deploy-status URL for the PR's
9
+ * head SHA is ever adopted; everything else falls back.
10
+ *
11
+ * Resolution precedence (resolveTargetUrl):
12
+ * 1. explicitTarget — an explicit per-call target (MCP tool `targetUrl` arg). Highest;
13
+ * passed through raw (explicit caller intent, like TARGET_DEV_URL).
14
+ * 2. ARGUS_PREVIEW_URL — explicit env override (opt-in by being set). Provider env vars
15
+ * DEPLOY_PRIME_URL (Netlify) + VERCEL_URL (Vercel, bare host) are
16
+ * also recognized for convenience.
17
+ * 3. auto-detected preview from the PR head SHA's GitHub Deployments — OPT-IN:
18
+ * ARGUS_PREVIEW_DETECT truthy + a token + a head SHA + a parseable PR URL. One+one
19
+ * GitHub API call, fully fail-safe (any error → no preview → fallback).
20
+ * 4. TARGET_DEV_URL (or http://localhost:3000) — the conservative fallback, byte-identical
21
+ * to the pre-D3 behaviour when nothing above matches.
22
+ *
23
+ * Pure helpers (Chrome-free, network-free) + one fail-safe async fetch. Imported (transitively)
24
+ * by the MCP server → nothing here writes to stdout (logs go to the injected/childLogger).
25
+ * No AI verdict — pure static/heuristic resolution (OSS side of the argus-pro boundary).
26
+ */
27
+
28
+ import { parsePrUrl } from './pr-diff-analyzer.js';
29
+ import { childLogger } from './logger.js';
30
+
31
+ const logger = childLogger('deploy-preview');
32
+
33
+ const GITHUB_API = 'https://api.github.com';
34
+ const FETCH_TIMEOUT = 10000;
35
+
36
+ // Env vars (priority order) that may carry an explicit preview URL. ARGUS_PREVIEW_URL is the
37
+ // portable, Argus-namespaced canonical; the rest are provider conventions surfaced for users
38
+ // who forward them into the runner. VERCEL_URL is a bare host (no scheme) — normalized below.
39
+ const ENV_PREVIEW_VARS = ['ARGUS_PREVIEW_URL', 'DEPLOY_PRIME_URL', 'VERCEL_URL'];
40
+
41
+ /**
42
+ * Trim a candidate URL and accept it only if it carries an http(s) scheme.
43
+ * Returns the trimmed URL or null (never throws). Untrusted/auto-detected URLs flow through
44
+ * this; explicit caller targets (the MCP arg, TARGET_DEV_URL) are passed through raw.
45
+ * @param {*} raw
46
+ * @returns {string|null}
47
+ */
48
+ export function normalizeUrl(raw) {
49
+ if (raw == null) return null;
50
+ const s = String(raw).trim();
51
+ if (!s) return null;
52
+ return /^https?:\/\//i.test(s) ? s : null;
53
+ }
54
+
55
+ /**
56
+ * Pick an explicit preview URL from the environment, honouring ENV_PREVIEW_VARS priority.
57
+ * VERCEL_URL is a bare host → prefixed with https:// before validation.
58
+ * @param {Record<string,string|undefined>} env
59
+ * @returns {{ url: string, source: string }|null}
60
+ */
61
+ export function pickPreviewFromEnv(env = {}) {
62
+ for (const name of ENV_PREVIEW_VARS) {
63
+ let raw = env[name];
64
+ if (raw == null || String(raw).trim() === '') continue;
65
+ // VERCEL_URL is conventionally a bare host (e.g. my-app-git-pr.vercel.app).
66
+ if (name === 'VERCEL_URL' && !/^https?:\/\//i.test(String(raw).trim())) {
67
+ raw = `https://${String(raw).trim()}`;
68
+ }
69
+ const url = normalizeUrl(raw);
70
+ if (url) return { url, source: `env:${name}` };
71
+ logger.warn(`[ARGUS] D3: ${name} is set but not a valid http(s) URL — ignoring`);
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Heuristic: is a GitHub Deployment object a PR/preview deployment (not production)?
78
+ * Recognizes Vercel ("Preview – <project>"), Netlify ("deploy-preview"), and any explicitly
79
+ * non-production / transient environment. Production deployments are excluded.
80
+ * @param {object} deployment
81
+ * @returns {boolean}
82
+ */
83
+ export function isPreviewDeployment(deployment) {
84
+ if (!deployment || typeof deployment !== 'object') return false;
85
+ const env = String(deployment.environment ?? '');
86
+ // Explicit production → never a preview target.
87
+ if (deployment.production_environment === true || /production/i.test(env)) return false;
88
+ return (
89
+ /preview|deploy[\s-]?preview|staging/i.test(env) ||
90
+ deployment.transient_environment === true ||
91
+ deployment.production_environment === false
92
+ );
93
+ }
94
+
95
+ /**
96
+ * From a list of GitHub Deployments (newest-first, as the API returns them), pick the most
97
+ * recent preview deployment, or null when none qualify.
98
+ * @param {Array<object>} deployments
99
+ * @returns {object|null}
100
+ */
101
+ export function pickPreviewDeployment(deployments) {
102
+ if (!Array.isArray(deployments)) return null;
103
+ return deployments.find(isPreviewDeployment) ?? null;
104
+ }
105
+
106
+ /**
107
+ * From a deployment's statuses (newest-first), return the live preview URL — the
108
+ * environment_url (preferred) or target_url of the most recent SUCCESS status — or null.
109
+ * Only a `success` status is adopted: a failed / error / pending / inactive deploy must
110
+ * never become the audit target (auditing a broken or stale preview would be a false result).
111
+ * @param {Array<object>} statuses
112
+ * @returns {string|null}
113
+ */
114
+ export function previewUrlFromStatuses(statuses) {
115
+ if (!Array.isArray(statuses)) return null;
116
+ for (const s of statuses) {
117
+ if (!s || s.state !== 'success') continue;
118
+ const url = normalizeUrl(s.environment_url) ?? normalizeUrl(s.target_url);
119
+ if (url) return url;
120
+ }
121
+ return null;
122
+ }
123
+
124
+ async function ghGet(url, headers) {
125
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT) });
126
+ if (!res.ok) return null;
127
+ return res.json();
128
+ }
129
+
130
+ /**
131
+ * Fetch the live preview URL for a PR head SHA via the GitHub Deployments API. Fully
132
+ * fail-safe: ANY failure (no SHA, non-2xx, network error, no preview deployment, no success
133
+ * status) resolves to null so the caller degrades to TARGET_DEV_URL. Never throws.
134
+ *
135
+ * @param {object} opts
136
+ * @param {string} opts.owner
137
+ * @param {string} opts.repo
138
+ * @param {string} opts.sha PR head SHA (NOT the merge commit)
139
+ * @param {string} [opts.token] GitHub token
140
+ * @returns {Promise<string|null>}
141
+ */
142
+ export async function fetchPreviewUrlFromDeployments({ owner, repo, sha, token } = {}) {
143
+ try {
144
+ if (!owner || !repo || !sha) return null;
145
+ const headers = {
146
+ Accept: 'application/vnd.github+json',
147
+ 'X-GitHub-Api-Version': '2022-11-28',
148
+ 'User-Agent': 'argusqa-os',
149
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
150
+ };
151
+ const deployments = await ghGet(
152
+ `${GITHUB_API}/repos/${owner}/${repo}/deployments?sha=${encodeURIComponent(sha)}&per_page=30`,
153
+ headers,
154
+ );
155
+ const dep = pickPreviewDeployment(deployments);
156
+ if (!dep) return null;
157
+ const statuses = await ghGet(
158
+ `${GITHUB_API}/repos/${owner}/${repo}/deployments/${dep.id}/statuses?per_page=30`,
159
+ headers,
160
+ );
161
+ return previewUrlFromStatuses(statuses);
162
+ } catch (err) {
163
+ // Detection is best-effort — a probe failure must degrade to TARGET_DEV_URL, never break
164
+ // the run. The token never rides into the message (err.message is GitHub's text only).
165
+ logger.warn(`[ARGUS] D3: deploy-preview detection failed — ${err.message}`);
166
+ return null;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Resolve the audit target URL for a PR run (D3). See the module header for precedence.
172
+ * Always resolves (never throws); `source` records which rung won, for logging/visibility.
173
+ *
174
+ * @param {object} opts
175
+ * @param {Record<string,string|undefined>} opts.env the process env (or a test object)
176
+ * @param {string} [opts.explicitTarget] an explicit per-call target (MCP `targetUrl` arg)
177
+ * @param {string} [opts.prUrl]
178
+ * @param {string} [opts.headSha]
179
+ * @param {string} [opts.token]
180
+ * @returns {Promise<{ url: string, source: string }>}
181
+ */
182
+ export async function resolveTargetUrl({ env = {}, explicitTarget, prUrl, headSha, token } = {}) {
183
+ // The conservative fallback — passed through RAW (explicit operator intent), so the default
184
+ // path is byte-identical to the pre-D3 `TARGET_DEV_URL ?? 'http://localhost:3000'`.
185
+ const fallback = env.TARGET_DEV_URL ?? 'http://localhost:3000';
186
+
187
+ // 1. Explicit per-call target (MCP arg) — raw, highest precedence.
188
+ if (explicitTarget != null && String(explicitTarget).trim() !== '') {
189
+ return { url: explicitTarget, source: 'explicit' };
190
+ }
191
+
192
+ // 2. Explicit env override (ARGUS_PREVIEW_URL / provider env vars).
193
+ const envPick = pickPreviewFromEnv(env);
194
+ if (envPick) return envPick;
195
+
196
+ // 3. Auto-detect from GitHub Deployments — opt-in + fully fail-safe.
197
+ const detectOn = /^(1|true|yes|on)$/i.test(env.ARGUS_PREVIEW_DETECT || '');
198
+ if (detectOn && token && headSha && prUrl) {
199
+ try {
200
+ const { owner, repo } = parsePrUrl(prUrl);
201
+ const url = await fetchPreviewUrlFromDeployments({ owner, repo, sha: headSha, token });
202
+ if (url) return { url, source: 'deployment' };
203
+ } catch {
204
+ // parsePrUrl or detection threw — degrade silently to the fallback below.
205
+ }
206
+ }
207
+
208
+ // 4. Conservative fallback.
209
+ return { url: fallback, source: 'target-dev-url' };
210
+ }