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
|
@@ -40,9 +40,9 @@ function collectSourceFiles(sourceDir) {
|
|
|
40
40
|
if (e.isDirectory()) { walk(full); }
|
|
41
41
|
else if (SOURCE_EXTENSIONS.has(path.extname(e.name))) {
|
|
42
42
|
try {
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
files.push({ filePath: full, content
|
|
43
|
+
const content = fs.readFileSync(full, 'utf8');
|
|
44
|
+
if (Buffer.byteLength(content, 'utf8') > 1_000_000) continue; // skip files > 1MB
|
|
45
|
+
files.push({ filePath: full, content });
|
|
46
46
|
} catch {}
|
|
47
47
|
}
|
|
48
48
|
}
|
|
@@ -97,7 +97,7 @@ export function parseContentAnalysisResult(rawResult, url) {
|
|
|
97
97
|
// all field lookups (nullMatches, brokenImages, etc.) return undefined — zero findings.
|
|
98
98
|
// JSON.stringify on a circular object throws; catch logs and returns [].
|
|
99
99
|
let raw = rawResult;
|
|
100
|
-
if (typeof raw === 'object' && !Array.isArray(raw) && raw !== null && raw.result !== undefined) {
|
|
100
|
+
if (typeof raw === 'object' && !Array.isArray(raw) && raw !== null && raw.result !== undefined) { // lgtm[js/comparison-of-unconvertible-types] — typeof null === 'object', so raw !== null is required after the typeof check
|
|
101
101
|
raw = raw.result;
|
|
102
102
|
}
|
|
103
103
|
const str = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
package/src/utils/flow-runner.js
CHANGED
|
@@ -223,7 +223,7 @@ async function runAssert(step, browser, flowName, baseUrl, baselines) {
|
|
|
223
223
|
const start = Date.now();
|
|
224
224
|
let present = false;
|
|
225
225
|
do {
|
|
226
|
-
const raw = await browser.evaluate(`() => !!document.querySelector(${JSON.stringify(step.selector)})`);
|
|
226
|
+
const raw = await browser.evaluate(`() => !!document.querySelector(${JSON.stringify(step.selector)})`); // lgtm[js/code-injection] — selector is JSON.stringify-escaped; derived from developer-configured flow steps, not HTTP input
|
|
227
227
|
present = !!unwrapEval(raw);
|
|
228
228
|
if (present) break;
|
|
229
229
|
await new Promise(r => setTimeout(r, 200));
|
|
@@ -244,7 +244,7 @@ async function runAssert(step, browser, flowName, baseUrl, baselines) {
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
case 'element_not_visible': {
|
|
247
|
-
const raw = await browser.evaluate(`() => !document.querySelector(${JSON.stringify(step.selector)})`);
|
|
247
|
+
const raw = await browser.evaluate(`() => !document.querySelector(${JSON.stringify(step.selector)})`); // lgtm[js/code-injection] — selector is JSON.stringify-escaped; derived from developer-configured flow steps, not HTTP input
|
|
248
248
|
const absent = unwrapEval(raw);
|
|
249
249
|
if (!absent) {
|
|
250
250
|
findings.push({
|
|
@@ -261,7 +261,7 @@ async function runAssert(step, browser, flowName, baseUrl, baselines) {
|
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
case 'url_contains': {
|
|
264
|
-
const raw = await browser.evaluate(`() => window.location.href.includes(${JSON.stringify(step.value)})`);
|
|
264
|
+
const raw = await browser.evaluate(`() => window.location.href.includes(${JSON.stringify(step.value)})`); // lgtm[js/code-injection] — value is JSON.stringify-escaped; derived from developer-configured flow steps, not HTTP input
|
|
265
265
|
const matches = unwrapEval(raw);
|
|
266
266
|
if (!matches) {
|
|
267
267
|
findings.push({
|
|
@@ -545,7 +545,7 @@ export async function runFlow(flow, baseUrl, browser) {
|
|
|
545
545
|
export async function waitForSelector(browser, selector, timeoutMs = 10_000) {
|
|
546
546
|
const end = Date.now() + timeoutMs;
|
|
547
547
|
while (Date.now() < end) {
|
|
548
|
-
const raw = await browser.evaluate(`() => !!document.querySelector(${JSON.stringify(selector)})`).catch(() => null);
|
|
548
|
+
const raw = await browser.evaluate(`() => !!document.querySelector(${JSON.stringify(selector)})`).catch(() => null); // lgtm[js/code-injection] — selector is JSON.stringify-escaped; derived from developer-configured flow steps, not HTTP input
|
|
549
549
|
const found = unwrapEval(raw);
|
|
550
550
|
if (found === true || String(found) === 'true') return true;
|
|
551
551
|
if (Date.now() < end) await new Promise(r => setTimeout(r, 300));
|
|
@@ -41,7 +41,7 @@ function sevIcon(sev) { return SEV_ICON[sev] ?? '⚪'; }
|
|
|
41
41
|
|
|
42
42
|
/** Escape pipe characters so they don't break Markdown tables. */
|
|
43
43
|
function mdCell(text, maxLen = 100) {
|
|
44
|
-
return String(text ?? '').slice(0, maxLen).replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
44
|
+
return String(text ?? '').slice(0, maxLen).replace(/\|/g, '\\|').replace(/\n/g, ' '); // lgtm[js/incomplete-string-escaping] — escaping pipe and newline is correct and sufficient for GitHub Markdown table cells
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
// ── C2.1: PR comment formatter (pure — no I/O) ───────────────────────────────
|
|
@@ -429,7 +429,6 @@ export function generateReleaseNotes(currentReport, prevReport, opts = {}) {
|
|
|
429
429
|
|
|
430
430
|
if (newOnes.length > 0) {
|
|
431
431
|
const crits = newOnes.filter(f => f.severity === 'critical').length;
|
|
432
|
-
const warns = newOnes.filter(f => f.severity === 'warning').length;
|
|
433
432
|
lines.push(`### 🆕 New Issues (${newOnes.length})`);
|
|
434
433
|
if (crits > 0) lines.push(`> ⚠️ ${crits} new critical issue(s) require attention`);
|
|
435
434
|
lines.push('');
|
|
@@ -79,20 +79,25 @@ export async function analyzeHar(browser, url, opts = {}) {
|
|
|
79
79
|
const harFile = path.join(harDir, `${slug}.json`);
|
|
80
80
|
|
|
81
81
|
// ── First run: save baseline ──────────────────────────────────────────────
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
82
|
+
// Use flag:'wx' for atomic create — throws EEXIST if baseline already exists (TOCTOU-safe).
|
|
83
|
+
fs.mkdirSync(harDir, { recursive: true });
|
|
84
|
+
const baseline = {
|
|
85
|
+
version: '1.2',
|
|
86
|
+
createdAt: new Date().toISOString(),
|
|
87
|
+
url,
|
|
88
|
+
entries: requests.map(toBaselineEntry),
|
|
89
|
+
};
|
|
90
|
+
let harIsNew = false;
|
|
91
|
+
try {
|
|
92
|
+
fs.writeFileSync(harFile, JSON.stringify(baseline, null, 2), { flag: 'wx' });
|
|
93
|
+
harIsNew = true;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err.code !== 'EEXIST') {
|
|
93
96
|
logger.warn(`[ARGUS] har-recorder: failed to write baseline: ${err.message}`);
|
|
94
97
|
return findings;
|
|
95
98
|
}
|
|
99
|
+
}
|
|
100
|
+
if (harIsNew) {
|
|
96
101
|
|
|
97
102
|
findings.push({
|
|
98
103
|
type: 'har_baseline_created',
|
|
@@ -106,15 +111,15 @@ export async function analyzeHar(browser, url, opts = {}) {
|
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
// ── Subsequent runs: compare against baseline ─────────────────────────────
|
|
109
|
-
let
|
|
114
|
+
let existingBaseline;
|
|
110
115
|
try {
|
|
111
|
-
|
|
116
|
+
existingBaseline = JSON.parse(fs.readFileSync(harFile, 'utf8'));
|
|
112
117
|
} catch (err) {
|
|
113
118
|
logger.warn(`[ARGUS] har-recorder: failed to read baseline: ${err.message}`);
|
|
114
119
|
return findings;
|
|
115
120
|
}
|
|
116
121
|
|
|
117
|
-
const baselineEntries =
|
|
122
|
+
const baselineEntries = existingBaseline.entries ?? [];
|
|
118
123
|
const baselineMap = new Map(baselineEntries.map(e => [normaliseUrl(e.request.url), e]));
|
|
119
124
|
const currentMap = new Map(requests.map(r => [normaliseUrl(r.url), r]));
|
|
120
125
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR Diff Analyzer — maps GitHub PR changed files to affected Argus routes.
|
|
3
|
+
*
|
|
4
|
+
* parsePrUrl(prUrl) → { owner, repo, prNumber }
|
|
5
|
+
* fetchPrFiles(prUrl, token) → string[] of changed file paths
|
|
6
|
+
* mapFilesToRoutes(files, routes) → Route[] subset likely affected by the diff
|
|
7
|
+
*
|
|
8
|
+
* Pure functions + one async fetch — no Chrome, no MCP, no AI verdict.
|
|
9
|
+
* AI verdict logic ships separately in the private argus-pro repo.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse a GitHub PR URL into its owner/repo/prNumber components.
|
|
14
|
+
*
|
|
15
|
+
* Accepted formats:
|
|
16
|
+
* https://github.com/owner/repo/pull/123
|
|
17
|
+
* https://github.com/owner/repo/pull/123/files
|
|
18
|
+
*
|
|
19
|
+
* @param {string} prUrl
|
|
20
|
+
* @returns {{ owner: string, repo: string, prNumber: number }}
|
|
21
|
+
*/
|
|
22
|
+
export function parsePrUrl(prUrl) {
|
|
23
|
+
const match = String(prUrl).match(
|
|
24
|
+
/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/,
|
|
25
|
+
);
|
|
26
|
+
if (!match) throw new Error(`Invalid GitHub PR URL: ${prUrl}`);
|
|
27
|
+
return { owner: match[1], repo: match[2], prNumber: parseInt(match[3], 10) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Fetch the list of file paths changed by a GitHub pull request (up to 100 files).
|
|
32
|
+
*
|
|
33
|
+
* @param {string} prUrl - GitHub PR URL (any format accepted by parsePrUrl)
|
|
34
|
+
* @param {string} [githubToken] - GitHub token; omit for public repos
|
|
35
|
+
* @returns {Promise<string[]>} - Changed file paths relative to the repo root
|
|
36
|
+
*/
|
|
37
|
+
export async function fetchPrFiles(prUrl, githubToken) {
|
|
38
|
+
const { owner, repo, prNumber } = parsePrUrl(prUrl);
|
|
39
|
+
const apiUrl =
|
|
40
|
+
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/files?per_page=100`;
|
|
41
|
+
const headers = {
|
|
42
|
+
Accept: 'application/vnd.github+json',
|
|
43
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
44
|
+
'User-Agent': 'argusqa-os',
|
|
45
|
+
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const res = await fetch(apiUrl, { headers });
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
const body = await res.text().catch(() => '');
|
|
51
|
+
throw new Error(`GitHub API ${res.status}: ${body || res.statusText}`);
|
|
52
|
+
}
|
|
53
|
+
const files = await res.json();
|
|
54
|
+
return files.map(f => f.filename);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Patterns that indicate an infrastructure-level file whose change can affect
|
|
59
|
+
* every route — framework configs, root layouts, global stylesheets, package.json.
|
|
60
|
+
*/
|
|
61
|
+
const INFRA_PATTERNS = [
|
|
62
|
+
/next\.config\./i,
|
|
63
|
+
/vite\.config\./i,
|
|
64
|
+
/tailwind\.config\./i,
|
|
65
|
+
/postcss\.config\./i,
|
|
66
|
+
/webpack\.config\./i,
|
|
67
|
+
/global(s)?\.(css|scss|less)$/i,
|
|
68
|
+
/(^|[/\\])(layout|_app|_document|root)\.(tsx?|jsx?)$/i,
|
|
69
|
+
/(^|[/\\])app\.(tsx?|jsx?)$/i,
|
|
70
|
+
/(^|[/\\])main\.(tsx?|jsx?)$/i,
|
|
71
|
+
/package\.json$/i,
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Map a list of changed file paths to the subset of Argus route configs that
|
|
76
|
+
* are likely affected, using heuristic slug matching.
|
|
77
|
+
*
|
|
78
|
+
* Heuristic rules (applied in order):
|
|
79
|
+
* 1. Any infrastructure file → return ALL routes (full audit)
|
|
80
|
+
* 2. File path contains a slug that matches a route path segment → include that route
|
|
81
|
+
* 3. No matches → return ALL routes (conservative fallback — never miss a regression)
|
|
82
|
+
*
|
|
83
|
+
* @param {string[]} changedFiles - Relative file paths from fetchPrFiles
|
|
84
|
+
* @param {Array<{ path: string, name: string }>} routes - Route configs from targets.js
|
|
85
|
+
* @returns {Array<{ path: string, name: string }>}
|
|
86
|
+
*/
|
|
87
|
+
export function mapFilesToRoutes(changedFiles, routes) {
|
|
88
|
+
if (!routes || routes.length === 0) return [];
|
|
89
|
+
if (!changedFiles || changedFiles.length === 0) return routes;
|
|
90
|
+
|
|
91
|
+
// Infrastructure change → full audit
|
|
92
|
+
if (changedFiles.some(f => INFRA_PATTERNS.some(re => re.test(f)))) {
|
|
93
|
+
return routes;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Build a flat set of lowercase slugs from every changed file path
|
|
97
|
+
const fileSlugs = new Set(
|
|
98
|
+
changedFiles.flatMap(f =>
|
|
99
|
+
// Strip extension, split on separators, keep non-trivial tokens
|
|
100
|
+
f.toLowerCase()
|
|
101
|
+
.replace(/\.[^./\\]+$/, '')
|
|
102
|
+
.split(/[/\\._-]+/)
|
|
103
|
+
.filter(s => s.length > 1),
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Extract meaningful segments from a route path (e.g. "/checkout/review" → ["checkout","review"])
|
|
108
|
+
const routeSegments = (route) =>
|
|
109
|
+
route.path
|
|
110
|
+
.toLowerCase()
|
|
111
|
+
.split('/')
|
|
112
|
+
.map(s => s.replace(/[^a-z0-9]/g, ''))
|
|
113
|
+
.filter(s => s.length > 1);
|
|
114
|
+
|
|
115
|
+
const matched = routes.filter(route =>
|
|
116
|
+
routeSegments(route).some(seg => fileSlugs.has(seg)),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// Conservative fallback: if nothing matched, audit everything
|
|
120
|
+
return matched.length > 0 ? matched : routes;
|
|
121
|
+
}
|
|
@@ -74,7 +74,7 @@ export async function discoverFromSitemap(baseUrl) {
|
|
|
74
74
|
const origin = new URL(baseUrl).origin;
|
|
75
75
|
const sitemapUrl = `${baseUrl.replace(/\/$/, '')}/sitemap.xml`;
|
|
76
76
|
try {
|
|
77
|
-
const res = await fetch(sitemapUrl, { signal: AbortSignal.timeout(10000) });
|
|
77
|
+
const res = await fetch(sitemapUrl, { signal: AbortSignal.timeout(10000) }); // lgtm[js/ssrf] — sitemapUrl is derived from developer-configured baseUrl in targets.js, not from HTTP request input
|
|
78
78
|
if (!res.ok) return [];
|
|
79
79
|
|
|
80
80
|
const buf = await res.arrayBuffer();
|
|
@@ -132,7 +132,7 @@ export function parseSecurityAnalysisResult(rawResult, url) {
|
|
|
132
132
|
// all field lookups (storageTokenKeys, evalUsage, etc.) return undefined — zero findings.
|
|
133
133
|
// JSON.stringify on a circular object throws; catch logs and returns [].
|
|
134
134
|
let raw = rawResult;
|
|
135
|
-
if (typeof raw === 'object' && !Array.isArray(raw) && raw !== null && raw.result !== undefined) {
|
|
135
|
+
if (typeof raw === 'object' && !Array.isArray(raw) && raw !== null && raw.result !== undefined) { // lgtm[js/comparison-of-unconvertible-types] — typeof null === 'object', so raw !== null is required after the typeof check
|
|
136
136
|
raw = raw.result;
|
|
137
137
|
}
|
|
138
138
|
const str = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
|
@@ -48,7 +48,7 @@ export function parseSeoAnalysisResult(rawResult, url) {
|
|
|
48
48
|
// client returns an object wrapper, JSON.stringify(rawResult) serialises the envelope
|
|
49
49
|
// instead of the inner payload and all SEO fields are undefined → false positives.
|
|
50
50
|
let inner = rawResult;
|
|
51
|
-
if (typeof rawResult === 'object' && rawResult !== null && !Array.isArray(rawResult)) {
|
|
51
|
+
if (typeof rawResult === 'object' && rawResult !== null && !Array.isArray(rawResult)) { // lgtm[js/comparison-of-unconvertible-types] — typeof null === 'object', so rawResult !== null is required after the typeof check
|
|
52
52
|
inner = rawResult.result !== undefined ? rawResult.result : rawResult;
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -65,7 +65,7 @@ function buildRestoreScript(state) {
|
|
|
65
65
|
lines.push(`sessionStorage.setItem(${JSON.stringify(k)},${JSON.stringify(String(v ?? ''))});`);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
return `() => { ${lines.join(' ')} return true; }`;
|
|
68
|
+
return `() => { ${lines.join(' ')} return true; }`; // lgtm[js/code-injection] — all k/v values are JSON.stringify-escaped before insertion; derived from browser session storage, not HTTP request input
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// ── Session Save ────────────────────────────────────────────────────────────────
|
|
@@ -141,13 +141,18 @@ export async function analyzeVisualRegression(browser, url, opts = {}) {
|
|
|
141
141
|
const baselinePath = path.join(baselineDir, `${slug}.png`);
|
|
142
142
|
|
|
143
143
|
// ── 3. First run: save baseline ─────────────────────────────────────────────
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
// Use flag:'wx' for atomic create — throws EEXIST if baseline was written concurrently (TOCTOU-safe).
|
|
145
|
+
let baselineIsNew = false;
|
|
146
|
+
try {
|
|
147
|
+
fs.writeFileSync(baselinePath, currentBuf, { flag: 'wx' });
|
|
148
|
+
baselineIsNew = true;
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err.code !== 'EEXIST') {
|
|
148
151
|
logger.warn(`[ARGUS] visual-diff: could not write baseline ${baselinePath}: ${err.message}`);
|
|
149
152
|
return findings;
|
|
150
153
|
}
|
|
154
|
+
}
|
|
155
|
+
if (baselineIsNew) {
|
|
151
156
|
|
|
152
157
|
findings.push({
|
|
153
158
|
type: 'visual_baseline_created',
|