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.
@@ -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 stat = fs.statSync(full);
44
- if (stat.size > 1_000_000) continue; // skip files > 1MB (minified bundles, etc.)
45
- files.push({ filePath: full, content: fs.readFileSync(full, 'utf8') });
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);
@@ -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 (!fs.existsSync(harFile)) {
83
- try {
84
- fs.mkdirSync(harDir, { recursive: true });
85
- const baseline = {
86
- version: '1.2',
87
- createdAt: new Date().toISOString(),
88
- url,
89
- entries: requests.map(toBaselineEntry),
90
- };
91
- fs.writeFileSync(harFile, JSON.stringify(baseline, null, 2));
92
- } catch (err) {
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 baseline;
114
+ let existingBaseline;
110
115
  try {
111
- baseline = JSON.parse(fs.readFileSync(harFile, 'utf8'));
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 = baseline.entries ?? [];
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 (!fs.existsSync(baselinePath)) {
145
- try {
146
- fs.writeFileSync(baselinePath, currentBuf);
147
- } catch (err) {
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',