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.
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +879 -0
- package/package.json +69 -0
- package/src/adapters/browser.js +82 -0
- package/src/argus.js +8 -0
- package/src/batch-runner.js +8 -0
- package/src/cli/init.js +314 -0
- package/src/config/schema.js +108 -0
- package/src/config/targets.js +309 -0
- package/src/domain/finding.js +25 -0
- package/src/mcp-server.js +156 -0
- package/src/orchestration/crawl-and-report.js +16 -0
- package/src/orchestration/dispatcher.js +263 -0
- package/src/orchestration/env-comparison.js +498 -0
- package/src/orchestration/orchestrator.js +1128 -0
- package/src/orchestration/report-processor.js +134 -0
- package/src/orchestration/slack-notifier.js +337 -0
- package/src/orchestration/watch-mode.js +316 -0
- package/src/registry.js +18 -0
- package/src/server/index.js +94 -0
- package/src/server/interaction-handler.js +126 -0
- package/src/server/slash-command-handler.js +185 -0
- package/src/utils/api-frequency.js +128 -0
- package/src/utils/baseline-manager.js +255 -0
- package/src/utils/codebase-analyzer.js +299 -0
- package/src/utils/content-analyzer.js +155 -0
- package/src/utils/contract-validator.js +178 -0
- package/src/utils/css-analyzer.js +407 -0
- package/src/utils/diff.js +189 -0
- package/src/utils/flakiness-detector.js +82 -0
- package/src/utils/flow-runner.js +572 -0
- package/src/utils/github-reporter.js +310 -0
- package/src/utils/hover-analyzer.js +214 -0
- package/src/utils/html-reporter.js +301 -0
- package/src/utils/issues-analyzer.js +171 -0
- package/src/utils/keyboard-analyzer.js +141 -0
- package/src/utils/lighthouse-checker.js +120 -0
- package/src/utils/logger.js +39 -0
- package/src/utils/login-orchestrator.js +99 -0
- package/src/utils/mcp-client.js +264 -0
- package/src/utils/mcp-parsers.js +57 -0
- package/src/utils/memory-analyzer.js +270 -0
- package/src/utils/network-timing-analyzer.js +76 -0
- package/src/utils/parallel-crawler.js +28 -0
- package/src/utils/responsive-analyzer.js +253 -0
- package/src/utils/retry.js +36 -0
- package/src/utils/route-discoverer.js +306 -0
- package/src/utils/security-analyzer.js +302 -0
- package/src/utils/seo-analyzer.js +164 -0
- package/src/utils/session-manager.js +12 -0
- package/src/utils/session-persistence.js +214 -0
- package/src/utils/severity-overrides.js +91 -0
- package/src/utils/slack-guard.js +18 -0
- package/src/utils/slug.js +8 -0
- package/src/utils/snapshot-analyzer.js +330 -0
- 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
|
+
}
|