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,498 @@
1
+ /**
2
+ * ARGUS Phase 3: Environment Comparison Engine
3
+ *
4
+ * Compares dev vs staging (or any two environments) for the same routes.
5
+ * Captures screenshots, DOM snapshots, console messages, and network requests
6
+ * from both environments, then diffs them across all four dimensions.
7
+ *
8
+ * Run: node src/orchestration/env-comparison.js
9
+ * Or invoke: runComparison(mcp) from Claude Code with MCP tools connected.
10
+ *
11
+ * MCP Tools Used:
12
+ * navigate_page, take_screenshot, take_snapshot, list_console_messages,
13
+ * list_network_requests, wait_for, evaluate_script
14
+ */
15
+
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+ import { fileURLToPath } from 'url';
19
+ import 'dotenv/config';
20
+ import { unwrapEval } from '../utils/mcp-client.js';
21
+ import { childLogger } from '../utils/logger.js';
22
+
23
+ const logger = childLogger('env-comparison');
24
+ import { normalizeArray } from '../utils/flow-runner.js';
25
+ import { CdpBrowserAdapter } from '../adapters/browser.js';
26
+
27
+ import { comparisonRoutes, config } from '../config/targets.js';
28
+ import { compareScreenshots, diffDomSnapshots, diffNetworkRequests, diffConsoleMessages } from '../utils/diff.js';
29
+ import { postBugReport } from './slack-notifier.js';
30
+ import { CSS_ANALYSIS_SCRIPT, parseCssAnalysisResult } from '../utils/css-analyzer.js';
31
+ import { analyzeApiFrequency } from '../utils/api-frequency.js';
32
+ import { slugify } from '../utils/slug.js';
33
+
34
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
35
+ const DEV_URL = process.env.TARGET_DEV_URL ?? 'http://localhost:3000';
36
+ const RAW_STAGING_URL = process.env.TARGET_STAGING_URL ?? '';
37
+ // Validate as a parseable URL with a non-localhost hostname — checking only against
38
+ // one hardcoded placeholder string misses 'TODO', 'your-url-here', http://localhost, etc.
39
+ const STAGING_URL_SET = (() => {
40
+ if (!RAW_STAGING_URL || RAW_STAGING_URL === 'https://staging.yourapp.com') return false;
41
+ try {
42
+ const u = new URL(RAW_STAGING_URL);
43
+ return u.hostname !== 'localhost' && u.hostname !== '127.0.0.1' && u.hostname !== '';
44
+ } catch {
45
+ return false;
46
+ }
47
+ })();
48
+ const STAGING_URL = RAW_STAGING_URL;
49
+ const OUTPUT_DIR = path.resolve(__dirname, '../../', config.outputDir);
50
+ const SCREENSHOT_THRESHOLD = config.screenshotDiffThreshold; // %
51
+
52
+ // ── Per-Environment Capture ────────────────────────────────────────────────────
53
+
54
+ /**
55
+ * Capture the full state of a page for comparison.
56
+ * Returns screenshot path, DOM snapshot, console messages, and network requests.
57
+ *
58
+ * @param {string} url - Full URL to capture
59
+ * @param {string} label - Label for file naming (e.g., 'dev', 'staging')
60
+ * @param {string} routeName - Human-readable route name
61
+ * @param {object} browser - CdpBrowserAdapter
62
+ * @returns {object} Captured page state
63
+ */
64
+ async function capturePage(url, label, routeName, browser) {
65
+ logger.info(`[ARGUS] Capturing ${label}: ${url}`);
66
+
67
+ // Snapshot buffer counts BEFORE navigation so staging capture does not
68
+ // include dev's accumulated console messages and network requests from the prior capture.
69
+ const consoleBaseline = (await browser.listConsole().catch(() => [])).length;
70
+ const networkBaseline = (await browser.listNetwork().catch(() => [])).length;
71
+
72
+ await browser.navigate(url);
73
+ await new Promise(r => setTimeout(r, config.pageSettleMs));
74
+
75
+ // Screenshot
76
+ const screenshotPath = path.join(
77
+ OUTPUT_DIR,
78
+ `compare-${slugify(routeName)}-${label}-${Date.now()}.png`
79
+ );
80
+ const screenshotData = await browser.screenshot({ format: 'png' });
81
+ if (screenshotData?.data) {
82
+ fs.writeFileSync(screenshotPath, Buffer.from(screenshotData.data, 'base64'));
83
+ }
84
+
85
+ // DOM snapshot
86
+ const domSnapshot = await browser.snapshot();
87
+ const domString = JSON.stringify(domSnapshot?.document ?? domSnapshot ?? '');
88
+
89
+ // Console messages — sliced from per-capture baseline to exclude prior environment's messages
90
+ const consoleMsgs = (await browser.listConsole().catch(() => [])).slice(consoleBaseline);
91
+
92
+ // Network requests — sliced from per-capture baseline to exclude prior environment's requests
93
+ const networkReqs = (await browser.listNetwork().catch(() => [])).slice(networkBaseline);
94
+
95
+ return {
96
+ url,
97
+ label,
98
+ screenshotPath: screenshotData?.data ? screenshotPath : null,
99
+ domString,
100
+ consoleMsgs,
101
+ networkReqs,
102
+ };
103
+ }
104
+
105
+ // ── Route Comparison ───────────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * Compare dev and staging for a single route.
109
+ *
110
+ * @param {object} route - Route definition from targets.js
111
+ * @param {object} browser - CdpBrowserAdapter
112
+ * @returns {object} Comparison result with all diffs
113
+ */
114
+ async function compareRoute(route, browser) {
115
+ const devUrl = `${DEV_URL}${route.path}`;
116
+ const stagingUrl = `${STAGING_URL}${route.path}`;
117
+
118
+ let devData, stagingData;
119
+ try {
120
+ devData = await capturePage(devUrl, 'dev', route.name, browser);
121
+ } catch (err) {
122
+ return { route: route.name, devUrl, stagingUrl, error: `dev capture failed: ${err.message}`, diffs: [] };
123
+ }
124
+ try {
125
+ stagingData = await capturePage(stagingUrl, 'staging', route.name, browser);
126
+ } catch (err) {
127
+ return { route: route.name, devUrl, stagingUrl, error: `staging capture failed: ${err.message}`, diffs: [] };
128
+ }
129
+
130
+ const diffs = [];
131
+
132
+ // ── 1. Screenshot diff ─────────────────────────────────────────────────────
133
+ if (devData.screenshotPath && stagingData.screenshotPath) {
134
+ const diffImagePath = path.join(
135
+ OUTPUT_DIR,
136
+ `diff-${slugify(route.name)}-${Date.now()}.png`
137
+ );
138
+
139
+ try {
140
+ // Pass a fixed pixelmatch color-sensitivity (0.1 = 10% per-channel tolerance);
141
+ // SCREENSHOT_THRESHOLD is a separate %‑of‑pixels threshold used only for alerting.
142
+ const { diffPercent } = await compareScreenshots(
143
+ devData.screenshotPath,
144
+ stagingData.screenshotPath,
145
+ diffImagePath,
146
+ 0.1
147
+ );
148
+
149
+ if (diffPercent > SCREENSHOT_THRESHOLD) {
150
+ diffs.push({
151
+ type: 'screenshot',
152
+ diffPercent: parseFloat(diffPercent.toFixed(2)),
153
+ threshold: SCREENSHOT_THRESHOLD,
154
+ diffImagePath,
155
+ devScreenshot: devData.screenshotPath,
156
+ stagingScreenshot: stagingData.screenshotPath,
157
+ severity: diffPercent > SCREENSHOT_THRESHOLD * 10 ? 'warning' : 'info',
158
+ description: `Visual diff ${diffPercent.toFixed(2)}% (threshold: ${SCREENSHOT_THRESHOLD}%)`,
159
+ });
160
+ }
161
+ } catch (err) {
162
+ diffs.push({
163
+ type: 'screenshot_error',
164
+ severity: 'info',
165
+ description: `Screenshot comparison failed: ${err.message}`,
166
+ });
167
+ }
168
+ }
169
+
170
+ // ── 2. DOM structural diff ─────────────────────────────────────────────────
171
+ const domDiffs = diffDomSnapshots(devData.domString, stagingData.domString);
172
+ for (const d of domDiffs) {
173
+ diffs.push({
174
+ type: 'dom',
175
+ severity: Math.abs(d.delta) > 5 ? 'warning' : 'info',
176
+ ...d,
177
+ });
178
+ }
179
+
180
+ // ── 3. Network request diff ────────────────────────────────────────────────
181
+ const { added, removed, changed } = diffNetworkRequests(devData.networkReqs, stagingData.networkReqs);
182
+
183
+ for (const req of added) {
184
+ diffs.push({
185
+ type: 'network_added',
186
+ severity: 'warning',
187
+ description: `New request in staging: ${req.method ?? 'GET'} ${req.url} (${req.status})`,
188
+ });
189
+ }
190
+ for (const req of removed) {
191
+ diffs.push({
192
+ type: 'network_removed',
193
+ severity: 'warning',
194
+ description: `Request present in dev but missing in staging: ${req.method ?? 'GET'} ${req.url}`,
195
+ });
196
+ }
197
+ for (const req of changed) {
198
+ const isRegression = req.statusB >= 400 && req.statusA < 400;
199
+ diffs.push({
200
+ type: 'network_status_changed',
201
+ severity: isRegression ? 'critical' : 'warning',
202
+ description: `${req.url}: status ${req.statusA} (dev) → ${req.statusB} (staging)`,
203
+ });
204
+ }
205
+
206
+ // ── 4. Console error diff ──────────────────────────────────────────────────
207
+ const newConsoleErrors = diffConsoleMessages(devData.consoleMsgs, stagingData.consoleMsgs);
208
+ for (const msg of newConsoleErrors) {
209
+ diffs.push({
210
+ type: 'console_regression',
211
+ severity: 'warning',
212
+ description: `New console error in staging (not in dev): ${msg.text ?? msg.message}`,
213
+ source: msg.source ?? null,
214
+ });
215
+ }
216
+
217
+ return {
218
+ route: route.name,
219
+ devUrl,
220
+ stagingUrl,
221
+ capturedAt: new Date().toISOString(),
222
+ devScreenshot: devData.screenshotPath,
223
+ stagingScreenshot: stagingData.screenshotPath,
224
+ diffs,
225
+ summary: {
226
+ total: diffs.length,
227
+ critical: diffs.filter(d => d.severity === 'critical').length,
228
+ warning: diffs.filter(d => d.severity === 'warning').length,
229
+ info: diffs.filter(d => d.severity === 'info').length,
230
+ },
231
+ };
232
+ }
233
+
234
+ // ── Main Orchestration ─────────────────────────────────────────────────────────
235
+
236
+ /**
237
+ * Run comparison across all configured routes.
238
+ *
239
+ * If TARGET_STAGING_URL is not set (or is still the placeholder), automatically
240
+ * falls back to CSS-only analysis mode — inspects the dev environment for:
241
+ * - CSS overrides and !important conflicts
242
+ * - Styles leaking from unexpected components
243
+ * - Unused CSS rules
244
+ * - API endpoints called multiple times per page load
245
+ *
246
+ * @param {object} mcp - Chrome DevTools MCP tool interface (provided by Claude Code)
247
+ * @returns {object} Full comparison or CSS-analysis report
248
+ */
249
+ export async function runComparison(mcp) {
250
+ const browser = new CdpBrowserAdapter(mcp);
251
+ fs.mkdirSync(OUTPUT_DIR, { recursive: true });
252
+
253
+ if (!STAGING_URL_SET) {
254
+ logger.info('[ARGUS] No staging URL configured — running CSS & API analysis mode on dev environment');
255
+ return runCssAnalysisMode(browser);
256
+ }
257
+
258
+ const report = {
259
+ mode: 'env-comparison',
260
+ generatedAt: new Date().toISOString(),
261
+ devUrl: DEV_URL,
262
+ stagingUrl: STAGING_URL,
263
+ summary: { total: 0, critical: 0, warning: 0, info: 0 },
264
+ routes: [],
265
+ };
266
+
267
+ for (const route of comparisonRoutes) {
268
+ logger.info(`[ARGUS] Comparing route: ${route.name} (${route.path})`);
269
+ const result = await compareRoute(route, browser);
270
+ report.routes.push(result);
271
+
272
+ report.summary.total += result.summary?.total ?? 0;
273
+ report.summary.critical += result.summary?.critical ?? 0;
274
+ report.summary.warning += result.summary?.warning ?? 0;
275
+ report.summary.info += result.summary?.info ?? 0;
276
+ }
277
+
278
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
279
+ const reportPath = path.join(OUTPUT_DIR, `comparison-report-${timestamp}.json`);
280
+ fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
281
+ logger.info(`[ARGUS] Comparison report: ${reportPath}`);
282
+
283
+ // Slack dispatch is best-effort — a network error or Slack API failure should not
284
+ // crash the entire comparison run and discard the already-written JSON report.
285
+ await dispatchComparisonToSlack(report).catch(err =>
286
+ logger.error('[ARGUS] Slack dispatch failed:', err.message)
287
+ );
288
+ return report;
289
+ }
290
+
291
+ // ── CSS-Only Analysis Mode (no staging URL) ────────────────────────────────────
292
+
293
+ /**
294
+ * Fallback mode when no staging URL is configured.
295
+ * Visits each dev route and runs deep CSS + API frequency analysis.
296
+ *
297
+ * Reports:
298
+ * - CSS property overrides (cascade conflicts, !important abuse)
299
+ * - Component style leaks (BEM selectors in wrong stylesheet)
300
+ * - Unused CSS rules (declared but no element matches)
301
+ * - API endpoints called more than once per page load
302
+ *
303
+ * @param {object} browser - CdpBrowserAdapter
304
+ * @returns {object} CSS analysis report
305
+ */
306
+ async function runCssAnalysisMode(browser) {
307
+ const report = {
308
+ mode: 'css-analysis',
309
+ generatedAt: new Date().toISOString(),
310
+ baseUrl: DEV_URL,
311
+ note: 'Staging URL not configured — running CSS & API analysis on dev environment only',
312
+ summary: { total: 0, critical: 0, warning: 0, info: 0 },
313
+ routes: [],
314
+ };
315
+
316
+ for (const route of comparisonRoutes) {
317
+ const url = `${DEV_URL}${route.path}`;
318
+ logger.info(`[ARGUS] CSS analysis: ${route.name} (${url})`);
319
+
320
+ const routeResult = {
321
+ route: route.name,
322
+ url,
323
+ analyzedAt: new Date().toISOString(),
324
+ findings: [],
325
+ screenshot: null,
326
+ };
327
+
328
+ try {
329
+ // Snapshot network count BEFORE navigation so API frequency analysis for
330
+ // this route does not include requests accumulated from previous CSS-analysis routes.
331
+ const networkBaseline = (await browser.listNetwork().catch(() => [])).length;
332
+
333
+ // Navigate and settle
334
+ await browser.navigate(url);
335
+ await new Promise(r => setTimeout(r, config.pageSettleMs));
336
+
337
+ // CSS analysis
338
+ const cssRaw = await browser.evaluate(CSS_ANALYSIS_SCRIPT);
339
+ const cssResult = unwrapEval(cssRaw);
340
+ // Type-check before parse — unwrapEval may return null/string on MCP error;
341
+ // parseCssAnalysisResult iterating a non-object would throw and drop all findings.
342
+ if (cssResult && typeof cssResult === 'object') {
343
+ const cssBugs = parseCssAnalysisResult(cssResult, url);
344
+ routeResult.findings.push(...cssBugs);
345
+ } else if (cssResult !== null) {
346
+ logger.warn(`[ARGUS] CSS analysis: unexpected response type (${typeof cssResult}), skipping ${url}`);
347
+ }
348
+
349
+ // API frequency analysis — sliced from per-route baseline
350
+ const networkReqs = (await browser.listNetwork().catch(() => [])).slice(networkBaseline);
351
+ const apiFindings = analyzeApiFrequency(networkReqs, url);
352
+ routeResult.findings.push(...apiFindings);
353
+
354
+ // Screenshot
355
+ const screenshotPath = path.join(OUTPUT_DIR, `css-analysis-${slugify(route.name)}-${Date.now()}.png`);
356
+ const screenshotData = await browser.screenshot({ format: 'png' });
357
+ if (screenshotData?.data) {
358
+ fs.writeFileSync(screenshotPath, Buffer.from(screenshotData.data, 'base64'));
359
+ routeResult.screenshot = screenshotPath;
360
+ }
361
+ } catch (err) {
362
+ routeResult.findings.push({
363
+ type: 'analysis_error',
364
+ message: `CSS analysis failed for ${url}: ${err.message}`,
365
+ severity: 'warning',
366
+ url,
367
+ });
368
+ }
369
+
370
+ // Tally summary
371
+ for (const f of routeResult.findings) {
372
+ report.summary.total++;
373
+ report.summary[f.severity] = (report.summary[f.severity] ?? 0) + 1;
374
+ }
375
+
376
+ report.routes.push(routeResult);
377
+ }
378
+
379
+ // Write report
380
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
381
+ const reportPath = path.join(OUTPUT_DIR, `css-analysis-report-${timestamp}.json`);
382
+ fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
383
+ logger.info(`[ARGUS] CSS analysis report: ${reportPath}`);
384
+
385
+ // Dispatch to Slack
386
+ await dispatchCssAnalysisToSlack(report).catch(err =>
387
+ logger.error('[ARGUS] CSS Slack dispatch failed:', err.message)
388
+ );
389
+
390
+ return report;
391
+ }
392
+
393
+ /**
394
+ * Dispatch CSS analysis findings to Slack.
395
+ */
396
+ async function dispatchCssAnalysisToSlack(report) {
397
+ for (const routeResult of report.routes) {
398
+ const overrides = routeResult.findings.filter(f => f.type === 'css_override' && f.severity === 'warning');
399
+ const leaks = routeResult.findings.filter(f => f.type === 'css_component_leak');
400
+ const duplicateApis = routeResult.findings.filter(f => f.type === 'api_duplicate_call' && f.severity !== 'info');
401
+ const criticalApis = routeResult.findings.filter(f => f.type === 'api_duplicate_call' && f.severity === 'critical');
402
+ const summary = routeResult.findings.find(f => f.type === 'css_summary');
403
+
404
+ // Critical API calls (5+ duplicates) → #bugs-critical
405
+ for (const api of criticalApis) {
406
+ await postBugReport({
407
+ severity: 'critical',
408
+ title: `Runaway API call on ${routeResult.route}`,
409
+ description: api.message,
410
+ url: routeResult.url,
411
+ screenshotPath: routeResult.screenshot,
412
+ details: api,
413
+ }).catch(err => logger.error('[ARGUS] Slack dispatch failed (css critical):', err.message));
414
+ }
415
+
416
+ // CSS overrides + leaks + duplicate APIs bundled as one warning per route
417
+ const warningItems = [...overrides, ...leaks, ...duplicateApis.filter(a => a.severity === 'warning')];
418
+ if (warningItems.length > 0) {
419
+ const desc = warningItems.map(f => `• ${f.message}`).join('\n');
420
+ await postBugReport({
421
+ severity: 'warning',
422
+ title: `CSS/API issues on ${routeResult.route} (${warningItems.length} found)`,
423
+ description: desc,
424
+ url: routeResult.url,
425
+ screenshotPath: routeResult.screenshot,
426
+ details: { route: routeResult.route, findings: warningItems },
427
+ }).catch(err => logger.error('[ARGUS] Slack dispatch failed (css warning):', err.message));
428
+ }
429
+
430
+ // Summary as info digest
431
+ if (summary) {
432
+ await postBugReport({
433
+ severity: 'info',
434
+ title: `CSS analysis complete: ${routeResult.route}`,
435
+ description: summary.message +
436
+ (summary.stylesheetSources?.length ? `\nStylesheets: ${summary.stylesheetSources.map(s => s.source.split('/').pop()).join(', ')}` : ''),
437
+ url: routeResult.url,
438
+ screenshotPath: null,
439
+ details: summary,
440
+ }).catch(err => logger.error('[ARGUS] Slack dispatch failed (css info):', err.message));
441
+ }
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Dispatch comparison diffs to Slack.
447
+ * Critical diffs (e.g., a 200→500 status regression) get immediate notification.
448
+ * Screenshot diffs get posted with both images and the diff overlay.
449
+ */
450
+ async function dispatchComparisonToSlack(report) {
451
+ for (const routeResult of report.routes) {
452
+ if (!routeResult.diffs?.length) continue;
453
+ const criticals = routeResult.diffs.filter(d => d.severity === 'critical');
454
+ const warnings = routeResult.diffs.filter(d => d.severity === 'warning');
455
+
456
+ for (const diff of criticals) {
457
+ await postBugReport({
458
+ severity: 'critical',
459
+ title: `Regression on ${routeResult.route}: ${diff.type}`,
460
+ description: diff.description,
461
+ url: routeResult.stagingUrl,
462
+ screenshotPath: diff.stagingScreenshot ?? routeResult.stagingScreenshot,
463
+ details: {
464
+ diff,
465
+ devUrl: routeResult.devUrl,
466
+ stagingUrl: routeResult.stagingUrl,
467
+ },
468
+ }).catch(err => logger.error('[ARGUS] Slack dispatch failed (comparison critical):', err.message));
469
+ }
470
+
471
+ if (warnings.length > 0) {
472
+ // For screenshot diffs, attach the diff overlay image
473
+ const screenshotDiff = warnings.find(d => d.type === 'screenshot');
474
+ await postBugReport({
475
+ severity: 'warning',
476
+ title: `${warnings.length} diff(s) on ${routeResult.route}`,
477
+ description: warnings.map(d => `• ${d.description}`).join('\n'),
478
+ url: routeResult.stagingUrl,
479
+ screenshotPath: screenshotDiff?.diffImagePath ?? routeResult.stagingScreenshot,
480
+ details: {
481
+ route: routeResult.route,
482
+ devUrl: routeResult.devUrl,
483
+ stagingUrl: routeResult.stagingUrl,
484
+ diffs: warnings,
485
+ },
486
+ }).catch(err => logger.error('[ARGUS] Slack dispatch failed (comparison warning):', err.message));
487
+ }
488
+ }
489
+ }
490
+
491
+ // ── CLI Entry ──────────────────────────────────────────────────────────────────
492
+
493
+ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
494
+ logger.info('[ARGUS] env-comparison.js loaded. Invoke runComparison(mcp) from Claude Code with MCP tools connected.');
495
+ logger.info('[ARGUS] Dev URL:', DEV_URL);
496
+ logger.info('[ARGUS] Staging URL:', STAGING_URL);
497
+ logger.info('[ARGUS] Routes to compare:', comparisonRoutes.map(r => r.path).join(', '));
498
+ }