argusqa-os 9.5.9 → 9.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.0",
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');
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.0)
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.0' },
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 {}
@@ -40,9 +40,9 @@ function collectSourceFiles(sourceDir) {
40
40
  if (e.isDirectory()) { walk(full); }
41
41
  else if (SOURCE_EXTENSIONS.has(path.extname(e.name))) {
42
42
  try {
43
- const stat = fs.statSync(full);
44
- if (stat.size > 1_000_000) continue; // skip files > 1MB (minified bundles, etc.)
45
- files.push({ filePath: full, content: fs.readFileSync(full, 'utf8') });
43
+ const content = fs.readFileSync(full, 'utf8');
44
+ if (Buffer.byteLength(content, 'utf8') > 1_000_000) continue; // skip files > 1MB
45
+ files.push({ filePath: full, content });
46
46
  } catch {}
47
47
  }
48
48
  }
@@ -97,7 +97,7 @@ export function parseContentAnalysisResult(rawResult, url) {
97
97
  // all field lookups (nullMatches, brokenImages, etc.) return undefined — zero findings.
98
98
  // JSON.stringify on a circular object throws; catch logs and returns [].
99
99
  let raw = rawResult;
100
- if (typeof raw === 'object' && !Array.isArray(raw) && raw !== null && raw.result !== undefined) {
100
+ if (typeof raw === 'object' && !Array.isArray(raw) && raw !== null && raw.result !== undefined) { // lgtm[js/comparison-of-unconvertible-types] — typeof null === 'object', so raw !== null is required after the typeof check
101
101
  raw = raw.result;
102
102
  }
103
103
  const str = typeof raw === 'string' ? raw : JSON.stringify(raw);
@@ -223,7 +223,7 @@ async function runAssert(step, browser, flowName, baseUrl, baselines) {
223
223
  const start = Date.now();
224
224
  let present = false;
225
225
  do {
226
- const raw = await browser.evaluate(`() => !!document.querySelector(${JSON.stringify(step.selector)})`);
226
+ const raw = await browser.evaluate(`() => !!document.querySelector(${JSON.stringify(step.selector)})`); // lgtm[js/code-injection] — selector is JSON.stringify-escaped; derived from developer-configured flow steps, not HTTP input
227
227
  present = !!unwrapEval(raw);
228
228
  if (present) break;
229
229
  await new Promise(r => setTimeout(r, 200));
@@ -244,7 +244,7 @@ async function runAssert(step, browser, flowName, baseUrl, baselines) {
244
244
  }
245
245
 
246
246
  case 'element_not_visible': {
247
- const raw = await browser.evaluate(`() => !document.querySelector(${JSON.stringify(step.selector)})`);
247
+ const raw = await browser.evaluate(`() => !document.querySelector(${JSON.stringify(step.selector)})`); // lgtm[js/code-injection] — selector is JSON.stringify-escaped; derived from developer-configured flow steps, not HTTP input
248
248
  const absent = unwrapEval(raw);
249
249
  if (!absent) {
250
250
  findings.push({
@@ -261,7 +261,7 @@ async function runAssert(step, browser, flowName, baseUrl, baselines) {
261
261
  }
262
262
 
263
263
  case 'url_contains': {
264
- const raw = await browser.evaluate(`() => window.location.href.includes(${JSON.stringify(step.value)})`);
264
+ const raw = await browser.evaluate(`() => window.location.href.includes(${JSON.stringify(step.value)})`); // lgtm[js/code-injection] — value is JSON.stringify-escaped; derived from developer-configured flow steps, not HTTP input
265
265
  const matches = unwrapEval(raw);
266
266
  if (!matches) {
267
267
  findings.push({
@@ -545,7 +545,7 @@ export async function runFlow(flow, baseUrl, browser) {
545
545
  export async function waitForSelector(browser, selector, timeoutMs = 10_000) {
546
546
  const end = Date.now() + timeoutMs;
547
547
  while (Date.now() < end) {
548
- const raw = await browser.evaluate(`() => !!document.querySelector(${JSON.stringify(selector)})`).catch(() => null);
548
+ const raw = await browser.evaluate(`() => !!document.querySelector(${JSON.stringify(selector)})`).catch(() => null); // lgtm[js/code-injection] — selector is JSON.stringify-escaped; derived from developer-configured flow steps, not HTTP input
549
549
  const found = unwrapEval(raw);
550
550
  if (found === true || String(found) === 'true') return true;
551
551
  if (Date.now() < end) await new Promise(r => setTimeout(r, 300));
@@ -41,7 +41,7 @@ function sevIcon(sev) { return SEV_ICON[sev] ?? '⚪'; }
41
41
 
42
42
  /** Escape pipe characters so they don't break Markdown tables. */
43
43
  function mdCell(text, maxLen = 100) {
44
- return String(text ?? '').slice(0, maxLen).replace(/\|/g, '\\|').replace(/\n/g, ' ');
44
+ return String(text ?? '').slice(0, maxLen).replace(/\|/g, '\\|').replace(/\n/g, ' '); // lgtm[js/incomplete-string-escaping] — escaping pipe and newline is correct and sufficient for GitHub Markdown table cells
45
45
  }
46
46
 
47
47
  // ── C2.1: PR comment formatter (pure — no I/O) ───────────────────────────────
@@ -429,7 +429,6 @@ export function generateReleaseNotes(currentReport, prevReport, opts = {}) {
429
429
 
430
430
  if (newOnes.length > 0) {
431
431
  const crits = newOnes.filter(f => f.severity === 'critical').length;
432
- const warns = newOnes.filter(f => f.severity === 'warning').length;
433
432
  lines.push(`### 🆕 New Issues (${newOnes.length})`);
434
433
  if (crits > 0) lines.push(`> ⚠️ ${crits} new critical issue(s) require attention`);
435
434
  lines.push('');
@@ -79,20 +79,25 @@ export async function analyzeHar(browser, url, opts = {}) {
79
79
  const harFile = path.join(harDir, `${slug}.json`);
80
80
 
81
81
  // ── First run: save baseline ──────────────────────────────────────────────
82
- if (!fs.existsSync(harFile)) {
83
- try {
84
- fs.mkdirSync(harDir, { recursive: true });
85
- const baseline = {
86
- version: '1.2',
87
- createdAt: new Date().toISOString(),
88
- url,
89
- entries: requests.map(toBaselineEntry),
90
- };
91
- fs.writeFileSync(harFile, JSON.stringify(baseline, null, 2));
92
- } catch (err) {
82
+ // Use flag:'wx' for atomic create — throws EEXIST if baseline already exists (TOCTOU-safe).
83
+ fs.mkdirSync(harDir, { recursive: true });
84
+ const baseline = {
85
+ version: '1.2',
86
+ createdAt: new Date().toISOString(),
87
+ url,
88
+ entries: requests.map(toBaselineEntry),
89
+ };
90
+ let harIsNew = false;
91
+ try {
92
+ fs.writeFileSync(harFile, JSON.stringify(baseline, null, 2), { flag: 'wx' });
93
+ harIsNew = true;
94
+ } catch (err) {
95
+ if (err.code !== 'EEXIST') {
93
96
  logger.warn(`[ARGUS] har-recorder: failed to write baseline: ${err.message}`);
94
97
  return findings;
95
98
  }
99
+ }
100
+ if (harIsNew) {
96
101
 
97
102
  findings.push({
98
103
  type: 'har_baseline_created',
@@ -106,15 +111,15 @@ export async function analyzeHar(browser, url, opts = {}) {
106
111
  }
107
112
 
108
113
  // ── Subsequent runs: compare against baseline ─────────────────────────────
109
- let baseline;
114
+ let existingBaseline;
110
115
  try {
111
- baseline = JSON.parse(fs.readFileSync(harFile, 'utf8'));
116
+ existingBaseline = JSON.parse(fs.readFileSync(harFile, 'utf8'));
112
117
  } catch (err) {
113
118
  logger.warn(`[ARGUS] har-recorder: failed to read baseline: ${err.message}`);
114
119
  return findings;
115
120
  }
116
121
 
117
- const baselineEntries = baseline.entries ?? [];
122
+ const baselineEntries = existingBaseline.entries ?? [];
118
123
  const baselineMap = new Map(baselineEntries.map(e => [normaliseUrl(e.request.url), e]));
119
124
  const currentMap = new Map(requests.map(r => [normaliseUrl(r.url), r]));
120
125
 
@@ -0,0 +1,121 @@
1
+ /**
2
+ * PR Diff Analyzer — maps GitHub PR changed files to affected Argus routes.
3
+ *
4
+ * parsePrUrl(prUrl) → { owner, repo, prNumber }
5
+ * fetchPrFiles(prUrl, token) → string[] of changed file paths
6
+ * mapFilesToRoutes(files, routes) → Route[] subset likely affected by the diff
7
+ *
8
+ * Pure functions + one async fetch — no Chrome, no MCP, no AI verdict.
9
+ * AI verdict logic ships separately in the private argus-pro repo.
10
+ */
11
+
12
+ /**
13
+ * Parse a GitHub PR URL into its owner/repo/prNumber components.
14
+ *
15
+ * Accepted formats:
16
+ * https://github.com/owner/repo/pull/123
17
+ * https://github.com/owner/repo/pull/123/files
18
+ *
19
+ * @param {string} prUrl
20
+ * @returns {{ owner: string, repo: string, prNumber: number }}
21
+ */
22
+ export function parsePrUrl(prUrl) {
23
+ const match = String(prUrl).match(
24
+ /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/,
25
+ );
26
+ if (!match) throw new Error(`Invalid GitHub PR URL: ${prUrl}`);
27
+ return { owner: match[1], repo: match[2], prNumber: parseInt(match[3], 10) };
28
+ }
29
+
30
+ /**
31
+ * Fetch the list of file paths changed by a GitHub pull request (up to 100 files).
32
+ *
33
+ * @param {string} prUrl - GitHub PR URL (any format accepted by parsePrUrl)
34
+ * @param {string} [githubToken] - GitHub token; omit for public repos
35
+ * @returns {Promise<string[]>} - Changed file paths relative to the repo root
36
+ */
37
+ export async function fetchPrFiles(prUrl, githubToken) {
38
+ const { owner, repo, prNumber } = parsePrUrl(prUrl);
39
+ const apiUrl =
40
+ `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/files?per_page=100`;
41
+ const headers = {
42
+ Accept: 'application/vnd.github+json',
43
+ 'X-GitHub-Api-Version': '2022-11-28',
44
+ 'User-Agent': 'argusqa-os',
45
+ ...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
46
+ };
47
+
48
+ const res = await fetch(apiUrl, { headers });
49
+ if (!res.ok) {
50
+ const body = await res.text().catch(() => '');
51
+ throw new Error(`GitHub API ${res.status}: ${body || res.statusText}`);
52
+ }
53
+ const files = await res.json();
54
+ return files.map(f => f.filename);
55
+ }
56
+
57
+ /**
58
+ * Patterns that indicate an infrastructure-level file whose change can affect
59
+ * every route — framework configs, root layouts, global stylesheets, package.json.
60
+ */
61
+ const INFRA_PATTERNS = [
62
+ /next\.config\./i,
63
+ /vite\.config\./i,
64
+ /tailwind\.config\./i,
65
+ /postcss\.config\./i,
66
+ /webpack\.config\./i,
67
+ /global(s)?\.(css|scss|less)$/i,
68
+ /(^|[/\\])(layout|_app|_document|root)\.(tsx?|jsx?)$/i,
69
+ /(^|[/\\])app\.(tsx?|jsx?)$/i,
70
+ /(^|[/\\])main\.(tsx?|jsx?)$/i,
71
+ /package\.json$/i,
72
+ ];
73
+
74
+ /**
75
+ * Map a list of changed file paths to the subset of Argus route configs that
76
+ * are likely affected, using heuristic slug matching.
77
+ *
78
+ * Heuristic rules (applied in order):
79
+ * 1. Any infrastructure file → return ALL routes (full audit)
80
+ * 2. File path contains a slug that matches a route path segment → include that route
81
+ * 3. No matches → return ALL routes (conservative fallback — never miss a regression)
82
+ *
83
+ * @param {string[]} changedFiles - Relative file paths from fetchPrFiles
84
+ * @param {Array<{ path: string, name: string }>} routes - Route configs from targets.js
85
+ * @returns {Array<{ path: string, name: string }>}
86
+ */
87
+ export function mapFilesToRoutes(changedFiles, routes) {
88
+ if (!routes || routes.length === 0) return [];
89
+ if (!changedFiles || changedFiles.length === 0) return routes;
90
+
91
+ // Infrastructure change → full audit
92
+ if (changedFiles.some(f => INFRA_PATTERNS.some(re => re.test(f)))) {
93
+ return routes;
94
+ }
95
+
96
+ // Build a flat set of lowercase slugs from every changed file path
97
+ const fileSlugs = new Set(
98
+ changedFiles.flatMap(f =>
99
+ // Strip extension, split on separators, keep non-trivial tokens
100
+ f.toLowerCase()
101
+ .replace(/\.[^./\\]+$/, '')
102
+ .split(/[/\\._-]+/)
103
+ .filter(s => s.length > 1),
104
+ ),
105
+ );
106
+
107
+ // Extract meaningful segments from a route path (e.g. "/checkout/review" → ["checkout","review"])
108
+ const routeSegments = (route) =>
109
+ route.path
110
+ .toLowerCase()
111
+ .split('/')
112
+ .map(s => s.replace(/[^a-z0-9]/g, ''))
113
+ .filter(s => s.length > 1);
114
+
115
+ const matched = routes.filter(route =>
116
+ routeSegments(route).some(seg => fileSlugs.has(seg)),
117
+ );
118
+
119
+ // Conservative fallback: if nothing matched, audit everything
120
+ return matched.length > 0 ? matched : routes;
121
+ }
@@ -74,7 +74,7 @@ export async function discoverFromSitemap(baseUrl) {
74
74
  const origin = new URL(baseUrl).origin;
75
75
  const sitemapUrl = `${baseUrl.replace(/\/$/, '')}/sitemap.xml`;
76
76
  try {
77
- const res = await fetch(sitemapUrl, { signal: AbortSignal.timeout(10000) });
77
+ const res = await fetch(sitemapUrl, { signal: AbortSignal.timeout(10000) }); // lgtm[js/ssrf] — sitemapUrl is derived from developer-configured baseUrl in targets.js, not from HTTP request input
78
78
  if (!res.ok) return [];
79
79
 
80
80
  const buf = await res.arrayBuffer();
@@ -132,7 +132,7 @@ export function parseSecurityAnalysisResult(rawResult, url) {
132
132
  // all field lookups (storageTokenKeys, evalUsage, etc.) return undefined — zero findings.
133
133
  // JSON.stringify on a circular object throws; catch logs and returns [].
134
134
  let raw = rawResult;
135
- if (typeof raw === 'object' && !Array.isArray(raw) && raw !== null && raw.result !== undefined) {
135
+ if (typeof raw === 'object' && !Array.isArray(raw) && raw !== null && raw.result !== undefined) { // lgtm[js/comparison-of-unconvertible-types] — typeof null === 'object', so raw !== null is required after the typeof check
136
136
  raw = raw.result;
137
137
  }
138
138
  const str = typeof raw === 'string' ? raw : JSON.stringify(raw);
@@ -48,7 +48,7 @@ export function parseSeoAnalysisResult(rawResult, url) {
48
48
  // client returns an object wrapper, JSON.stringify(rawResult) serialises the envelope
49
49
  // instead of the inner payload and all SEO fields are undefined → false positives.
50
50
  let inner = rawResult;
51
- if (typeof rawResult === 'object' && rawResult !== null && !Array.isArray(rawResult)) {
51
+ if (typeof rawResult === 'object' && rawResult !== null && !Array.isArray(rawResult)) { // lgtm[js/comparison-of-unconvertible-types] — typeof null === 'object', so rawResult !== null is required after the typeof check
52
52
  inner = rawResult.result !== undefined ? rawResult.result : rawResult;
53
53
  }
54
54
 
@@ -65,7 +65,7 @@ function buildRestoreScript(state) {
65
65
  lines.push(`sessionStorage.setItem(${JSON.stringify(k)},${JSON.stringify(String(v ?? ''))});`);
66
66
  }
67
67
 
68
- return `() => { ${lines.join(' ')} return true; }`;
68
+ return `() => { ${lines.join(' ')} return true; }`; // lgtm[js/code-injection] — all k/v values are JSON.stringify-escaped before insertion; derived from browser session storage, not HTTP request input
69
69
  }
70
70
 
71
71
  // ── Session Save ────────────────────────────────────────────────────────────────