argusqa-os 9.5.9 → 9.6.1
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/README.md +384 -1112
- package/glama.json +5 -1
- package/package.json +3 -3
- package/src/cli/init.js +8 -4
- package/src/cli/pr-validate.js +309 -0
- package/src/mcp-server.js +64 -2
- package/src/orchestration/dispatcher.js +1 -1
- package/src/orchestration/env-comparison.js +0 -1
- package/src/orchestration/orchestrator.js +5 -5
- package/src/orchestration/report-processor.js +1 -1
- package/src/orchestration/slack-notifier.js +1 -1
- package/src/orchestration/watch-mode.js +0 -4
- package/src/server/index.js +24 -2
- package/src/server/slash-command-handler.js +0 -1
- package/src/utils/a11y-deep-analyzer.js +0 -2
- package/src/utils/baseline-manager.js +3 -3
- package/src/utils/codebase-analyzer.js +3 -3
- package/src/utils/content-analyzer.js +1 -1
- package/src/utils/flow-runner.js +4 -4
- package/src/utils/github-reporter.js +1 -2
- package/src/utils/har-recorder.js +19 -14
- package/src/utils/pr-diff-analyzer.js +121 -0
- package/src/utils/route-discoverer.js +1 -1
- package/src/utils/security-analyzer.js +1 -1
- package/src/utils/seo-analyzer.js +1 -1
- package/src/utils/session-persistence.js +1 -1
- package/src/utils/visual-diff-analyzer.js +9 -4
package/glama.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://glama.ai/mcp/schemas/server.json",
|
|
3
3
|
"name": "argus",
|
|
4
|
-
"description": "AI-powered QA harness that audits web apps via Chrome DevTools Protocol. Catches JS errors, network failures, a11y violations, SEO issues, security headers, CSS regressions, and more — directly from Claude conversations.
|
|
4
|
+
"description": "AI-powered QA harness that audits web apps via Chrome DevTools Protocol. Catches JS errors, network failures, a11y violations, SEO issues, security headers, CSS regressions, and more — directly from Claude conversations. 9 MCP tools: argus_audit (fast 8-analyzer pass), argus_audit_full (Lighthouse + memory + responsive), argus_compare (dev vs staging diff), argus_last_report (retrieve last JSON report), argus_watch_snapshot (live tab snapshot without navigating), argus_get_context (LLM-optimized context + fix loop with snapshot_id diff), argus_design_audit (Figma design fidelity — 13 finding types), argus_visual_diff (screenshot baseline comparison, updateBaseline flag), argus_pr_validate (PR diff → affected routes → targeted audit → blocked flag). 137 test blocks, 634 hard assertions, 63 detection categories.",
|
|
5
5
|
"maintainers": ["ironclawdevs27"],
|
|
6
6
|
"tools": [
|
|
7
7
|
{
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
{
|
|
36
36
|
"name": "argus_visual_diff",
|
|
37
37
|
"description": "Screenshot baseline comparison for a URL. First call saves a baseline PNG to reports/baselines/screenshots/. Subsequent calls diff the current screenshot against the baseline using pixelmatch and return visual_regression (warning ≥0.1% / critical ≥5% pixels changed) + visual_diff_summary (always). Pass updateBaseline: true to force-refresh the stored baseline after intentional UI changes."
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "argus_pr_validate",
|
|
41
|
+
"description": "Targeted QA audit driven by a GitHub pull request diff. Fetches the PR's changed files, maps them to affected routes in your target config using path-slug heuristics (infrastructure changes trigger a full audit), then audits only those routes. Returns { findings, affectedRoutes, changedFiles, perRoute, summary, blocked, blockOn }. Use in CI to gate merges — blocked:true when findings meet the blockOn threshold (none/warning/critical, default: critical). Requires Chrome on --remote-debugging-port=9222. GITHUB_TOKEN env var recommended for private repos."
|
|
38
42
|
}
|
|
39
43
|
]
|
|
40
44
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "argusqa-os",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.6.1",
|
|
4
4
|
"mcpName": "io.github.ironclawdevs27/argus",
|
|
5
5
|
"description": "Argus — AI-powered automated dev-testing platform using Chrome DevTools MCP and Claude Code",
|
|
6
6
|
"keywords": [
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"@opentelemetry/sdk-node": "^0.218.0",
|
|
57
57
|
"@slack/web-api": "^7.16.0",
|
|
58
58
|
"axe-core": "^4.12.0",
|
|
59
|
-
"dotenv": "^
|
|
59
|
+
"dotenv": "^17.4.2",
|
|
60
60
|
"express": "^5.2.1",
|
|
61
61
|
"pino": "^10.3.1",
|
|
62
62
|
"pino-pretty": "^13.1.3",
|
|
@@ -65,6 +65,6 @@
|
|
|
65
65
|
"zod": "^4.4.3"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
|
-
"vitest": "^4.1.
|
|
68
|
+
"vitest": "^4.1.8"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/cli/init.js
CHANGED
|
@@ -287,11 +287,15 @@ async function main() {
|
|
|
287
287
|
githubToken, githubRepo, sourceDir, envFile: envFilePath });
|
|
288
288
|
const targetsContent = generateTargetsJs(finalRoutes, { framework, sourceDir, envFile: envFilePath });
|
|
289
289
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
} else {
|
|
293
|
-
fs.writeFileSync('.env', envContent, 'utf8');
|
|
290
|
+
try {
|
|
291
|
+
fs.writeFileSync('.env', envContent, { flag: 'wx', encoding: 'utf8' });
|
|
294
292
|
tick('Wrote .env');
|
|
293
|
+
} catch (err) {
|
|
294
|
+
if (err.code === 'EEXIST') {
|
|
295
|
+
logger.warn(' ⚠ .env already exists — skipping write to preserve existing credentials. Delete it manually to regenerate.');
|
|
296
|
+
} else {
|
|
297
|
+
throw err;
|
|
298
|
+
}
|
|
295
299
|
}
|
|
296
300
|
|
|
297
301
|
const targetsPath = path.join('src', 'config', 'targets.js');
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Argus PR Validator — headless CI entry point for GitHub Actions.
|
|
4
|
+
*
|
|
5
|
+
* Environment variables (set by action.yml):
|
|
6
|
+
* ARGUS_PR_URL Full GitHub PR URL, e.g. https://github.com/owner/repo/pull/42 (required)
|
|
7
|
+
* TARGET_DEV_URL Base URL of the running application, e.g. https://staging.example.com (required)
|
|
8
|
+
* ARGUS_BLOCK_ON critical | warning | none (default: critical)
|
|
9
|
+
* GITHUB_TOKEN GitHub PAT or workflow GITHUB_TOKEN — optional for public repos
|
|
10
|
+
* ARGUS_ROUTES_FILE Path to a JSON routes array [{path,name}] — optional, see loadRoutes()
|
|
11
|
+
* GITHUB_OUTPUT Set by GitHub runner — path for step output key=value pairs
|
|
12
|
+
* GITHUB_STEP_SUMMARY Set by GitHub runner — path for markdown step summary
|
|
13
|
+
*
|
|
14
|
+
* Exit codes:
|
|
15
|
+
* 0 — audit passed (blocked=false) or no routes to audit
|
|
16
|
+
* 1 — audit blocked (findings at or above ARGUS_BLOCK_ON threshold) OR startup error
|
|
17
|
+
*
|
|
18
|
+
* Exports (used by test harness — no Chrome required):
|
|
19
|
+
* buildStepSummary(opts) → markdown string
|
|
20
|
+
* writeGithubOutputs(opts) → void (writes to GITHUB_OUTPUT file)
|
|
21
|
+
* writeStepSummary(markdown) → void (writes to GITHUB_STEP_SUMMARY file)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'fs';
|
|
25
|
+
import path from 'path';
|
|
26
|
+
import { fileURLToPath } from 'url';
|
|
27
|
+
import { createMcpClient } from '../utils/mcp-client.js';
|
|
28
|
+
import { crawlRouteCheap } from '../orchestration/crawl-and-report.js';
|
|
29
|
+
import { parsePrUrl, fetchPrFiles, mapFilesToRoutes } from '../utils/pr-diff-analyzer.js';
|
|
30
|
+
|
|
31
|
+
// ── Exported helpers (testable without Chrome) ────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build a GitHub-flavoured markdown step summary.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} opts
|
|
37
|
+
* @param {boolean} opts.blocked
|
|
38
|
+
* @param {{ critical: number, warning: number, info: number }} opts.summary
|
|
39
|
+
* @param {Array<{ path: string }>} opts.affectedRoutes
|
|
40
|
+
* @param {Array<{ route: string, critical: number, warning: number, info: number, error?: string }>} opts.perRoute
|
|
41
|
+
* @param {Array<object>} opts.findings
|
|
42
|
+
* @param {string[]} opts.changedFiles
|
|
43
|
+
* @param {string} opts.blockOn critical | warning | none
|
|
44
|
+
* @param {string} [opts.error] top-level error message (startup / fetch failure)
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function buildStepSummary({ blocked, summary, affectedRoutes, perRoute, findings, changedFiles, blockOn, error }) {
|
|
48
|
+
const icon = blocked ? '🔴' : summary.critical + summary.warning === 0 ? '✅' : '⚠️';
|
|
49
|
+
const status = blocked ? 'BLOCKED — merge prevented' : 'PASSED';
|
|
50
|
+
|
|
51
|
+
let md = `## ${icon} Argus PR Validator — ${status}\n\n`;
|
|
52
|
+
|
|
53
|
+
if (error) {
|
|
54
|
+
md += `> **Error:** ${String(error).replace(/`/g, "'")}\n\n`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
md += `| Metric | Value |\n|--------|-------|\n`;
|
|
58
|
+
md += `| Block threshold | \`${blockOn}\` |\n`;
|
|
59
|
+
md += `| Critical findings | **${summary.critical}** |\n`;
|
|
60
|
+
md += `| Warning findings | ${summary.warning} |\n`;
|
|
61
|
+
md += `| Info findings | ${summary.info} |\n`;
|
|
62
|
+
md += `| Routes audited | ${affectedRoutes.length} |\n`;
|
|
63
|
+
md += `| Files changed | ${changedFiles.length} |\n\n`;
|
|
64
|
+
|
|
65
|
+
if (perRoute.length > 0) {
|
|
66
|
+
md += `### Route Breakdown\n\n`;
|
|
67
|
+
md += `| Route | 🔴 Critical | ⚠️ Warning | ℹ️ Info |\n|-------|------------|-----------|--------|\n`;
|
|
68
|
+
for (const r of perRoute) {
|
|
69
|
+
const errNote = r.error ? ` _(error: ${String(r.error).slice(0, 60)})_` : '';
|
|
70
|
+
md += `| \`${r.route}\` | ${r.critical} | ${r.warning} | ${r.info}${errNote} |\n`;
|
|
71
|
+
}
|
|
72
|
+
md += '\n';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (findings.length > 0) {
|
|
76
|
+
md += `### Findings\n\n`;
|
|
77
|
+
md += `| Severity | Type | Message | URL |\n|----------|------|---------|-----|\n`;
|
|
78
|
+
const shown = findings.slice(0, 50);
|
|
79
|
+
for (const f of shown) {
|
|
80
|
+
const sev = f.severity === 'critical' ? '🔴 critical'
|
|
81
|
+
: f.severity === 'warning' ? '⚠️ warning'
|
|
82
|
+
: 'ℹ️ info';
|
|
83
|
+
const msg = String(f.message ?? '').replace(/\|/g, '\\|').slice(0, 100);
|
|
84
|
+
const url = String(f.url ?? '').replace(/\|/g, '\\|').slice(0, 80);
|
|
85
|
+
md += `| ${sev} | \`${f.type ?? ''}\` | ${msg} | ${url} |\n`;
|
|
86
|
+
}
|
|
87
|
+
if (findings.length > 50) {
|
|
88
|
+
md += `\n_…and ${findings.length - 50} more findings._\n`;
|
|
89
|
+
}
|
|
90
|
+
md += '\n';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
md += `---\n_Powered by [Argus QA](https://argus-qa.com)_\n`;
|
|
94
|
+
return md;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Write step outputs to $GITHUB_OUTPUT (key=value pairs).
|
|
99
|
+
* No-ops when GITHUB_OUTPUT is not set (local / non-Actions environments).
|
|
100
|
+
*/
|
|
101
|
+
export function writeGithubOutputs({ blocked, summary, affectedRoutes }) {
|
|
102
|
+
const outputPath = process.env.GITHUB_OUTPUT;
|
|
103
|
+
if (!outputPath) return;
|
|
104
|
+
const routes = Array.isArray(affectedRoutes)
|
|
105
|
+
? affectedRoutes.map(r => (typeof r === 'string' ? r : r.path)).join(',')
|
|
106
|
+
: '';
|
|
107
|
+
const lines = [
|
|
108
|
+
`blocked=${blocked}`,
|
|
109
|
+
`critical_count=${summary.critical}`,
|
|
110
|
+
`warning_count=${summary.warning}`,
|
|
111
|
+
`affected_routes=${routes}`,
|
|
112
|
+
].join('\n') + '\n';
|
|
113
|
+
fs.appendFileSync(outputPath, lines);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Append markdown to $GITHUB_STEP_SUMMARY.
|
|
118
|
+
* No-ops when GITHUB_STEP_SUMMARY is not set.
|
|
119
|
+
*/
|
|
120
|
+
export function writeStepSummary(markdown) {
|
|
121
|
+
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
|
122
|
+
if (!summaryPath) return;
|
|
123
|
+
fs.appendFileSync(summaryPath, markdown);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Route loader ──────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
async function loadRoutes() {
|
|
129
|
+
// 1. ARGUS_ROUTES_FILE env var
|
|
130
|
+
const routesFile = process.env.ARGUS_ROUTES_FILE;
|
|
131
|
+
if (routesFile) {
|
|
132
|
+
try {
|
|
133
|
+
const raw = JSON.parse(fs.readFileSync(routesFile, 'utf8'));
|
|
134
|
+
if (Array.isArray(raw) && raw.length > 0) {
|
|
135
|
+
console.log(`[argus] Loaded ${raw.length} route(s) from ${routesFile}`);
|
|
136
|
+
return raw;
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.error(`::warning::Could not parse ARGUS_ROUTES_FILE (${routesFile}): ${err.message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 2. argus.routes.json in working directory
|
|
144
|
+
const localFile = path.join(process.cwd(), 'argus.routes.json');
|
|
145
|
+
if (fs.existsSync(localFile)) {
|
|
146
|
+
try {
|
|
147
|
+
const raw = JSON.parse(fs.readFileSync(localFile, 'utf8'));
|
|
148
|
+
if (Array.isArray(raw) && raw.length > 0) {
|
|
149
|
+
console.log(`[argus] Loaded ${raw.length} route(s) from argus.routes.json`);
|
|
150
|
+
return raw;
|
|
151
|
+
}
|
|
152
|
+
} catch (err) {
|
|
153
|
+
console.error(`::warning::Could not parse argus.routes.json: ${err.message}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 3. Default routes from the package's targets.js
|
|
158
|
+
try {
|
|
159
|
+
const { routes } = await import('../config/targets.js');
|
|
160
|
+
if (Array.isArray(routes) && routes.length > 0) {
|
|
161
|
+
return routes;
|
|
162
|
+
}
|
|
163
|
+
} catch { /* targets.js may not export routes in all environments */ }
|
|
164
|
+
|
|
165
|
+
// 4. Final fallback — audit root path only
|
|
166
|
+
console.log('[argus] No routes configured — falling back to root path audit');
|
|
167
|
+
return [{ path: '/', name: 'home' }];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
// Guard: run main() only when this script is executed directly, not when imported.
|
|
173
|
+
const _thisFile = fileURLToPath(import.meta.url);
|
|
174
|
+
if (process.argv[1] === _thisFile) {
|
|
175
|
+
await main();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function main() {
|
|
179
|
+
const prUrl = process.env.ARGUS_PR_URL;
|
|
180
|
+
const targetUrl = process.env.TARGET_DEV_URL ?? 'http://localhost:3000';
|
|
181
|
+
const blockOn = (process.env.ARGUS_BLOCK_ON ?? 'critical').toLowerCase().trim();
|
|
182
|
+
const token = process.env.GITHUB_TOKEN;
|
|
183
|
+
|
|
184
|
+
// Input validation
|
|
185
|
+
if (!prUrl) {
|
|
186
|
+
console.error('::error::ARGUS_PR_URL is not set. Set it to the full GitHub PR URL (e.g. https://github.com/owner/repo/pull/42).');
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
if (!['none', 'warning', 'critical'].includes(blockOn)) {
|
|
190
|
+
console.error(`::error::ARGUS_BLOCK_ON must be none | warning | critical, got: "${blockOn}"`);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let mcp;
|
|
195
|
+
const changedFiles = [];
|
|
196
|
+
const affectedRoutes = [];
|
|
197
|
+
const allFindings = [];
|
|
198
|
+
const perRoute = [];
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
// Step 1: Fetch the PR file list from GitHub
|
|
202
|
+
console.log(`[argus] Fetching PR diff: ${prUrl}`);
|
|
203
|
+
const files = await fetchPrFiles(prUrl, token);
|
|
204
|
+
changedFiles.push(...files);
|
|
205
|
+
console.log(`[argus] ${files.length} changed file(s)`);
|
|
206
|
+
|
|
207
|
+
// Step 2: Map changed files to affected routes
|
|
208
|
+
const routes = await loadRoutes();
|
|
209
|
+
const affected = mapFilesToRoutes(files, routes);
|
|
210
|
+
affectedRoutes.push(...affected);
|
|
211
|
+
|
|
212
|
+
if (affected.length === 0) {
|
|
213
|
+
console.log('[argus] No affected routes resolved — skipping audit');
|
|
214
|
+
const summary = { critical: 0, warning: 0, info: 0 };
|
|
215
|
+
writeGithubOutputs({ blocked: false, summary, affectedRoutes: [] });
|
|
216
|
+
writeStepSummary(buildStepSummary({ blocked: false, summary, affectedRoutes: [], perRoute: [], findings: [], changedFiles: files, blockOn }));
|
|
217
|
+
process.exit(0);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
console.log(`[argus] Auditing ${affected.length} route(s): ${affected.map(r => r.path).join(', ')}`);
|
|
221
|
+
|
|
222
|
+
// Step 3: Connect to Chrome via the chrome-devtools MCP client
|
|
223
|
+
console.log('[argus] Connecting to Chrome on port 9222...');
|
|
224
|
+
mcp = await createMcpClient();
|
|
225
|
+
console.log('[argus] Chrome connected.');
|
|
226
|
+
|
|
227
|
+
const baseOrigin = new URL(targetUrl).origin;
|
|
228
|
+
|
|
229
|
+
// Step 4: Audit each affected route via crawlRouteCheap
|
|
230
|
+
for (const route of affected) {
|
|
231
|
+
const url = new URL(route.path, targetUrl).href;
|
|
232
|
+
console.log(`[argus] → Auditing ${url}`);
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const raw = await crawlRouteCheap(route, baseOrigin, mcp);
|
|
236
|
+
const findings = Array.isArray(raw.errors) ? raw.errors : [];
|
|
237
|
+
allFindings.push(...findings);
|
|
238
|
+
|
|
239
|
+
const critical = findings.filter(f => f.severity === 'critical').length;
|
|
240
|
+
const warning = findings.filter(f => f.severity === 'warning').length;
|
|
241
|
+
const info = findings.filter(f => f.severity === 'info').length;
|
|
242
|
+
perRoute.push({ route: route.path, critical, warning, info });
|
|
243
|
+
|
|
244
|
+
console.log(`[argus] ${url}: ${critical} critical, ${warning} warning, ${info} info`);
|
|
245
|
+
|
|
246
|
+
// Emit inline GitHub Actions annotations for visible CI feedback
|
|
247
|
+
for (const f of findings.filter(g => g.severity === 'critical')) {
|
|
248
|
+
console.log(`::error::${String(f.message ?? '').replace(/\n/g, ' ')} [${f.type}] on ${url}`);
|
|
249
|
+
}
|
|
250
|
+
for (const f of findings.filter(g => g.severity === 'warning')) {
|
|
251
|
+
console.log(`::warning::${String(f.message ?? '').replace(/\n/g, ' ')} [${f.type}] on ${url}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
} catch (routeErr) {
|
|
255
|
+
console.error(`::warning::Audit failed for ${url}: ${routeErr.message}`);
|
|
256
|
+
perRoute.push({ route: route.path, critical: 0, warning: 0, info: 0, error: routeErr.message });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Step 5: Compute aggregate summary and merge-block decision
|
|
261
|
+
const summary = {
|
|
262
|
+
critical: allFindings.filter(f => f.severity === 'critical').length,
|
|
263
|
+
warning: allFindings.filter(f => f.severity === 'warning').length,
|
|
264
|
+
info: allFindings.filter(f => f.severity === 'info').length,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const blocked =
|
|
268
|
+
blockOn === 'critical' ? summary.critical > 0 :
|
|
269
|
+
blockOn === 'warning' ? summary.critical + summary.warning > 0 :
|
|
270
|
+
false;
|
|
271
|
+
|
|
272
|
+
// Step 6: Write GitHub Actions outputs and step summary
|
|
273
|
+
writeGithubOutputs({ blocked, summary, affectedRoutes: affected });
|
|
274
|
+
writeStepSummary(buildStepSummary({ blocked, summary, affectedRoutes: affected, perRoute, findings: allFindings, changedFiles: files, blockOn }));
|
|
275
|
+
|
|
276
|
+
// Step 7: Emit JSON result to stdout for downstream pipeline steps
|
|
277
|
+
const result = {
|
|
278
|
+
prUrl, targetUrl,
|
|
279
|
+
affectedRoutes: affected.map(r => r.path),
|
|
280
|
+
changedFiles: files,
|
|
281
|
+
findings: allFindings,
|
|
282
|
+
perRoute,
|
|
283
|
+
summary,
|
|
284
|
+
blocked,
|
|
285
|
+
blockOn,
|
|
286
|
+
};
|
|
287
|
+
console.log(JSON.stringify(result, null, 2));
|
|
288
|
+
|
|
289
|
+
if (blocked) {
|
|
290
|
+
console.error(`::error::Argus PR Validator: ${summary.critical} critical finding(s) found. Merge blocked (block-on=${blockOn}).`);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
console.log(`[argus] ✓ Audit passed — ${summary.critical} critical, ${summary.warning} warning, ${summary.info} info.`);
|
|
295
|
+
process.exit(0);
|
|
296
|
+
|
|
297
|
+
} catch (err) {
|
|
298
|
+
const summary = { critical: 0, warning: 0, info: 0 };
|
|
299
|
+
console.error(`::error::Argus PR validation failed: ${err.message}`);
|
|
300
|
+
writeGithubOutputs({ blocked: false, summary, affectedRoutes: [] });
|
|
301
|
+
writeStepSummary(buildStepSummary({ blocked: false, summary, affectedRoutes: [], perRoute: [], findings: [], changedFiles, blockOn, error: err.message }));
|
|
302
|
+
process.exit(1);
|
|
303
|
+
|
|
304
|
+
} finally {
|
|
305
|
+
if (mcp) {
|
|
306
|
+
try { mcp.close(); } catch { /* ignore teardown errors */ }
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
package/src/mcp-server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Argus MCP Server (v9.
|
|
3
|
+
* Argus MCP Server (v9.6.1)
|
|
4
4
|
*
|
|
5
5
|
* Exposes Argus as an MCP server so Claude (or any MCP client) can call
|
|
6
6
|
* argus_audit, argus_audit_full, argus_compare, argus_last_report, and
|
|
@@ -33,6 +33,7 @@ import { CdpBrowserAdapter } from './adapters/browser.js';
|
|
|
33
33
|
import { getFigmaFrame } from './adapters/figma.js';
|
|
34
34
|
import { analyzeDesignFidelity } from './utils/design-fidelity-analyzer.js';
|
|
35
35
|
import { analyzeVisualRegression } from './utils/visual-diff-analyzer.js';
|
|
36
|
+
import { parsePrUrl, fetchPrFiles, mapFilesToRoutes } from './utils/pr-diff-analyzer.js';
|
|
36
37
|
|
|
37
38
|
const REPORTS_DIR = path.resolve(process.cwd(), 'reports');
|
|
38
39
|
|
|
@@ -145,6 +146,20 @@ const TOOLS = [
|
|
|
145
146
|
required: ['url', 'figmaFrameUrl'],
|
|
146
147
|
},
|
|
147
148
|
},
|
|
149
|
+
{
|
|
150
|
+
name: 'argus_pr_validate',
|
|
151
|
+
description: 'Runs a targeted Argus audit on the routes affected by a GitHub pull request. Fetches the PR diff, maps changed files to routes in your target config using path-slug heuristics (infrastructure changes trigger a full audit; targeted otherwise), and audits only those routes — faster than a full scan and focused on what the PR actually touched. Returns { findings, affectedRoutes, changedFiles, perRoute, summary, blocked, blockOn }. Use in CI to gate merges: check blocked:true or pipe findings to an AI verdict step. Requires Chrome on --remote-debugging-port=9222. GITHUB_TOKEN env var recommended for private repos.',
|
|
152
|
+
inputSchema: {
|
|
153
|
+
type: 'object',
|
|
154
|
+
properties: {
|
|
155
|
+
prUrl: { type: 'string', description: 'Full GitHub PR URL (e.g. https://github.com/owner/repo/pull/42). Used to fetch the list of changed files via the GitHub REST API.' },
|
|
156
|
+
targetUrl: { type: 'string', description: 'Base URL to audit (e.g. https://staging.example.com). Overrides TARGET_DEV_URL env var.' },
|
|
157
|
+
githubToken: { type: 'string', description: 'GitHub Personal Access Token or workflow GITHUB_TOKEN. Optional for public repos. Falls back to GITHUB_TOKEN env var.' },
|
|
158
|
+
blockOn: { type: 'string', enum: ['none', 'warning', 'critical'], description: '"critical" = block only when critical findings exist. "warning" = block on any warning or critical. "none" = never block. Defaults to ARGUS_BLOCK_ON env var, then "critical".', default: 'critical' },
|
|
159
|
+
},
|
|
160
|
+
required: ['prUrl'],
|
|
161
|
+
},
|
|
162
|
+
},
|
|
148
163
|
];
|
|
149
164
|
|
|
150
165
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
@@ -368,6 +383,52 @@ async function handleDesignAudit({ url, figmaFrameUrl }) {
|
|
|
368
383
|
});
|
|
369
384
|
}
|
|
370
385
|
|
|
386
|
+
async function handlePrValidate({ prUrl, targetUrl, githubToken, blockOn } = {}) {
|
|
387
|
+
if (!prUrl) throw new Error('argus_pr_validate: prUrl is required');
|
|
388
|
+
|
|
389
|
+
const { routes } = await import('./config/targets.js');
|
|
390
|
+
const token = githubToken ?? process.env.GITHUB_TOKEN;
|
|
391
|
+
const base = targetUrl ?? process.env.TARGET_DEV_URL ?? 'http://localhost:3000';
|
|
392
|
+
const policy = blockOn ?? process.env.ARGUS_BLOCK_ON ?? 'critical';
|
|
393
|
+
|
|
394
|
+
const changedFiles = await fetchPrFiles(prUrl, token);
|
|
395
|
+
const affectedRoutes = mapFilesToRoutes(changedFiles, routes ?? []);
|
|
396
|
+
|
|
397
|
+
const allFindings = [];
|
|
398
|
+
const perRoute = [];
|
|
399
|
+
|
|
400
|
+
for (const route of affectedRoutes) {
|
|
401
|
+
const url = new URL(route.path, base).href;
|
|
402
|
+
const res = await handleAudit({ url, critical: route.critical ?? false });
|
|
403
|
+
const data = JSON.parse(res.content[0].text);
|
|
404
|
+
allFindings.push(...(data.findings ?? []));
|
|
405
|
+
perRoute.push({ route: route.path, ...data.summary });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const summary = {
|
|
409
|
+
critical: allFindings.filter(f => f.severity === 'critical').length,
|
|
410
|
+
warning: allFindings.filter(f => f.severity === 'warning').length,
|
|
411
|
+
info: allFindings.filter(f => f.severity === 'info').length,
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const blocked =
|
|
415
|
+
policy === 'critical' ? summary.critical > 0 :
|
|
416
|
+
policy === 'warning' ? summary.critical + summary.warning > 0 :
|
|
417
|
+
false;
|
|
418
|
+
|
|
419
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
420
|
+
prUrl,
|
|
421
|
+
targetUrl: base,
|
|
422
|
+
affectedRoutes: affectedRoutes.map(r => r.path),
|
|
423
|
+
changedFiles,
|
|
424
|
+
findings: allFindings,
|
|
425
|
+
perRoute,
|
|
426
|
+
summary,
|
|
427
|
+
blocked,
|
|
428
|
+
blockOn: policy,
|
|
429
|
+
}, null, 2) }] };
|
|
430
|
+
}
|
|
431
|
+
|
|
371
432
|
async function handleLastReport() {
|
|
372
433
|
if (!fs.existsSync(REPORTS_DIR)) {
|
|
373
434
|
return { content: [{ type: 'text', text: '{"error":"No reports found in reports/"}' }] };
|
|
@@ -386,7 +447,7 @@ async function handleLastReport() {
|
|
|
386
447
|
// ── Server bootstrap ──────────────────────────────────────────────────────────
|
|
387
448
|
|
|
388
449
|
const server = new Server(
|
|
389
|
-
{ name: 'argus', version: '9.
|
|
450
|
+
{ name: 'argus', version: '9.6.1' },
|
|
390
451
|
{ capabilities: { tools: {} } },
|
|
391
452
|
);
|
|
392
453
|
|
|
@@ -403,6 +464,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
403
464
|
case 'argus_get_context': return await handleGetContext(req.params.arguments ?? {});
|
|
404
465
|
case 'argus_visual_diff': return await handleVisualDiff(req.params.arguments ?? {});
|
|
405
466
|
case 'argus_design_audit': return await handleDesignAudit(req.params.arguments ?? {});
|
|
467
|
+
case 'argus_pr_validate': return await handlePrValidate(req.params.arguments ?? {});
|
|
406
468
|
default: throw new Error(`Unknown tool: ${req.params.name}`);
|
|
407
469
|
}
|
|
408
470
|
} catch (err) {
|
|
@@ -26,7 +26,7 @@ function openInBrowser(filePath) {
|
|
|
26
26
|
try {
|
|
27
27
|
const abs = path.resolve(filePath);
|
|
28
28
|
if (process.platform === 'win32') {
|
|
29
|
-
execFile('cmd', ['/c', 'start', '', abs], () => {});
|
|
29
|
+
execFile('cmd', ['/c', 'start', '', abs], () => {}); // lgtm[js/shell-command-injection-from-environment] — execFile with args array is injection-safe; abs is path.resolve() of an internally-computed report path
|
|
30
30
|
} else if (process.platform === 'darwin') {
|
|
31
31
|
execFile('open', [abs], () => {});
|
|
32
32
|
} else {
|
|
@@ -21,7 +21,6 @@ import { unwrapEval } from '../utils/mcp-client.js';
|
|
|
21
21
|
import { childLogger } from '../utils/logger.js';
|
|
22
22
|
|
|
23
23
|
const logger = childLogger('env-comparison');
|
|
24
|
-
import { normalizeArray } from '../utils/flow-runner.js';
|
|
25
24
|
import { CdpBrowserAdapter } from '../adapters/browser.js';
|
|
26
25
|
|
|
27
26
|
import { comparisonRoutes, config } from '../config/targets.js';
|
|
@@ -490,7 +490,7 @@ export async function crawlRouteCheap(route, baseUrl, mcp) {
|
|
|
490
490
|
const text = (msg.text ?? msg.message ?? '');
|
|
491
491
|
if (text.toLowerCase().includes('has been blocked by cors policy')) continue;
|
|
492
492
|
const severity = classifyConsoleMessage(msg, route.critical);
|
|
493
|
-
if (
|
|
493
|
+
if (msg.level !== 'log') {
|
|
494
494
|
result.errors.push({
|
|
495
495
|
type: 'console',
|
|
496
496
|
level: msg.level,
|
|
@@ -1026,10 +1026,10 @@ export async function runCrawl(mcp, routeOverrides = null, baseUrlOverride = nul
|
|
|
1026
1026
|
// Auth session persistence (B2)
|
|
1027
1027
|
const sessionFile = auth?.sessionFile ?? '.argus-session.json';
|
|
1028
1028
|
if (auth?.steps?.length > 0) {
|
|
1029
|
-
if (!hasSession(sessionFile, auth
|
|
1030
|
-
logger.info(`[ARGUS] Auth: running login flow (${auth
|
|
1029
|
+
if (!hasSession(sessionFile, auth?.sessionMaxAgeMs)) {
|
|
1030
|
+
logger.info(`[ARGUS] Auth: running login flow (${auth?.steps?.length ?? 0} steps)...`);
|
|
1031
1031
|
try {
|
|
1032
|
-
await runLoginFlow(browser, targetBaseUrl, auth
|
|
1032
|
+
await runLoginFlow(browser, targetBaseUrl, auth?.steps ?? []);
|
|
1033
1033
|
await saveSession(browser, sessionFile);
|
|
1034
1034
|
} catch (err) {
|
|
1035
1035
|
logger.warn(`[ARGUS] Auth: login flow failed — crawl will proceed unauthenticated: ${err.message}`);
|
|
@@ -1154,5 +1154,5 @@ export async function runCrawl(mcp, routeOverrides = null, baseUrlOverride = nul
|
|
|
1154
1154
|
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
|
1155
1155
|
logger.info('[ARGUS] orchestrator.js loaded. Invoke runCrawl(mcp) from Claude Code with MCP tools connected.');
|
|
1156
1156
|
logger.info('[ARGUS] Target base URL: ' + BASE_URL);
|
|
1157
|
-
logger.info('[ARGUS] Routes to crawl: ' +
|
|
1157
|
+
logger.info('[ARGUS] Routes to crawl: ' + routes.map(r => r?.path ?? '(no path)').join(', '));
|
|
1158
1158
|
}
|
|
@@ -108,7 +108,7 @@ export async function processReport(report, { outputDir, severityOverrides }) {
|
|
|
108
108
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
109
109
|
const reportPath = path.join(outputDir, `error-report-${timestamp}.json`);
|
|
110
110
|
try {
|
|
111
|
-
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
|
111
|
+
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); // lgtm[js/network-data-to-file] — intentional: Argus persists crawl findings to a local JSON report file by design
|
|
112
112
|
} catch (err) {
|
|
113
113
|
logger.error(`[ARGUS] Failed to write report JSON: ${err.message}`);
|
|
114
114
|
throw err;
|
|
@@ -99,7 +99,7 @@ async function uploadFileToSlack(filePath, channelId, filename) {
|
|
|
99
99
|
const response = await fetch(uploadUrl, {
|
|
100
100
|
method: 'PUT',
|
|
101
101
|
headers: { 'Content-Type': 'application/octet-stream' },
|
|
102
|
-
body: fileBuffer,
|
|
102
|
+
body: fileBuffer, // lgtm[js/file-access-to-http] — intentional screenshot upload to Slack pre-signed URL; file path is internally generated by Argus, not from HTTP request input
|
|
103
103
|
signal: AbortSignal.timeout(30000),
|
|
104
104
|
});
|
|
105
105
|
if (!response.ok) {
|
|
@@ -35,10 +35,6 @@ import {
|
|
|
35
35
|
import { postBugReport } from './slack-notifier.js';
|
|
36
36
|
import { isSlackConfigured } from '../utils/slack-guard.js';
|
|
37
37
|
import { generateHtmlReport } from '../utils/html-reporter.js';
|
|
38
|
-
import {
|
|
39
|
-
parseConsoleMsgResponse,
|
|
40
|
-
parseNetworkReqResponse,
|
|
41
|
-
} from '../utils/mcp-parsers.js';
|
|
42
38
|
|
|
43
39
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
44
40
|
const REPORTS_DIR = path.resolve(__dirname, '../../reports');
|
package/src/server/index.js
CHANGED
|
@@ -50,6 +50,28 @@ app.use((req, res, next) => {
|
|
|
50
50
|
next();
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
// ── Rate limiting (per-IP, in-memory) ──────────────────────────────────────────
|
|
54
|
+
// 30 requests per minute per IP — prevents Slack endpoint flooding.
|
|
55
|
+
// Slack signature verification rejects invalid payloads, but rate limiting adds
|
|
56
|
+
// a defence-in-depth layer before signature verification even runs.
|
|
57
|
+
const RATE_WINDOW_MS = 60_000;
|
|
58
|
+
const RATE_MAX = 30;
|
|
59
|
+
const rateLimitMap = new Map();
|
|
60
|
+
|
|
61
|
+
function slackRateLimit(req, res, next) {
|
|
62
|
+
const key = req.ip ?? 'unknown';
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
const entry = rateLimitMap.get(key) ?? { count: 0, start: now };
|
|
65
|
+
if (now - entry.start > RATE_WINDOW_MS) { entry.count = 0; entry.start = now; }
|
|
66
|
+
entry.count++;
|
|
67
|
+
rateLimitMap.set(key, entry);
|
|
68
|
+
if (entry.count > RATE_MAX) {
|
|
69
|
+
res.setHeader('Retry-After', Math.ceil(RATE_WINDOW_MS / 1000));
|
|
70
|
+
return res.status(429).json({ error: 'Too Many Requests' });
|
|
71
|
+
}
|
|
72
|
+
next();
|
|
73
|
+
}
|
|
74
|
+
|
|
53
75
|
// ── Routes ─────────────────────────────────────────────────────────────────────
|
|
54
76
|
|
|
55
77
|
app.get('/health', (req, res) => {
|
|
@@ -57,10 +79,10 @@ app.get('/health', (req, res) => {
|
|
|
57
79
|
});
|
|
58
80
|
|
|
59
81
|
// Slack slash commands
|
|
60
|
-
app.post('/slack/commands', handleSlashCommand);
|
|
82
|
+
app.post('/slack/commands', slackRateLimit, handleSlashCommand);
|
|
61
83
|
|
|
62
84
|
// Slack Block Kit interactions (button clicks)
|
|
63
|
-
app.post('/slack/interactions', handleInteraction);
|
|
85
|
+
app.post('/slack/interactions', slackRateLimit, handleInteraction);
|
|
64
86
|
|
|
65
87
|
// ── Start ──────────────────────────────────────────────────────────────────────
|
|
66
88
|
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import crypto from 'crypto';
|
|
18
|
-
import { postBugReport } from '../orchestration/slack-notifier.js';
|
|
19
18
|
import { createMcpClient } from '../utils/mcp-client.js';
|
|
20
19
|
import { runCrawl } from '../orchestration/crawl-and-report.js';
|
|
21
20
|
import { WebClient } from '@slack/web-api';
|
|
@@ -27,9 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import fs from 'fs';
|
|
30
|
-
import path from 'path';
|
|
31
30
|
import { createRequire } from 'module';
|
|
32
|
-
import { fileURLToPath } from 'url';
|
|
33
31
|
import { registerExpensive } from '../registry.js';
|
|
34
32
|
import { unwrapEval } from './mcp-client.js';
|
|
35
33
|
import { childLogger } from './logger.js';
|
|
@@ -110,7 +110,7 @@ export function saveBaseline(baselineFile, report) {
|
|
|
110
110
|
flows[flowResult.flowName] = (flowResult.findings ?? []).map(findingKey);
|
|
111
111
|
}
|
|
112
112
|
const codebase = (report.codebase ?? []).map(findingKey);
|
|
113
|
-
const tmpBaseline = baselineFile
|
|
113
|
+
const tmpBaseline = `${baselineFile}.${process.pid}.${Date.now()}.tmp`;
|
|
114
114
|
fs.writeFileSync(
|
|
115
115
|
tmpBaseline,
|
|
116
116
|
JSON.stringify({ savedAt: new Date().toISOString(), routes, flows, codebase }, null, 2),
|
|
@@ -245,8 +245,8 @@ export function appendTrend(trendsFile, entry) {
|
|
|
245
245
|
}
|
|
246
246
|
trends.push(entry);
|
|
247
247
|
if (trends.length > 500) trends = trends.slice(-500);
|
|
248
|
-
const tmpTrends = trendsFile
|
|
249
|
-
fs.writeFileSync(tmpTrends, JSON.stringify(trends, null, 2));
|
|
248
|
+
const tmpTrends = `${trendsFile}.${process.pid}.${Date.now()}.tmp`;
|
|
249
|
+
fs.writeFileSync(tmpTrends, JSON.stringify(trends, null, 2)); // lgtm[js/network-data-to-file] — intentional: Argus persists crawl trend data to a local baseline file by design
|
|
250
250
|
fs.renameSync(tmpTrends, trendsFile);
|
|
251
251
|
} finally {
|
|
252
252
|
try { fs.closeSync(lockFd); } catch {}
|