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,134 @@
1
+ /**
2
+ * Argus Report Processor (v9.1.3)
3
+ *
4
+ * Post-crawl pipeline: dedup → severity overrides → summary rebuild →
5
+ * baseline load/apply/save → trend append → JSON write.
6
+ *
7
+ * Extracted from crawl-and-report.js god object.
8
+ */
9
+
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+
13
+ import { childLogger } from '../utils/logger.js';
14
+ import { applyOverrides } from '../utils/severity-overrides.js';
15
+ import { loadBaseline, saveBaseline, applyBaseline, appendTrend, getCurrentBranch } from '../utils/baseline-manager.js';
16
+
17
+ const logger = childLogger('report-processor');
18
+
19
+ // ── Deduplication ─────────────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Deduplicate findings: same type + message (first 200 chars) + url = one entry.
23
+ * @param {object[]} findings
24
+ * @returns {object[]}
25
+ */
26
+ export function deduplicateFindings(findings) {
27
+ const seen = new Set();
28
+ return findings.filter(e => {
29
+ if (!e || typeof e !== 'object') return false;
30
+ const key = `${e.type ?? 'unknown'}::${(e.message ?? '').slice(0, 200)}::${e.url ?? ''}`;
31
+ if (seen.has(key)) return false;
32
+ seen.add(key);
33
+ return true;
34
+ });
35
+ }
36
+
37
+ // ── Summary Rebuild ───────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Recount report.summary from all findings in routes, flows, and codebase.
41
+ * Called after applyOverrides() which may suppress or reclassify findings.
42
+ * @param {object} report - Mutable report object
43
+ */
44
+ export function rebuildSummary(report) {
45
+ report.summary = { total: 0, critical: 0, warning: 0, info: 0 };
46
+
47
+ function countFinding(finding) {
48
+ report.summary.total++;
49
+ if (finding.severity === 'critical' || finding.severity === 'warning' || finding.severity === 'info') {
50
+ report.summary[finding.severity]++;
51
+ } else if (finding.severity) {
52
+ logger.warn(`[ARGUS] Unknown severity "${finding.severity}" on finding type "${finding.type ?? 'unknown'}"`);
53
+ }
54
+ }
55
+
56
+ for (const routeResult of report.routes) {
57
+ for (const err of routeResult.errors) countFinding(err);
58
+ }
59
+ for (const flowResult of (report.flows ?? [])) {
60
+ for (const finding of (flowResult.findings ?? [])) countFinding(finding);
61
+ }
62
+ for (const finding of (report.codebase ?? [])) {
63
+ countFinding(finding);
64
+ }
65
+ }
66
+
67
+ // ── Main Post-Crawl Processor ─────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Apply overrides → rebuild summary → baseline load/apply → write JSON → save baseline + trend.
71
+ *
72
+ * @param {object} report - Mutable report object (modified in place)
73
+ * @param {object} options
74
+ * @param {string} options.outputDir - Directory to write error-report-*.json
75
+ * @param {Array} options.severityOverrides - From targets.js
76
+ * @returns {{ reportPath: string, diff: object }}
77
+ */
78
+ export async function processReport(report, { outputDir, severityOverrides }) {
79
+ // 1. Apply severity overrides (suppress or reclassify findings)
80
+ const { overriddenCount, suppressedCount } = applyOverrides(report, severityOverrides);
81
+ if (overriddenCount > 0 || suppressedCount > 0) {
82
+ logger.info(`[ARGUS] Severity overrides: ${overriddenCount} remapped, ${suppressedCount} suppressed`);
83
+ }
84
+
85
+ // 2. Rebuild summary after overrides
86
+ rebuildSummary(report);
87
+
88
+ // 3. Load baseline + compute diff
89
+ const branch = getCurrentBranch();
90
+ const safeBranch = branch.replace(/[/\\]/g, '__').replace(/[^a-zA-Z0-9._-]/g, '_');
91
+ const baselinePath = path.join(outputDir, 'baselines', `${safeBranch}.json`);
92
+ const trendsPath = path.join(outputDir, 'baselines', `${safeBranch}-trends.json`);
93
+ logger.info(`[ARGUS] Branch: "${branch}" → baseline: ${baselinePath}`);
94
+
95
+ const baseline = loadBaseline(baselinePath);
96
+ const diff = applyBaseline(report, baseline);
97
+
98
+ if (!diff.isFirstRun) {
99
+ logger.info(`[ARGUS] Baseline diff: ${diff.newCount} new finding(s), ${diff.resolvedCount} resolved`);
100
+ if ((diff.flowNewCount ?? 0) > 0 || (diff.flowResolvedCount ?? 0) > 0) {
101
+ logger.info(`[ARGUS] Flow diff: ${diff.flowNewCount} new flow finding(s), ${diff.flowResolvedCount} resolved`);
102
+ }
103
+ } else {
104
+ logger.info('[ARGUS] First run — no baseline to compare; all findings treated as new');
105
+ }
106
+
107
+ // 4. Write JSON report
108
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
109
+ const reportPath = path.join(outputDir, `error-report-${timestamp}.json`);
110
+ try {
111
+ fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
112
+ } catch (err) {
113
+ logger.error(`[ARGUS] Failed to write report JSON: ${err.message}`);
114
+ throw err;
115
+ }
116
+ logger.info(`[ARGUS] Report written: ${reportPath}`);
117
+
118
+ // 5. Persist baseline + append trend entry
119
+ saveBaseline(baselinePath, report);
120
+ appendTrend(trendsPath, {
121
+ runAt: report.generatedAt,
122
+ baseUrl: report.baseUrl,
123
+ summary: report.summary,
124
+ newFindings: diff.newCount,
125
+ resolvedFindings: diff.resolvedCount,
126
+ routeCount: report.routes.length,
127
+ flowCount: report.flows?.length ?? 0,
128
+ flowNewFindings: diff.flowNewCount ?? 0,
129
+ flowResolvedFindings: diff.flowResolvedCount ?? 0,
130
+ });
131
+ logger.info(`[ARGUS] Baseline saved → ${baselinePath} (branch: "${branch}")`);
132
+
133
+ return { reportPath, diff };
134
+ }
@@ -0,0 +1,337 @@
1
+ /**
2
+ * ARGUS Phase 4: Slack Notification Dispatcher
3
+ *
4
+ * Posts rich Block Kit bug reports to Slack with:
5
+ * - Severity-based channel routing
6
+ * - Screenshot uploads via files.getUploadURLExternal + files.completeUploadExternal
7
+ * - Interactive action buttons (View Page, Acknowledge, Retest)
8
+ * - Threaded follow-up support
9
+ *
10
+ * Requires environment variables:
11
+ * SLACK_BOT_TOKEN — xoxb-... token
12
+ * SLACK_CHANNEL_CRITICAL — channel ID for critical bugs
13
+ * SLACK_CHANNEL_WARNINGS — channel ID for warnings
14
+ * SLACK_CHANNEL_DIGEST — channel ID for daily digest
15
+ */
16
+
17
+ import { WebClient } from '@slack/web-api';
18
+ import fs from 'fs';
19
+ import path from 'path';
20
+ import 'dotenv/config';
21
+ import { childLogger } from '../utils/logger.js';
22
+
23
+ const logger = childLogger('slack-notifier');
24
+
25
+ const slack = new WebClient(process.env.SLACK_BOT_TOKEN);
26
+
27
+ const CHANNELS = {
28
+ critical: process.env.SLACK_CHANNEL_CRITICAL,
29
+ warning: process.env.SLACK_CHANNEL_WARNINGS,
30
+ info: process.env.SLACK_CHANNEL_DIGEST,
31
+ };
32
+
33
+ const SEVERITY_EMOJI = {
34
+ critical: '🔴',
35
+ warning: '🟡',
36
+ info: '🔵',
37
+ };
38
+
39
+ // ── Rate-limit-aware postMessage wrapper ─────────────────────────────────────
40
+
41
+ const SLACK_RATE_LIMIT_RETRIES = 3;
42
+
43
+ async function slackPostWithBackoff(args) {
44
+ for (let attempt = 0; attempt < SLACK_RATE_LIMIT_RETRIES; attempt++) {
45
+ try {
46
+ return await slack.chat.postMessage(args);
47
+ } catch (err) {
48
+ const isRateLimit = err.code === 'slack_webapi_rate_limited'
49
+ || err.message?.toLowerCase().includes('ratelimited');
50
+ if (!isRateLimit || attempt === SLACK_RATE_LIMIT_RETRIES - 1) throw err;
51
+ const retryAfterMs = (err.retryAfter ?? 1) * 1000;
52
+ logger.warn(`[ARGUS] Slack rate limited — retrying in ${retryAfterMs}ms (attempt ${attempt + 1})`);
53
+ await new Promise(r => setTimeout(r, retryAfterMs));
54
+ }
55
+ }
56
+ }
57
+
58
+ // ── File Upload (Current Slack API) ───────────────────────────────────────────
59
+
60
+ /**
61
+ * Upload a file to Slack using the current (non-deprecated) upload API.
62
+ * Steps: getUploadURLExternal → POST binary → completeUploadExternal
63
+ *
64
+ * @param {string} filePath - Absolute path to the file
65
+ * @param {string} channelId - Channel to share the file into
66
+ * @param {string} filename - Display filename in Slack
67
+ * @returns {string|null} Slack file ID if successful, null on failure
68
+ */
69
+ async function uploadFileToSlack(filePath, channelId, filename) {
70
+ if (!filePath || !fs.existsSync(filePath)) return null;
71
+
72
+ const fileBuffer = fs.readFileSync(filePath);
73
+ const fileSize = fileBuffer.length;
74
+
75
+ // Step 1: Get a pre-signed upload URL from Slack
76
+ let uploadUrl, fileId;
77
+ try {
78
+ const urlResponse = await slack.files.getUploadURLExternal({
79
+ filename,
80
+ length: fileSize,
81
+ });
82
+ uploadUrl = urlResponse.upload_url;
83
+ fileId = urlResponse.file_id;
84
+ } catch (err) {
85
+ logger.error('[ARGUS] Failed to get Slack upload URL:', err.message);
86
+ return null;
87
+ }
88
+
89
+ // Step 2: PUT the binary data to the pre-signed URL
90
+ // Slack requires PUT here — POST silently fails and produces a broken/missing file
91
+ try {
92
+ const response = await fetch(uploadUrl, {
93
+ method: 'PUT',
94
+ headers: { 'Content-Type': 'application/octet-stream' },
95
+ body: fileBuffer,
96
+ signal: AbortSignal.timeout(30000),
97
+ });
98
+ if (!response.ok) {
99
+ logger.error('[ARGUS] Slack upload PUT failed:', response.status, response.statusText);
100
+ return null;
101
+ }
102
+ } catch (err) {
103
+ logger.error('[ARGUS] Slack upload fetch error:', err.message);
104
+ return null;
105
+ }
106
+
107
+ // Step 3: Complete the upload and share to channel
108
+ try {
109
+ await slack.files.completeUploadExternal({
110
+ files: [{ id: fileId, title: filename }],
111
+ channel_id: channelId,
112
+ });
113
+ } catch (err) {
114
+ logger.error('[ARGUS] Failed to complete Slack upload:', err.message);
115
+ return null;
116
+ }
117
+
118
+ return fileId;
119
+ }
120
+
121
+ // ── Block Kit Message Builder ─────────────────────────────────────────────────
122
+
123
+ /**
124
+ * Build a Slack Block Kit message payload for a bug report.
125
+ *
126
+ * @param {object} opts
127
+ * @param {string} opts.severity - 'critical' | 'warning' | 'info'
128
+ * @param {string} opts.title - Short title
129
+ * @param {string} opts.description - Longer description / AI-generated summary
130
+ * @param {string} opts.url - Affected URL
131
+ * @param {string|null} opts.fileId - Slack file ID of uploaded screenshot (or null)
132
+ * @param {object} opts.details - Raw detail object (shown as JSON in fallback)
133
+ * @returns {object[]} Slack blocks array
134
+ */
135
+ function buildBugReportBlocks({ severity, title, description, url, fileId, details }) {
136
+ const emoji = SEVERITY_EMOJI[severity] ?? '⚪';
137
+ const severityLabel = severity.charAt(0).toUpperCase() + severity.slice(1);
138
+
139
+ const blocks = [
140
+ // Header
141
+ {
142
+ type: 'header',
143
+ text: {
144
+ type: 'plain_text',
145
+ text: `${emoji} [${severityLabel}] ${title}`,
146
+ emoji: true,
147
+ },
148
+ },
149
+ // Description
150
+ {
151
+ type: 'section',
152
+ text: {
153
+ type: 'mrkdwn',
154
+ text: description.length > 3000 ? description.slice(0, 2997) + '...' : description,
155
+ },
156
+ },
157
+ // URL + timestamp
158
+ {
159
+ type: 'context',
160
+ elements: [
161
+ {
162
+ type: 'mrkdwn',
163
+ text: `*URL:* ${url} | *Detected:* <!date^${Math.floor(Date.now() / 1000)}^{date_short_pretty} at {time}|${new Date().toISOString()}>`,
164
+ },
165
+ ],
166
+ },
167
+ // Divider
168
+ { type: 'divider' },
169
+ ];
170
+
171
+ // Screenshot block — uses slack_file reference so no external hosting needed
172
+ if (fileId) {
173
+ blocks.push({
174
+ type: 'image',
175
+ slack_file: { id: fileId },
176
+ alt_text: `Screenshot for: ${title}`,
177
+ });
178
+ blocks.push({ type: 'divider' });
179
+ }
180
+
181
+ // Action buttons
182
+ blocks.push({
183
+ type: 'actions',
184
+ elements: [
185
+ {
186
+ type: 'button',
187
+ text: { type: 'plain_text', text: 'View Page', emoji: true },
188
+ url,
189
+ action_id: 'view_page',
190
+ style: severity === 'critical' ? 'danger' : 'primary',
191
+ },
192
+ {
193
+ type: 'button',
194
+ text: { type: 'plain_text', text: 'Acknowledge', emoji: true },
195
+ action_id: 'acknowledge',
196
+ value: JSON.stringify({ title: title.slice(0, 100), url: url.slice(0, 200), severity }),
197
+ },
198
+ {
199
+ type: 'button',
200
+ text: { type: 'plain_text', text: 'Retest', emoji: true },
201
+ action_id: 'retest',
202
+ value: JSON.stringify({ url: url.slice(0, 200), severity }),
203
+ },
204
+ ],
205
+ });
206
+
207
+ return blocks;
208
+ }
209
+
210
+ // ── Main Dispatcher ───────────────────────────────────────────────────────────
211
+
212
+ /**
213
+ * Post a bug report to the appropriate Slack channel.
214
+ *
215
+ * @param {object} opts
216
+ * @param {'critical'|'warning'|'info'} opts.severity
217
+ * @param {string} opts.title
218
+ * @param {string} opts.description
219
+ * @param {string} opts.url - Affected URL
220
+ * @param {string|null} opts.screenshotPath - Local path to screenshot file
221
+ * @param {object} opts.details - Additional raw detail data
222
+ * @param {string|null} opts.threadTs - If set, post as thread reply (from follow-up retest)
223
+ * @returns {{ ts: string, channel: string }|null} Message timestamp + channel, or null on failure
224
+ */
225
+ export async function postBugReport({ severity, title, description, url, screenshotPath, details, threadTs = null }) {
226
+ const channelId = CHANNELS[severity];
227
+
228
+ if (!channelId) {
229
+ logger.warn(`[ARGUS] No Slack channel configured for severity: ${severity}. Set SLACK_CHANNEL_${severity.toUpperCase()} in .env`);
230
+ return null;
231
+ }
232
+
233
+ if (!process.env.SLACK_BOT_TOKEN) {
234
+ logger.warn('[ARGUS] SLACK_BOT_TOKEN not set — skipping Slack notification');
235
+ logger.info(`[ARGUS] Would post: [${severity}] ${title} → ${url}`);
236
+ return null;
237
+ }
238
+
239
+ // Upload screenshot if provided
240
+ const filename = screenshotPath ? path.basename(screenshotPath) : null;
241
+ const fileId = screenshotPath
242
+ ? await uploadFileToSlack(screenshotPath, channelId, filename)
243
+ : null;
244
+
245
+ // Build Block Kit blocks
246
+ const blocks = buildBugReportBlocks({ severity, title, description, url, fileId, details });
247
+
248
+ // Post message
249
+ try {
250
+ const result = await slackPostWithBackoff({
251
+ channel: channelId,
252
+ text: `[${severity.toUpperCase()}] ${title} — ${url}`, // fallback text
253
+ blocks,
254
+ thread_ts: threadTs ?? undefined,
255
+ });
256
+
257
+ logger.info(`[ARGUS] Slack message posted: ${result.ts} → channel ${channelId}`);
258
+ return { ts: result.ts, channel: channelId };
259
+ } catch (err) {
260
+ logger.error('[ARGUS] Failed to post Slack message:', err.message);
261
+ return null;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Post a retest follow-up as a thread reply to the original bug message.
267
+ *
268
+ * @param {string} originalTs - Timestamp of the original bug message
269
+ * @param {string} channelId - Channel of the original message
270
+ * @param {'pass'|'fail'} outcome
271
+ * @param {string} details - Human-readable retest result summary
272
+ */
273
+ export async function postRetestResult(originalTs, channelId, outcome, details) {
274
+ if (!process.env.SLACK_BOT_TOKEN) return;
275
+
276
+ const emoji = outcome === 'pass' ? '✅' : '❌';
277
+ const text = `${emoji} *Retest ${outcome.toUpperCase()}*\n${details}`;
278
+
279
+ try {
280
+ await slackPostWithBackoff({
281
+ channel: channelId,
282
+ text,
283
+ thread_ts: originalTs,
284
+ });
285
+ } catch (err) {
286
+ logger.error('[ARGUS] Failed to post retest reply:', err.message);
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Update an existing Slack message (e.g., to mark a bug as acknowledged).
292
+ *
293
+ * @param {string} ts - Message timestamp
294
+ * @param {string} channelId - Channel ID
295
+ * @param {string} acknowledgingUser - Display name of acknowledging user
296
+ */
297
+ export async function acknowledgeMessage(ts, channelId, acknowledgingUser) {
298
+ if (!process.env.SLACK_BOT_TOKEN) return;
299
+
300
+ try {
301
+ // Append an acknowledged context block by updating the message
302
+ const existing = await slack.conversations.history({
303
+ channel: channelId,
304
+ latest: ts,
305
+ inclusive: true,
306
+ limit: 1,
307
+ });
308
+
309
+ const msg = existing.messages?.[0];
310
+ if (!msg) {
311
+ logger.warn('[ARGUS] acknowledgeMessage: original message not found for ts:', ts);
312
+ return;
313
+ }
314
+
315
+ const updatedBlocks = [
316
+ ...(msg.blocks ?? []),
317
+ {
318
+ type: 'context',
319
+ elements: [
320
+ {
321
+ type: 'mrkdwn',
322
+ text: `✅ Acknowledged by *${acknowledgingUser}* at <!date^${Math.floor(Date.now() / 1000)}^{time}|now>`,
323
+ },
324
+ ],
325
+ },
326
+ ];
327
+
328
+ await slack.chat.update({
329
+ channel: channelId,
330
+ ts,
331
+ blocks: updatedBlocks,
332
+ text: msg.text + ' [ACKNOWLEDGED]',
333
+ });
334
+ } catch (err) {
335
+ logger.error('[ARGUS] Failed to acknowledge message:', err.message);
336
+ }
337
+ }