argusqa-os 9.7.6 → 9.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Argus PR Validator — baseline-aware blocking (PR_VALIDATOR plan, Phase B1).
3
+ *
4
+ * The PR Validator's job is to block on regressions the PR INTRODUCES, not on findings
5
+ * that already exist on the affected routes. This module supplies the head-vs-base finding
6
+ * diff and the (safety-critical) merge-block decision built on it.
7
+ *
8
+ * Base source (PR_VALIDATOR §0): a stored, per-branch baseline file in the standard
9
+ * baseline-manager on-disk format (`reports/baselines/<branch>.json`) — restored in CI via
10
+ * the actions/cache pattern, keyed on the PR's target branch (GITHUB_BASE_REF). NOT a second
11
+ * live audit. When no baseline is available the decision FAILS SAFE to absolute blocking (the
12
+ * validator's pre-B1 behaviour) and SAYS SO — it must never silently pass a broken app.
13
+ *
14
+ * Keying: the baseline route map is keyed by route PATH (e.g. "/checkout"), not the full
15
+ * URL, so a PR-head deploy on a different host (a Vercel/Netlify preview) still diffs against
16
+ * the base correctly. Finding identity reuses findingKey() (type::message[:100]::status) from
17
+ * flakiness-detector, so new/persisting classification matches every other Argus diff.
18
+ *
19
+ * stdout discipline: this module is reachable from src/mcp-server.js — it writes NOTHING to
20
+ * stdout. The CLI owns stdout (the JSON result + CI annotations); diagnostics go to stderr.
21
+ */
22
+
23
+ import path from 'path';
24
+ import { loadBaseline, saveBaseline } from './baseline-manager.js';
25
+ import { findingKey } from './flakiness-detector.js';
26
+
27
+ /** Severity tally for a flat findings array → { critical, warning, info }. */
28
+ export function severityTally(findings) {
29
+ const t = { critical: 0, warning: 0, info: 0 };
30
+ for (const f of (Array.isArray(findings) ? findings : [])) {
31
+ if (f && Object.prototype.hasOwnProperty.call(t, f.severity)) t[f.severity]++;
32
+ }
33
+ return t;
34
+ }
35
+
36
+ /**
37
+ * Diff per-route PR-head findings against a loaded baseline.
38
+ *
39
+ * Pure — does not mutate inputs. A finding is NEW when its findingKey is absent from its
40
+ * route's baseline key set; PERSISTING when present. RESOLVED counts baseline keys (on the
41
+ * AUDITED routes only) with no current finding — un-audited baseline routes are never counted
42
+ * resolved (they were not checked this run).
43
+ *
44
+ * @param {Array<{ path: string, findings: Array }>} routeResults audited routes + their findings
45
+ * @param {{ routes: Map<string, Set<string>> } | null} baseline loadPrBaseline() output
46
+ * @returns {{ newFindings: Array, persistingFindings: Array,
47
+ * newSummary: {critical:number,warning:number,info:number},
48
+ * persistingCount: number, resolvedCount: number }}
49
+ */
50
+ export function diffRoutesAgainstBaseline(routeResults, baseline) {
51
+ const routes = Array.isArray(routeResults) ? routeResults : [];
52
+ const baseRoutes = baseline && baseline.routes instanceof Map ? baseline.routes : new Map();
53
+
54
+ const newFindings = [];
55
+ const persistingFindings = [];
56
+ let resolvedCount = 0;
57
+
58
+ for (const r of routes) {
59
+ const routePath = String(r?.path ?? '');
60
+ const findings = Array.isArray(r?.findings) ? r.findings : [];
61
+ const baseKeys = baseRoutes.get(routePath) ?? new Set();
62
+ const seen = new Set();
63
+
64
+ for (const f of findings) {
65
+ const key = findingKey(f);
66
+ seen.add(key);
67
+ if (baseKeys.has(key)) persistingFindings.push(f);
68
+ else newFindings.push(f);
69
+ }
70
+
71
+ // Resolved: a baseline finding on THIS audited route that no longer appears.
72
+ for (const k of baseKeys) {
73
+ if (!seen.has(k)) resolvedCount++;
74
+ }
75
+ }
76
+
77
+ return {
78
+ newFindings,
79
+ persistingFindings,
80
+ newSummary: severityTally(newFindings),
81
+ persistingCount: persistingFindings.length,
82
+ resolvedCount,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Tag each PR-head finding with `isNew` (true = PR-introduced, false = persisting) based on the
88
+ * per-route baseline, so the GitHub PR comment's new/persisting surfacing (Phase B2) and the
89
+ * existing isNew-aware reporters (formatPrComment / buildStatusPayload) reflect what the PR
90
+ * actually introduced rather than every finding on the affected routes.
91
+ *
92
+ * Mutates the finding objects in place. In both PR-validate paths the per-route findings ARE the
93
+ * same object references that populate the flat `result.findings` array, so tagging here tags the
94
+ * comment's source data transitively — no second pass needed.
95
+ *
96
+ * Uses findingKey() (the same identity diffRoutesAgainstBaseline uses), so the tags and the
97
+ * head-vs-base counts can never disagree. No-op when no baseline is available: the run fails safe
98
+ * to absolute blocking, where every finding is correctly treated as new (left untagged →
99
+ * `isNew !== false` → counted as new by formatPrComment).
100
+ *
101
+ * @param {Array<{ path: string, findings: Array }>} routeResults
102
+ * @param {{ routes: Map<string, Set<string>> } | null} baseline
103
+ */
104
+ export function tagFindingNovelty(routeResults, baseline) {
105
+ if (!(baseline && baseline.routes instanceof Map)) return;
106
+ const baseRoutes = baseline.routes;
107
+ for (const r of (Array.isArray(routeResults) ? routeResults : [])) {
108
+ const baseKeys = baseRoutes.get(String(r?.path ?? '')) ?? new Set();
109
+ for (const f of (Array.isArray(r?.findings) ? r.findings : [])) {
110
+ // A finding is NEW when its key is ABSENT from the baseline; present → persisting.
111
+ if (f && typeof f === 'object') f.isNew = !baseKeys.has(findingKey(f));
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * The PR Validator's merge-block decision — baseline-aware, fail-safe. SAFETY-CRITICAL.
118
+ *
119
+ * Reused by BOTH PR-validate paths (src/cli/pr-validate.js + src/mcp-server.js
120
+ * handlePrValidate) so they cannot diverge on the block semantics.
121
+ *
122
+ * - baseline available → gate on the NEW (PR-introduced) findings only.
123
+ * - baseline absent → FAIL SAFE: gate on the ABSOLUTE findings (the pre-B1 behaviour)
124
+ * AND set `note`, so the summary/comment states that blocking is on
125
+ * absolute counts. Never silently passes a broken app.
126
+ *
127
+ * The gate itself is the existing block-on matrix (none | warning | critical).
128
+ *
129
+ * @param {object} args
130
+ * @param {Array<{path:string,findings:Array}>} [args.routeFindings] audited routes + findings
131
+ * @param {{critical:number,warning:number,info:number}} [args.summary] absolute severity tally
132
+ * @param {string} [args.blockOn] none | warning | critical
133
+ * @param {{routes: Map}|null} [args.baseline] loadPrBaseline() output
134
+ * @returns {{ blocked:boolean, baselineAvailable:boolean,
135
+ * effectiveSummary:{critical:number,warning:number,info:number},
136
+ * newSummary:object|null, persistingCount:number, resolvedCount:number,
137
+ * reason:string|null, note:string|null }}
138
+ */
139
+ export function decidePrBlock({
140
+ routeFindings = [],
141
+ summary = { critical: 0, warning: 0, info: 0 },
142
+ blockOn = 'critical',
143
+ baseline = null,
144
+ } = {}) {
145
+ const policy = String(blockOn ?? 'critical').toLowerCase().trim();
146
+ const baselineAvailable = !!(baseline && baseline.routes instanceof Map);
147
+
148
+ const diff = baselineAvailable ? diffRoutesAgainstBaseline(routeFindings, baseline) : null;
149
+ const effectiveSummary = baselineAvailable ? diff.newSummary : { ...summary };
150
+
151
+ const blocked =
152
+ policy === 'critical' ? effectiveSummary.critical > 0 :
153
+ policy === 'warning' ? effectiveSummary.critical + effectiveSummary.warning > 0 :
154
+ false;
155
+
156
+ // "new" vs "total" makes the block reason honest about what it counted.
157
+ const scope = baselineAvailable ? 'new' : 'total';
158
+ const reason = !blocked ? null
159
+ : policy === 'warning'
160
+ ? `${effectiveSummary.critical} critical + ${effectiveSummary.warning} warning ${scope} finding(s) at or above the block threshold`
161
+ : `${effectiveSummary.critical} critical ${scope} finding(s) found`;
162
+
163
+ const note = baselineAvailable
164
+ ? null
165
+ : 'Baseline unavailable — blocking on absolute finding counts (no per-branch baseline found to diff against).';
166
+
167
+ return {
168
+ blocked,
169
+ baselineAvailable,
170
+ effectiveSummary,
171
+ newSummary: diff ? diff.newSummary : null,
172
+ persistingCount: diff ? diff.persistingCount : 0,
173
+ resolvedCount: diff ? diff.resolvedCount : 0,
174
+ reason,
175
+ note,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Resolve the baseline file path for a PR-validate run.
181
+ *
182
+ * 1. ARGUS_BASELINE_FILE — explicit override (used verbatim).
183
+ * 2. <outputDir>/baselines/<safe(baseRef)>.json — baseRef defaults to the PR target branch
184
+ * (GITHUB_BASE_REF) for the READ path; pass getCurrentBranch() for the WRITE path.
185
+ * 3. null — no resolvable base ref → the caller fails safe to absolute blocking.
186
+ *
187
+ * @param {object} [opts]
188
+ * @param {string} [opts.outputDir] reports dir (default REPORT_OUTPUT_DIR or ./reports)
189
+ * @param {string} [opts.baseRef] branch ref to resolve the file for
190
+ * @returns {string|null}
191
+ */
192
+ export function resolvePrBaselineFile({ outputDir, baseRef } = {}) {
193
+ if (process.env.ARGUS_BASELINE_FILE) return process.env.ARGUS_BASELINE_FILE;
194
+ const ref = baseRef ?? process.env.GITHUB_BASE_REF ?? process.env.ARGUS_BASE_BRANCH;
195
+ if (!ref) return null;
196
+ const dir = outputDir || process.env.REPORT_OUTPUT_DIR || './reports';
197
+ const safe = String(ref).replace(/[/\\]/g, '__').replace(/[^a-zA-Z0-9._-]/g, '_') || 'default';
198
+ return path.join(dir, 'baselines', `${safe}.json`);
199
+ }
200
+
201
+ /**
202
+ * Load a PR baseline file. Returns { routes: Map<path, Set<key>> } or null (absent/corrupt).
203
+ * Thin wrapper over baseline-manager.loadBaseline — identical on-disk format; here the route
204
+ * keys are PATHS rather than full URLs (see the module header).
205
+ */
206
+ export function loadPrBaseline(file) {
207
+ if (!file) return null;
208
+ return loadBaseline(file);
209
+ }
210
+
211
+ /**
212
+ * Persist the current run's per-route findings as this branch's baseline (path-keyed).
213
+ *
214
+ * Reuses baseline-manager.saveBaseline by adapting routeResults into its report shape: each
215
+ * route's `url` is set to the route PATH, so the stored `routes` map is keyed by path.
216
+ *
217
+ * @param {string} file
218
+ * @param {Array<{ path: string, findings: Array }>} routeResults
219
+ */
220
+ export function savePrBaseline(file, routeResults) {
221
+ const report = {
222
+ routes: (Array.isArray(routeResults) ? routeResults : []).map(r => ({
223
+ url: String(r?.path ?? ''),
224
+ errors: Array.isArray(r?.findings) ? r.findings : [],
225
+ })),
226
+ flows: [],
227
+ codebase: [],
228
+ };
229
+ saveBaseline(file, report);
230
+ }