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,185 @@
1
+ /**
2
+ * ARGUS Slash Command Handler
3
+ *
4
+ * Handles Slack slash command: /argus-retest <url>
5
+ *
6
+ * Flow:
7
+ * 1. Slack POSTs to this handler with the slash command payload
8
+ * 2. We verify the request signature (SLACK_SIGNING_SECRET)
9
+ * 3. Respond immediately with 200 + "Running..." (Slack requires < 3s response)
10
+ * 4. Kick off the test run asynchronously
11
+ * 5. Post results back to the channel as a follow-up message
12
+ *
13
+ * Configure in Slack App:
14
+ * Slash Commands → /argus-retest → Request URL: https://your-server.com/slack/commands
15
+ */
16
+
17
+ import crypto from 'crypto';
18
+ import { postBugReport } from '../orchestration/slack-notifier.js';
19
+ import { createMcpClient } from '../utils/mcp-client.js';
20
+ import { runCrawl } from '../orchestration/crawl-and-report.js';
21
+ import { WebClient } from '@slack/web-api';
22
+ import { childLogger } from '../utils/logger.js';
23
+
24
+ const logger = childLogger('slash-command-handler');
25
+
26
+ // Lazy-initialize the Slack client so SLACK_BOT_TOKEN is read at call time,
27
+ // not at module import time (before dotenv has run).
28
+ let _slack;
29
+ function getSlack() {
30
+ return (_slack ??= new WebClient(process.env.SLACK_BOT_TOKEN));
31
+ }
32
+
33
+ /**
34
+ * Verify that a request genuinely came from Slack using the signing secret.
35
+ * https://api.slack.com/authentication/verifying-requests-from-slack
36
+ *
37
+ * @param {object} req - Express request
38
+ * @returns {boolean}
39
+ */
40
+ export function verifySlackSignature(req) {
41
+ const signingSecret = process.env.SLACK_SIGNING_SECRET;
42
+ if (!signingSecret) return false;
43
+
44
+ const slackSignature = req.headers['x-slack-signature'];
45
+ const timestamp = req.headers['x-slack-request-timestamp'];
46
+
47
+ if (!slackSignature || !timestamp) return false;
48
+
49
+ // Reject requests older than 5 minutes (replay attack protection)
50
+ const nowSeconds = Math.floor(Date.now() / 1000);
51
+ const ts = parseInt(timestamp, 10);
52
+ if (!Number.isFinite(ts) || Math.abs(nowSeconds - ts) > 300) return false;
53
+
54
+ const sigBasestring = `v0:${timestamp}:${req.rawBody}`;
55
+ const mySignature = 'v0=' + crypto
56
+ .createHmac('sha256', signingSecret)
57
+ .update(sigBasestring)
58
+ .digest('hex');
59
+
60
+ // timingSafeEqual throws TypeError if buffer lengths differ — pre-check so we
61
+ // return false (invalid signature) instead of crashing with an unhandled exception.
62
+ if (mySignature.length !== slackSignature.length) return false;
63
+ return crypto.timingSafeEqual(
64
+ Buffer.from(mySignature),
65
+ Buffer.from(slackSignature)
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Handle POST /slack/commands
71
+ * @param {object} req - Express request
72
+ * @param {object} res - Express response
73
+ */
74
+ export async function handleSlashCommand(req, res) {
75
+ // Verify signature
76
+ if (!verifySlackSignature(req)) {
77
+ return res.status(401).json({ error: 'Invalid signature' });
78
+ }
79
+
80
+ const { command, text, response_url } = req.body;
81
+ // Slack guarantees channel_id and user_name in slash commands, but crafted or
82
+ // malformed POSTs may omit them. Guard explicitly so downstream interpolations are safe.
83
+ const channel_id = req.body.channel_id;
84
+ const user_name = req.body.user_name ?? 'unknown';
85
+ if (!channel_id) return res.status(400).json({ error: 'Missing channel_id' });
86
+
87
+ if (command !== '/argus-retest') {
88
+ return res.status(400).json({ error: 'Unknown command' });
89
+ }
90
+
91
+ const targetUrl = (text ?? '').trim();
92
+
93
+ if (!targetUrl) {
94
+ return res.json({
95
+ response_type: 'ephemeral',
96
+ text: '⚠️ Usage: `/argus-retest <url>`\nExample: `/argus-retest https://staging.yourapp.com/checkout`',
97
+ });
98
+ }
99
+
100
+ // Validate URL — reject invalid, non-http(s), and private/loopback addresses (SSRF prevention)
101
+ let parsedTarget;
102
+ try {
103
+ parsedTarget = new URL(targetUrl);
104
+ } catch {
105
+ return res.json({
106
+ response_type: 'ephemeral',
107
+ text: `⚠️ Invalid URL: \`${targetUrl}\`. Please provide a full URL including protocol.`,
108
+ });
109
+ }
110
+ if (!['http:', 'https:'].includes(parsedTarget.protocol)) {
111
+ return res.json({ response_type: 'ephemeral', text: '⚠️ Only http and https URLs are allowed.' });
112
+ }
113
+ if (/^(localhost|127\.|0\.0\.0\.0|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|169\.254\.|::1)/i.test(parsedTarget.hostname)) {
114
+ return res.json({ response_type: 'ephemeral', text: '⚠️ Private and loopback URLs are not allowed.' });
115
+ }
116
+
117
+ // Strip backticks from the URL before interpolating into Slack mrkdwn — a URL
118
+ // containing a backtick would break out of the inline code span and could alter formatting.
119
+ const safeUrl = targetUrl.replace(/`/g, '');
120
+ const safeName = user_name.replace(/[*_`~<>&]/g, '');
121
+
122
+ // Respond immediately — Slack requires a response within 3 seconds
123
+ res.json({
124
+ response_type: 'in_channel',
125
+ text: `🔄 *ARGUS retest started* for \`${safeUrl}\`\nRequested by @${safeName}. Results will appear here shortly...`,
126
+ });
127
+
128
+ // Attach .catch() so an unexpected rejection doesn't become an unhandled rejection
129
+ // that crashes the server (Node 15+ terminates on unhandled rejections).
130
+ runRetestAsync({ targetUrl, channelId: channel_id, responseUrl: response_url, requestedBy: user_name })
131
+ .catch(err => logger.error('[ARGUS] runRetestAsync unhandled:', err.message));
132
+ }
133
+
134
+ /**
135
+ * Run a retest for a specific URL and post results back to Slack.
136
+ * Runs after the 200 response is already sent.
137
+ */
138
+ async function runRetestAsync({ targetUrl, channelId, responseUrl, requestedBy }) {
139
+ let mcp;
140
+ try {
141
+ mcp = await createMcpClient();
142
+
143
+ // Do NOT mutate process.env.TARGET_DEV_URL — concurrent retests share
144
+ // the same Node.js process env and would corrupt each other's URLs. Pass targetUrl directly.
145
+ const singleRoute = [{ path: '', name: 'Retest', critical: true, waitFor: null }];
146
+ const CRAWL_TIMEOUT_MS = parseInt(process.env.ARGUS_CRAWL_TIMEOUT_MS ?? '120000', 10);
147
+ const report = await Promise.race([
148
+ runCrawl(mcp, singleRoute, targetUrl),
149
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Crawl timed out after ${Math.round(CRAWL_TIMEOUT_MS / 1000)}s`)), CRAWL_TIMEOUT_MS)),
150
+ ]);
151
+
152
+ const { summary } = report;
153
+ const passed = summary.critical === 0;
154
+ const emoji = passed ? '✅' : '❌';
155
+ const status = passed ? 'PASSED' : 'FAILED';
156
+
157
+ const safeRequestedBy = (requestedBy ?? 'unknown').replace(/[*_`~<>&]/g, '');
158
+ await getSlack().chat.postMessage({
159
+ channel: channelId,
160
+ text: `${emoji} *Retest ${status}* for \`${targetUrl}\`\n` +
161
+ `Requested by @${safeRequestedBy}\n` +
162
+ `Critical: ${summary.critical} | Warnings: ${summary.warning} | Info: ${summary.info}`,
163
+ });
164
+
165
+ // Guard against SLACK_CHANNEL_CRITICAL being unset — would post "#undefined"
166
+ if (!passed && process.env.SLACK_CHANNEL_CRITICAL) {
167
+ await getSlack().chat.postMessage({
168
+ channel: channelId,
169
+ text: `↑ Full bug reports sent to <#${process.env.SLACK_CHANNEL_CRITICAL}>`,
170
+ });
171
+ }
172
+ } catch (err) {
173
+ // Log full error server-side; post only a generic message to Slack so internal
174
+ // paths/stack traces/env var names are not leaked to the channel.
175
+ logger.error('[ARGUS] Retest failed:', err);
176
+ // Log delivery failures — silent .catch(() => {}) meant the operator
177
+ // had no indication when the error notification itself failed to post.
178
+ await getSlack().chat.postMessage({
179
+ channel: channelId,
180
+ text: `⚠️ *Retest error* for \`${targetUrl}\` — check server logs for details`,
181
+ }).catch(e => logger.error('[ARGUS] Failed to post error notification:', e.message));
182
+ } finally {
183
+ mcp?.close?.();
184
+ }
185
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Shared API frequency analysis utilities.
3
+ *
4
+ * Previously duplicated in crawl-and-report.js and env-comparison.js.
5
+ * Single source of truth — import from here in both orchestrators.
6
+ */
7
+
8
+ import { thresholds } from '../config/targets.js';
9
+
10
+ /**
11
+ * Detect API endpoints called more than once in a single page load.
12
+ * Groups by normalized URL + method. Flags duplicates with severity based
13
+ * on call count and whether it looks like an accidental double-fetch.
14
+ *
15
+ * @param {object[]} networkReqs - All network requests from list_network_requests
16
+ * @param {string} pageUrl - Page URL (for error reporting)
17
+ * @returns {object[]} Bug entries for duplicate/excessive API calls
18
+ */
19
+ export function analyzeApiFrequency(networkReqs, pageUrl) {
20
+ const bugs = [];
21
+
22
+ // Only examine XHR/fetch calls — filter out static assets
23
+ const staticExtensions = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|map|webp|avif)(\?|$)/i;
24
+ const apiCalls = networkReqs.filter(req => {
25
+ const u = req.url ?? '';
26
+ if (staticExtensions.test(u)) return false;
27
+ // Include if it has /api/, /graphql, /v1/, /v2/, or is XHR/fetch type
28
+ return (
29
+ /\/(api|graphql|rest|v\d+|_next\/data|trpc)\//i.test(u) ||
30
+ req.resourceType === 'XHR' ||
31
+ req.resourceType === 'Fetch' ||
32
+ req.initiatorType === 'xmlhttprequest' ||
33
+ req.initiatorType === 'fetch'
34
+ );
35
+ });
36
+
37
+ // Group by method + normalized URL (strip query string for grouping key,
38
+ // but keep it in the report so you can see the exact calls made)
39
+ const groups = {};
40
+ for (const req of apiCalls) {
41
+ const method = (req.method ?? 'GET').toUpperCase();
42
+ // Coalesce req.url — the filter uses a coerced copy but the loop uses the
43
+ // original; req.url could still be undefined, causing new URL(undefined) to throw.
44
+ const normalized = normalizeApiUrl(req.url ?? '');
45
+ const key = `${method}::${normalized}`;
46
+ if (!groups[key]) {
47
+ groups[key] = { method, normalizedUrl: normalized, calls: [], key };
48
+ }
49
+ groups[key].calls.push({
50
+ url: req.url,
51
+ status: req.status,
52
+ duration: req.duration ?? req.time ?? null,
53
+ initiator: req.initiator ?? null,
54
+ });
55
+ }
56
+
57
+ // Report groups with more than one call
58
+ for (const group of Object.values(groups)) {
59
+ const count = group.calls.length;
60
+ if (count <= 1) continue;
61
+
62
+ // Severity ladder:
63
+ // 2 calls → info (might be intentional: prefetch + actual)
64
+ // ≥ warningCount → warning (likely a bug: double render, missing dependency array)
65
+ // ≥ criticalCount → critical (runaway loop, missing cleanup)
66
+ let severity = 'info';
67
+ if (count >= thresholds.apiFrequency.criticalCount) severity = 'critical';
68
+ else if (count >= thresholds.apiFrequency.warningCount) severity = 'warning';
69
+
70
+ const durations = group.calls
71
+ .map(c => c.duration)
72
+ .filter(Boolean)
73
+ .map(d => `${Math.round(d)}ms`);
74
+
75
+ bugs.push({
76
+ type: 'api_duplicate_call',
77
+ method: group.method,
78
+ endpoint: group.normalizedUrl,
79
+ callCount: count,
80
+ calls: group.calls,
81
+ durations,
82
+ message: `API called ${count}x in one page load: ${group.method} ${group.normalizedUrl}${count >= 5 ? ' — possible infinite loop or missing cleanup' : count >= 3 ? ' — likely double-fetch bug (check useEffect deps or component re-mounts)' : ' — called twice (verify this is intentional)'}`,
83
+ severity,
84
+ url: pageUrl,
85
+ });
86
+ }
87
+
88
+ // Also report total unique API calls as an info summary
89
+ const uniqueCount = Object.keys(groups).length;
90
+ const totalCount = apiCalls.length;
91
+ if (totalCount > 0) {
92
+ bugs.push({
93
+ type: 'api_call_summary',
94
+ uniqueEndpoints: uniqueCount,
95
+ totalCalls: totalCount,
96
+ duplicateEndpoints: Object.values(groups).filter(g => g.calls.length > 1).length,
97
+ message: `API summary: ${totalCount} calls to ${uniqueCount} unique endpoints${Object.values(groups).filter(g => g.calls.length > 1).length > 0 ? ` (${Object.values(groups).filter(g => g.calls.length > 1).length} called more than once)` : ''}`,
98
+ severity: 'info',
99
+ url: pageUrl,
100
+ });
101
+ }
102
+
103
+ return bugs;
104
+ }
105
+
106
+ /**
107
+ * Normalize an API URL for grouping: strip query params, collapse IDs.
108
+ * e.g. /api/users/123/posts?page=2 → /api/users/{id}/posts
109
+ *
110
+ * @param {string} url
111
+ * @returns {string}
112
+ */
113
+ export function normalizeApiUrl(url) {
114
+ // Guard non-string input — new URL(null) throws into the catch, then
115
+ // null.replace() in the catch block throws a second uncaught TypeError.
116
+ if (typeof url !== 'string') return '';
117
+ try {
118
+ const u = new URL(url);
119
+ const pathname = u.pathname
120
+ .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/{id}')
121
+ .replace(/\/\d+/g, '/{id}');
122
+ // Include protocol so http://api.example.com and https://api.example.com
123
+ // are not collapsed to the same key in frequency analysis and diffs.
124
+ return `${u.protocol}//${u.hostname}${pathname}`;
125
+ } catch {
126
+ return url.replace(/[?#].*/, '').replace(/\/\d+/g, '/{id}');
127
+ }
128
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Argus v3 Phase B3 — Historical baselines + trend tracking
3
+ * Phase D4 — Extended to cover flow findings (flow_assert_failed, flow_step_failed)
4
+ * Phase D7.2 — Per-branch baselines (getCurrentBranch → <branch>.json / <branch>-trends.json)
5
+ *
6
+ * Baseline file (reports/baselines/<branch>.json): per-route + per-flow finding key arrays.
7
+ * Trends file (reports/baselines/<branch>-trends.json): append-only run history.
8
+ *
9
+ * Finding key: `type::message[:100]::status` — stable across runs, excludes timestamps.
10
+ */
11
+
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { execSync } from 'child_process';
15
+ // Import shared findingKey from flakiness-detector so both modules use
16
+ // identical normalization (trim + whitespace collapse). Local copy removed.
17
+ import { findingKey } from './flakiness-detector.js';
18
+ import { childLogger } from './logger.js';
19
+
20
+ const logger = childLogger('baseline-manager');
21
+
22
+ /**
23
+ * Sanitize a git branch name into a safe filename segment.
24
+ * Replaces any character that is not alphanumeric, dot, hyphen, or underscore with a hyphen.
25
+ * Collapses consecutive hyphens and strips leading/trailing hyphens.
26
+ */
27
+ function sanitizeBranch(branch) {
28
+ return branch
29
+ .replace(/[^a-zA-Z0-9._-]/g, '-')
30
+ .replace(/-{2,}/g, '-')
31
+ .replace(/^-+|-+$/g, '')
32
+ || 'default';
33
+ }
34
+
35
+ /**
36
+ * Return the current git branch name as a sanitized filename segment.
37
+ *
38
+ * Resolution order:
39
+ * 1. Read <cwd>/.git/HEAD directly (fast, no subprocess)
40
+ * 2. Fall back to `git rev-parse --abbrev-ref HEAD`
41
+ * 3. Fall back to `'default'` when not in a git repo or in detached HEAD state
42
+ *
43
+ * Examples: "main" → "main", "feature/my-feat" → "feature-my-feat"
44
+ *
45
+ * @returns {string}
46
+ */
47
+ export function getCurrentBranch() {
48
+ // Strategy 1: read .git/HEAD directly (no subprocess, works in any Node version)
49
+ try {
50
+ const headPath = path.resolve(process.cwd(), '.git', 'HEAD');
51
+ const head = fs.readFileSync(headPath, 'utf8').trim();
52
+ const match = head.match(/^ref: refs\/heads\/(.+)$/);
53
+ if (match) return sanitizeBranch(match[1]);
54
+ // Detached HEAD (contains a commit hash) — fall through to git command
55
+ } catch { /* .git/HEAD not readable — fall through */ }
56
+
57
+ // Strategy 2: git command
58
+ try {
59
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', {
60
+ stdio: ['pipe', 'pipe', 'pipe'],
61
+ timeout: 3000,
62
+ }).toString().trim();
63
+ if (branch && branch !== 'HEAD') return sanitizeBranch(branch);
64
+ } catch { /* not a git repo or git not installed — fall through */ }
65
+
66
+ // Strategy 3: CI environment variables (GitHub Actions, GitLab CI, etc.)
67
+ const ciBranch = process.env.GITHUB_REF_NAME ?? process.env.CI_COMMIT_BRANCH ?? process.env.BRANCH_NAME;
68
+ if (ciBranch) return sanitizeBranch(ciBranch);
69
+
70
+ return 'default';
71
+ }
72
+
73
+ /**
74
+ * Load baseline from disk. Returns null if file does not exist or cannot be parsed.
75
+ * Route and flow keys are stored as Sets for O(1) lookup.
76
+ * Old baselines (pre-D4) have no `flows` field — flows defaults to an empty Map.
77
+ */
78
+ export function loadBaseline(baselineFile) {
79
+ if (!fs.existsSync(baselineFile)) return null;
80
+ try {
81
+ const raw = JSON.parse(fs.readFileSync(baselineFile, 'utf8'));
82
+ const routes = new Map();
83
+ for (const [url, keys] of Object.entries(raw.routes ?? {})) {
84
+ routes.set(url, new Set(keys));
85
+ }
86
+ const flows = new Map();
87
+ for (const [flowName, keys] of Object.entries(raw.flows ?? {})) {
88
+ flows.set(flowName, new Set(keys));
89
+ }
90
+ const codebase = new Set(raw.codebase ?? []);
91
+ return { savedAt: raw.savedAt, routes, flows, codebase };
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Persist current report as the new baseline.
99
+ * Writes per-route and per-flow arrays of finding keys — timestamps are excluded.
100
+ */
101
+ export function saveBaseline(baselineFile, report) {
102
+ const dir = path.dirname(baselineFile);
103
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
104
+ const routes = {};
105
+ for (const routeResult of report.routes) {
106
+ routes[routeResult.url] = (routeResult.errors ?? []).map(findingKey);
107
+ }
108
+ const flows = {};
109
+ for (const flowResult of (report.flows ?? [])) {
110
+ flows[flowResult.flowName] = (flowResult.findings ?? []).map(findingKey);
111
+ }
112
+ const codebase = (report.codebase ?? []).map(findingKey);
113
+ const tmpBaseline = baselineFile + '.tmp';
114
+ fs.writeFileSync(
115
+ tmpBaseline,
116
+ JSON.stringify({ savedAt: new Date().toISOString(), routes, flows, codebase }, null, 2),
117
+ );
118
+ fs.renameSync(tmpBaseline, baselineFile);
119
+ }
120
+
121
+ /**
122
+ * Annotate each finding in the report with `isNew: boolean`.
123
+ * Returns { isFirstRun, newCount, resolvedCount, flowNewCount, flowResolvedCount }.
124
+ *
125
+ * First run (baseline === null): all findings are new, resolved counts = 0.
126
+ * Old baselines (pre-D4) have no `flows` map — flow findings are treated as new.
127
+ */
128
+ export function applyBaseline(report, baseline) {
129
+ if (!baseline) {
130
+ for (const routeResult of report.routes) {
131
+ for (const finding of routeResult.errors) {
132
+ finding.isNew = true;
133
+ }
134
+ }
135
+ for (const flowResult of (report.flows ?? [])) {
136
+ for (const finding of flowResult.findings) {
137
+ finding.isNew = true;
138
+ }
139
+ }
140
+ const newCount = report.routes.reduce((n, r) => n + r.errors.length, 0);
141
+ const flowNewCount = (report.flows ?? []).reduce((n, f) => n + f.findings.length, 0);
142
+ const codebaseNewCount = (report.codebase ?? []).length;
143
+ for (const finding of (report.codebase ?? [])) {
144
+ finding.isNew = true;
145
+ }
146
+ // Include codebase counts so PR/Slack trend summaries reflect codebase findings.
147
+ return { isFirstRun: true, newCount, resolvedCount: 0, flowNewCount, flowResolvedCount: 0, codebaseNewCount, codebaseResolvedCount: 0 };
148
+ }
149
+
150
+ let newCount = 0;
151
+ let resolvedCount = 0;
152
+
153
+ for (const routeResult of report.routes) {
154
+ const baselineKeys = baseline.routes.get(routeResult.url) ?? new Set();
155
+ const currentKeys = new Set();
156
+
157
+ for (const finding of routeResult.errors) {
158
+ const key = findingKey(finding);
159
+ currentKeys.add(key);
160
+ finding.isNew = !baselineKeys.has(key);
161
+ if (finding.isNew) newCount++;
162
+ }
163
+
164
+ for (const key of baselineKeys) {
165
+ if (!currentKeys.has(key)) resolvedCount++;
166
+ }
167
+ }
168
+
169
+ let flowNewCount = 0;
170
+ let flowResolvedCount = 0;
171
+ const baselineFlows = baseline.flows ?? new Map();
172
+
173
+ for (const flowResult of (report.flows ?? [])) {
174
+ const baselineKeys = baselineFlows.get(flowResult.flowName) ?? new Set();
175
+ const currentKeys = new Set();
176
+
177
+ for (const finding of flowResult.findings) {
178
+ const key = findingKey(finding);
179
+ currentKeys.add(key);
180
+ finding.isNew = !baselineKeys.has(key);
181
+ if (finding.isNew) flowNewCount++;
182
+ }
183
+
184
+ for (const key of baselineKeys) {
185
+ if (!currentKeys.has(key)) flowResolvedCount++;
186
+ }
187
+ }
188
+
189
+ // C1 codebase findings — annotate isNew + count new/resolved
190
+ const baselineCodebase = baseline.codebase ?? new Set();
191
+ const currentCodebaseKeys = new Set();
192
+ let codebaseNewCount = 0;
193
+ let codebaseResolvedCount = 0;
194
+ for (const finding of (report.codebase ?? [])) {
195
+ const key = findingKey(finding);
196
+ currentCodebaseKeys.add(key);
197
+ finding.isNew = !baselineCodebase.has(key);
198
+ if (finding.isNew) codebaseNewCount++;
199
+ }
200
+ for (const key of baselineCodebase) {
201
+ if (!currentCodebaseKeys.has(key)) codebaseResolvedCount++;
202
+ }
203
+
204
+ // Return codebase counts alongside route/flow counts so Slack/GitHub reporters
205
+ // can include codebase findings in trend summaries and PR comments.
206
+ return { isFirstRun: false, newCount, resolvedCount, flowNewCount, flowResolvedCount, codebaseNewCount, codebaseResolvedCount };
207
+ }
208
+
209
+ /**
210
+ * Append one trend entry to the trends file (creates the file if absent).
211
+ */
212
+ export function appendTrend(trendsFile, entry) {
213
+ const dir = path.dirname(trendsFile);
214
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
215
+
216
+ const lockFile = trendsFile + '.lock';
217
+ // Remove stale lock files left by crashed processes (older than 60s)
218
+ try {
219
+ const lockStat = fs.statSync(lockFile);
220
+ if (Date.now() - lockStat.mtimeMs > 60_000) {
221
+ fs.unlinkSync(lockFile);
222
+ logger.warn('[ARGUS] Removed stale trend lock file:', lockFile);
223
+ }
224
+ } catch { /* lock doesn't exist — good */ }
225
+
226
+ let lockFd = null;
227
+ try {
228
+ lockFd = fs.openSync(lockFile, 'wx');
229
+ } catch (err) {
230
+ if (err.code === 'EEXIST') {
231
+ logger.warn('[ARGUS] appendTrend: lock held by another shard — skipping to avoid corruption');
232
+ return;
233
+ }
234
+ throw err;
235
+ }
236
+ try {
237
+ let trends = [];
238
+ if (fs.existsSync(trendsFile)) {
239
+ // JSON.parse may return a non-array (corrupt file contains `{}`); assigning
240
+ // that to trends would cause trends.push() to throw "not a function" later.
241
+ try {
242
+ const parsed = JSON.parse(fs.readFileSync(trendsFile, 'utf8'));
243
+ trends = Array.isArray(parsed) ? parsed : [];
244
+ } catch { trends = []; }
245
+ }
246
+ trends.push(entry);
247
+ if (trends.length > 500) trends = trends.slice(-500);
248
+ const tmpTrends = trendsFile + '.tmp';
249
+ fs.writeFileSync(tmpTrends, JSON.stringify(trends, null, 2));
250
+ fs.renameSync(tmpTrends, trendsFile);
251
+ } finally {
252
+ try { fs.closeSync(lockFd); } catch {}
253
+ try { fs.unlinkSync(lockFile); } catch {}
254
+ }
255
+ }