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,407 @@
1
+ /**
2
+ * ARGUS CSS Analyzer
3
+ *
4
+ * Works with React + SCSS projects. SCSS is compiled to CSS before the browser
5
+ * sees it, so this analyzer works on the live compiled output.
6
+ *
7
+ * React-aware features:
8
+ * - CSS Modules: detects hashed class names (_button_abc123) and maps them
9
+ * back to readable component names from data-* attributes and element context
10
+ * - Inline style conflicts: detects React inline style props that override
11
+ * stylesheet declarations on the same element
12
+ * - SCSS compiled source: reads sourceMappingURL comments to attribute rules
13
+ * back to original .scss files where possible
14
+ * - CSS-in-JS (styled-components/emotion): inline <style> tags are analyzed
15
+ * the same way as external stylesheets
16
+ *
17
+ * Injected via evaluate_script into the live page to analyze:
18
+ * 1. Which CSS rules are actually applied to matched elements
19
+ * 2. Which properties are overridden (cascade conflicts)
20
+ * 3. Which styles are leaking from unexpected components/sources
21
+ * 4. Unused rules — declared but no element matches them
22
+ * 5. React inline style conflicts with stylesheet declarations
23
+ * 6. CSS Modules health (hashed class name leakage across components)
24
+ *
25
+ * Returns a structured JSON report that Claude Code processes into bug entries.
26
+ */
27
+
28
+ import { childLogger } from './logger.js';
29
+
30
+ const logger = childLogger('css-analyzer');
31
+
32
+ /**
33
+ * JavaScript string injected into the page via mcp.evaluate_script.
34
+ * Runs entirely in the page context — no Node.js APIs available here.
35
+ */
36
+ export const CSS_ANALYSIS_SCRIPT = `
37
+ () => {
38
+ const report = {
39
+ stylesheetSources: [],
40
+ overriddenProperties: [],
41
+ unusedRules: [],
42
+ componentLeaks: [],
43
+ inlineStyleConflicts: [],
44
+ cssModulesDetected: false,
45
+ scssSourceFiles: [],
46
+ summary: { totalRules: 0, appliedRules: 0, unusedRules: 0, overrides: 0, leaks: 0, inlineConflicts: 0 }
47
+ };
48
+
49
+ // ── 1. Collect all stylesheets and their sources ───────────────────────────
50
+ const sheets = Array.from(document.styleSheets);
51
+ for (const sheet of sheets) {
52
+ try {
53
+ const source = sheet.href ?? (sheet.ownerNode?.tagName === 'STYLE' ? 'inline' : 'unknown');
54
+ const ruleCount = sheet.cssRules?.length ?? 0;
55
+ report.stylesheetSources.push({ source, ruleCount });
56
+ report.summary.totalRules += ruleCount;
57
+ } catch {
58
+ // Cross-origin stylesheet — can't access cssRules
59
+ report.stylesheetSources.push({ source: sheet.href ?? 'cross-origin', ruleCount: -1, blocked: true });
60
+ }
61
+ }
62
+
63
+ // ── 2. Flatten all accessible CSS rules ───────────────────────────────────
64
+ const allRules = [];
65
+ for (const sheet of sheets) {
66
+ let rules;
67
+ try { rules = Array.from(sheet.cssRules ?? []); } catch { continue; }
68
+ const sheetSource = sheet.href ?? 'inline';
69
+
70
+ for (const rule of rules) {
71
+ if (rule.type === CSSRule.STYLE_RULE) {
72
+ allRules.push({ selector: rule.selectorText, declarations: rule.style, source: sheetSource, rule });
73
+ }
74
+ // Handle @media rules — flatten their contents
75
+ if (rule.type === CSSRule.MEDIA_RULE) {
76
+ try {
77
+ for (const mediaRule of Array.from(rule.cssRules ?? [])) {
78
+ if (mediaRule.type === CSSRule.STYLE_RULE) {
79
+ allRules.push({ selector: mediaRule.selectorText, declarations: mediaRule.style, source: sheetSource + ' (@media)', rule: mediaRule });
80
+ }
81
+ }
82
+ } catch {}
83
+ }
84
+ }
85
+ }
86
+
87
+ // ── 3. Check each rule: does it match any element? ─────────────────────────
88
+ for (const { selector, declarations, source, rule } of allRules) {
89
+ if (!selector) continue;
90
+ let matched = false;
91
+ try {
92
+ matched = document.querySelectorAll(selector).length > 0;
93
+ } catch {
94
+ // Invalid selector (e.g. vendor-prefixed pseudo-elements) — skip
95
+ continue;
96
+ }
97
+
98
+ if (!matched) {
99
+ report.unusedRules.push({
100
+ selector,
101
+ source,
102
+ propertyCount: declarations.length,
103
+ });
104
+ report.summary.unusedRules++;
105
+ } else {
106
+ report.summary.appliedRules++;
107
+ }
108
+ }
109
+
110
+ // ── 4. Detect property overrides (cascade conflicts) ──────────────────────
111
+ // For each matched element, collect all rules that apply and find properties
112
+ // declared more than once (the losers are overridden).
113
+ const keyElements = [
114
+ ...Array.from(document.querySelectorAll('h1,h2,h3,p,button,a,input,nav,header,footer,main,section,article')).slice(0, 50)
115
+ ];
116
+
117
+ for (const el of keyElements) {
118
+ const elementRules = []; // { property, value, source, priority, selector }
119
+
120
+ for (const { selector, declarations, source } of allRules) {
121
+ let matches = false;
122
+ try { matches = el.matches(selector); } catch { continue; }
123
+ if (!matches) continue;
124
+
125
+ for (let i = 0; i < declarations.length; i++) {
126
+ const prop = declarations.item(i);
127
+ const value = declarations.getPropertyValue(prop);
128
+ const priority = declarations.getPropertyPriority(prop);
129
+ elementRules.push({ property: prop, value, source, priority, selector });
130
+ }
131
+ }
132
+
133
+ // Group by property — more than one entry = override
134
+ const byProp = {};
135
+ for (const entry of elementRules) {
136
+ if (!byProp[entry.property]) byProp[entry.property] = [];
137
+ byProp[entry.property].push(entry);
138
+ }
139
+
140
+ for (const [property, entries] of Object.entries(byProp)) {
141
+ if (entries.length <= 1) continue;
142
+
143
+ const tag = el.tagName.toLowerCase();
144
+ const id = el.id ? '#' + el.id : '';
145
+ const cls = el.className && typeof el.className === 'string'
146
+ ? '.' + el.className.trim().split(/\\s+/).slice(0, 2).join('.')
147
+ : '';
148
+ const elementDesc = tag + id + cls;
149
+
150
+ // Detect !important overrides — higher severity
151
+ const hasImportant = entries.some(e => e.priority === 'important');
152
+
153
+ report.overriddenProperties.push({
154
+ element: elementDesc,
155
+ property,
156
+ declarations: entries.map(e => ({ selector: e.selector, value: e.value, source: e.source, important: e.priority === 'important' })),
157
+ hasImportant,
158
+ overrideCount: entries.length,
159
+ });
160
+ report.summary.overrides++;
161
+ }
162
+ }
163
+
164
+ // ── 5. CSS Modules detection ───────────────────────────────────────────────
165
+ // Detect if the project uses CSS Modules by checking for hashed class names
166
+ // on DOM elements (pattern: _ComponentName_classname_hash or ComponentName_class_hash)
167
+ const allClassNames = Array.from(document.querySelectorAll('[class]'))
168
+ .flatMap(el => Array.from(el.classList));
169
+ const cssModulePattern = /^_?[A-Za-z][\\w-]*_[A-Za-z][\\w-]*_[A-Za-z0-9]{4,}$/;
170
+ const hashedClasses = allClassNames.filter(c => cssModulePattern.test(c));
171
+ report.cssModulesDetected = hashedClasses.length > 0;
172
+
173
+ if (report.cssModulesDetected) {
174
+ // Extract readable component names from the hash pattern
175
+ // e.g. _Button_primary_abc123 → component = "Button"
176
+ const moduleComponents = new Set();
177
+ for (const cls of hashedClasses) {
178
+ const parts = cls.replace(/^_/, '').split('_');
179
+ if (parts.length >= 2) moduleComponents.add(parts[0]);
180
+ }
181
+ report.cssModulesComponents = Array.from(moduleComponents);
182
+ }
183
+
184
+ // ── 6. Component style leak detection (global SCSS / BEM only) ────────────
185
+ // Skip for CSS Modules — hashed class names are intentionally scoped.
186
+ // Only check BEM selectors in non-hashed, non-CSS-Modules stylesheets.
187
+ const componentPatterns = [
188
+ /\\.([\\w-]+)__([\\w-]+)/, // BEM: .block__element
189
+ /\\.([\\w-]+)--([\\w-]+)/, // BEM modifier: .block--modifier
190
+ /\\[data-component="([^"]+)"\\]/, // data-component attribute selectors
191
+ ];
192
+
193
+ for (const { selector, source } of allRules) {
194
+ if (!selector) continue;
195
+ // Skip CSS Modules hashed selectors entirely
196
+ if (/\\._?[A-Z][\\w]*_[\\w-]+_[A-Za-z0-9]{4,}/.test(selector)) continue;
197
+ for (const pattern of componentPatterns) {
198
+ const match = selector.match(pattern);
199
+ if (!match) continue;
200
+ const componentName = match[1];
201
+ const sourceFile = source.split('/').pop() ?? source;
202
+ if (!sourceFile.toLowerCase().includes(componentName.toLowerCase()) && source !== 'inline') {
203
+ report.componentLeaks.push({
204
+ selector,
205
+ componentHint: componentName,
206
+ foundInSource: source,
207
+ description: \`Selector "\${selector}" suggests component "\${componentName}" but was found in "\${sourceFile}"\`,
208
+ });
209
+ report.summary.leaks++;
210
+ }
211
+ break;
212
+ }
213
+ }
214
+
215
+ // ── 7. SCSS source file detection ─────────────────────────────────────────
216
+ // Read sourceMappingURL comments from inline <style> tags to trace compiled
217
+ // CSS back to original .scss source files where source maps are available.
218
+ const styleTags = Array.from(document.querySelectorAll('style'));
219
+ for (const tag of styleTags) {
220
+ const content = tag.textContent ?? '';
221
+ const sourceMapMatch = content.match(/\\/\\*#\\s*sourceMappingURL=([^\\s*]+)/);
222
+ if (sourceMapMatch) {
223
+ report.scssSourceFiles = report.scssSourceFiles || [];
224
+ report.scssSourceFiles.push({ sourceMap: sourceMapMatch[1], inline: true });
225
+ }
226
+ }
227
+
228
+ // ── 8. React inline style conflicts ───────────────────────────────────────
229
+ // Find React elements with style="" attributes where the inline value
230
+ // overrides a stylesheet declaration on the same property.
231
+ // Common source of hard-to-debug style issues in React components.
232
+ report.inlineStyleConflicts = [];
233
+ const elementsWithInlineStyles = Array.from(
234
+ document.querySelectorAll('[style]')
235
+ ).slice(0, 100);
236
+
237
+ for (const el of elementsWithInlineStyles) {
238
+ const inlineProps = {};
239
+ for (let i = 0; i < el.style.length; i++) {
240
+ const prop = el.style.item(i);
241
+ inlineProps[prop] = el.style.getPropertyValue(prop);
242
+ }
243
+
244
+ for (const { selector, declarations, source } of allRules) {
245
+ let matches = false;
246
+ try { matches = el.matches(selector); } catch { continue; }
247
+ if (!matches) continue;
248
+
249
+ for (const prop of Object.keys(inlineProps)) {
250
+ const sheetValue = declarations.getPropertyValue(prop);
251
+ if (!sheetValue || sheetValue === inlineProps[prop]) continue;
252
+
253
+ const tag = el.tagName.toLowerCase();
254
+ const id = el.id ? '#' + el.id : '';
255
+ const cls = el.className && typeof el.className === 'string'
256
+ ? '.' + el.className.trim().split(/\\s+/).slice(0, 2).join('.')
257
+ : '';
258
+ const isReactEl = Object.keys(el).some(k =>
259
+ k.startsWith('__reactFiber') || k.startsWith('__reactProps')
260
+ );
261
+
262
+ report.inlineStyleConflicts.push({
263
+ element: tag + id + cls,
264
+ property: prop,
265
+ inlineValue: inlineProps[prop],
266
+ stylesheetValue: sheetValue,
267
+ stylesheetSource: source.split('/').pop(),
268
+ selector,
269
+ isReactComponent: isReactEl,
270
+ description: \`React inline style overrides stylesheet on <\${tag + id + cls}>: "\${prop}: \${inlineProps[prop]}" (inline) wins over "\${prop}: \${sheetValue}" from \${source.split('/').pop()}\`,
271
+ });
272
+ report.summary.inlineConflicts++;
273
+ }
274
+ }
275
+ }
276
+
277
+ return JSON.stringify(report);
278
+ }
279
+ `;
280
+
281
+ /**
282
+ * Parse the raw JSON string result from CSS_ANALYSIS_SCRIPT into bug entries.
283
+ *
284
+ * @param {string} rawResult - JSON string returned by evaluate_script
285
+ * @param {string} url - URL that was analyzed
286
+ * @returns {object[]} Array of bug-report-compatible error objects
287
+ */
288
+ export function parseCssAnalysisResult(rawResult, url) {
289
+ let data;
290
+ try {
291
+ const str = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult);
292
+ data = JSON.parse(str);
293
+ // Detect double-encoding: if the result is still a string after first parse, unwrap once more
294
+ if (typeof data === 'string') {
295
+ logger.warn('[ARGUS] css-analyzer: double-encoded JSON detected — unwrapping');
296
+ data = JSON.parse(data);
297
+ }
298
+ } catch {
299
+ return [{
300
+ type: 'css_analysis_error',
301
+ message: 'CSS analysis script failed to return valid JSON',
302
+ severity: 'info',
303
+ url,
304
+ }];
305
+ }
306
+
307
+ const bugs = [];
308
+
309
+ // ── Overridden properties ──────────────────────────────────────────────────
310
+ for (const override of (data.overriddenProperties ?? [])) {
311
+ // Only report if there are many overrides or !important is involved
312
+ if (override.overrideCount < 2) continue;
313
+
314
+ const severity = override.hasImportant ? 'warning' : 'info';
315
+ const sources = [...new Set(override.declarations.map(d => d.source.split('/').pop()))].join(', ');
316
+ bugs.push({
317
+ type: 'css_override',
318
+ element: override.element,
319
+ property: override.property,
320
+ overrideCount: override.overrideCount,
321
+ hasImportant: override.hasImportant,
322
+ message: `CSS override: "${override.property}" declared ${override.overrideCount}x on <${override.element}>${override.hasImportant ? ' — includes !important' : ''} (sources: ${sources})`,
323
+ declarations: override.declarations,
324
+ severity,
325
+ url,
326
+ });
327
+ }
328
+
329
+ // ── Unused rules ──────────────────────────────────────────────────────────
330
+ // Only report if there are a notable number — a few unused rules is normal
331
+ const unusedCount = (data.unusedRules ?? []).length;
332
+ if (unusedCount > 10) {
333
+ bugs.push({
334
+ type: 'css_unused_rules',
335
+ count: unusedCount,
336
+ message: `${unusedCount} CSS rules matched no elements on this page — possible dead styles or wrong component loaded`,
337
+ examples: (data.unusedRules ?? []).slice(0, 5).map(r => r.selector),
338
+ severity: unusedCount > 50 ? 'warning' : 'info',
339
+ url,
340
+ });
341
+ }
342
+
343
+ // ── Component leaks ───────────────────────────────────────────────────────
344
+ for (const leak of (data.componentLeaks ?? [])) {
345
+ bugs.push({
346
+ type: 'css_component_leak',
347
+ selector: leak.selector,
348
+ componentHint: leak.componentHint,
349
+ source: leak.foundInSource,
350
+ message: leak.description,
351
+ severity: 'warning',
352
+ url,
353
+ });
354
+ }
355
+
356
+ // ── React inline style conflicts ──────────────────────────────────────────
357
+ for (const conflict of (data.inlineStyleConflicts ?? [])) {
358
+ bugs.push({
359
+ type: 'react_inline_style_conflict',
360
+ element: conflict.element,
361
+ property: conflict.property,
362
+ inlineValue: conflict.inlineValue,
363
+ stylesheetValue: conflict.stylesheetValue,
364
+ source: conflict.stylesheetSource,
365
+ isReactComponent: conflict.isReactComponent,
366
+ message: conflict.description,
367
+ severity: 'warning',
368
+ url,
369
+ });
370
+ }
371
+
372
+ // ── CSS Modules info ───────────────────────────────────────────────────────
373
+ if (data.cssModulesDetected) {
374
+ bugs.push({
375
+ type: 'css_modules_detected',
376
+ components: data.cssModulesComponents ?? [],
377
+ message: `CSS Modules detected — ${(data.cssModulesComponents ?? []).length} scoped component(s): ${(data.cssModulesComponents ?? []).join(', ')}. BEM leak detection skipped for hashed selectors.`,
378
+ severity: 'info',
379
+ url,
380
+ });
381
+ }
382
+
383
+ // ── Summary entry ─────────────────────────────────────────────────────────
384
+ if (data.summary) {
385
+ const parts = [
386
+ `${data.summary.totalRules} total rules`,
387
+ `${data.summary.appliedRules} applied`,
388
+ `${data.summary.unusedRules} unused`,
389
+ `${data.summary.overrides} cascade overrides`,
390
+ `${data.summary.leaks} component leaks`,
391
+ `${data.summary.inlineConflicts ?? 0} inline style conflicts`,
392
+ ];
393
+ bugs.push({
394
+ type: 'css_summary',
395
+ message: `CSS analysis (${data.cssModulesDetected ? 'CSS Modules + ' : ''}${data.scssSourceFiles?.length ? 'SCSS' : 'CSS'}): ${parts.join(', ')}`,
396
+ severity: 'info',
397
+ url,
398
+ summary: data.summary,
399
+ cssModulesDetected: data.cssModulesDetected,
400
+ cssModulesComponents: data.cssModulesComponents,
401
+ scssSourceFiles: data.scssSourceFiles,
402
+ stylesheetSources: data.stylesheetSources,
403
+ });
404
+ }
405
+
406
+ return bugs;
407
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * ARGUS Diff Utilities
3
+ *
4
+ * Pixel-level screenshot comparison using pixelmatch + pngjs.
5
+ * Also provides DOM structural diff utilities.
6
+ */
7
+
8
+ import { PNG } from 'pngjs';
9
+ import pixelmatch from 'pixelmatch';
10
+ import fs from 'fs';
11
+ // Import shared URL normalizer so diffNetworkRequests uses the same ID-collapsing
12
+ // strategy as analyzeApiFrequency — previously each module had its own private normalizer,
13
+ // causing the same endpoint to be keyed differently in frequency vs diff analysis.
14
+ import { normalizeApiUrl } from './api-frequency.js';
15
+ import { childLogger } from './logger.js';
16
+
17
+ const logger = childLogger('diff');
18
+
19
+ /**
20
+ * Compare two screenshot files pixel-by-pixel.
21
+ *
22
+ * @param {string} pathA - Absolute path to first screenshot (PNG)
23
+ * @param {string} pathB - Absolute path to second screenshot (PNG)
24
+ * @param {string} diffOutputPath - Where to write the diff overlay image
25
+ * @param {number} threshold - Pixel sensitivity 0–1 (default 0.1)
26
+ * @returns {{ diffPixels: number, diffPercent: number, totalPixels: number }}
27
+ */
28
+ export async function compareScreenshots(pathA, pathB, diffOutputPath, threshold = 0.1) {
29
+ // Wrap file I/O in try/catch — readFileSync throws on missing/invalid files,
30
+ // PNG.sync.read throws on corrupt data; both would crash the entire report pipeline.
31
+ let imgA, imgB;
32
+ try {
33
+ imgA = PNG.sync.read(fs.readFileSync(pathA));
34
+ imgB = PNG.sync.read(fs.readFileSync(pathB));
35
+ } catch (err) {
36
+ throw new Error(`compareScreenshots: failed to read screenshot files — ${err.message} (pathA: ${pathA}, pathB: ${pathB})`);
37
+ }
38
+
39
+ // Ensure same dimensions — use the smaller of the two
40
+ const width = Math.min(imgA.width, imgB.width);
41
+ const height = Math.min(imgA.height, imgB.height);
42
+
43
+ if (width === 0 || height === 0) {
44
+ throw new Error(`compareScreenshots: one or both screenshots have zero dimensions (${imgA.width}×${imgA.height} vs ${imgB.width}×${imgB.height}) — screenshot capture likely failed`);
45
+ }
46
+
47
+ // Crop both images to matching dimensions
48
+ const croppedA = cropPNG(imgA, width, height);
49
+ const croppedB = cropPNG(imgB, width, height);
50
+
51
+ const diff = new PNG({ width, height });
52
+
53
+ const diffPixels = pixelmatch(
54
+ croppedA.data,
55
+ croppedB.data,
56
+ diff.data,
57
+ width,
58
+ height,
59
+ { threshold }
60
+ );
61
+
62
+ // Don't let a bad output path crash the report — diff images are optional visuals.
63
+ try {
64
+ fs.writeFileSync(diffOutputPath, PNG.sync.write(diff));
65
+ } catch (err) {
66
+ logger.warn(`[ARGUS] compareScreenshots: could not write diff image to ${diffOutputPath} — ${err.message}`);
67
+ }
68
+
69
+ const totalPixels = width * height;
70
+ // Guard against division by zero if both images are 0×0 pixels.
71
+ const diffPercent = totalPixels > 0 ? (diffPixels / totalPixels) * 100 : 0;
72
+
73
+ return { diffPixels, diffPercent, totalPixels, width, height };
74
+ }
75
+
76
+ /**
77
+ * Crop a PNG object to the given width/height (top-left origin).
78
+ * @param {PNG} png
79
+ * @param {number} width
80
+ * @param {number} height
81
+ * @returns {PNG}
82
+ */
83
+ function cropPNG(png, width, height) {
84
+ if (png.width === width && png.height === height) return png;
85
+ const cropped = new PNG({ width, height });
86
+ PNG.bitblt(png, cropped, 0, 0, width, height, 0, 0);
87
+ return cropped;
88
+ }
89
+
90
+ /**
91
+ * Perform a structural diff on two serialized DOM trees.
92
+ * Returns an array of difference objects.
93
+ *
94
+ * @param {string} domA - Serialized DOM string from take_snapshot (env A)
95
+ * @param {string} domB - Serialized DOM string from take_snapshot (env B)
96
+ * @returns {object[]} Array of diff entries
97
+ */
98
+ export function diffDomSnapshots(domA, domB) {
99
+ if (typeof domA !== 'string' || typeof domB !== 'string') {
100
+ logger.warn('[ARGUS] diffDomSnapshots: non-string argument — one or both DOM snapshots may be missing');
101
+ return [];
102
+ }
103
+ const diffs = [];
104
+
105
+ // Parse tag/attribute counts as a lightweight structural fingerprint
106
+ const countTags = (dom) => {
107
+ const counts = {};
108
+ const regex = /<([a-zA-Z][a-zA-Z0-9-]*)[^>]*>/g;
109
+ let m;
110
+ while ((m = regex.exec(dom)) !== null) {
111
+ const tag = m[1].toLowerCase();
112
+ counts[tag] = (counts[tag] ?? 0) + 1;
113
+ }
114
+ return counts;
115
+ };
116
+
117
+ const tagsA = countTags(domA);
118
+ const tagsB = countTags(domB);
119
+ const allTags = new Set([...Object.keys(tagsA), ...Object.keys(tagsB)]);
120
+
121
+ for (const tag of allTags) {
122
+ const countA = tagsA[tag] ?? 0;
123
+ const countB = tagsB[tag] ?? 0;
124
+ if (countA !== countB) {
125
+ diffs.push({
126
+ type: 'element_count_change',
127
+ tag,
128
+ countA,
129
+ countB,
130
+ delta: countB - countA,
131
+ description: `<${tag}>: ${countA} in dev → ${countB} in staging (delta: ${countB - countA > 0 ? '+' : ''}${countB - countA})`,
132
+ });
133
+ }
134
+ }
135
+
136
+ return diffs;
137
+ }
138
+
139
+ /**
140
+ * Diff two arrays of network requests by URL + status.
141
+ * Returns added (in B not in A), removed (in A not in B), and changed (same URL, different status).
142
+ *
143
+ * @param {object[]} reqsA - Network requests from env A
144
+ * @param {object[]} reqsB - Network requests from env B
145
+ * @returns {{ added: object[], removed: object[], changed: object[] }}
146
+ */
147
+ export function diffNetworkRequests(reqsA, reqsB) {
148
+ // Use the shared normalizeApiUrl (from api-frequency.js) which collapses numeric
149
+ // and UUID path segments to /{id}. The previous private normalizeUrl didn't do this,
150
+ // so /api/123 and /api/456 were treated as different endpoints in diffs but the same
151
+ // endpoint in frequency analysis — inconsistent findings across modules.
152
+ // Object.fromEntries last-write-wins — if two requests normalize to the same key
153
+ // (e.g. /api/123 and /api/456 → /api/{id}), the first request object is silently dropped.
154
+ // Use first-entry-wins so the earlier request (usually the most representative) is kept.
155
+ function buildRequestMap(reqs) {
156
+ const map = {};
157
+ for (const r of (reqs ?? [])) {
158
+ const key = normalizeApiUrl(r.url ?? '');
159
+ if (!Object.prototype.hasOwnProperty.call(map, key)) map[key] = r;
160
+ }
161
+ return map;
162
+ }
163
+ const mapA = buildRequestMap(reqsA);
164
+ const mapB = buildRequestMap(reqsB);
165
+
166
+ const urlsA = new Set(Object.keys(mapA));
167
+ const urlsB = new Set(Object.keys(mapB));
168
+
169
+ const added = [...urlsB].filter(u => !urlsA.has(u)).map(u => mapB[u]);
170
+ const removed = [...urlsA].filter(u => !urlsB.has(u)).map(u => mapA[u]);
171
+ const changed = [...urlsA]
172
+ .filter(u => urlsB.has(u) && mapA[u].status !== mapB[u].status)
173
+ .map(u => ({ url: u, statusA: mapA[u].status, statusB: mapB[u].status }));
174
+
175
+ return { added, removed, changed };
176
+ }
177
+
178
+ /**
179
+ * Diff console messages: find errors in B (staging) that are not in A (dev).
180
+ * These are new regressions introduced in staging.
181
+ *
182
+ * @param {object[]} msgsA
183
+ * @param {object[]} msgsB
184
+ * @returns {object[]} New errors in B not present in A
185
+ */
186
+ export function diffConsoleMessages(msgsA, msgsB) {
187
+ const textSetA = new Set((msgsA ?? []).filter(m => m.level === 'error').map(m => m.text ?? m.message));
188
+ return (msgsB ?? []).filter(m => m.level === 'error' && !textSetA.has(m.text ?? m.message));
189
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Argus v3 Phase B4 — Flakiness detection
3
+ *
4
+ * Each route is crawled twice. Findings present in both runs are "confirmed"
5
+ * (severity unchanged). Findings that appear in only one run are "flaky" —
6
+ * severity is downgraded to 'info' and flaky: true is set so the Slack digest
7
+ * can label them visually. This filters out timing-sensitive false positives
8
+ * (race conditions, GC-dependent heap readings, one-off network blips).
9
+ *
10
+ * Finding key: same scheme as baseline-manager — type::message[:100]::status
11
+ */
12
+
13
+ // Exported so baseline-manager.js uses the same normalization (trim + collapse
14
+ // whitespace). Previously each module had its own private findingKey() with different
15
+ // whitespace handling, so the same finding could be new in baselines but confirmed in
16
+ // flakiness, producing inconsistent cross-module annotation.
17
+ export function findingKey(finding) {
18
+ // Normalize whitespace before truncating — same finding with minor formatting
19
+ // differences (extra spaces, newlines) between run1 and run2 would produce different
20
+ // keys and be incorrectly classified as flaky.
21
+ const msg = (finding.message ?? '').trim().replace(/\s+/g, ' ').slice(0, 100);
22
+ const status = finding.status != null ? '::' + finding.status : '';
23
+ return `${finding.type}::${msg}${status}`;
24
+ }
25
+
26
+ /**
27
+ * Merge two crawl results for the same route.
28
+ *
29
+ * - Findings present in both runs → confirmed (flaky: false, original severity kept)
30
+ * - Findings present in only one run → flaky (flaky: true, severity → 'info')
31
+ *
32
+ * The returned result uses run2's screenshot and responsiveScreenshots (more recent).
33
+ *
34
+ * @param {object} run1 - First crawl result from crawlRoute + analysis engines
35
+ * @param {object} run2 - Second crawl result for the same route
36
+ * @returns {object} Merged result with confirmed + flaky findings combined
37
+ */
38
+ export function mergeRunResults(run1, run2) {
39
+ // Validate inputs — accessing .errors on undefined throws a cryptic TypeError.
40
+ if (!run1 || !Array.isArray(run1.errors)) {
41
+ throw new TypeError('mergeRunResults: run1.errors must be an array');
42
+ }
43
+ if (!run2 || !Array.isArray(run2.errors)) {
44
+ throw new TypeError('mergeRunResults: run2.errors must be an array');
45
+ }
46
+
47
+ const keys1 = new Map(run1.errors.map(f => [findingKey(f), f]));
48
+ const keys2 = new Set(run2.errors.map(findingKey));
49
+
50
+ const confirmed = [];
51
+ const flaky = [];
52
+
53
+ for (const f of run1.errors) {
54
+ if (keys2.has(findingKey(f))) {
55
+ confirmed.push({ ...f, flaky: false });
56
+ } else {
57
+ flaky.push({ ...f, severity: 'info', flaky: true });
58
+ }
59
+ }
60
+
61
+ // Build O(1) index map to avoid O(n²) findIndex scan on large confirmed arrays
62
+ const confirmedIndexByKey = new Map(confirmed.map((c, i) => [findingKey(c), i]));
63
+
64
+ for (const f of run2.errors) {
65
+ const key = findingKey(f);
66
+ if (keys1.has(key)) {
67
+ // Prefer run2's version of confirmed findings — run2 is more recent and
68
+ // may have updated metadata. Replace run1's copy in the confirmed array.
69
+ const idx = confirmedIndexByKey.get(key) ?? -1;
70
+ if (idx !== -1) confirmed[idx] = { ...f, flaky: false };
71
+ } else {
72
+ flaky.push({ ...f, severity: 'info', flaky: true });
73
+ }
74
+ }
75
+
76
+ return {
77
+ ...run2,
78
+ errors: [...confirmed, ...flaky],
79
+ responsiveScreenshots: run2.responsiveScreenshots ?? run1.responsiveScreenshots,
80
+ screenshot: run2.screenshot ?? run1.screenshot,
81
+ };
82
+ }