argusqa-os 9.2.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.
Files changed (57) hide show
  1. package/.mcp.json +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +879 -0
  4. package/package.json +69 -0
  5. package/src/adapters/browser.js +82 -0
  6. package/src/argus.js +8 -0
  7. package/src/batch-runner.js +8 -0
  8. package/src/cli/init.js +314 -0
  9. package/src/config/schema.js +108 -0
  10. package/src/config/targets.js +309 -0
  11. package/src/domain/finding.js +25 -0
  12. package/src/mcp-server.js +156 -0
  13. package/src/orchestration/crawl-and-report.js +16 -0
  14. package/src/orchestration/dispatcher.js +263 -0
  15. package/src/orchestration/env-comparison.js +498 -0
  16. package/src/orchestration/orchestrator.js +1128 -0
  17. package/src/orchestration/report-processor.js +134 -0
  18. package/src/orchestration/slack-notifier.js +337 -0
  19. package/src/orchestration/watch-mode.js +316 -0
  20. package/src/registry.js +18 -0
  21. package/src/server/index.js +94 -0
  22. package/src/server/interaction-handler.js +126 -0
  23. package/src/server/slash-command-handler.js +185 -0
  24. package/src/utils/api-frequency.js +128 -0
  25. package/src/utils/baseline-manager.js +255 -0
  26. package/src/utils/codebase-analyzer.js +299 -0
  27. package/src/utils/content-analyzer.js +155 -0
  28. package/src/utils/contract-validator.js +178 -0
  29. package/src/utils/css-analyzer.js +407 -0
  30. package/src/utils/diff.js +189 -0
  31. package/src/utils/flakiness-detector.js +82 -0
  32. package/src/utils/flow-runner.js +572 -0
  33. package/src/utils/github-reporter.js +310 -0
  34. package/src/utils/hover-analyzer.js +214 -0
  35. package/src/utils/html-reporter.js +301 -0
  36. package/src/utils/issues-analyzer.js +171 -0
  37. package/src/utils/keyboard-analyzer.js +141 -0
  38. package/src/utils/lighthouse-checker.js +120 -0
  39. package/src/utils/logger.js +39 -0
  40. package/src/utils/login-orchestrator.js +99 -0
  41. package/src/utils/mcp-client.js +264 -0
  42. package/src/utils/mcp-parsers.js +57 -0
  43. package/src/utils/memory-analyzer.js +270 -0
  44. package/src/utils/network-timing-analyzer.js +76 -0
  45. package/src/utils/parallel-crawler.js +28 -0
  46. package/src/utils/responsive-analyzer.js +253 -0
  47. package/src/utils/retry.js +36 -0
  48. package/src/utils/route-discoverer.js +306 -0
  49. package/src/utils/security-analyzer.js +302 -0
  50. package/src/utils/seo-analyzer.js +164 -0
  51. package/src/utils/session-manager.js +12 -0
  52. package/src/utils/session-persistence.js +214 -0
  53. package/src/utils/severity-overrides.js +91 -0
  54. package/src/utils/slack-guard.js +18 -0
  55. package/src/utils/slug.js +8 -0
  56. package/src/utils/snapshot-analyzer.js +330 -0
  57. package/src/utils/telemetry.js +190 -0
@@ -0,0 +1,1128 @@
1
+ /**
2
+ * Argus Orchestrator (v9.3.0)
3
+ *
4
+ * Per-route crawl loop: cheap×2 flakiness pass + expensive×1 pass.
5
+ * Extracted from crawl-and-report.js god object.
6
+ *
7
+ * Public exports: runCrawl, crawlRouteCheap, crawlRouteExpensive
8
+ */
9
+
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import { fileURLToPath } from 'url';
13
+ import 'dotenv/config';
14
+
15
+ import { routes, config, auth, flows, apiContracts, severityOverrides, codebase, autoDiscover, thresholds } from '../config/targets.js';
16
+ import { discoverRoutes } from '../utils/route-discoverer.js';
17
+ import { analyzeCodebase, detectDeadRoutes, INTERNAL_LINKS_SCRIPT } from '../utils/codebase-analyzer.js';
18
+ import { CSS_ANALYSIS_SCRIPT, parseCssAnalysisResult } from '../utils/css-analyzer.js';
19
+ import { SEO_ANALYSIS_SCRIPT, parseSeoAnalysisResult } from '../utils/seo-analyzer.js';
20
+ import { SECURITY_ANALYSIS_SCRIPT, parseSecurityAnalysisResult, analyzeSecurityConsole, analyzeSecurityNetwork } from '../utils/security-analyzer.js';
21
+ import { CONTENT_ANALYSIS_SCRIPT, parseContentAnalysisResult } from '../utils/content-analyzer.js';
22
+ import { runLoginFlow, saveSession, restoreSession, hasSession, refreshSession } from '../utils/session-manager.js';
23
+ import { mergeRunResults } from '../utils/flakiness-detector.js';
24
+ import { runAllFlows, normalizeArray, waitForSelector } from '../utils/flow-runner.js';
25
+ import { analyzeApiFrequency } from '../utils/api-frequency.js';
26
+ import { slugify } from '../utils/slug.js';
27
+ import { unwrapEval, createMcpClient } from '../utils/mcp-client.js';
28
+ import { CdpBrowserAdapter } from '../adapters/browser.js';
29
+ import { chunkArray } from '../utils/parallel-crawler.js';
30
+ import { validateApiContracts } from '../utils/contract-validator.js';
31
+ import { checkLighthouse } from '../utils/lighthouse-checker.js';
32
+ import { parseIssues } from '../utils/issues-analyzer.js';
33
+ import { parseNetworkTiming } from '../utils/network-timing-analyzer.js';
34
+
35
+ // Side-effect imports: each module calls registerExpensive() at load time.
36
+ // lighthouse-checker.js also self-registers via its direct named import above (line 31).
37
+ // Order below controls iteration order in crawlAndAnalyzeRoute — must match original call order.
38
+ import '../utils/responsive-analyzer.js';
39
+ import '../utils/memory-analyzer.js';
40
+ import '../utils/hover-analyzer.js';
41
+ import '../utils/snapshot-analyzer.js';
42
+ import '../utils/keyboard-analyzer.js';
43
+
44
+ import { getExpensive } from '../registry.js';
45
+ import { deduplicateFindings as deduplicateErrors } from './report-processor.js';
46
+ import { processReport } from './report-processor.js';
47
+ import { dispatchAll } from './dispatcher.js';
48
+ import { validateConfig } from '../config/schema.js';
49
+ import { childLogger } from '../utils/logger.js';
50
+ import { startSpan, recordFinding, recordFlaky, recordNewFindings } from '../utils/telemetry.js';
51
+
52
+ const logger = childLogger('orchestrator');
53
+
54
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
55
+ const BASE_URL = process.env.TARGET_DEV_URL ?? 'http://localhost:3000';
56
+ const OUTPUT_DIR = path.resolve(__dirname, '../../', config.outputDir);
57
+
58
+ // Thresholds for perf budgets and network analysis are centralized in targets.js.
59
+
60
+ // ── Injected Page Scripts ──────────────────────────────────────────────────────
61
+
62
+ const NETWORK_PERF_SCRIPT = `() => window.performance.getEntriesByType('resource').map(function(e){return{url:e.name,resourceType:e.initiatorType,duration:Math.round(e.duration||0),transferSize:e.transferSize||0,decodedBodySize:e.decodedBodySize||0}})`;
63
+
64
+ const CACHE_HEADER_SCRIPT = `async () => {
65
+ var ASSET_EXT = /\\.(js|css|png|jpg|jpeg|gif|webp|svg|ico|woff2?|ttf|otf)(\\?.*)?$/i;
66
+ var origin = window.location.origin;
67
+ var seen = {};
68
+ var candidates = window.performance.getEntriesByType('resource')
69
+ .map(function(e){ return e.name; })
70
+ .filter(function(u){
71
+ if (!u.startsWith(origin) || !ASSET_EXT.test(u)) return false;
72
+ if (seen[u]) return false;
73
+ seen[u] = true;
74
+ return true;
75
+ })
76
+ .slice(0, 25);
77
+ var missing = [];
78
+ await Promise.all(candidates.map(async function(assetUrl){
79
+ try {
80
+ var r = await fetch(assetUrl, { method: 'HEAD', cache: 'reload', credentials: 'same-origin' });
81
+ if (!r.headers.get('cache-control') && !r.headers.get('etag')) {
82
+ missing.push({ url: assetUrl });
83
+ }
84
+ } catch(e) {}
85
+ }));
86
+ return JSON.stringify(missing);
87
+ }`;
88
+
89
+ const INJECT_ERROR_LISTENER = `() => {
90
+ if (window.__argusErrorsPatched) return;
91
+ window.__argusErrorsPatched = true;
92
+ window.__argusErrors = [];
93
+ window.onerror = function(message, source, lineno, colno, error) {
94
+ window.__argusErrors.push({
95
+ type: 'uncaught_exception',
96
+ message: message,
97
+ source: source,
98
+ line: lineno,
99
+ col: colno,
100
+ stack: error ? error.stack : null,
101
+ ts: Date.now()
102
+ });
103
+ return false;
104
+ };
105
+ window.addEventListener('unhandledrejection', function(event) {
106
+ window.__argusErrors.push({
107
+ type: 'unhandled_rejection',
108
+ message: String(event.reason),
109
+ stack: event.reason && event.reason.stack ? event.reason.stack : null,
110
+ ts: Date.now()
111
+ });
112
+ });
113
+ }`;
114
+
115
+ const EXTRACT_ERROR_LISTENER = `() => JSON.stringify(window.__argusErrors ?? [])`;
116
+
117
+ const DETECT_DOC_WRITE_STATIC = `async () => {
118
+ var found = [];
119
+ var seen = new Set();
120
+ function checkSrc(src, label) {
121
+ if (/\\bdocument\\.write\\s*\\(/.test(src) && !seen.has('write:'+label)) {
122
+ found.push({ method: 'write', content: label }); seen.add('write:'+label);
123
+ }
124
+ if (/\\bdocument\\.writeln\\s*\\(/.test(src) && !seen.has('writeln:'+label)) {
125
+ found.push({ method: 'writeln', content: label }); seen.add('writeln:'+label);
126
+ }
127
+ }
128
+ function isJsType(el) {
129
+ var t = (el.type || '').toLowerCase().trim();
130
+ return t === '' || t === 'text/javascript' || t === 'application/javascript' || t === 'module';
131
+ }
132
+ var inlines = document.querySelectorAll('script:not([src])');
133
+ for (var i = 0; i < inlines.length; i++) {
134
+ if (isJsType(inlines[i])) checkSrc(inlines[i].textContent||'','(inline)');
135
+ }
136
+ var externals = document.querySelectorAll('script[src]');
137
+ var fetches = [];
138
+ for (var i = 0; i < externals.length; i++) {
139
+ if (!isJsType(externals[i])) continue;
140
+ var u = externals[i].src;
141
+ if (!u || !u.startsWith(location.origin)) continue;
142
+ fetches.push(fetch(u).then(function(r){return r.text();}).then(function(t){checkSrc(t,u);}).catch(function(){}));
143
+ }
144
+ await Promise.all(fetches);
145
+ return JSON.stringify(found);
146
+ }`;
147
+
148
+ const INJECT_SW_LISTENER = `() => {
149
+ if (!window.__argusSwErrors) window.__argusSwErrors = [];
150
+ if (window.__argusSwPatched) return;
151
+ window.__argusSwPatched = true;
152
+ if (!navigator.serviceWorker) return;
153
+ var _register = navigator.serviceWorker.register.bind(navigator.serviceWorker);
154
+ navigator.serviceWorker.register = function(scriptURL, options) {
155
+ var reg = _register(scriptURL, options);
156
+ reg.catch(function(err) {
157
+ window.__argusSwErrors.push({
158
+ scriptURL: String(scriptURL || ''),
159
+ message: err && err.message ? err.message : String(err),
160
+ });
161
+ });
162
+ return reg;
163
+ };
164
+ }`;
165
+
166
+ const EXTRACT_SW_LISTENER = `() => JSON.stringify(window.__argusSwErrors ?? [])`;
167
+
168
+ const DEBUGGER_SCRIPT = `async () => {
169
+ var found = [];
170
+ function isJsType(el) {
171
+ var t = (el.type || '').toLowerCase().trim();
172
+ return t === '' || t === 'text/javascript' || t === 'application/javascript' || t === 'module';
173
+ }
174
+ var inline = document.querySelectorAll('script:not([src])');
175
+ for (var i = 0; i < inline.length; i++) {
176
+ if (!isJsType(inline[i])) continue;
177
+ var src = inline[i].textContent || '';
178
+ var lines = src.split('\\n');
179
+ for (var ln = 0; ln < lines.length; ln++) {
180
+ if (/\\bdebugger\\s*;/.test(lines[ln])) {
181
+ found.push({ scriptUrl: '(inline)', line: ln + 1, snippet: lines[ln].trim().slice(0, 120) });
182
+ }
183
+ }
184
+ }
185
+ var origin = window.location.origin;
186
+ var seen = {};
187
+ var extEls = document.querySelectorAll('script[src]');
188
+ var extUrls = [];
189
+ for (var i = 0; i < extEls.length && extUrls.length < 20; i++) {
190
+ if (!isJsType(extEls[i])) continue;
191
+ var u = extEls[i].src;
192
+ if (!u || !u.startsWith(origin) || seen[u]) continue;
193
+ seen[u] = true;
194
+ extUrls.push(u);
195
+ }
196
+ await Promise.all(extUrls.map(async function(scriptUrl) {
197
+ try {
198
+ var r = await fetch(scriptUrl, { cache: 'force-cache', credentials: 'same-origin' });
199
+ var text = await r.text();
200
+ var lines = text.split('\\n');
201
+ for (var ln = 0; ln < lines.length; ln++) {
202
+ if (/\\bdebugger\\s*;/.test(lines[ln])) {
203
+ var filename = scriptUrl.replace(/^.*\\//, '').split('?')[0];
204
+ found.push({ scriptUrl: filename || scriptUrl, line: ln + 1, snippet: lines[ln].trim().slice(0, 120) });
205
+ }
206
+ }
207
+ } catch(e) {}
208
+ }));
209
+ return JSON.stringify(found);
210
+ }`;
211
+
212
+ const DUPLICATE_ID_SCRIPT = `() => {
213
+ var counts = {};
214
+ var els = document.querySelectorAll('[id]');
215
+ for (var i = 0; i < els.length; i++) {
216
+ var id = els[i].id;
217
+ if (!id) continue;
218
+ counts[id] = (counts[id] || 0) + 1;
219
+ }
220
+ var dupes = [];
221
+ for (var id in counts) {
222
+ if (counts[id] > 1) dupes.push({ id: id, count: counts[id] });
223
+ }
224
+ return JSON.stringify(dupes);
225
+ }`;
226
+
227
+ const INJECT_LONG_TASK_LISTENER = `() => {
228
+ if (!window.__argusLongTasks) window.__argusLongTasks = [];
229
+ if (window.__argusLongTaskPatched) return;
230
+ window.__argusLongTaskPatched = true;
231
+ try {
232
+ var obs = new PerformanceObserver(function(list) {
233
+ var entries = list.getEntries();
234
+ for (var i = 0; i < entries.length; i++) {
235
+ var e = entries[i];
236
+ var attr = e.attribution && e.attribution[0];
237
+ window.__argusLongTasks.push({
238
+ duration: Math.round(e.duration),
239
+ startTime: Math.round(e.startTime),
240
+ attribution: attr ? {
241
+ name: attr.name || null,
242
+ containerType: attr.containerType || null,
243
+ containerSrc: attr.containerSrc || null,
244
+ } : null,
245
+ });
246
+ }
247
+ });
248
+ obs.observe({ entryTypes: ['longtask'] });
249
+ } catch (e) { /* longtask not supported */ }
250
+ }`;
251
+
252
+ const EXTRACT_LONG_TASK_LISTENER = `() => JSON.stringify(window.__argusLongTasks ?? [])`;
253
+
254
+ const INJECT_SYNC_XHR_LISTENER = `() => {
255
+ if (window.__argusSyncXhrPatched) return;
256
+ window.__argusSyncXhrPatched = true;
257
+ window.__argusSyncXhrs = [];
258
+ var _open = XMLHttpRequest.prototype.open;
259
+ XMLHttpRequest.prototype.open = function(method, url, async) {
260
+ if (async === false) {
261
+ window.__argusSyncXhrs.push({ method: String(method || 'GET'), url: String(url) });
262
+ }
263
+ return _open.apply(this, arguments);
264
+ };
265
+ }`;
266
+
267
+ const EXTRACT_SYNC_XHR_LISTENER = `() => JSON.stringify(window.__argusSyncXhrs ?? [])`;
268
+
269
+ const REDIRECT_COUNT_SCRIPT = `() => window.performance.getEntriesByType('navigation')[0]?.redirectCount ?? 0`;
270
+
271
+ // ── Severity Classification ────────────────────────────────────────────────────
272
+
273
+ function classifyConsoleMessage(msg, routeIsCritical) {
274
+ const level = (msg.level ?? '').toLowerCase();
275
+ if (level === 'error') return routeIsCritical ? 'critical' : 'warning';
276
+ if (level === 'warning') return 'info';
277
+ return 'info';
278
+ }
279
+
280
+ function classifyNetworkRequest(req, routeIsCritical) {
281
+ const status = req.status ?? 0;
282
+ if (status >= 500) return 'critical';
283
+ if (status === 401 || status === 403) return 'critical';
284
+ if (status >= 400) return routeIsCritical ? 'warning' : 'info';
285
+ return null;
286
+ }
287
+
288
+ function classifyOrigin(reqUrl, pageUrl) {
289
+ try {
290
+ return new URL(reqUrl).origin === new URL(pageUrl).origin ? 'first-party' : 'third-party';
291
+ } catch {
292
+ return 'first-party';
293
+ }
294
+ }
295
+
296
+ // ── Network Performance Analysis ──────────────────────────────────────────────
297
+
298
+ function analyzeNetworkPerformance(perfEntries, pageUrl) {
299
+ const bugs = [];
300
+ const staticExt = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map|webp|avif)(\?|$)/i;
301
+
302
+ for (const entry of perfEntries) {
303
+ const reqUrl = entry.url ?? '';
304
+ if (staticExt.test(reqUrl)) continue;
305
+ if (
306
+ !/\/(api|graphql|rest|v\d+)\//i.test(reqUrl) &&
307
+ !['xmlhttprequest', 'fetch', 'xhr'].includes((entry.resourceType ?? '').toLowerCase())
308
+ ) continue;
309
+
310
+ const duration = entry.duration ?? 0;
311
+ const payloadBytes = entry.decodedBodySize || entry.transferSize || 0;
312
+
313
+ if (duration > thresholds.network.slowCritical) {
314
+ bugs.push({
315
+ type: 'slow_api',
316
+ requestUrl: reqUrl,
317
+ duration: Math.round(duration),
318
+ threshold: thresholds.network.slowCritical,
319
+ message: `Slow API response ${Math.round(duration)} ms — ${reqUrl} (critical threshold: ${thresholds.network.slowCritical} ms)`,
320
+ severity: 'critical',
321
+ url: pageUrl,
322
+ });
323
+ } else if (duration > thresholds.network.slowWarning) {
324
+ bugs.push({
325
+ type: 'slow_api',
326
+ requestUrl: reqUrl,
327
+ duration: Math.round(duration),
328
+ threshold: thresholds.network.slowWarning,
329
+ message: `Slow API response ${Math.round(duration)} ms — ${reqUrl} (warning threshold: ${thresholds.network.slowWarning} ms)`,
330
+ severity: 'warning',
331
+ url: pageUrl,
332
+ });
333
+ }
334
+
335
+ if (payloadBytes > thresholds.network.sizeCritical) {
336
+ bugs.push({
337
+ type: 'large_payload',
338
+ requestUrl: reqUrl,
339
+ bytes: payloadBytes,
340
+ threshold: thresholds.network.sizeCritical,
341
+ message: `Oversized API payload ${Math.round(payloadBytes / 1024)} KB — ${reqUrl} (critical threshold: 2 MB)`,
342
+ severity: 'critical',
343
+ url: pageUrl,
344
+ });
345
+ } else if (payloadBytes > thresholds.network.sizeWarning) {
346
+ bugs.push({
347
+ type: 'large_payload',
348
+ requestUrl: reqUrl,
349
+ bytes: payloadBytes,
350
+ threshold: thresholds.network.sizeWarning,
351
+ message: `Oversized API payload ${Math.round(payloadBytes / 1024)} KB — ${reqUrl} (warning threshold: 500 KB)`,
352
+ severity: 'warning',
353
+ url: pageUrl,
354
+ });
355
+ }
356
+ }
357
+
358
+ return bugs;
359
+ }
360
+
361
+ // ── Performance Budgets ────────────────────────────────────────────────────────
362
+
363
+ async function checkPerformanceBudgets(browser, url) {
364
+ const violations = [];
365
+ const LIGHTHOUSE_TIMEOUT_MS = parseInt(process.env.ARGUS_LIGHTHOUSE_TIMEOUT ?? '120000', 10);
366
+
367
+ try {
368
+ await browser.startTrace();
369
+ await new Promise(r => setTimeout(r, 3000));
370
+ const trace = await browser.stopTrace();
371
+ const insights = await browser.analyzeInsight({ insightSetId: trace?.insightSetId ?? trace?.id ?? trace });
372
+
373
+ const metrics = insights?.metrics ?? insights?.performanceMetrics ?? {};
374
+
375
+ const checks = [
376
+ { key: 'LCP', value: metrics.largestContentfulPaint ?? metrics.LCP, budget: thresholds.perf.LCP, unit: 'ms' },
377
+ { key: 'CLS', value: metrics.cumulativeLayoutShift ?? metrics.CLS, budget: thresholds.perf.CLS, unit: '' },
378
+ { key: 'FID', value: metrics.totalBlockingTime ?? metrics.TBT ?? metrics.FID, budget: thresholds.perf.FID, unit: 'ms' },
379
+ { key: 'TTFB', value: metrics.timeToFirstByte ?? metrics.TTFB, budget: thresholds.perf.TTFB, unit: 'ms' },
380
+ ];
381
+
382
+ for (const { key, value, budget, unit } of checks) {
383
+ if (value == null) continue;
384
+ if (value > budget) {
385
+ violations.push({
386
+ type: 'performance_budget',
387
+ metric: key,
388
+ value: `${value}${unit}`,
389
+ budget: `${budget}${unit}`,
390
+ message: `Performance budget exceeded: ${key} = ${value}${unit} (budget: ${budget}${unit})`,
391
+ severity: 'warning',
392
+ url,
393
+ });
394
+ }
395
+ }
396
+ } catch (err) {
397
+ logger.warn(`[ARGUS] Performance trace skipped for ${url}: ${err.message}`);
398
+ }
399
+
400
+ void LIGHTHOUSE_TIMEOUT_MS; // referenced only here to prevent unused-var lint
401
+ return violations;
402
+ }
403
+
404
+ // ── Cheap Crawl (called ×2 for flakiness detection) ───────────────────────────
405
+
406
+ /**
407
+ * Cheap detections for one route.
408
+ * Runs: console, network, JS errors, blank page, API frequency, contracts,
409
+ * SEO, security, content, CSS, debugger statements, duplicate ids, screenshot.
410
+ * Does NOT run: Lighthouse, perf budgets, network perf, redirect chain, broken links, cache headers.
411
+ */
412
+ export async function crawlRouteCheap(route, baseUrl, mcp) {
413
+ const browser = new CdpBrowserAdapter(mcp);
414
+ const url = `${baseUrl}${route.path}`;
415
+ const result = {
416
+ route: route.name,
417
+ url,
418
+ crawledAt: new Date().toISOString(),
419
+ errors: [],
420
+ screenshot: null,
421
+ pageTitle: null,
422
+ isBlankPage: false,
423
+ };
424
+
425
+ // 0. Snapshot session-wide baselines BEFORE this route starts (D5).
426
+ const consoleBaseline = (await browser.listConsole().catch(() => [])).length;
427
+ const networkBaseline = (await browser.listNetwork().catch(() => [])).length;
428
+ // listConsoleRaw returns raw MCP response — normalizeArray required before .length
429
+ const issuesBaselineRaw = await browser.listConsoleRaw({ types: ['issue'] }).catch(() => null);
430
+ const issuesBaseline = normalizeArray(issuesBaselineRaw).length;
431
+
432
+ // 1. Navigate
433
+ await browser.navigate(url);
434
+
435
+ // 2. Inject listeners immediately after navigation (before settle)
436
+ await browser.evaluate(INJECT_ERROR_LISTENER).catch(() => {});
437
+ await browser.evaluate(INJECT_SYNC_XHR_LISTENER).catch(() => {});
438
+ await browser.evaluate(INJECT_LONG_TASK_LISTENER).catch(() => {});
439
+ await browser.evaluate(INJECT_SW_LISTENER).catch(() => {});
440
+
441
+ // 3. Wait for page settle
442
+ if (route.waitFor) {
443
+ const found = await waitForSelector(browser, route.waitFor, 10000);
444
+ if (!found) {
445
+ result.errors.push({
446
+ type: 'load_failure',
447
+ message: `Selector "${route.waitFor}" not found after 10s — page may not have loaded`,
448
+ severity: route.critical ? 'critical' : 'warning',
449
+ url,
450
+ });
451
+ }
452
+ } else {
453
+ await new Promise(r => setTimeout(r, config.pageSettleMs));
454
+ }
455
+
456
+ // 4. Blank/error page check
457
+ const titleResult = await browser.evaluate('() => document.title');
458
+ result.pageTitle = String(unwrapEval(titleResult) ?? '');
459
+ const bodyText = await browser.evaluate('() => document.body?.innerText?.trim() ?? ""');
460
+ const bodyTextVal = String(unwrapEval(bodyText) ?? '');
461
+ result.isBlankPage = !bodyTextVal || bodyTextVal.length < 50;
462
+ if (result.isBlankPage) {
463
+ result.errors.push({
464
+ type: 'blank_page',
465
+ message: `Page appears blank or nearly empty (body text length < 50 chars)`,
466
+ severity: 'critical',
467
+ url,
468
+ });
469
+ }
470
+
471
+ // 5. Console messages — sliced from per-route baseline
472
+ const consoleMsgs = (await browser.listConsole().catch(() => [])).slice(consoleBaseline);
473
+ for (const msg of consoleMsgs) {
474
+ const text = (msg.text ?? msg.message ?? '');
475
+ if (text.toLowerCase().includes('has been blocked by cors policy')) continue;
476
+ const severity = classifyConsoleMessage(msg, route.critical);
477
+ if (severity !== null && msg.level !== 'log') {
478
+ result.errors.push({
479
+ type: 'console',
480
+ level: msg.level,
481
+ message: text || String(msg),
482
+ source: msg.source ?? null,
483
+ line: msg.lineNumber ?? null,
484
+ severity,
485
+ url,
486
+ });
487
+ }
488
+ }
489
+
490
+ // 5b. CORS error detection
491
+ for (const msg of consoleMsgs) {
492
+ const text = (msg.text ?? msg.message ?? '');
493
+ if (text.toLowerCase().includes('has been blocked by cors policy')) {
494
+ result.errors.push({
495
+ type: 'cors_error',
496
+ message: text || 'CORS policy violation',
497
+ severity: 'critical',
498
+ url,
499
+ });
500
+ }
501
+ }
502
+
503
+ // 6. Network requests — sliced from per-route baseline (cap AFTER slice, not before)
504
+ const networkReqs = (await browser.listNetwork())
505
+ .slice(networkBaseline).slice(0, 500);
506
+ for (const req of networkReqs) {
507
+ const severity = classifyNetworkRequest(req, route.critical);
508
+ if (severity !== null) {
509
+ result.errors.push({
510
+ type: 'network',
511
+ method: req.method ?? 'GET',
512
+ requestUrl: req.url,
513
+ status: req.status,
514
+ statusText: req.statusText ?? null,
515
+ origin: classifyOrigin(req.url, url),
516
+ message: `HTTP ${req.status}${req.statusText ? ` ${req.statusText}` : ''} — ${req.method ?? 'GET'} ${req.url}`,
517
+ severity,
518
+ url,
519
+ });
520
+ }
521
+ }
522
+
523
+ // 6b. API frequency analysis
524
+ result.errors.push(...analyzeApiFrequency(networkReqs, url));
525
+
526
+ // 6d. Third-party blocking resource detection via HAR timing
527
+ try {
528
+ result.errors.push(...parseNetworkTiming(networkReqs, url));
529
+ } catch (err) {
530
+ logger.warn(`[ARGUS] Network timing analysis skipped for ${url}: ${err.message}`);
531
+ }
532
+
533
+ // 6c. API contract validation
534
+ if (apiContracts?.length > 0) {
535
+ try {
536
+ const contractFindings = await validateApiContracts(networkReqs, browser, apiContracts, url);
537
+ result.errors.push(...contractFindings);
538
+ } catch (err) {
539
+ logger.warn(`[ARGUS] API contract validation skipped for ${url}: ${err.message}`);
540
+ }
541
+ }
542
+
543
+ // 7. Injected uncaught exceptions
544
+ const injectedErrors = await browser.evaluate(EXTRACT_ERROR_LISTENER);
545
+ try {
546
+ const rawInjected = unwrapEval(injectedErrors);
547
+ const parsed = Array.isArray(rawInjected) ? rawInjected
548
+ : JSON.parse(typeof rawInjected === 'string' ? rawInjected : '[]');
549
+ for (const err of parsed) {
550
+ result.errors.push({
551
+ type: err.type,
552
+ message: err.message,
553
+ stack: err.stack,
554
+ source: err.source ?? null,
555
+ line: err.line ?? null,
556
+ severity: route.critical ? 'critical' : 'warning',
557
+ url,
558
+ });
559
+ }
560
+ } catch { /* parse failure */ }
561
+
562
+ // 7b. Sync XHR detection
563
+ try {
564
+ const syncXhrRaw = await browser.evaluate(EXTRACT_SYNC_XHR_LISTENER);
565
+ const rawSyncXhr = unwrapEval(syncXhrRaw);
566
+ const syncXhrs = Array.isArray(rawSyncXhr) ? rawSyncXhr
567
+ : JSON.parse(typeof rawSyncXhr === 'string' ? rawSyncXhr : '[]');
568
+ for (const entry of syncXhrs) {
569
+ result.errors.push({
570
+ type: 'sync_xhr',
571
+ method: entry.method,
572
+ requestUrl: entry.url,
573
+ message: `Synchronous XHR: ${entry.method} ${entry.url} — blocks the main thread`,
574
+ severity: 'warning',
575
+ url,
576
+ });
577
+ }
578
+ } catch { /* parse failure */ }
579
+
580
+ // 7c. document.write detection
581
+ try {
582
+ const docWriteRaw = await browser.evaluate(DETECT_DOC_WRITE_STATIC);
583
+ const rawDocWrite = unwrapEval(docWriteRaw);
584
+ const docWrites = Array.isArray(rawDocWrite) ? rawDocWrite
585
+ : JSON.parse(typeof rawDocWrite === 'string' ? rawDocWrite : '[]');
586
+ for (const entry of docWrites) {
587
+ result.errors.push({
588
+ type: 'document_write',
589
+ method: entry.method,
590
+ content: entry.content,
591
+ message: `document.${entry.method}() is parser-blocking and degrades page performance`,
592
+ severity: 'warning',
593
+ url,
594
+ });
595
+ }
596
+ } catch { /* parse failure or fetch error */ }
597
+
598
+ // 7d. Long task detection
599
+ try {
600
+ const longTaskRaw = await browser.evaluate(EXTRACT_LONG_TASK_LISTENER);
601
+ const rawLongTasks = unwrapEval(longTaskRaw);
602
+ const longTasks = Array.isArray(rawLongTasks) ? rawLongTasks
603
+ : JSON.parse(typeof rawLongTasks === 'string' ? rawLongTasks : '[]');
604
+ for (const entry of longTasks) {
605
+ result.errors.push({
606
+ type: 'long_task',
607
+ duration: entry.duration,
608
+ startTime: entry.startTime,
609
+ attribution: entry.attribution,
610
+ message: `Long task: ${entry.duration}ms — blocks the main thread (threshold: 50ms)`,
611
+ severity: 'warning',
612
+ url,
613
+ });
614
+ }
615
+ } catch { /* PerformanceObserver not available */ }
616
+
617
+ // 7e. Service worker registration failures
618
+ try {
619
+ const swRaw = await browser.evaluate(EXTRACT_SW_LISTENER);
620
+ const rawSw = unwrapEval(swRaw);
621
+ const swErrs = Array.isArray(rawSw) ? rawSw
622
+ : JSON.parse(typeof rawSw === 'string' ? rawSw : '[]');
623
+ for (const entry of swErrs) {
624
+ result.errors.push({
625
+ type: 'sw_registration_error',
626
+ scriptURL: entry.scriptURL,
627
+ message: `Service worker registration failed for "${entry.scriptURL}": ${entry.message}`,
628
+ severity: 'warning',
629
+ url,
630
+ });
631
+ }
632
+ } catch { /* service worker not supported */ }
633
+
634
+ // 7f. debugger; statement detection
635
+ try {
636
+ const dbgRaw = await browser.evaluate(DEBUGGER_SCRIPT);
637
+ const rawDbg = unwrapEval(dbgRaw);
638
+ const dbgHits = Array.isArray(rawDbg) ? rawDbg
639
+ : JSON.parse(typeof rawDbg === 'string' ? rawDbg : '[]');
640
+ for (const entry of dbgHits) {
641
+ result.errors.push({
642
+ type: 'debugger_statement',
643
+ scriptUrl: entry.scriptUrl,
644
+ line: entry.line,
645
+ snippet: entry.snippet,
646
+ message: `debugger; statement found in "${entry.scriptUrl}" (line ${entry.line}) — remove before shipping`,
647
+ severity: 'critical',
648
+ url,
649
+ });
650
+ }
651
+ } catch { /* parse failure */ }
652
+
653
+ // 7g. Duplicate id="" detection
654
+ try {
655
+ const dupIdRaw = await browser.evaluate(DUPLICATE_ID_SCRIPT);
656
+ const rawDupIds = unwrapEval(dupIdRaw);
657
+ const dupIds = Array.isArray(rawDupIds) ? rawDupIds
658
+ : JSON.parse(typeof rawDupIds === 'string' ? rawDupIds : '[]');
659
+ for (const entry of dupIds) {
660
+ result.errors.push({
661
+ type: 'duplicate_id',
662
+ id: entry.id,
663
+ count: entry.count,
664
+ message: `Duplicate id="${entry.id}" found on ${entry.count} elements — id must be unique per document`,
665
+ severity: 'warning',
666
+ url,
667
+ });
668
+ }
669
+ } catch { /* parse failure */ }
670
+
671
+ // 9b. SEO DOM checks
672
+ try {
673
+ const seoRaw = await browser.evaluate(SEO_ANALYSIS_SCRIPT);
674
+ result.errors.push(...parseSeoAnalysisResult(unwrapEval(seoRaw), url));
675
+ } catch (err) {
676
+ logger.warn(`[ARGUS] SEO analysis skipped for ${url}: ${err.message}`);
677
+ }
678
+
679
+ // 9c. Security checks
680
+ try {
681
+ const secRaw = await browser.evaluate(SECURITY_ANALYSIS_SCRIPT);
682
+ result.errors.push(...parseSecurityAnalysisResult(unwrapEval(secRaw), url));
683
+ } catch (err) {
684
+ logger.warn(`[ARGUS] Security DOM analysis skipped for ${url}: ${err.message}`);
685
+ }
686
+ result.errors.push(...analyzeSecurityConsole(consoleMsgs, url));
687
+ result.errors.push(...analyzeSecurityNetwork(networkReqs, url));
688
+
689
+ // 9d. Content quality checks
690
+ try {
691
+ const contentRaw = await browser.evaluate(CONTENT_ANALYSIS_SCRIPT);
692
+ result.errors.push(...parseContentAnalysisResult(unwrapEval(contentRaw), url));
693
+ } catch (err) {
694
+ logger.warn(`[ARGUS] Content analysis skipped for ${url}: ${err.message}`);
695
+ }
696
+
697
+ // 9e. Chrome DevTools Issues panel
698
+ try {
699
+ const issueRaw = await browser.listConsoleRaw({ types: ['issue'] });
700
+ const issues = normalizeArray(issueRaw).slice(issuesBaseline);
701
+ result.errors.push(...parseIssues(issues, url, route.critical));
702
+ } catch (err) {
703
+ logger.warn(`[ARGUS] Issues analysis skipped for ${url}: ${err.message}`);
704
+ }
705
+
706
+ // 9f. HTTPS enforcement check
707
+ try {
708
+ const parsed = new URL(url);
709
+ const isLocalhost = /^(localhost|127\.|::1)/.test(parsed.hostname);
710
+ if (parsed.protocol === 'http:' && !isLocalhost) {
711
+ result.errors.push({
712
+ type: 'security_no_https',
713
+ message: `Page served over HTTP — enforce HTTPS via server redirect or HSTS`,
714
+ severity: 'warning',
715
+ url,
716
+ });
717
+ }
718
+ } catch { /* URL parse failure */ }
719
+
720
+ // 10. CSS analysis
721
+ try {
722
+ const cssRaw = await browser.evaluate(CSS_ANALYSIS_SCRIPT);
723
+ result.errors.push(...parseCssAnalysisResult(unwrapEval(cssRaw), url));
724
+ } catch (err) {
725
+ logger.warn(`[ARGUS] CSS analysis skipped for ${url}: ${err.message}`);
726
+ }
727
+
728
+ // 11. Deduplicate within this cheap run
729
+ result.errors = deduplicateErrors(result.errors);
730
+
731
+ // 12. Screenshot
732
+ const screenshotPath = path.join(OUTPUT_DIR, `screenshot-${slugify(route.name)}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.png`);
733
+ try {
734
+ const screenshotData = await browser.screenshot({ format: 'png' });
735
+ if (screenshotData?.data) {
736
+ fs.writeFileSync(screenshotPath, Buffer.from(screenshotData.data, 'base64'));
737
+ result.screenshot = screenshotPath;
738
+ }
739
+ } catch (err) {
740
+ logger.warn(`[ARGUS] Screenshot failed for ${url}: ${err.message}`);
741
+ }
742
+
743
+ return result;
744
+ }
745
+
746
+ // ── Expensive Crawl (called ×1) ────────────────────────────────────────────────
747
+
748
+ /**
749
+ * Expensive/deterministic analyzers for one route — called ONCE per route.
750
+ * Runs: network perf, redirect chain, perf budgets, Lighthouse,
751
+ * broken internal links, cache headers.
752
+ */
753
+ export async function crawlRouteExpensive(route, baseUrl, mcp) {
754
+ const browser = new CdpBrowserAdapter(mcp);
755
+ const url = `${baseUrl}${route.path}`;
756
+ const errors = [];
757
+
758
+ try {
759
+ await browser.navigate(url);
760
+ if (route.waitFor) {
761
+ await waitForSelector(browser, route.waitFor, 10000);
762
+ } else {
763
+ await new Promise(r => setTimeout(r, config.pageSettleMs));
764
+ }
765
+ } catch (err) {
766
+ logger.warn(`[ARGUS] Expensive crawl: navigation failed for ${url}: ${err.message}`);
767
+ return errors;
768
+ }
769
+
770
+ // Network performance — slow responses + oversized payloads
771
+ try {
772
+ const perfRaw = await browser.evaluate(NETWORK_PERF_SCRIPT);
773
+ const perfResult = unwrapEval(perfRaw);
774
+ let perfEntries = Array.isArray(perfResult) ? perfResult
775
+ : JSON.parse(typeof perfResult === 'string' ? perfResult : '[]');
776
+ errors.push(...analyzeNetworkPerformance(Array.isArray(perfEntries) ? perfEntries : [], url));
777
+ } catch (err) {
778
+ logger.warn(`[ARGUS] Network performance analysis skipped for ${url}: ${err.message}`);
779
+ }
780
+
781
+ // Redirect chain detection
782
+ try {
783
+ const rdRaw = await browser.evaluate(REDIRECT_COUNT_SCRIPT);
784
+ const rdCount = Number(unwrapEval(rdRaw) ?? 0);
785
+ if (rdCount > 2) {
786
+ errors.push({
787
+ type: 'redirect_chain',
788
+ count: rdCount,
789
+ message: `Redirect chain length ${rdCount} — navigated through ${rdCount} redirects (threshold: > 2)`,
790
+ severity: 'warning',
791
+ url,
792
+ });
793
+ }
794
+ } catch (err) {
795
+ logger.warn(`[ARGUS] Redirect chain check skipped for ${url}: ${err.message}`);
796
+ }
797
+
798
+ // Performance budget check
799
+ errors.push(...(await checkPerformanceBudgets(browser, url)));
800
+
801
+ // Full Lighthouse audit
802
+ errors.push(...(await checkLighthouse(browser, url)));
803
+
804
+ // Broken internal link detection
805
+ try {
806
+ const linksRaw = await browser.evaluate(INTERNAL_LINKS_SCRIPT);
807
+ const rawLinks = unwrapEval(linksRaw);
808
+ const links = [...new Set(Array.isArray(rawLinks) ? rawLinks.filter(Boolean) : [])];
809
+ const headResults = await Promise.all(
810
+ links.map(async href => {
811
+ try {
812
+ const res = await fetch(href, { method: 'HEAD', signal: AbortSignal.timeout(5000) });
813
+ return { href, status: res.status };
814
+ } catch (err) {
815
+ return { href, status: 0, error: err.message };
816
+ }
817
+ })
818
+ );
819
+ for (const { href, status } of headResults) {
820
+ if (status === 404) {
821
+ errors.push({
822
+ type: 'broken_link',
823
+ requestUrl: href,
824
+ status: 404,
825
+ message: `Broken internal link: ${href} (HTTP 404)`,
826
+ severity: 'warning',
827
+ url,
828
+ });
829
+ }
830
+ }
831
+ } catch (err) {
832
+ logger.warn(`[ARGUS] Broken link check skipped for ${url}: ${err.message}`);
833
+ }
834
+
835
+ // Cache header detection
836
+ try {
837
+ const cacheRaw = await browser.evaluate(CACHE_HEADER_SCRIPT);
838
+ const rawCache = unwrapEval(cacheRaw);
839
+ const cacheItems = Array.isArray(rawCache) ? rawCache
840
+ : JSON.parse(typeof rawCache === 'string' ? rawCache : '[]');
841
+ for (const entry of cacheItems) {
842
+ const filename = (entry.url ?? '').replace(/^.*\//, '').split('?')[0] || entry.url;
843
+ errors.push({
844
+ type: 'cache_headers_missing',
845
+ requestUrl: entry.url,
846
+ message: `No cache headers on "${filename}" — missing both Cache-Control and ETag`,
847
+ severity: 'info',
848
+ url,
849
+ });
850
+ }
851
+ } catch (err) {
852
+ logger.warn(`[ARGUS] Cache header check skipped for ${url}: ${err.message}`);
853
+ }
854
+
855
+ return errors;
856
+ }
857
+
858
+ // ── Per-Route Crawl Coordinator ────────────────────────────────────────────────
859
+
860
+ async function crawlAndAnalyzeRoute(route, targetBaseUrl, mcp, sessionFile) {
861
+ return startSpan('argus.crawl_route', { url: `${targetBaseUrl}${route.path}`, critical: String(!!route.critical) }, async () => {
862
+ const browser = new CdpBrowserAdapter(mcp);
863
+ const url = `${targetBaseUrl}${route.path}`;
864
+
865
+ if (auth?.steps?.length > 0) {
866
+ try {
867
+ await refreshSession(browser, auth, targetBaseUrl);
868
+ await restoreSession(browser, targetBaseUrl, sessionFile);
869
+ } catch (err) {
870
+ logger.warn(`[ARGUS] Auth: session restore skipped for ${route.name}: ${err.message}`);
871
+ }
872
+ }
873
+
874
+ // Cheap pass × 2 → merge for flakiness
875
+ logger.info(`[ARGUS] ${route.name}: cheap run 1/2...`);
876
+ const cheapRun1 = await startSpan('argus.crawl_route', { url, critical: String(!!route.critical), pass: 'cheap_1' }, () => crawlRouteCheap(route, targetBaseUrl, mcp));
877
+ logger.info(`[ARGUS] ${route.name}: cheap run 2/2 (flakiness check)...`);
878
+ const cheapRun2 = await startSpan('argus.crawl_route', { url, critical: String(!!route.critical), pass: 'cheap_2' }, () => crawlRouteCheap(route, targetBaseUrl, mcp));
879
+ const result = mergeRunResults(cheapRun1, cheapRun2);
880
+
881
+ // Expensive pass × 1
882
+ logger.info(`[ARGUS] ${route.name}: expensive analyzers (once)...`);
883
+ const expensiveErrors = await startSpan('argus.crawl_route', { url, critical: String(!!route.critical), pass: 'expensive' }, () => crawlRouteExpensive(route, targetBaseUrl, mcp));
884
+ result.errors.push(...expensiveErrors);
885
+ result.errors = deduplicateErrors(result.errors);
886
+
887
+ // Post-crawl expensive analyzers via registry (responsive, memory, hover, snapshot, keyboard)
888
+ for (const { name, analyze } of getExpensive()) {
889
+ if (name === 'lighthouse') continue; // runs inside crawlRouteExpensive
890
+ try {
891
+ const raw = await startSpan('argus.analyzer', { name, url }, () => analyze(browser, url, route));
892
+ const findings = Array.isArray(raw) ? raw : (raw?.findings ?? []);
893
+ result.errors.push(...findings);
894
+ // Handle responsive screenshot return shape: { findings, screenshots }
895
+ if (raw?.screenshots && Object.keys(raw.screenshots).length > 0) {
896
+ const screenshotPaths = {};
897
+ for (const [viewport, data] of Object.entries(raw.screenshots)) {
898
+ if (typeof data !== 'string') continue; // skip omitted entries ({ omitted: true, reason, bytes })
899
+ const shotPath = path.join(
900
+ OUTPUT_DIR,
901
+ `screenshot-${slugify(route.name)}-responsive-${viewport}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.png`
902
+ );
903
+ try {
904
+ fs.writeFileSync(shotPath, Buffer.from(data, 'base64'));
905
+ screenshotPaths[viewport] = shotPath;
906
+ } catch (err) {
907
+ logger.warn(`[ARGUS] Responsive screenshot write failed (${viewport}): ${err.message}`);
908
+ }
909
+ }
910
+ if (Object.keys(screenshotPaths).length > 0) result.responsiveScreenshots = screenshotPaths;
911
+ }
912
+ } catch (err) {
913
+ logger.warn(`[ARGUS] ${name} skipped for ${route.name}: ${err.message}`);
914
+ }
915
+ }
916
+
917
+ // Collect internal navigation links for dead route detection (C1.4)
918
+ try {
919
+ const linksRaw = await browser.evaluate(INTERNAL_LINKS_SCRIPT);
920
+ const parsed = unwrapEval(linksRaw);
921
+ result.discoveredLinks = Array.isArray(parsed) ? parsed
922
+ : (() => { try { const p = JSON.parse(String(parsed ?? '[]')); return Array.isArray(p) ? p : []; } catch { return []; } })();
923
+ } catch {
924
+ result.discoveredLinks = [];
925
+ }
926
+
927
+ // Record per-route finding metrics and flakiness
928
+ const flakyCount = result.errors.filter(e => e.flaky).length;
929
+ recordFlaky(flakyCount, route.name);
930
+ for (const f of result.errors) recordFinding(f.type, f.severity, route.name);
931
+
932
+ return result;
933
+ }); // end argus.crawl_route span
934
+ }
935
+
936
+ // ── Parallel Shard Runner (D7.3) ──────────────────────────────────────────────
937
+
938
+ async function crawlShardWithClient(shard, targetBaseUrl, mcp, sessionFile) {
939
+ const results = [];
940
+ for (const route of shard) {
941
+ logger.info(`[ARGUS/parallel] Crawling: ${route.name} → ${targetBaseUrl}${route.path}`);
942
+ const result = await crawlAndAnalyzeRoute(route, targetBaseUrl, mcp, sessionFile);
943
+ const flakyCount = result.errors.filter(e => e.flaky).length;
944
+ if (flakyCount > 0) {
945
+ logger.info(`[ARGUS/parallel] ${route.name}: ${flakyCount} finding(s) downgraded to info (flaky)`);
946
+ }
947
+ results.push(result);
948
+ }
949
+ return results;
950
+ }
951
+
952
+ // ── Main Entry Point ───────────────────────────────────────────────────────────
953
+
954
+ /**
955
+ * Run all routes, collect results, process report, and dispatch.
956
+ * In production, `mcp` is provided by Claude Code's MCP integration.
957
+ *
958
+ * @param {object} mcp - Chrome DevTools MCP tool interface
959
+ * @param {Array} [routeOverrides] - Override the default routes from targets.js
960
+ * @param {string} [baseUrlOverride] - Override the default base URL
961
+ * @returns {object} Full report object
962
+ */
963
+ export async function runCrawl(mcp, routeOverrides = null, baseUrlOverride = null) {
964
+ return startSpan('argus.run_crawl', { baseUrl: baseUrlOverride ?? BASE_URL }, async () => {
965
+ // Validate config at startup — catches targets.js misconfiguration before any crawl work begins.
966
+ // Named exports are already statically imported above; build the namespace object here.
967
+ validateConfig({ config, routes, thresholds, apiContracts, severityOverrides, auth, flows, codebase, autoDiscover });
968
+
969
+ const browser = new CdpBrowserAdapter(mcp);
970
+ fs.mkdirSync(OUTPUT_DIR, { recursive: true });
971
+
972
+ const targetBaseUrl = baseUrlOverride ?? BASE_URL;
973
+
974
+ // C3: auto route discovery
975
+ const baseRoutes = routeOverrides ?? routes;
976
+ const targetRoutes = (autoDiscover && !routeOverrides)
977
+ ? await discoverRoutes(targetBaseUrl, codebase?.sourceDir ?? null, autoDiscover, baseRoutes)
978
+ : baseRoutes;
979
+
980
+ // Validate route objects
981
+ for (const route of targetRoutes) {
982
+ if (!route || typeof route !== 'object') throw new Error(`[ARGUS] Invalid route entry: ${JSON.stringify(route)}`);
983
+ if (typeof route.path !== 'string' || !route.path.startsWith('/')) {
984
+ throw new Error(`[ARGUS] Invalid route.path "${route.path}" — must be a string starting with "/"`);
985
+ }
986
+ }
987
+
988
+ const report = {
989
+ generatedAt: new Date().toISOString(),
990
+ baseUrl: targetBaseUrl,
991
+ summary: { total: 0, critical: 0, warning: 0, info: 0 },
992
+ routes: [],
993
+ flows: [],
994
+ };
995
+
996
+ // Auth session persistence (B2)
997
+ const sessionFile = auth?.sessionFile ?? '.argus-session.json';
998
+ if (auth?.steps?.length > 0) {
999
+ if (!hasSession(sessionFile, auth.sessionMaxAgeMs)) {
1000
+ logger.info(`[ARGUS] Auth: running login flow (${auth.steps.length} steps)...`);
1001
+ try {
1002
+ await runLoginFlow(browser, targetBaseUrl, auth.steps);
1003
+ await saveSession(browser, sessionFile);
1004
+ } catch (err) {
1005
+ logger.warn(`[ARGUS] Auth: login flow failed — crawl will proceed unauthenticated: ${err.message}`);
1006
+ }
1007
+ } else {
1008
+ logger.info(`[ARGUS] Auth: reusing existing session from ${sessionFile}`);
1009
+ }
1010
+ }
1011
+
1012
+ // D7.3: parallel route crawling
1013
+ const _rawConcurrency = parseInt(process.env.ARGUS_CONCURRENCY ?? '1', 10);
1014
+ const concurrency = Math.min(10, Math.max(1, isNaN(_rawConcurrency) ? 1 : _rawConcurrency));
1015
+
1016
+ if (concurrency > 1) {
1017
+ logger.info(`[ARGUS] Parallel mode: concurrency=${concurrency}, sharding ${targetRoutes.length} route(s)`);
1018
+ const shards = chunkArray(targetRoutes, concurrency);
1019
+ const extraClients = [];
1020
+ try {
1021
+ for (let i = 1; i < shards.length; i++) {
1022
+ extraClients.push(await createMcpClient());
1023
+ }
1024
+ const shardPromises = shards.map((shard, idx) => {
1025
+ const shardMcp = idx === 0 ? mcp : extraClients[idx - 1];
1026
+ return crawlShardWithClient(shard, targetBaseUrl, shardMcp, sessionFile);
1027
+ });
1028
+ const shardResults = await Promise.all(shardPromises);
1029
+ for (const shardResult of shardResults) {
1030
+ for (const result of shardResult) {
1031
+ report.routes.push(result);
1032
+ for (const err of result.errors) {
1033
+ report.summary.total++;
1034
+ report.summary[err.severity] = (report.summary[err.severity] ?? 0) + 1;
1035
+ }
1036
+ }
1037
+ }
1038
+ } finally {
1039
+ for (const client of extraClients) {
1040
+ try { await client?.close?.(); } catch {}
1041
+ }
1042
+ }
1043
+ } else {
1044
+ for (const route of targetRoutes) {
1045
+ logger.info(`[ARGUS] Crawling: ${route.name} → ${targetBaseUrl}${route.path}`);
1046
+ const result = await crawlAndAnalyzeRoute(route, targetBaseUrl, mcp, sessionFile);
1047
+
1048
+ const flakyCount = result.errors.filter(e => e.flaky).length;
1049
+ if (flakyCount > 0) {
1050
+ logger.info(`[ARGUS] ${route.name}: ${flakyCount} finding(s) downgraded to info (flaky — appeared in only one cheap run)`);
1051
+ }
1052
+
1053
+ report.routes.push(result);
1054
+ for (const err of result.errors) {
1055
+ report.summary.total++;
1056
+ report.summary[err.severity] = (report.summary[err.severity] ?? 0) + 1;
1057
+ }
1058
+ }
1059
+ }
1060
+
1061
+ // User flow testing (B5)
1062
+ if (flows?.length > 0) {
1063
+ logger.info(`[ARGUS] Running ${flows.length} user flow(s)...`);
1064
+ const { results: flowResults, findings: flowFindings } = await runAllFlows(flows, targetBaseUrl, browser);
1065
+ report.flows = flowResults;
1066
+ for (const finding of flowFindings) {
1067
+ report.summary.total++;
1068
+ report.summary[finding.severity] = (report.summary[finding.severity] ?? 0) + 1;
1069
+ }
1070
+ }
1071
+
1072
+ // C1: Codebase cross-reference
1073
+ report.codebase = [];
1074
+ const allConsoleFindings = report.routes.flatMap(r => r.errors.filter(e => e.type === 'console'));
1075
+ try {
1076
+ const cbFindings = await analyzeCodebase({
1077
+ sourceDir: codebase?.sourceDir ?? null,
1078
+ envFile: codebase?.envFile ?? null,
1079
+ consoleFindings: allConsoleFindings,
1080
+ });
1081
+ report.codebase.push(...cbFindings);
1082
+ if (cbFindings.length > 0) {
1083
+ logger.info(`[ARGUS] C1: ${cbFindings.length} codebase finding(s)`);
1084
+ }
1085
+ } catch (err) {
1086
+ logger.warn(`[ARGUS] C1: codebase analysis skipped: ${err.message}`);
1087
+ }
1088
+
1089
+ // C1.4: Dead route detection
1090
+ try {
1091
+ const allLinks = [...new Set(report.routes.flatMap(r => r.discoveredLinks ?? []))];
1092
+ const testedPaths = targetRoutes.map(r => r.path);
1093
+ const deadFindings = await detectDeadRoutes(targetBaseUrl, allLinks, testedPaths);
1094
+ report.codebase.push(...deadFindings);
1095
+ if (deadFindings.length > 0) {
1096
+ logger.info(`[ARGUS] C1: ${deadFindings.length} dead route(s) detected`);
1097
+ }
1098
+ } catch (err) {
1099
+ logger.warn(`[ARGUS] C1: dead route detection skipped: ${err.message}`);
1100
+ }
1101
+
1102
+ // Add codebase findings to running summary
1103
+ for (const finding of report.codebase) {
1104
+ report.summary.total++;
1105
+ report.summary[finding.severity] = (report.summary[finding.severity] ?? 0) + 1;
1106
+ }
1107
+
1108
+ // Post-crawl: overrides, baseline, write JSON, dispatch
1109
+ const { reportPath, diff } = await processReport(report, {
1110
+ outputDir: OUTPUT_DIR,
1111
+ severityOverrides,
1112
+ });
1113
+
1114
+ if (diff && !diff.isFirstRun) recordNewFindings(diff.newCount ?? 0);
1115
+
1116
+ await dispatchAll(report, diff, reportPath);
1117
+
1118
+ return report;
1119
+ }); // end argus.run_crawl span
1120
+ }
1121
+
1122
+ // ── CLI Entry ──────────────────────────────────────────────────────────────────
1123
+
1124
+ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
1125
+ logger.info('[ARGUS] orchestrator.js loaded. Invoke runCrawl(mcp) from Claude Code with MCP tools connected.');
1126
+ logger.info('[ARGUS] Target base URL: ' + BASE_URL);
1127
+ logger.info('[ARGUS] Routes to crawl: ' + (routes ?? []).map(r => r?.path ?? '(no path)').join(', '));
1128
+ }