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/README.md +10 -9
- package/glama.json +2 -2
- package/package.json +12 -4
- package/src/adapters/browser.js +13 -1
- package/src/cli/pr-validate.js +275 -56
- package/src/mcp-server.js +142 -26
- package/src/orchestration/crawl-and-report.js +1 -1
- package/src/orchestration/orchestrator.js +64 -13
- package/src/utils/audit-depth.js +148 -0
- package/src/utils/deploy-preview.js +210 -0
- package/src/utils/github-api.js +242 -0
- package/src/utils/github-reporter.js +251 -39
- package/src/utils/html-reporter.js +283 -92
- package/src/utils/import-graph.js +290 -0
- package/src/utils/issues-analyzer.js +8 -2
- package/src/utils/lighthouse-checker.js +44 -4
- package/src/utils/parallel-crawler.js +202 -0
- package/src/utils/pr-baseline.js +230 -0
- package/src/utils/pr-diff-analyzer.js +378 -40
- package/src/utils/route-discoverer.js +25 -3
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
|
420
|
-
const res
|
|
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
|
-
|
|
423
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
685
|
-
|
|
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
|
+
}
|