argusqa-os 9.6.5 → 9.7.3

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/src/mcp-server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Argus MCP Server (v9.6.5)
3
+ * Argus MCP Server (v9.6.6)
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,7 +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
+ import { fetchPrFiles, mapFilesToRoutes } from './utils/pr-diff-analyzer.js';
37
37
 
38
38
  const REPORTS_DIR = path.resolve(process.cwd(), 'reports');
39
39
 
@@ -447,7 +447,7 @@ async function handleLastReport() {
447
447
  // ── Server bootstrap ──────────────────────────────────────────────────────────
448
448
 
449
449
  const server = new Server(
450
- { name: 'argus', version: '9.6.5' },
450
+ { name: 'argus', version: '9.6.6' },
451
451
  { capabilities: { tools: {} } },
452
452
  );
453
453
 
@@ -20,10 +20,11 @@ import { SECURITY_ANALYSIS_SCRIPT, parseSecurityAnalysisResult, analyzeSecurityC
20
20
  import { CONTENT_ANALYSIS_SCRIPT, parseContentAnalysisResult } from '../utils/content-analyzer.js';
21
21
  import { runLoginFlow, saveSession, restoreSession, hasSession, refreshSession } from '../utils/session-manager.js';
22
22
  import { mergeRunResults } from '../utils/flakiness-detector.js';
23
- import { runAllFlows, normalizeArray, waitForSelector } from '../utils/flow-runner.js';
23
+ import { runAllFlows, waitForSelector } from '../utils/flow-runner.js';
24
24
  import { analyzeApiFrequency } from '../utils/api-frequency.js';
25
25
  import { slugify } from '../utils/slug.js';
26
26
  import { unwrapEval, createMcpClient } from '../utils/mcp-client.js';
27
+ import { parseConsoleMsgResponse } from '../utils/mcp-parsers.js';
27
28
  import { CdpBrowserAdapter } from '../adapters/browser.js';
28
29
  import { getFigmaFrame } from '../adapters/figma.js';
29
30
  import { chunkArray } from '../utils/parallel-crawler.js';
@@ -435,9 +436,9 @@ export async function crawlRouteCheap(route, baseUrl, mcp) {
435
436
  const consoleBaseline = (await browser.listConsole().catch(() => [])).length;
436
437
  const baselineNetList = await browser.listNetwork().catch(() => []);
437
438
  const networkMaxReqId = baselineNetList.reduce((max, r) => Math.max(max, r._reqid ?? 0), 0);
438
- // listConsoleRaw returns raw MCP responsenormalizeArray required before .length
439
+ // listConsoleRaw returns markdown text ("msgid=N [issue] text") parse like console messages
439
440
  const issuesBaselineRaw = await browser.listConsoleRaw({ types: ['issue'] }).catch(() => null);
440
- const issuesBaseline = normalizeArray(issuesBaselineRaw).length;
441
+ const issuesBaseline = parseConsoleMsgResponse(issuesBaselineRaw).length;
441
442
 
442
443
  // 1. Navigate
443
444
  await browser.navigate(url);
@@ -710,11 +711,12 @@ export async function crawlRouteCheap(route, baseUrl, mcp) {
710
711
  logger.warn(`[ARGUS] Content analysis skipped for ${url}: ${err.message}`);
711
712
  }
712
713
 
713
- // 9e. Chrome DevTools Issues panel
714
+ // 9e. Chrome DevTools Issues panel — same reset-per-navigation guard as console (D5)
714
715
  try {
715
- const issueRaw = await browser.listConsoleRaw({ types: ['issue'] });
716
- const issues = normalizeArray(issueRaw).slice(issuesBaseline);
717
- result.errors.push(...parseIssues(issues, url, route.critical));
716
+ const issueRaw = await browser.listConsoleRaw({ types: ['issue'] });
717
+ const allIssues = parseConsoleMsgResponse(issueRaw);
718
+ const issuesSliceAt = allIssues.length > issuesBaseline ? issuesBaseline : 0;
719
+ result.errors.push(...parseIssues(allIssues.slice(issuesSliceAt), url, route.critical));
718
720
  } catch (err) {
719
721
  logger.warn(`[ARGUS] Issues analysis skipped for ${url}: ${err.message}`);
720
722
  }
@@ -13,6 +13,8 @@ import path from 'path';
13
13
  import { childLogger } from '../utils/logger.js';
14
14
  import { applyOverrides } from '../utils/severity-overrides.js';
15
15
  import { loadBaseline, saveBaseline, applyBaseline, appendTrend, getCurrentBranch } from '../utils/baseline-manager.js';
16
+ import { loadRunHistory, recordRunHistory, applyNoiseFilter } from '../utils/noise-filter.js';
17
+ import { getRecentChanges, linkRootCauses } from '../utils/root-cause-linker.js';
16
18
 
17
19
  const logger = childLogger('report-processor');
18
20
 
@@ -104,6 +106,29 @@ export async function processReport(report, { outputDir, severityOverrides }) {
104
106
  logger.info('[ARGUS] First run — no baseline to compare; all findings treated as new');
105
107
  }
106
108
 
109
+ // 3a. Intelligent baseline filtering — downgrade cross-run flip-flopping findings
110
+ // to info. Best-effort; disable with ARGUS_NOISE_FILTER=0.
111
+ const historyPath = path.join(outputDir, 'baselines', `${safeBranch}-history.json`);
112
+ if (process.env.ARGUS_NOISE_FILTER !== '0') {
113
+ try {
114
+ const history = loadRunHistory(historyPath);
115
+ const { noisyCount } = applyNoiseFilter(report, history);
116
+ if (noisyCount > 0) rebuildSummary(report); // downgrades change severity counts
117
+ } catch (err) {
118
+ logger.warn(`[ARGUS] Noise filter skipped: ${err.message}`);
119
+ }
120
+ }
121
+
122
+ // 3b. Root cause linking — annotate new findings with recent git changes that
123
+ // map to their route. Best-effort; disable with ARGUS_ROOT_CAUSE=0.
124
+ if (process.env.ARGUS_ROOT_CAUSE !== '0') {
125
+ try {
126
+ linkRootCauses(report, getRecentChanges());
127
+ } catch (err) {
128
+ logger.warn(`[ARGUS] Root cause linking skipped: ${err.message}`);
129
+ }
130
+ }
131
+
107
132
  // 4. Write JSON report
108
133
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
109
134
  const reportPath = path.join(outputDir, `error-report-${timestamp}.json`);
@@ -115,8 +140,15 @@ export async function processReport(report, { outputDir, severityOverrides }) {
115
140
  }
116
141
  logger.info(`[ARGUS] Report written: ${reportPath}`);
117
142
 
118
- // 5. Persist baseline + append trend entry
143
+ // 5. Persist baseline + run history + append trend entry
119
144
  saveBaseline(baselinePath, report);
145
+ if (process.env.ARGUS_NOISE_FILTER !== '0') {
146
+ try {
147
+ recordRunHistory(historyPath, report);
148
+ } catch (err) {
149
+ logger.warn(`[ARGUS] Run history write skipped: ${err.message}`);
150
+ }
151
+ }
120
152
  appendTrend(trendsPath, {
121
153
  runAt: report.generatedAt,
122
154
  baseUrl: report.baseUrl,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ARGUS Deep Accessibility Analyzer (Sprint 4 — A12)
2
+ * ARGUS Deep Accessibility Analyzer (A12)
3
3
  *
4
4
  * Extends Argus accessibility coverage via two mechanisms:
5
5
  *
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ARGUS Design Fidelity Analyzer (Sprint 2 — D9: Design Fidelity)
2
+ * ARGUS Design Fidelity Analyzer (D9: Design Fidelity)
3
3
  *
4
4
  * Compares a live page's computed CSS against every property extracted by
5
5
  * src/adapters/figma.js. Requires pre-fetched figmaData — analysis is skipped
@@ -102,12 +102,26 @@ export async function resolveUidForSelector(browser, selector) {
102
102
  const fence = text.match(/```(?:json|text)?\s*([\s\S]*?)\s*```/);
103
103
  if (fence) text = fence[1];
104
104
 
105
+ // Pass 1 — exact accessible-name match across ALL identifiers before any
106
+ // substring matching. Substring matches can hit unrelated nodes whose text
107
+ // merely mentions the identifier (e.g. a paragraph documenting "#drag-source"
108
+ // matches the id "drag-source" and wins over the real element's text node).
105
109
  for (const identifier of identifiers) {
106
110
  const esc = identifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
107
- // Current snapshot format: "uid=N_M role "accessible name" [attrs]"
108
- // uid precedes the role and accessible name; MCP tools expect just the N_M part (no "uid=" prefix).
109
111
  // Prefer interactive element lines (combobox, button, etc.) over StaticText label
110
112
  // nodes — both may share the same accessible name (e.g. a <label> and its <select>).
113
+ const e1 = text.match(new RegExp(`uid=([^\\s]+)\\s+(?!StaticText)[^\\n]*"${esc}"`, 'm'));
114
+ if (e1) return e1[1];
115
+ const e1b = text.match(new RegExp(`uid=([^\\s]+)[^\\n]*"${esc}"`, 'm'));
116
+ if (e1b) return e1b[1];
117
+ }
118
+
119
+ // Pass 2 — substring fallback (accessible names that embed the identifier,
120
+ // e.g. truncated textContent or label text with surrounding punctuation).
121
+ for (const identifier of identifiers) {
122
+ const esc = identifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
123
+ // Current snapshot format: "uid=N_M role "accessible name" [attrs]"
124
+ // uid precedes the role and accessible name; MCP tools expect just the N_M part (no "uid=" prefix).
111
125
  const m1 = text.match(new RegExp(`uid=([^\\s]+)\\s+(?!StaticText)[^\\n]*"[^"]*${esc}`, 'm'));
112
126
  if (m1) return m1[1];
113
127
  // Fallback: accept StaticText nodes (e.g. draggable divs whose only a11y node is text)
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ARGUS Font Loading Analyzer (Sprint 5c — A10)
2
+ * ARGUS Font Loading Analyzer (A10)
3
3
  *
4
4
  * Detects web font performance and reliability issues that cause invisible
5
5
  * text (FOIT), layout shifts (FOUT/CLS), or deliver fonts in suboptimal formats.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ARGUS Form Validation Analyzer (Sprint 5d — A11)
2
+ * ARGUS Form Validation Analyzer (A11)
3
3
  *
4
4
  * Detects accessibility and security gaps in HTML forms — one of the most
5
5
  * commonly broken areas in web apps.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ARGUS HAR Network Baseline Recorder (Sprint 5 — N1)
2
+ * ARGUS HAR Network Baseline Recorder (N1)
3
3
  *
4
4
  * Records all network requests made during a page load as a HAR-style
5
5
  * baseline. On first run, saves the baseline. On subsequent runs, diffs
@@ -23,7 +23,7 @@
23
23
  * analyzeIssues(browser, url, isCritical) — standalone navigator for direct harness use.
24
24
  */
25
25
 
26
- import { normalizeArray } from './flow-runner.js';
26
+ import { parseConsoleMsgResponse } from './mcp-parsers.js';
27
27
 
28
28
  // ── Issue classifiers ─────────────────────────────────────────────────────────
29
29
 
@@ -112,7 +112,8 @@ function classifyIssue(issue, url, isCritical) {
112
112
  * Parse a pre-fetched, already-baseline-sliced issues array into findings.
113
113
  * Pure function — used by crawlRouteCheap after the D5 baseline-slice.
114
114
  *
115
- * @param {object[]} issues - Issues from list_console_messages({ types: ['issue'] })
115
+ * @param {object[]} issues - Parsed issue objects ({ level, text }) from
116
+ * parseConsoleMsgResponse(list_console_messages({ types: ['issue'] }))
116
117
  * @param {string} url - Page URL (used as finding context)
117
118
  * @param {boolean} isCritical
118
119
  * @returns {object[]}
@@ -127,11 +128,12 @@ export function parseIssues(issues, url, isCritical = false) {
127
128
  }
128
129
 
129
130
  /**
130
- * Standalone issues analyzer — navigates to a URL, baselines the current
131
- * Issues count, queries the panel after load, and returns findings.
131
+ * Standalone issues analyzer — navigates to a URL, queries the Issues
132
+ * panel after load, and returns findings.
132
133
  *
133
- * Used by the test harness and any standalone caller. Baselines before
134
- * navigation (D5 pattern) so pre-existing issues from prior pages are excluded.
134
+ * Used by the test harness and any standalone caller. No baseline slice is
135
+ * needed: list_console_messages resets per navigation, so the post-navigation
136
+ * response contains only the current page's issues.
135
137
  *
136
138
  * @param {object} browser
137
139
  * @param {string} url
@@ -141,14 +143,6 @@ export function parseIssues(issues, url, isCritical = false) {
141
143
  export async function analyzeIssues(browser, url, isCritical = false) {
142
144
  const findings = [];
143
145
 
144
- let baseline = 0;
145
- try {
146
- const priorRaw = await browser.listConsoleRaw({ types: ['issue'], includePreservedMessages: true });
147
- baseline = normalizeArray(priorRaw).length;
148
- } catch {
149
- // Issues API may not be available — baseline stays 0
150
- }
151
-
152
146
  try {
153
147
  await browser.navigate(url);
154
148
  await new Promise(r => setTimeout(r, 1000));
@@ -157,11 +151,10 @@ export async function analyzeIssues(browser, url, isCritical = false) {
157
151
  }
158
152
 
159
153
  try {
160
- const raw = await browser.listConsoleRaw({
161
- types: ['issue'],
162
- includePreservedMessages: true,
163
- });
164
- const issues = normalizeArray(raw).slice(baseline);
154
+ // Response is markdown text ("msgid=N [issue] text") — same format as
155
+ // console messages. parseConsoleMsgResponse extracts { level, text }.
156
+ const raw = await browser.listConsoleRaw({ types: ['issue'] });
157
+ const issues = parseConsoleMsgResponse(raw);
165
158
  findings.push(...parseIssues(issues, url, isCritical));
166
159
  } catch {
167
160
  // Issues API not available in this chrome-devtools-mcp build — silent skip
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ARGUS Motion & Animation Accessibility Analyzer (Sprint 5b — A9)
2
+ * ARGUS Motion & Animation Accessibility Analyzer (A9)
3
3
  *
4
4
  * Detects pages that trigger motion/animation without respecting the user's
5
5
  * `prefers-reduced-motion` OS preference — a WCAG 2.1 SC 2.3.3 (AAA) violation
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Intelligent Baseline Filtering — cross-run noise classifier.
3
+ *
4
+ * Pure algorithmic false-positive filter: no external API, no per-run cost.
5
+ * Tracks which finding keys appeared on which routes across the last N runs
6
+ * (reports/baselines/<branch>-history.json) and flags findings that flip-flop
7
+ * between present and absent as "noisy". Noisy findings are downgraded to
8
+ * severity "info" (never suppressed — visibility is kept) and annotated with
9
+ * `noisy: true`, `noiseScore`, and `originalSeverity`.
10
+ *
11
+ * Distinct from flakiness-detector.js (B4), which compares two crawls WITHIN
12
+ * one run. This module classifies across run HISTORY, catching findings that
13
+ * are stable within a run but unstable between runs (timing-dependent ads,
14
+ * third-party scripts, A/B-tested content).
15
+ *
16
+ * Disable with ARGUS_NOISE_FILTER=0.
17
+ */
18
+
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+ import { findingKey } from './flakiness-detector.js';
22
+ import { childLogger } from './logger.js';
23
+
24
+ const logger = childLogger('noise-filter');
25
+
26
+ /** Minimum recorded runs for a route before its findings can be classified noisy. */
27
+ export const NOISE_MIN_RUNS = 4;
28
+ /** Presence-flip ratio (transitions / (runs - 1)) at or above which a finding is noisy. */
29
+ export const NOISE_FLIP_THRESHOLD = 0.4;
30
+ /** Maximum run entries kept in the history file. */
31
+ export const MAX_HISTORY_RUNS = 20;
32
+
33
+ /**
34
+ * Load run history from disk. Returns [] when the file is absent or corrupt.
35
+ *
36
+ * @param {string} historyFile
37
+ * @returns {Array<{ runAt: string, routes: Record<string, string[]> }>}
38
+ */
39
+ export function loadRunHistory(historyFile) {
40
+ if (!fs.existsSync(historyFile)) return [];
41
+ try {
42
+ const parsed = JSON.parse(fs.readFileSync(historyFile, 'utf8'));
43
+ return Array.isArray(parsed) ? parsed : [];
44
+ } catch {
45
+ return [];
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Append the current report's finding keys as one run entry, capped at maxRuns.
51
+ * Atomic write (tmp + rename) — same pattern as baseline-manager.
52
+ *
53
+ * @param {string} historyFile
54
+ * @param {object} report - { generatedAt, routes: [{ url, errors }] }
55
+ * @param {number} [maxRuns]
56
+ */
57
+ export function recordRunHistory(historyFile, report, maxRuns = MAX_HISTORY_RUNS) {
58
+ const dir = path.dirname(historyFile);
59
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
60
+
61
+ const entry = { runAt: report.generatedAt ?? new Date().toISOString(), routes: {} };
62
+ for (const routeResult of (report.routes ?? [])) {
63
+ entry.routes[routeResult.url] = (routeResult.errors ?? []).map(findingKey);
64
+ }
65
+
66
+ let history = loadRunHistory(historyFile);
67
+ history.push(entry);
68
+ if (history.length > maxRuns) history = history.slice(-maxRuns);
69
+
70
+ const tmp = `${historyFile}.${process.pid}.${Date.now()}.tmp`;
71
+ fs.writeFileSync(tmp, JSON.stringify(history, null, 2)); // lgtm[js/network-data-to-file] — intentional: Argus persists crawl history to a local baseline file by design
72
+ fs.renameSync(tmp, historyFile);
73
+ }
74
+
75
+ /**
76
+ * Compute per-finding noise scores from run history.
77
+ *
78
+ * For every route, builds a presence series per finding key across the runs in
79
+ * which that route was crawled, then scores `transitions / (runs - 1)` — 0 for
80
+ * a finding that is always present (or always absent), 1 for one that flips on
81
+ * every consecutive run pair.
82
+ *
83
+ * @param {Array<{ routes: Record<string, string[]> }>} history
84
+ * @returns {Map<string, { score: number, runs: number, transitions: number }>}
85
+ * keyed by `${url}::${findingKey}`
86
+ */
87
+ export function computeNoiseScores(history) {
88
+ const scores = new Map();
89
+ if (!Array.isArray(history) || history.length < 2) return scores;
90
+
91
+ // url → array of Set(keys), one per run that crawled the url (run order preserved)
92
+ const routeSeries = new Map();
93
+ for (const run of history) {
94
+ for (const [url, keys] of Object.entries(run.routes ?? {})) {
95
+ if (!routeSeries.has(url)) routeSeries.set(url, []);
96
+ routeSeries.get(url).push(new Set(keys));
97
+ }
98
+ }
99
+
100
+ for (const [url, series] of routeSeries) {
101
+ if (series.length < 2) continue;
102
+ const allKeys = new Set();
103
+ for (const runKeys of series) for (const k of runKeys) allKeys.add(k);
104
+
105
+ for (const key of allKeys) {
106
+ let transitions = 0;
107
+ for (let i = 1; i < series.length; i++) {
108
+ if (series[i].has(key) !== series[i - 1].has(key)) transitions++;
109
+ }
110
+ scores.set(`${url}::${key}`, {
111
+ score: transitions / (series.length - 1),
112
+ runs: series.length,
113
+ transitions,
114
+ });
115
+ }
116
+ }
117
+ return scores;
118
+ }
119
+
120
+ /**
121
+ * Annotate and downgrade noisy findings in the report (mutates in place).
122
+ *
123
+ * A finding is noisy when its route has ≥ minRuns of history AND its presence
124
+ * flip ratio ≥ flipThreshold. Noisy findings get `noisy: true`, `noiseScore`,
125
+ * `originalSeverity`, and severity downgraded to "info". Caller is responsible
126
+ * for rebuilding report.summary afterwards.
127
+ *
128
+ * @param {object} report
129
+ * @param {Array} history - From loadRunHistory()
130
+ * @param {object} [opts]
131
+ * @param {number} [opts.minRuns]
132
+ * @param {number} [opts.flipThreshold]
133
+ * @returns {{ noisyCount: number }}
134
+ */
135
+ export function applyNoiseFilter(report, history, { minRuns = NOISE_MIN_RUNS, flipThreshold = NOISE_FLIP_THRESHOLD } = {}) {
136
+ const scores = computeNoiseScores(history);
137
+ let noisyCount = 0;
138
+ if (scores.size === 0) return { noisyCount };
139
+
140
+ for (const routeResult of (report.routes ?? [])) {
141
+ for (const finding of (routeResult.errors ?? [])) {
142
+ const entry = scores.get(`${routeResult.url}::${findingKey(finding)}`);
143
+ if (!entry || entry.runs < minRuns || entry.score < flipThreshold) continue;
144
+
145
+ finding.noisy = true;
146
+ finding.noiseScore = Math.round(entry.score * 100) / 100;
147
+ if (finding.severity !== 'info') {
148
+ finding.originalSeverity = finding.severity;
149
+ finding.severity = 'info';
150
+ }
151
+ noisyCount++;
152
+ }
153
+ }
154
+
155
+ if (noisyCount > 0) {
156
+ logger.info(`[ARGUS] Noise filter: ${noisyCount} flip-flopping finding(s) downgraded to info`);
157
+ }
158
+ return { noisyCount };
159
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Argus PDF Exporter
3
+ *
4
+ * Exports the Argus HTML report as a branded A4 PDF using puppeteer.
5
+ * puppeteer is an optional peer dependency — install when needed:
6
+ * npm install puppeteer
7
+ *
8
+ * Usage (programmatic):
9
+ * import { exportReportToPdf } from './pdf-exporter.js';
10
+ * const pdfPath = await exportReportToPdf('./reports/report.html', './reports/report.pdf');
11
+ *
12
+ * Usage (CLI):
13
+ * node src/utils/pdf-exporter.js ./reports/report.html ./reports/report.pdf
14
+ */
15
+
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+ import { fileURLToPath, pathToFileURL } from 'url';
19
+
20
+ const __filename = fileURLToPath(import.meta.url);
21
+
22
+ /**
23
+ * Load puppeteer dynamically so the import failure is a clear runtime error,
24
+ * not a module load error on startup.
25
+ *
26
+ * @returns {Promise<object>} puppeteer default export
27
+ * @throws {Error} with install instructions if not installed
28
+ */
29
+ async function loadPuppeteer() {
30
+ try {
31
+ return (await import('puppeteer')).default;
32
+ } catch {
33
+ throw new Error(
34
+ 'PDF export requires puppeteer:\n' +
35
+ ' npm install puppeteer\n' +
36
+ 'Then retry.'
37
+ );
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Export an Argus HTML report to a branded A4 PDF.
43
+ *
44
+ * @param {string} htmlPath - Absolute or relative path to the source HTML report
45
+ * @param {string} outputPath - Destination PDF file path (written to disk)
46
+ * @param {{ format?: string, landscape?: boolean, scale?: number }} [options]
47
+ * @returns {Promise<string>} Resolved outputPath
48
+ */
49
+ export async function exportReportToPdf(htmlPath, outputPath, options = {}) {
50
+ const {
51
+ format = 'A4',
52
+ landscape = false,
53
+ scale = 1,
54
+ } = options;
55
+
56
+ const resolvedHtml = path.resolve(htmlPath);
57
+ if (!fs.existsSync(resolvedHtml)) {
58
+ throw new Error(`HTML report not found: ${resolvedHtml}`);
59
+ }
60
+
61
+ const resolvedOut = path.resolve(outputPath);
62
+ fs.mkdirSync(path.dirname(resolvedOut), { recursive: true });
63
+
64
+ const puppeteer = await loadPuppeteer();
65
+ const browser = await puppeteer.launch({ headless: 'new' });
66
+
67
+ try {
68
+ const page = await browser.newPage();
69
+ await page.setViewport({ width: 1280, height: 900 });
70
+ await page.goto(pathToFileURL(resolvedHtml).href, { waitUntil: 'networkidle0', timeout: 30_000 });
71
+
72
+ await page.pdf({
73
+ path: resolvedOut,
74
+ format,
75
+ landscape,
76
+ scale,
77
+ printBackground: true,
78
+ margin: { top: '18mm', right: '14mm', bottom: '18mm', left: '14mm' },
79
+ });
80
+ } finally {
81
+ await browser.close();
82
+ }
83
+
84
+ return resolvedOut;
85
+ }
86
+
87
+ /**
88
+ * Navigate to a live URL and export to PDF.
89
+ *
90
+ * @param {string} pageUrl - URL to navigate to before printing
91
+ * @param {string} outputPath - Destination PDF file path
92
+ * @param {{ format?: string, landscape?: boolean, scale?: number, waitUntil?: string }} [options]
93
+ * @returns {Promise<string>}
94
+ */
95
+ export async function exportPageToPdf(pageUrl, outputPath, options = {}) {
96
+ const {
97
+ format = 'A4',
98
+ landscape = false,
99
+ scale = 1,
100
+ waitUntil = 'networkidle0',
101
+ } = options;
102
+
103
+ const resolvedOut = path.resolve(outputPath);
104
+ fs.mkdirSync(path.dirname(resolvedOut), { recursive: true });
105
+
106
+ const puppeteer = await loadPuppeteer();
107
+ const browser = await puppeteer.launch({ headless: 'new' });
108
+
109
+ try {
110
+ const page = await browser.newPage();
111
+ await page.setViewport({ width: 1280, height: 900 });
112
+ await page.goto(pageUrl, { waitUntil, timeout: 30_000 });
113
+
114
+ await page.pdf({
115
+ path: resolvedOut,
116
+ format,
117
+ landscape,
118
+ scale,
119
+ printBackground: true,
120
+ margin: { top: '18mm', right: '14mm', bottom: '18mm', left: '14mm' },
121
+ });
122
+ } finally {
123
+ await browser.close();
124
+ }
125
+
126
+ return resolvedOut;
127
+ }
128
+
129
+ // ── CLI entry ─────────────────────────────────────────────────────────────────
130
+
131
+ if (process.argv[1] === __filename) {
132
+ const [,, htmlArg, outArg] = process.argv;
133
+
134
+ if (!htmlArg || !outArg) {
135
+ process.stderr.write('Usage: node src/utils/pdf-exporter.js <report.html> <output.pdf>\n');
136
+ process.exit(1);
137
+ }
138
+
139
+ try {
140
+ const out = await exportReportToPdf(htmlArg, outArg);
141
+ process.stdout.write(`✓ PDF written: ${out}\n`);
142
+ } catch (err) {
143
+ process.stderr.write(`✗ ${err.message}\n`);
144
+ process.exit(1);
145
+ }
146
+ }
@@ -81,8 +81,9 @@ const EXCLUDED_PATTERNS = [
81
81
  /**
82
82
  * Patterns that indicate an infrastructure-level file whose change can affect
83
83
  * every route — framework configs, root layouts, global stylesheets, package.json.
84
+ * Exported for reuse by root-cause-linker.js (MIT).
84
85
  */
85
- const INFRA_PATTERNS = [
86
+ export const INFRA_PATTERNS = [
86
87
  /next\.config\./i,
87
88
  /vite\.config\./i,
88
89
  /tailwind\.config\./i,