argusqa-os 9.5.5 → 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.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * ARGUS HAR Network Baseline Recorder (Sprint 5 — N1)
3
+ *
4
+ * Records all network requests made during a page load as a HAR-style
5
+ * baseline. On first run, saves the baseline. On subsequent runs, diffs
6
+ * the current requests against the baseline and surfaces regressions:
7
+ * new requests, missing requests, and status-code changes.
8
+ *
9
+ * This isolates frontend bugs from backend noise: if a finding appears
10
+ * only when the request set has changed, it is likely environment-specific.
11
+ *
12
+ * Findings emitted:
13
+ * har_baseline_created — info, first run: baseline saved
14
+ * har_new_request — warning: request not in baseline
15
+ * har_missing_request — warning: baseline request no longer made
16
+ * har_status_changed — warning/critical: HTTP status differs from baseline
17
+ * har_comparison_summary — info, always emitted
18
+ *
19
+ * Baseline stored at: {REPORT_OUTPUT_DIR}/baselines/har/{slug}.json
20
+ */
21
+
22
+ import fs from 'fs';
23
+ import path from 'path';
24
+ import { registerExpensive } from '../registry.js';
25
+ import { childLogger } from './logger.js';
26
+ import { slugify } from './slug.js';
27
+ import { config } from '../config/targets.js';
28
+
29
+ const logger = childLogger('har-recorder');
30
+ const HAR_DIR = path.join(config.outputDir, 'baselines', 'har');
31
+
32
+ // Normalise a URL for baseline keying — strip query strings that vary per run
33
+ // (cache-busters, tokens) to reduce false-positive "new request" findings.
34
+ function normaliseUrl(rawUrl) {
35
+ try {
36
+ const u = new URL(rawUrl);
37
+ // Keep only stable query params — drop ones that look like cache-busters
38
+ const stable = new URLSearchParams();
39
+ for (const [k, v] of u.searchParams) {
40
+ if (/^(v|ver|version|_|cb|bust|ts|t)$/i.test(k)) continue;
41
+ stable.set(k, v);
42
+ }
43
+ u.search = stable.toString();
44
+ return u.toString();
45
+ } catch {
46
+ return rawUrl;
47
+ }
48
+ }
49
+
50
+ function toBaselineEntry(req) {
51
+ return {
52
+ request: { method: req.method ?? 'GET', url: normaliseUrl(req.url) },
53
+ response: { status: req.status ?? 0 },
54
+ };
55
+ }
56
+
57
+ export async function analyzeHar(browser, url, opts = {}) {
58
+ const harDir = opts.baselineDir ?? HAR_DIR;
59
+ const findings = [];
60
+
61
+ try {
62
+ await browser.navigate(url);
63
+ await browser.waitFor({ state: 'networkidle' }).catch(() => {});
64
+ await new Promise(r => setTimeout(r, 600));
65
+ } catch {
66
+ return findings;
67
+ }
68
+
69
+ // ── Capture current network requests ─────────────────────────────────────
70
+ let requests = [];
71
+ try {
72
+ requests = await browser.listNetwork();
73
+ } catch (err) {
74
+ logger.warn(`[ARGUS] har-recorder: listNetwork failed for ${url}: ${err.message}`);
75
+ return findings;
76
+ }
77
+
78
+ const slug = slugify(url);
79
+ const harFile = path.join(harDir, `${slug}.json`);
80
+
81
+ // ── First run: save baseline ──────────────────────────────────────────────
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') {
96
+ logger.warn(`[ARGUS] har-recorder: failed to write baseline: ${err.message}`);
97
+ return findings;
98
+ }
99
+ }
100
+ if (harIsNew) {
101
+
102
+ findings.push({
103
+ type: 'har_baseline_created',
104
+ message: `HAR baseline saved for ${url} (${requests.length} requests recorded)`,
105
+ requestCount: requests.length,
106
+ baselineFile: harFile,
107
+ severity: 'info',
108
+ url,
109
+ });
110
+ return findings;
111
+ }
112
+
113
+ // ── Subsequent runs: compare against baseline ─────────────────────────────
114
+ let existingBaseline;
115
+ try {
116
+ existingBaseline = JSON.parse(fs.readFileSync(harFile, 'utf8'));
117
+ } catch (err) {
118
+ logger.warn(`[ARGUS] har-recorder: failed to read baseline: ${err.message}`);
119
+ return findings;
120
+ }
121
+
122
+ const baselineEntries = existingBaseline.entries ?? [];
123
+ const baselineMap = new Map(baselineEntries.map(e => [normaliseUrl(e.request.url), e]));
124
+ const currentMap = new Map(requests.map(r => [normaliseUrl(r.url), r]));
125
+
126
+ let newCount = 0, missingCount = 0, changedCount = 0;
127
+
128
+ // New requests not in baseline
129
+ for (const [normUrl, req] of currentMap) {
130
+ if (!baselineMap.has(normUrl)) {
131
+ newCount++;
132
+ findings.push({
133
+ type: 'har_new_request',
134
+ message: `New network request not in baseline: ${req.method ?? 'GET'} ${normUrl}`,
135
+ method: req.method ?? 'GET',
136
+ requestUrl: normUrl,
137
+ status: req.status ?? 0,
138
+ severity: 'warning',
139
+ url,
140
+ });
141
+ }
142
+ }
143
+
144
+ // Baseline requests no longer made
145
+ for (const [normUrl, entry] of baselineMap) {
146
+ if (!currentMap.has(normUrl)) {
147
+ missingCount++;
148
+ findings.push({
149
+ type: 'har_missing_request',
150
+ message: `Baseline request no longer made: ${entry.request.method} ${normUrl}`,
151
+ method: entry.request.method,
152
+ requestUrl: normUrl,
153
+ severity: 'warning',
154
+ url,
155
+ });
156
+ }
157
+ }
158
+
159
+ // Status code regressions
160
+ for (const [normUrl, req] of currentMap) {
161
+ const base = baselineMap.get(normUrl);
162
+ if (!base) continue;
163
+ const baseStatus = base.response.status;
164
+ const currStatus = req.status ?? 0;
165
+ if (baseStatus !== currStatus && currStatus > 0 && baseStatus > 0) {
166
+ changedCount++;
167
+ findings.push({
168
+ type: 'har_status_changed',
169
+ message: `HTTP status changed for ${normUrl}: ${baseStatus} → ${currStatus}`,
170
+ requestUrl: normUrl,
171
+ baselineStatus: baseStatus,
172
+ currentStatus: currStatus,
173
+ severity: currStatus >= 400 ? 'critical' : 'warning',
174
+ url,
175
+ });
176
+ }
177
+ }
178
+
179
+ findings.push({
180
+ type: 'har_comparison_summary',
181
+ message: `HAR diff: ${newCount} new, ${missingCount} missing, ${changedCount} status-changed (${requests.length} total vs ${baselineEntries.length} baseline)`,
182
+ newRequests: newCount,
183
+ missingRequests: missingCount,
184
+ statusChanges: changedCount,
185
+ totalCurrent: requests.length,
186
+ totalBaseline: baselineEntries.length,
187
+ severity: 'info',
188
+ url,
189
+ });
190
+
191
+ return findings;
192
+ }
193
+
194
+ registerExpensive({
195
+ name: 'har-recorder',
196
+ analyze: (browser, url) => analyzeHar(browser, url),
197
+ });
@@ -0,0 +1,243 @@
1
+ /**
2
+ * ARGUS Motion & Animation Accessibility Analyzer (Sprint 5b — A9)
3
+ *
4
+ * Detects pages that trigger motion/animation without respecting the user's
5
+ * `prefers-reduced-motion` OS preference — a WCAG 2.1 SC 2.3.3 (AAA) violation
6
+ * that can trigger vestibular disorders in motion-sensitive users.
7
+ *
8
+ * Findings emitted:
9
+ * motion_no_reduced_motion_query — CSS animation/transition present but no
10
+ * @media (prefers-reduced-motion) query anywhere in page stylesheets
11
+ * motion_autoplay_no_pause — <video autoplay> without visible pause control
12
+ * or animated <img> (GIF/APNG/WebP) without pause mechanism
13
+ * motion_interactive_animation — transition/animation on interactive elements
14
+ * (button, a, input, [role=button]) without a reduced-motion override
15
+ * motion_reduced_not_honoured — after emulating prefers-reduced-motion: reduce,
16
+ * animated properties are still applied (requires MCP emulate support)
17
+ * motion_summary — info, always emitted
18
+ */
19
+
20
+ import { registerExpensive } from '../registry.js';
21
+ import { unwrapEval } from './mcp-client.js';
22
+ import { childLogger } from './logger.js';
23
+ import { thresholds } from '../config/targets.js';
24
+
25
+ const logger = childLogger('motion-analyzer');
26
+
27
+ // Threshold: flag animated interactive elements even if count is 1
28
+ const ANIM_COUNT_THRESHOLD = thresholds.motion?.animationPropertyCount ?? 1;
29
+
30
+ // ── In-browser motion analysis script ────────────────────────────────────────
31
+ const MOTION_SCRIPT = `() => {
32
+ var result = {
33
+ hasAnimation: false,
34
+ hasReducedQuery: false,
35
+ interactiveAnimated: [],
36
+ autoplayVideos: [],
37
+ animatedImages: [],
38
+ };
39
+
40
+ var INTERACTIVE = ['button','a','input','select','textarea'];
41
+ var ANIM_PROPS = ['animation','animation-name','transition'];
42
+
43
+ // Scan all accessible stylesheets
44
+ var sheets = Array.from(document.styleSheets);
45
+ for (var i = 0; i < sheets.length; i++) {
46
+ var sheet = sheets[i];
47
+ var rules;
48
+ try { rules = Array.from(sheet.cssRules || []); } catch { continue; }
49
+ for (var j = 0; j < rules.length; j++) {
50
+ var rule = rules[j];
51
+ // Check for @media (prefers-reduced-motion)
52
+ if (rule.type === CSSRule.MEDIA_RULE) {
53
+ var condText = rule.conditionText || rule.media && rule.media.mediaText || '';
54
+ if (/prefers-reduced-motion/i.test(condText)) {
55
+ result.hasReducedQuery = true;
56
+ }
57
+ }
58
+ // Check style rules for animation/transition
59
+ if (rule.type === CSSRule.STYLE_RULE && rule.style) {
60
+ var anim = rule.style.animationName || rule.style.animation;
61
+ var trans = rule.style.transition;
62
+ if ((anim && anim !== 'none' && anim !== '') ||
63
+ (trans && trans !== 'none' && trans !== '')) {
64
+ result.hasAnimation = true;
65
+ // Check if selector matches an interactive element
66
+ var sel = rule.selectorText || '';
67
+ var isInteractive = INTERACTIVE.some(function(tag) {
68
+ return sel.indexOf(tag) !== -1 || sel.indexOf('[role="button"]') !== -1;
69
+ });
70
+ if (isInteractive) {
71
+ result.interactiveAnimated.push({
72
+ selector: sel.slice(0, 120),
73
+ animation: anim || '',
74
+ transition: trans || '',
75
+ });
76
+ }
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ // Check for autoplay video without pause control
83
+ var videos = Array.from(document.querySelectorAll('video[autoplay]'));
84
+ for (var v = 0; v < videos.length; v++) {
85
+ var vid = videos[v];
86
+ result.autoplayVideos.push({
87
+ src: (vid.src || vid.currentSrc || '').slice(0, 100),
88
+ hasMuted: vid.muted || vid.hasAttribute('muted'),
89
+ hasControls: vid.controls || vid.hasAttribute('controls'),
90
+ });
91
+ }
92
+
93
+ // Check for animated images (GIF/APNG) without pause mechanism
94
+ var imgs = Array.from(document.querySelectorAll('img'));
95
+ for (var k = 0; k < imgs.length; k++) {
96
+ var src = imgs[k].src || '';
97
+ if (/\\.gif$/i.test(src) || /\\.apng$/i.test(src)) {
98
+ result.animatedImages.push({ src: src.slice(0, 100) });
99
+ }
100
+ }
101
+
102
+ return JSON.stringify(result);
103
+ }`;
104
+
105
+ // ── Post-emulation check: do animations still run under reduced motion? ───────
106
+ const REDUCED_MOTION_CHECK = `() => {
107
+ var result = { stillAnimated: [] };
108
+ var sheets = Array.from(document.styleSheets);
109
+ for (var i = 0; i < sheets.length; i++) {
110
+ var rules;
111
+ try { rules = Array.from(sheets[i].cssRules || []); } catch { continue; }
112
+ for (var j = 0; j < rules.length; j++) {
113
+ var rule = rules[j];
114
+ if (rule.type === CSSRule.STYLE_RULE && rule.style) {
115
+ var anim = rule.style.animationName || rule.style.animation;
116
+ var trans = rule.style.transition;
117
+ if ((anim && anim !== 'none') || (trans && trans !== 'none')) {
118
+ result.stillAnimated.push({ selector: (rule.selectorText || '').slice(0, 80) });
119
+ }
120
+ }
121
+ }
122
+ }
123
+ return JSON.stringify(result);
124
+ }`;
125
+
126
+ export async function analyzeMotion(browser, url) {
127
+ const findings = [];
128
+
129
+ try {
130
+ await browser.navigate(url);
131
+ await browser.waitFor({ state: 'networkidle' }).catch(() => {});
132
+ await new Promise(r => setTimeout(r, 400));
133
+ } catch {
134
+ return findings;
135
+ }
136
+
137
+ // ── 1. Run CSS + DOM motion analysis ─────────────────────────────────────
138
+ let data = null;
139
+ try {
140
+ const raw = await browser.evaluate(MOTION_SCRIPT);
141
+ const s = unwrapEval(raw);
142
+ data = typeof s === 'object' ? s : JSON.parse(s);
143
+ } catch (err) {
144
+ logger.warn(`[ARGUS] motion-analyzer: analysis failed for ${url}: ${err.message}`);
145
+ data = { hasAnimation: false, hasReducedQuery: false, interactiveAnimated: [], autoplayVideos: [], animatedImages: [] };
146
+ }
147
+
148
+ // Finding: animation without prefers-reduced-motion query
149
+ if (data.hasAnimation && !data.hasReducedQuery) {
150
+ findings.push({
151
+ type: 'motion_no_reduced_motion_query',
152
+ message: 'CSS animation/transition in use but no @media (prefers-reduced-motion) query found in any stylesheet',
153
+ severity: 'warning',
154
+ url,
155
+ });
156
+ }
157
+
158
+ // Finding: autoplay video without pause control
159
+ for (const vid of (data.autoplayVideos ?? [])) {
160
+ if (!vid.hasControls) {
161
+ findings.push({
162
+ type: 'motion_autoplay_no_pause',
163
+ message: `<video autoplay> without visible pause controls: ${vid.src || '(no src)'}`,
164
+ src: vid.src,
165
+ hasMuted: vid.hasMuted,
166
+ severity: 'warning',
167
+ url,
168
+ });
169
+ }
170
+ }
171
+
172
+ // Finding: animated GIFs without pause
173
+ for (const img of (data.animatedImages ?? [])) {
174
+ findings.push({
175
+ type: 'motion_autoplay_no_pause',
176
+ message: `Animated image (GIF/APNG) without pause mechanism: ${img.src}`,
177
+ src: img.src,
178
+ severity: 'info',
179
+ url,
180
+ });
181
+ }
182
+
183
+ // Finding: interactive elements with animation/transition
184
+ const interactiveCount = (data.interactiveAnimated ?? []).length;
185
+ if (interactiveCount >= ANIM_COUNT_THRESHOLD) {
186
+ for (const el of data.interactiveAnimated.slice(0, 10)) {
187
+ findings.push({
188
+ type: 'motion_interactive_animation',
189
+ message: `Interactive element has animation/transition without prefers-reduced-motion override: ${el.selector}`,
190
+ selector: el.selector,
191
+ animation: el.animation,
192
+ transition: el.transition,
193
+ severity: 'warning',
194
+ url,
195
+ });
196
+ }
197
+ }
198
+
199
+ // ── 2. Emulate prefers-reduced-motion: reduce and re-check ───────────────
200
+ try {
201
+ await browser.emulateReducedMotion('reduce');
202
+ await new Promise(r => setTimeout(r, 300));
203
+ const raw2 = await browser.evaluate(REDUCED_MOTION_CHECK);
204
+ const s2 = unwrapEval(raw2);
205
+ const d2 = typeof s2 === 'object' ? s2 : JSON.parse(s2);
206
+ if (Array.isArray(d2.stillAnimated) && d2.stillAnimated.length > 0 && !data.hasReducedQuery) {
207
+ findings.push({
208
+ type: 'motion_reduced_not_honoured',
209
+ message: `${d2.stillAnimated.length} animated element(s) still animate after emulating prefers-reduced-motion: reduce`,
210
+ count: d2.stillAnimated.length,
211
+ severity: 'warning',
212
+ url,
213
+ });
214
+ }
215
+ // Reset emulation
216
+ await browser.emulateReducedMotion('no-preference').catch(() => {});
217
+ } catch {
218
+ // Emulation not supported in this Chrome/MCP build — skip gracefully
219
+ }
220
+
221
+ // ── 3. Summary — always emitted ──────────────────────────────────────────
222
+ const animCount = interactiveCount;
223
+ const autoplayCount = (data.autoplayVideos ?? []).filter(v => !v.hasControls).length +
224
+ (data.animatedImages ?? []).length;
225
+
226
+ findings.push({
227
+ type: 'motion_summary',
228
+ hasAnimation: data.hasAnimation,
229
+ hasReducedQuery: data.hasReducedQuery,
230
+ animationCount: animCount,
231
+ autoplayCount,
232
+ message: `Motion: animation=${data.hasAnimation}, reducedMotionQuery=${data.hasReducedQuery}, interactiveAnimated=${animCount}, autoplay=${autoplayCount}`,
233
+ severity: 'info',
234
+ url,
235
+ });
236
+
237
+ return findings;
238
+ }
239
+
240
+ registerExpensive({
241
+ name: 'motion',
242
+ analyze: (browser, url) => analyzeMotion(browser, url),
243
+ });
@@ -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 ────────────────────────────────────────────────────────────────
@@ -141,13 +141,18 @@ export async function analyzeVisualRegression(browser, url, opts = {}) {
141
141
  const baselinePath = path.join(baselineDir, `${slug}.png`);
142
142
 
143
143
  // ── 3. First run: save baseline ─────────────────────────────────────────────
144
- if (!fs.existsSync(baselinePath)) {
145
- try {
146
- fs.writeFileSync(baselinePath, currentBuf);
147
- } catch (err) {
144
+ // Use flag:'wx' for atomic create — throws EEXIST if baseline was written concurrently (TOCTOU-safe).
145
+ let baselineIsNew = false;
146
+ try {
147
+ fs.writeFileSync(baselinePath, currentBuf, { flag: 'wx' });
148
+ baselineIsNew = true;
149
+ } catch (err) {
150
+ if (err.code !== 'EEXIST') {
148
151
  logger.warn(`[ARGUS] visual-diff: could not write baseline ${baselinePath}: ${err.message}`);
149
152
  return findings;
150
153
  }
154
+ }
155
+ if (baselineIsNew) {
151
156
 
152
157
  findings.push({
153
158
  type: 'visual_baseline_created',