argusqa-os 9.5.9 → 9.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/glama.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://glama.ai/mcp/schemas/server.json",
3
3
  "name": "argus",
4
- "description": "AI-powered QA harness that audits web apps via Chrome DevTools Protocol. Catches JS errors, network failures, a11y violations, SEO issues, security headers, CSS regressions, and more — directly from Claude conversations. 8 MCP tools: argus_audit (fast 8-analyzer pass), argus_audit_full (Lighthouse + memory + responsive), argus_compare (dev vs staging diff), argus_last_report (retrieve last JSON report), argus_watch_snapshot (live tab snapshot without navigating), argus_get_context (LLM-optimized context + fix loop with snapshot_id diff), argus_design_audit (Figma design fidelity — 13 finding types), argus_visual_diff (screenshot baseline comparison, updateBaseline flag). 136 test blocks, 626 hard assertions, 63 detection categories.",
4
+ "description": "AI-powered QA harness that audits web apps via Chrome DevTools Protocol. Catches JS errors, network failures, a11y violations, SEO issues, security headers, CSS regressions, and more — directly from Claude conversations. 9 MCP tools: argus_audit (fast 8-analyzer pass), argus_audit_full (Lighthouse + memory + responsive), argus_compare (dev vs staging diff), argus_last_report (retrieve last JSON report), argus_watch_snapshot (live tab snapshot without navigating), argus_get_context (LLM-optimized context + fix loop with snapshot_id diff), argus_design_audit (Figma design fidelity — 13 finding types), argus_visual_diff (screenshot baseline comparison, updateBaseline flag), argus_pr_validate (PR diff → affected routes → targeted audit → blocked flag). 137 test blocks, 634 hard assertions, 63 detection categories.",
5
5
  "maintainers": ["ironclawdevs27"],
6
6
  "tools": [
7
7
  {
@@ -35,6 +35,10 @@
35
35
  {
36
36
  "name": "argus_visual_diff",
37
37
  "description": "Screenshot baseline comparison for a URL. First call saves a baseline PNG to reports/baselines/screenshots/. Subsequent calls diff the current screenshot against the baseline using pixelmatch and return visual_regression (warning ≥0.1% / critical ≥5% pixels changed) + visual_diff_summary (always). Pass updateBaseline: true to force-refresh the stored baseline after intentional UI changes."
38
+ },
39
+ {
40
+ "name": "argus_pr_validate",
41
+ "description": "Targeted QA audit driven by a GitHub pull request diff. Fetches the PR's changed files, maps them to affected routes in your target config using path-slug heuristics (infrastructure changes trigger a full audit), then audits only those routes. Returns { findings, affectedRoutes, changedFiles, perRoute, summary, blocked, blockOn }. Use in CI to gate merges — blocked:true when findings meet the blockOn threshold (none/warning/critical, default: critical). Requires Chrome on --remote-debugging-port=9222. GITHUB_TOKEN env var recommended for private repos."
38
42
  }
39
43
  ]
40
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argusqa-os",
3
- "version": "9.5.9",
3
+ "version": "9.6.1",
4
4
  "mcpName": "io.github.ironclawdevs27/argus",
5
5
  "description": "Argus — AI-powered automated dev-testing platform using Chrome DevTools MCP and Claude Code",
6
6
  "keywords": [
@@ -56,7 +56,7 @@
56
56
  "@opentelemetry/sdk-node": "^0.218.0",
57
57
  "@slack/web-api": "^7.16.0",
58
58
  "axe-core": "^4.12.0",
59
- "dotenv": "^16.4.5",
59
+ "dotenv": "^17.4.2",
60
60
  "express": "^5.2.1",
61
61
  "pino": "^10.3.1",
62
62
  "pino-pretty": "^13.1.3",
@@ -65,6 +65,6 @@
65
65
  "zod": "^4.4.3"
66
66
  },
67
67
  "devDependencies": {
68
- "vitest": "^4.1.7"
68
+ "vitest": "^4.1.8"
69
69
  }
70
70
  }
package/src/cli/init.js CHANGED
@@ -287,11 +287,15 @@ async function main() {
287
287
  githubToken, githubRepo, sourceDir, envFile: envFilePath });
288
288
  const targetsContent = generateTargetsJs(finalRoutes, { framework, sourceDir, envFile: envFilePath });
289
289
 
290
- if (fs.existsSync('.env')) {
291
- logger.warn('.env already exists skipping write to preserve existing credentials. Delete it manually to regenerate.');
292
- } else {
293
- fs.writeFileSync('.env', envContent, 'utf8');
290
+ try {
291
+ fs.writeFileSync('.env', envContent, { flag: 'wx', encoding: 'utf8' });
294
292
  tick('Wrote .env');
293
+ } catch (err) {
294
+ if (err.code === 'EEXIST') {
295
+ logger.warn(' ⚠ .env already exists — skipping write to preserve existing credentials. Delete it manually to regenerate.');
296
+ } else {
297
+ throw err;
298
+ }
295
299
  }
296
300
 
297
301
  const targetsPath = path.join('src', 'config', 'targets.js');
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Argus PR Validator — headless CI entry point for GitHub Actions.
4
+ *
5
+ * Environment variables (set by action.yml):
6
+ * ARGUS_PR_URL Full GitHub PR URL, e.g. https://github.com/owner/repo/pull/42 (required)
7
+ * TARGET_DEV_URL Base URL of the running application, e.g. https://staging.example.com (required)
8
+ * ARGUS_BLOCK_ON critical | warning | none (default: critical)
9
+ * GITHUB_TOKEN GitHub PAT or workflow GITHUB_TOKEN — optional for public repos
10
+ * ARGUS_ROUTES_FILE Path to a JSON routes array [{path,name}] — optional, see loadRoutes()
11
+ * GITHUB_OUTPUT Set by GitHub runner — path for step output key=value pairs
12
+ * GITHUB_STEP_SUMMARY Set by GitHub runner — path for markdown step summary
13
+ *
14
+ * Exit codes:
15
+ * 0 — audit passed (blocked=false) or no routes to audit
16
+ * 1 — audit blocked (findings at or above ARGUS_BLOCK_ON threshold) OR startup error
17
+ *
18
+ * Exports (used by test harness — no Chrome required):
19
+ * buildStepSummary(opts) → markdown string
20
+ * writeGithubOutputs(opts) → void (writes to GITHUB_OUTPUT file)
21
+ * writeStepSummary(markdown) → void (writes to GITHUB_STEP_SUMMARY file)
22
+ */
23
+
24
+ import fs from 'fs';
25
+ import path from 'path';
26
+ import { fileURLToPath } from 'url';
27
+ import { createMcpClient } from '../utils/mcp-client.js';
28
+ import { crawlRouteCheap } from '../orchestration/crawl-and-report.js';
29
+ import { parsePrUrl, fetchPrFiles, mapFilesToRoutes } from '../utils/pr-diff-analyzer.js';
30
+
31
+ // ── Exported helpers (testable without Chrome) ────────────────────────────────
32
+
33
+ /**
34
+ * Build a GitHub-flavoured markdown step summary.
35
+ *
36
+ * @param {object} opts
37
+ * @param {boolean} opts.blocked
38
+ * @param {{ critical: number, warning: number, info: number }} opts.summary
39
+ * @param {Array<{ path: string }>} opts.affectedRoutes
40
+ * @param {Array<{ route: string, critical: number, warning: number, info: number, error?: string }>} opts.perRoute
41
+ * @param {Array<object>} opts.findings
42
+ * @param {string[]} opts.changedFiles
43
+ * @param {string} opts.blockOn critical | warning | none
44
+ * @param {string} [opts.error] top-level error message (startup / fetch failure)
45
+ * @returns {string}
46
+ */
47
+ export function buildStepSummary({ blocked, summary, affectedRoutes, perRoute, findings, changedFiles, blockOn, error }) {
48
+ const icon = blocked ? '🔴' : summary.critical + summary.warning === 0 ? '✅' : '⚠️';
49
+ const status = blocked ? 'BLOCKED — merge prevented' : 'PASSED';
50
+
51
+ let md = `## ${icon} Argus PR Validator — ${status}\n\n`;
52
+
53
+ if (error) {
54
+ md += `> **Error:** ${String(error).replace(/`/g, "'")}\n\n`;
55
+ }
56
+
57
+ md += `| Metric | Value |\n|--------|-------|\n`;
58
+ md += `| Block threshold | \`${blockOn}\` |\n`;
59
+ md += `| Critical findings | **${summary.critical}** |\n`;
60
+ md += `| Warning findings | ${summary.warning} |\n`;
61
+ md += `| Info findings | ${summary.info} |\n`;
62
+ md += `| Routes audited | ${affectedRoutes.length} |\n`;
63
+ md += `| Files changed | ${changedFiles.length} |\n\n`;
64
+
65
+ if (perRoute.length > 0) {
66
+ md += `### Route Breakdown\n\n`;
67
+ md += `| Route | 🔴 Critical | ⚠️ Warning | ℹ️ Info |\n|-------|------------|-----------|--------|\n`;
68
+ for (const r of perRoute) {
69
+ const errNote = r.error ? ` _(error: ${String(r.error).slice(0, 60)})_` : '';
70
+ md += `| \`${r.route}\` | ${r.critical} | ${r.warning} | ${r.info}${errNote} |\n`;
71
+ }
72
+ md += '\n';
73
+ }
74
+
75
+ if (findings.length > 0) {
76
+ md += `### Findings\n\n`;
77
+ md += `| Severity | Type | Message | URL |\n|----------|------|---------|-----|\n`;
78
+ const shown = findings.slice(0, 50);
79
+ for (const f of shown) {
80
+ const sev = f.severity === 'critical' ? '🔴 critical'
81
+ : f.severity === 'warning' ? '⚠️ warning'
82
+ : 'ℹ️ info';
83
+ const msg = String(f.message ?? '').replace(/\|/g, '\\|').slice(0, 100);
84
+ const url = String(f.url ?? '').replace(/\|/g, '\\|').slice(0, 80);
85
+ md += `| ${sev} | \`${f.type ?? ''}\` | ${msg} | ${url} |\n`;
86
+ }
87
+ if (findings.length > 50) {
88
+ md += `\n_…and ${findings.length - 50} more findings._\n`;
89
+ }
90
+ md += '\n';
91
+ }
92
+
93
+ md += `---\n_Powered by [Argus QA](https://argus-qa.com)_\n`;
94
+ return md;
95
+ }
96
+
97
+ /**
98
+ * Write step outputs to $GITHUB_OUTPUT (key=value pairs).
99
+ * No-ops when GITHUB_OUTPUT is not set (local / non-Actions environments).
100
+ */
101
+ export function writeGithubOutputs({ blocked, summary, affectedRoutes }) {
102
+ const outputPath = process.env.GITHUB_OUTPUT;
103
+ if (!outputPath) return;
104
+ const routes = Array.isArray(affectedRoutes)
105
+ ? affectedRoutes.map(r => (typeof r === 'string' ? r : r.path)).join(',')
106
+ : '';
107
+ const lines = [
108
+ `blocked=${blocked}`,
109
+ `critical_count=${summary.critical}`,
110
+ `warning_count=${summary.warning}`,
111
+ `affected_routes=${routes}`,
112
+ ].join('\n') + '\n';
113
+ fs.appendFileSync(outputPath, lines);
114
+ }
115
+
116
+ /**
117
+ * Append markdown to $GITHUB_STEP_SUMMARY.
118
+ * No-ops when GITHUB_STEP_SUMMARY is not set.
119
+ */
120
+ export function writeStepSummary(markdown) {
121
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
122
+ if (!summaryPath) return;
123
+ fs.appendFileSync(summaryPath, markdown);
124
+ }
125
+
126
+ // ── Route loader ──────────────────────────────────────────────────────────────
127
+
128
+ async function loadRoutes() {
129
+ // 1. ARGUS_ROUTES_FILE env var
130
+ const routesFile = process.env.ARGUS_ROUTES_FILE;
131
+ if (routesFile) {
132
+ try {
133
+ const raw = JSON.parse(fs.readFileSync(routesFile, 'utf8'));
134
+ if (Array.isArray(raw) && raw.length > 0) {
135
+ console.log(`[argus] Loaded ${raw.length} route(s) from ${routesFile}`);
136
+ return raw;
137
+ }
138
+ } catch (err) {
139
+ console.error(`::warning::Could not parse ARGUS_ROUTES_FILE (${routesFile}): ${err.message}`);
140
+ }
141
+ }
142
+
143
+ // 2. argus.routes.json in working directory
144
+ const localFile = path.join(process.cwd(), 'argus.routes.json');
145
+ if (fs.existsSync(localFile)) {
146
+ try {
147
+ const raw = JSON.parse(fs.readFileSync(localFile, 'utf8'));
148
+ if (Array.isArray(raw) && raw.length > 0) {
149
+ console.log(`[argus] Loaded ${raw.length} route(s) from argus.routes.json`);
150
+ return raw;
151
+ }
152
+ } catch (err) {
153
+ console.error(`::warning::Could not parse argus.routes.json: ${err.message}`);
154
+ }
155
+ }
156
+
157
+ // 3. Default routes from the package's targets.js
158
+ try {
159
+ const { routes } = await import('../config/targets.js');
160
+ if (Array.isArray(routes) && routes.length > 0) {
161
+ return routes;
162
+ }
163
+ } catch { /* targets.js may not export routes in all environments */ }
164
+
165
+ // 4. Final fallback — audit root path only
166
+ console.log('[argus] No routes configured — falling back to root path audit');
167
+ return [{ path: '/', name: 'home' }];
168
+ }
169
+
170
+ // ── Main ──────────────────────────────────────────────────────────────────────
171
+
172
+ // Guard: run main() only when this script is executed directly, not when imported.
173
+ const _thisFile = fileURLToPath(import.meta.url);
174
+ if (process.argv[1] === _thisFile) {
175
+ await main();
176
+ }
177
+
178
+ async function main() {
179
+ const prUrl = process.env.ARGUS_PR_URL;
180
+ const targetUrl = process.env.TARGET_DEV_URL ?? 'http://localhost:3000';
181
+ const blockOn = (process.env.ARGUS_BLOCK_ON ?? 'critical').toLowerCase().trim();
182
+ const token = process.env.GITHUB_TOKEN;
183
+
184
+ // Input validation
185
+ if (!prUrl) {
186
+ console.error('::error::ARGUS_PR_URL is not set. Set it to the full GitHub PR URL (e.g. https://github.com/owner/repo/pull/42).');
187
+ process.exit(1);
188
+ }
189
+ if (!['none', 'warning', 'critical'].includes(blockOn)) {
190
+ console.error(`::error::ARGUS_BLOCK_ON must be none | warning | critical, got: "${blockOn}"`);
191
+ process.exit(1);
192
+ }
193
+
194
+ let mcp;
195
+ const changedFiles = [];
196
+ const affectedRoutes = [];
197
+ const allFindings = [];
198
+ const perRoute = [];
199
+
200
+ try {
201
+ // Step 1: Fetch the PR file list from GitHub
202
+ console.log(`[argus] Fetching PR diff: ${prUrl}`);
203
+ const files = await fetchPrFiles(prUrl, token);
204
+ changedFiles.push(...files);
205
+ console.log(`[argus] ${files.length} changed file(s)`);
206
+
207
+ // Step 2: Map changed files to affected routes
208
+ const routes = await loadRoutes();
209
+ const affected = mapFilesToRoutes(files, routes);
210
+ affectedRoutes.push(...affected);
211
+
212
+ if (affected.length === 0) {
213
+ console.log('[argus] No affected routes resolved — skipping audit');
214
+ const summary = { critical: 0, warning: 0, info: 0 };
215
+ writeGithubOutputs({ blocked: false, summary, affectedRoutes: [] });
216
+ writeStepSummary(buildStepSummary({ blocked: false, summary, affectedRoutes: [], perRoute: [], findings: [], changedFiles: files, blockOn }));
217
+ process.exit(0);
218
+ }
219
+
220
+ console.log(`[argus] Auditing ${affected.length} route(s): ${affected.map(r => r.path).join(', ')}`);
221
+
222
+ // Step 3: Connect to Chrome via the chrome-devtools MCP client
223
+ console.log('[argus] Connecting to Chrome on port 9222...');
224
+ mcp = await createMcpClient();
225
+ console.log('[argus] Chrome connected.');
226
+
227
+ const baseOrigin = new URL(targetUrl).origin;
228
+
229
+ // Step 4: Audit each affected route via crawlRouteCheap
230
+ for (const route of affected) {
231
+ const url = new URL(route.path, targetUrl).href;
232
+ console.log(`[argus] → Auditing ${url}`);
233
+
234
+ try {
235
+ const raw = await crawlRouteCheap(route, baseOrigin, mcp);
236
+ const findings = Array.isArray(raw.errors) ? raw.errors : [];
237
+ allFindings.push(...findings);
238
+
239
+ const critical = findings.filter(f => f.severity === 'critical').length;
240
+ const warning = findings.filter(f => f.severity === 'warning').length;
241
+ const info = findings.filter(f => f.severity === 'info').length;
242
+ perRoute.push({ route: route.path, critical, warning, info });
243
+
244
+ console.log(`[argus] ${url}: ${critical} critical, ${warning} warning, ${info} info`);
245
+
246
+ // Emit inline GitHub Actions annotations for visible CI feedback
247
+ for (const f of findings.filter(g => g.severity === 'critical')) {
248
+ console.log(`::error::${String(f.message ?? '').replace(/\n/g, ' ')} [${f.type}] on ${url}`);
249
+ }
250
+ for (const f of findings.filter(g => g.severity === 'warning')) {
251
+ console.log(`::warning::${String(f.message ?? '').replace(/\n/g, ' ')} [${f.type}] on ${url}`);
252
+ }
253
+
254
+ } catch (routeErr) {
255
+ console.error(`::warning::Audit failed for ${url}: ${routeErr.message}`);
256
+ perRoute.push({ route: route.path, critical: 0, warning: 0, info: 0, error: routeErr.message });
257
+ }
258
+ }
259
+
260
+ // Step 5: Compute aggregate summary and merge-block decision
261
+ const summary = {
262
+ critical: allFindings.filter(f => f.severity === 'critical').length,
263
+ warning: allFindings.filter(f => f.severity === 'warning').length,
264
+ info: allFindings.filter(f => f.severity === 'info').length,
265
+ };
266
+
267
+ const blocked =
268
+ blockOn === 'critical' ? summary.critical > 0 :
269
+ blockOn === 'warning' ? summary.critical + summary.warning > 0 :
270
+ false;
271
+
272
+ // Step 6: Write GitHub Actions outputs and step summary
273
+ writeGithubOutputs({ blocked, summary, affectedRoutes: affected });
274
+ writeStepSummary(buildStepSummary({ blocked, summary, affectedRoutes: affected, perRoute, findings: allFindings, changedFiles: files, blockOn }));
275
+
276
+ // Step 7: Emit JSON result to stdout for downstream pipeline steps
277
+ const result = {
278
+ prUrl, targetUrl,
279
+ affectedRoutes: affected.map(r => r.path),
280
+ changedFiles: files,
281
+ findings: allFindings,
282
+ perRoute,
283
+ summary,
284
+ blocked,
285
+ blockOn,
286
+ };
287
+ console.log(JSON.stringify(result, null, 2));
288
+
289
+ if (blocked) {
290
+ console.error(`::error::Argus PR Validator: ${summary.critical} critical finding(s) found. Merge blocked (block-on=${blockOn}).`);
291
+ process.exit(1);
292
+ }
293
+
294
+ console.log(`[argus] ✓ Audit passed — ${summary.critical} critical, ${summary.warning} warning, ${summary.info} info.`);
295
+ process.exit(0);
296
+
297
+ } catch (err) {
298
+ const summary = { critical: 0, warning: 0, info: 0 };
299
+ console.error(`::error::Argus PR validation failed: ${err.message}`);
300
+ writeGithubOutputs({ blocked: false, summary, affectedRoutes: [] });
301
+ writeStepSummary(buildStepSummary({ blocked: false, summary, affectedRoutes: [], perRoute: [], findings: [], changedFiles, blockOn, error: err.message }));
302
+ process.exit(1);
303
+
304
+ } finally {
305
+ if (mcp) {
306
+ try { mcp.close(); } catch { /* ignore teardown errors */ }
307
+ }
308
+ }
309
+ }
package/src/mcp-server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Argus MCP Server (v9.5.9)
3
+ * Argus MCP Server (v9.6.1)
4
4
  *
5
5
  * Exposes Argus as an MCP server so Claude (or any MCP client) can call
6
6
  * argus_audit, argus_audit_full, argus_compare, argus_last_report, and
@@ -33,6 +33,7 @@ import { CdpBrowserAdapter } from './adapters/browser.js';
33
33
  import { getFigmaFrame } from './adapters/figma.js';
34
34
  import { analyzeDesignFidelity } from './utils/design-fidelity-analyzer.js';
35
35
  import { analyzeVisualRegression } from './utils/visual-diff-analyzer.js';
36
+ import { parsePrUrl, fetchPrFiles, mapFilesToRoutes } from './utils/pr-diff-analyzer.js';
36
37
 
37
38
  const REPORTS_DIR = path.resolve(process.cwd(), 'reports');
38
39
 
@@ -145,6 +146,20 @@ const TOOLS = [
145
146
  required: ['url', 'figmaFrameUrl'],
146
147
  },
147
148
  },
149
+ {
150
+ name: 'argus_pr_validate',
151
+ 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.',
152
+ inputSchema: {
153
+ type: 'object',
154
+ properties: {
155
+ prUrl: { type: 'string', description: 'Full GitHub PR URL (e.g. https://github.com/owner/repo/pull/42). Used to fetch the list of changed files via the GitHub REST API.' },
156
+ targetUrl: { type: 'string', description: 'Base URL to audit (e.g. https://staging.example.com). Overrides TARGET_DEV_URL env var.' },
157
+ githubToken: { type: 'string', description: 'GitHub Personal Access Token or workflow GITHUB_TOKEN. Optional for public repos. Falls back to GITHUB_TOKEN env var.' },
158
+ blockOn: { type: 'string', enum: ['none', 'warning', 'critical'], description: '"critical" = block only when critical findings exist. "warning" = block on any warning or critical. "none" = never block. Defaults to ARGUS_BLOCK_ON env var, then "critical".', default: 'critical' },
159
+ },
160
+ required: ['prUrl'],
161
+ },
162
+ },
148
163
  ];
149
164
 
150
165
  // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -368,6 +383,52 @@ async function handleDesignAudit({ url, figmaFrameUrl }) {
368
383
  });
369
384
  }
370
385
 
386
+ async function handlePrValidate({ prUrl, targetUrl, githubToken, blockOn } = {}) {
387
+ if (!prUrl) throw new Error('argus_pr_validate: prUrl is required');
388
+
389
+ const { routes } = await import('./config/targets.js');
390
+ const token = githubToken ?? process.env.GITHUB_TOKEN;
391
+ const base = targetUrl ?? process.env.TARGET_DEV_URL ?? 'http://localhost:3000';
392
+ const policy = blockOn ?? process.env.ARGUS_BLOCK_ON ?? 'critical';
393
+
394
+ const changedFiles = await fetchPrFiles(prUrl, token);
395
+ const affectedRoutes = mapFilesToRoutes(changedFiles, routes ?? []);
396
+
397
+ const allFindings = [];
398
+ const perRoute = [];
399
+
400
+ for (const route of affectedRoutes) {
401
+ const url = new URL(route.path, base).href;
402
+ const res = await handleAudit({ url, critical: route.critical ?? false });
403
+ const data = JSON.parse(res.content[0].text);
404
+ allFindings.push(...(data.findings ?? []));
405
+ perRoute.push({ route: route.path, ...data.summary });
406
+ }
407
+
408
+ const summary = {
409
+ critical: allFindings.filter(f => f.severity === 'critical').length,
410
+ warning: allFindings.filter(f => f.severity === 'warning').length,
411
+ info: allFindings.filter(f => f.severity === 'info').length,
412
+ };
413
+
414
+ const blocked =
415
+ policy === 'critical' ? summary.critical > 0 :
416
+ policy === 'warning' ? summary.critical + summary.warning > 0 :
417
+ false;
418
+
419
+ return { content: [{ type: 'text', text: JSON.stringify({
420
+ prUrl,
421
+ targetUrl: base,
422
+ affectedRoutes: affectedRoutes.map(r => r.path),
423
+ changedFiles,
424
+ findings: allFindings,
425
+ perRoute,
426
+ summary,
427
+ blocked,
428
+ blockOn: policy,
429
+ }, null, 2) }] };
430
+ }
431
+
371
432
  async function handleLastReport() {
372
433
  if (!fs.existsSync(REPORTS_DIR)) {
373
434
  return { content: [{ type: 'text', text: '{"error":"No reports found in reports/"}' }] };
@@ -386,7 +447,7 @@ async function handleLastReport() {
386
447
  // ── Server bootstrap ──────────────────────────────────────────────────────────
387
448
 
388
449
  const server = new Server(
389
- { name: 'argus', version: '9.5.9' },
450
+ { name: 'argus', version: '9.6.1' },
390
451
  { capabilities: { tools: {} } },
391
452
  );
392
453
 
@@ -403,6 +464,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
403
464
  case 'argus_get_context': return await handleGetContext(req.params.arguments ?? {});
404
465
  case 'argus_visual_diff': return await handleVisualDiff(req.params.arguments ?? {});
405
466
  case 'argus_design_audit': return await handleDesignAudit(req.params.arguments ?? {});
467
+ case 'argus_pr_validate': return await handlePrValidate(req.params.arguments ?? {});
406
468
  default: throw new Error(`Unknown tool: ${req.params.name}`);
407
469
  }
408
470
  } catch (err) {
@@ -26,7 +26,7 @@ function openInBrowser(filePath) {
26
26
  try {
27
27
  const abs = path.resolve(filePath);
28
28
  if (process.platform === 'win32') {
29
- execFile('cmd', ['/c', 'start', '', abs], () => {});
29
+ execFile('cmd', ['/c', 'start', '', abs], () => {}); // lgtm[js/shell-command-injection-from-environment] — execFile with args array is injection-safe; abs is path.resolve() of an internally-computed report path
30
30
  } else if (process.platform === 'darwin') {
31
31
  execFile('open', [abs], () => {});
32
32
  } else {
@@ -21,7 +21,6 @@ import { unwrapEval } from '../utils/mcp-client.js';
21
21
  import { childLogger } from '../utils/logger.js';
22
22
 
23
23
  const logger = childLogger('env-comparison');
24
- import { normalizeArray } from '../utils/flow-runner.js';
25
24
  import { CdpBrowserAdapter } from '../adapters/browser.js';
26
25
 
27
26
  import { comparisonRoutes, config } from '../config/targets.js';
@@ -490,7 +490,7 @@ export async function crawlRouteCheap(route, baseUrl, mcp) {
490
490
  const text = (msg.text ?? msg.message ?? '');
491
491
  if (text.toLowerCase().includes('has been blocked by cors policy')) continue;
492
492
  const severity = classifyConsoleMessage(msg, route.critical);
493
- if (severity !== null && msg.level !== 'log') {
493
+ if (msg.level !== 'log') {
494
494
  result.errors.push({
495
495
  type: 'console',
496
496
  level: msg.level,
@@ -1026,10 +1026,10 @@ export async function runCrawl(mcp, routeOverrides = null, baseUrlOverride = nul
1026
1026
  // Auth session persistence (B2)
1027
1027
  const sessionFile = auth?.sessionFile ?? '.argus-session.json';
1028
1028
  if (auth?.steps?.length > 0) {
1029
- if (!hasSession(sessionFile, auth.sessionMaxAgeMs)) {
1030
- logger.info(`[ARGUS] Auth: running login flow (${auth.steps.length} steps)...`);
1029
+ if (!hasSession(sessionFile, auth?.sessionMaxAgeMs)) {
1030
+ logger.info(`[ARGUS] Auth: running login flow (${auth?.steps?.length ?? 0} steps)...`);
1031
1031
  try {
1032
- await runLoginFlow(browser, targetBaseUrl, auth.steps);
1032
+ await runLoginFlow(browser, targetBaseUrl, auth?.steps ?? []);
1033
1033
  await saveSession(browser, sessionFile);
1034
1034
  } catch (err) {
1035
1035
  logger.warn(`[ARGUS] Auth: login flow failed — crawl will proceed unauthenticated: ${err.message}`);
@@ -1154,5 +1154,5 @@ export async function runCrawl(mcp, routeOverrides = null, baseUrlOverride = nul
1154
1154
  if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
1155
1155
  logger.info('[ARGUS] orchestrator.js loaded. Invoke runCrawl(mcp) from Claude Code with MCP tools connected.');
1156
1156
  logger.info('[ARGUS] Target base URL: ' + BASE_URL);
1157
- logger.info('[ARGUS] Routes to crawl: ' + (routes ?? []).map(r => r?.path ?? '(no path)').join(', '));
1157
+ logger.info('[ARGUS] Routes to crawl: ' + routes.map(r => r?.path ?? '(no path)').join(', '));
1158
1158
  }
@@ -108,7 +108,7 @@ export async function processReport(report, { outputDir, severityOverrides }) {
108
108
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
109
109
  const reportPath = path.join(outputDir, `error-report-${timestamp}.json`);
110
110
  try {
111
- fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
111
+ fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); // lgtm[js/network-data-to-file] — intentional: Argus persists crawl findings to a local JSON report file by design
112
112
  } catch (err) {
113
113
  logger.error(`[ARGUS] Failed to write report JSON: ${err.message}`);
114
114
  throw err;
@@ -99,7 +99,7 @@ async function uploadFileToSlack(filePath, channelId, filename) {
99
99
  const response = await fetch(uploadUrl, {
100
100
  method: 'PUT',
101
101
  headers: { 'Content-Type': 'application/octet-stream' },
102
- body: fileBuffer,
102
+ body: fileBuffer, // lgtm[js/file-access-to-http] — intentional screenshot upload to Slack pre-signed URL; file path is internally generated by Argus, not from HTTP request input
103
103
  signal: AbortSignal.timeout(30000),
104
104
  });
105
105
  if (!response.ok) {
@@ -35,10 +35,6 @@ import {
35
35
  import { postBugReport } from './slack-notifier.js';
36
36
  import { isSlackConfigured } from '../utils/slack-guard.js';
37
37
  import { generateHtmlReport } from '../utils/html-reporter.js';
38
- import {
39
- parseConsoleMsgResponse,
40
- parseNetworkReqResponse,
41
- } from '../utils/mcp-parsers.js';
42
38
 
43
39
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
44
40
  const REPORTS_DIR = path.resolve(__dirname, '../../reports');
@@ -50,6 +50,28 @@ app.use((req, res, next) => {
50
50
  next();
51
51
  });
52
52
 
53
+ // ── Rate limiting (per-IP, in-memory) ──────────────────────────────────────────
54
+ // 30 requests per minute per IP — prevents Slack endpoint flooding.
55
+ // Slack signature verification rejects invalid payloads, but rate limiting adds
56
+ // a defence-in-depth layer before signature verification even runs.
57
+ const RATE_WINDOW_MS = 60_000;
58
+ const RATE_MAX = 30;
59
+ const rateLimitMap = new Map();
60
+
61
+ function slackRateLimit(req, res, next) {
62
+ const key = req.ip ?? 'unknown';
63
+ const now = Date.now();
64
+ const entry = rateLimitMap.get(key) ?? { count: 0, start: now };
65
+ if (now - entry.start > RATE_WINDOW_MS) { entry.count = 0; entry.start = now; }
66
+ entry.count++;
67
+ rateLimitMap.set(key, entry);
68
+ if (entry.count > RATE_MAX) {
69
+ res.setHeader('Retry-After', Math.ceil(RATE_WINDOW_MS / 1000));
70
+ return res.status(429).json({ error: 'Too Many Requests' });
71
+ }
72
+ next();
73
+ }
74
+
53
75
  // ── Routes ─────────────────────────────────────────────────────────────────────
54
76
 
55
77
  app.get('/health', (req, res) => {
@@ -57,10 +79,10 @@ app.get('/health', (req, res) => {
57
79
  });
58
80
 
59
81
  // Slack slash commands
60
- app.post('/slack/commands', handleSlashCommand);
82
+ app.post('/slack/commands', slackRateLimit, handleSlashCommand);
61
83
 
62
84
  // Slack Block Kit interactions (button clicks)
63
- app.post('/slack/interactions', handleInteraction);
85
+ app.post('/slack/interactions', slackRateLimit, handleInteraction);
64
86
 
65
87
  // ── Start ──────────────────────────────────────────────────────────────────────
66
88
 
@@ -15,7 +15,6 @@
15
15
  */
16
16
 
17
17
  import crypto from 'crypto';
18
- import { postBugReport } from '../orchestration/slack-notifier.js';
19
18
  import { createMcpClient } from '../utils/mcp-client.js';
20
19
  import { runCrawl } from '../orchestration/crawl-and-report.js';
21
20
  import { WebClient } from '@slack/web-api';
@@ -27,9 +27,7 @@
27
27
  */
28
28
 
29
29
  import fs from 'fs';
30
- import path from 'path';
31
30
  import { createRequire } from 'module';
32
- import { fileURLToPath } from 'url';
33
31
  import { registerExpensive } from '../registry.js';
34
32
  import { unwrapEval } from './mcp-client.js';
35
33
  import { childLogger } from './logger.js';
@@ -110,7 +110,7 @@ export function saveBaseline(baselineFile, report) {
110
110
  flows[flowResult.flowName] = (flowResult.findings ?? []).map(findingKey);
111
111
  }
112
112
  const codebase = (report.codebase ?? []).map(findingKey);
113
- const tmpBaseline = baselineFile + '.tmp';
113
+ const tmpBaseline = `${baselineFile}.${process.pid}.${Date.now()}.tmp`;
114
114
  fs.writeFileSync(
115
115
  tmpBaseline,
116
116
  JSON.stringify({ savedAt: new Date().toISOString(), routes, flows, codebase }, null, 2),
@@ -245,8 +245,8 @@ export function appendTrend(trendsFile, entry) {
245
245
  }
246
246
  trends.push(entry);
247
247
  if (trends.length > 500) trends = trends.slice(-500);
248
- const tmpTrends = trendsFile + '.tmp';
249
- fs.writeFileSync(tmpTrends, JSON.stringify(trends, null, 2));
248
+ const tmpTrends = `${trendsFile}.${process.pid}.${Date.now()}.tmp`;
249
+ fs.writeFileSync(tmpTrends, JSON.stringify(trends, null, 2)); // lgtm[js/network-data-to-file] — intentional: Argus persists crawl trend data to a local baseline file by design
250
250
  fs.renameSync(tmpTrends, trendsFile);
251
251
  } finally {
252
252
  try { fs.closeSync(lockFd); } catch {}