@clear-capabilities/agentic-security-scanner 0.128.1 → 0.132.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/bin/agentic-security.js +52 -2
  3. package/dist/11.index.js +2 -2
  4. package/dist/113.index.js +498 -7
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +220 -0
  7. package/dist/238.index.js +218 -0
  8. package/dist/259.index.js +975 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/415.index.js +1 -1
  11. package/dist/435.index.js +4 -4
  12. package/dist/526.index.js +844 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/830.index.js +1 -1
  15. package/dist/agentic-security.mjs +106 -194
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +33 -17
  18. package/src/dataflow/CLAUDE.md +4 -1
  19. package/src/dataflow/async-sequencing.js +8 -3
  20. package/src/dataflow/catalog.js +278 -11
  21. package/src/dataflow/cross-repo.js +1 -1
  22. package/src/dataflow/cross-service-taint.js +1 -1
  23. package/src/dataflow/engine.js +182 -61
  24. package/src/dataflow/ifds.js +10 -5
  25. package/src/dataflow/index.js +15 -3
  26. package/src/dataflow/points-to.js +8 -2
  27. package/src/dataflow/proof-gate.js +7 -0
  28. package/src/dataflow/sanitizer-gate.js +89 -0
  29. package/src/dataflow/tabulation.js +14 -3
  30. package/src/engine.js +170 -7
  31. package/src/integrations/index.js +1 -1
  32. package/src/ir/CLAUDE.md +49 -4
  33. package/src/ir/call-sites.js +66 -0
  34. package/src/ir/callgraph.js +174 -7
  35. package/src/ir/class-hierarchy.js +22 -2
  36. package/src/ir/index.js +138 -51
  37. package/src/ir/ir-stats.js +126 -0
  38. package/src/ir/parser-cpp.js +829 -0
  39. package/src/ir/parser-cs.js +4 -1
  40. package/src/ir/parser-go.js +4 -1
  41. package/src/ir/parser-js.js +13 -1
  42. package/src/ir/parser-kt.js +4 -1
  43. package/src/ir/parser-php.js +10 -3
  44. package/src/ir/parser-py-cst.js +62 -10
  45. package/src/ir/tree-sitter-loader.js +13 -1
  46. package/src/llm-validator/index.js +9 -2
  47. package/src/llm-validator/redact.js +157 -0
  48. package/src/mcp/tools.js +2 -2
  49. package/src/posture/CLAUDE.md +193 -1
  50. package/src/posture/accuracy-scorecard.js +317 -0
  51. package/src/posture/api-contract.js +1 -1
  52. package/src/posture/attestation.js +202 -0
  53. package/src/posture/auditor-walkthrough.js +12 -3
  54. package/src/posture/compliance-policy.js +1 -1
  55. package/src/posture/corpus-enroll.js +303 -0
  56. package/src/posture/corpus-match.js +52 -0
  57. package/src/posture/cross-lang-openapi.js +1 -1
  58. package/src/posture/custom-rules.js +3 -3
  59. package/src/posture/execution-proof.js +92 -0
  60. package/src/posture/exploitability-probability.js +1 -1
  61. package/src/posture/falsification.js +45 -1
  62. package/src/posture/fix-metrics.js +197 -0
  63. package/src/posture/fix-verify.js +129 -2
  64. package/src/posture/license-policy.js +1 -1
  65. package/src/posture/profile.js +1 -1
  66. package/src/posture/proof-tier.js +33 -0
  67. package/src/posture/relevance.js +379 -0
  68. package/src/posture/root-cause-sweep.js +0 -0
  69. package/src/posture/rule-overrides.js +1 -1
  70. package/src/posture/sca-policy.js +1 -1
  71. package/src/posture/scan-checkpoint.js +277 -0
  72. package/src/posture/suppressions.js +1 -1
  73. package/src/posture/test-runner.js +147 -0
  74. package/src/posture/verification-separation.js +131 -0
  75. package/src/report/index.js +11 -0
  76. package/src/runScan.js +5 -7
  77. package/src/sandbox/CLAUDE.md +340 -0
  78. package/src/sandbox/backend-disabled.js +14 -0
  79. package/src/sandbox/backend-namespace.js +335 -0
  80. package/src/sandbox/backend-userspace.js +83 -0
  81. package/src/sandbox/capabilities.js +181 -0
  82. package/src/sandbox/index.js +30 -0
  83. package/src/sandbox/limits.js +63 -0
  84. package/src/sandbox/result.js +104 -0
  85. package/src/sca/dep-confusion.js +1 -1
  86. package/src/util/glob.js +173 -0
  87. package/src/util/yaml.js +24 -0
@@ -0,0 +1,220 @@
1
+ export const id = 207;
2
+ export const ids = [207];
3
+ export const modules = {
4
+
5
+ /***/ 9207:
6
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
+
8
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
9
+ /* harmony export */ computeRunAttestation: () => (/* binding */ computeRunAttestation)
10
+ /* harmony export */ });
11
+ /* unused harmony exports ATTESTATION_CANONICALISATION, verifyRunAttestation */
12
+ /* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(7598);
13
+ /* harmony import */ var _integrity_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(1130);
14
+ // R4 — determinism as a contract.
15
+ //
16
+ // Determinism has been an implementation property of this engine (stable sorts,
17
+ // no Date.now() in ids, `--deterministic`) with no way for a third party to
18
+ // VERIFY it. This module turns it into something checkable: a stable digest
19
+ // over the finding set, bound to the engine version, ruleset version, and
20
+ // bundle hash that produced it.
21
+ //
22
+ // WHAT THE DIGEST IS INDEPENDENT OF (by construction):
23
+ // - the ORDER findings were emitted in (entries are canonicalised, then sorted)
24
+ // - run ids, timestamps, durations, and every other field not on the
25
+ // identity allowlist below — canonicalisation is an ALLOWLIST, not a
26
+ // denylist, so a new non-deterministic field cannot silently leak in
27
+ // - path separator style, and the absolute prefix when `root` is supplied
28
+ //
29
+ // WHAT IT IS NOT INDEPENDENT OF (all of these are real differences):
30
+ // - a changed severity, file, line, rule id, cwe, or vuln title
31
+ // - a finding appearing or disappearing (including a duplicate — multiplicity
32
+ // is preserved; two identical findings are not collapsed into one)
33
+ // - the engine version, ruleset version, or bundle sha
34
+ //
35
+ // WHAT THIS PROVES, AND WHAT IT DOES NOT (read before quoting it at anyone):
36
+ // PROVES — two finding sets carrying the same digest under the same
37
+ // canonicalisation are the same set of findings, modulo emission order and
38
+ // the excluded volatile fields; and (when signed) that the attestation was
39
+ // produced on an install holding this HMAC key.
40
+ // DOES NOT PROVE — cross-machine reproducibility. Nothing here demonstrates
41
+ // that a different host, OS, Node version, or toolchain produces the same
42
+ // finding set; several detectors are environment-sensitive (e.g. the Python
43
+ // AST path is used when python3 is available and falls back to regex when it
44
+ // is not, which can change what is found). Two runs on two machines agreeing
45
+ // would be evidence FOR reproducibility, but that experiment has not been
46
+ // run here and this module does not assert its outcome. The signature is a
47
+ // symmetric per-install HMAC, so it is tamper-evidence for the operator, not
48
+ // third-party non-repudiation.
49
+ //
50
+ // Identity fields deliberately EXCLUDE `parser` and `family`: `parser` records
51
+ // which analysis engine fired, which is environment-sensitive (see the python3
52
+ // case above), so including it would make the digest report an environment
53
+ // difference as a findings difference. The finding's identity — where it is
54
+ // and what it is — is fully captured without it.
55
+ //
56
+ // SIGNING: reuses `integrity.js`'s per-install HMAC key handling verbatim
57
+ // (`signLastScan`, keyed from $AGENTIC_SECURITY_HMAC_KEY or the 0600 key file
58
+ // at $XDG_CONFIG_HOME/agentic-security/scan-key). No second key mechanism is
59
+ // introduced. `integrity.verifyLastScan` is NOT reused because it verifies a
60
+ // body against a sibling `.sig` FILE; an attestation carries its signature
61
+ // inline, so verification re-signs and compares in constant time here.
62
+ //
63
+ // NO THROWING (posture/CLAUDE.md convention): malformed input yields an empty
64
+ // canonical set or an `{ok:false, reason}` refusal, never an exception.
65
+
66
+
67
+
68
+
69
+ const ATTESTATION_CANONICALISATION = 'agentic-security/run-attestation-canon-v1';
70
+
71
+ const PROVES =
72
+ 'Two finding sets with this digest, under this canonicalisation, are the same findings ' +
73
+ '(same rule id, severity, file, line, cwe, vuln, and multiplicity) produced by the same ' +
74
+ 'engine version, ruleset version, and bundle — regardless of emission order.';
75
+ const DOES_NOT_PROVE =
76
+ 'It does not prove cross-machine reproducibility: this attestation is one run on one machine, ' +
77
+ 'nothing here compares a second machine, OS, or Node version, and some detectors are ' +
78
+ 'environment-sensitive. That property is tested separately by the determinism-attest / ' +
79
+ 'determinism-compare CI jobs, which run the same commit on two operating systems and fail ' +
80
+ 'unless the digests match — evidence about the ENGINE, not about this attestation. ' +
81
+ 'A signature, when present, is a symmetric per-install HMAC — tamper-evidence for this ' +
82
+ 'install, not third-party non-repudiation.';
83
+
84
+ function _str(v) { return v === undefined || v === null ? '' : String(v); }
85
+
86
+ // Normalise a file path: separators to '/', drop a supplied absolute root,
87
+ // drop a leading './'. Everything else is left alone — guessing at a root we
88
+ // were not given would make two genuinely different files collide.
89
+ function _normPath(file, root) {
90
+ let p = _str(file).replace(/\\/g, '/');
91
+ if (root) {
92
+ let r = String(root).replace(/\\/g, '/').replace(/\/+$/, '');
93
+ if (r && p.startsWith(r + '/')) p = p.slice(r.length + 1);
94
+ else if (r && p === r) p = '';
95
+ }
96
+ return p.replace(/^\.\//, '');
97
+ }
98
+
99
+ /**
100
+ * The canonical, order-independent representation of a finding set: one
101
+ * tab-joined record per finding over the identity allowlist, sorted.
102
+ * Multiplicity is preserved (duplicates are NOT deduped).
103
+ */
104
+ function canonicaliseFindings(findings, { root } = {}) {
105
+ const list = Array.isArray(findings) ? findings : [];
106
+ const rows = [];
107
+ for (const f of list) {
108
+ if (!f || typeof f !== 'object') continue;
109
+ rows.push([
110
+ _str(f.id),
111
+ _str(f.severity),
112
+ _normPath(f.file, root),
113
+ _str(f.line),
114
+ _str(f.cwe),
115
+ _str(f.vuln),
116
+ ].join('\t'));
117
+ }
118
+ return rows.sort();
119
+ }
120
+
121
+ /**
122
+ * Compute the run attestation.
123
+ *
124
+ * @param {object[]} findings the finding set (any order)
125
+ * @param {string} engineVersion
126
+ * @param {string} rulesetVersion
127
+ * @param {string} bundleSha
128
+ * @param {string} [root] scan root, used to relativise absolute paths
129
+ * @param {boolean} [sign] attach a per-install HMAC over the digest
130
+ */
131
+ function computeRunAttestation({
132
+ findings, engineVersion, rulesetVersion, bundleSha, root, sign = false,
133
+ } = {}) {
134
+ const entries = canonicaliseFindings(findings, { root });
135
+ const payload = JSON.stringify({
136
+ canonicalisation: ATTESTATION_CANONICALISATION,
137
+ engineVersion: _str(engineVersion),
138
+ rulesetVersion: _str(rulesetVersion),
139
+ bundleSha: _str(bundleSha),
140
+ findingCount: entries.length,
141
+ entries,
142
+ });
143
+ const digest = node_crypto__WEBPACK_IMPORTED_MODULE_0__.createHash('sha256').update(payload).digest('hex');
144
+ const att = {
145
+ digest,
146
+ algorithm: 'sha256',
147
+ findingCount: entries.length,
148
+ engineVersion: _str(engineVersion),
149
+ rulesetVersion: _str(rulesetVersion),
150
+ bundleSha: _str(bundleSha),
151
+ canonicalisation: ATTESTATION_CANONICALISATION,
152
+ proves: PROVES,
153
+ doesNotProve: DOES_NOT_PROVE,
154
+ };
155
+ if (sign) {
156
+ try {
157
+ att.signature = (0,_integrity_js__WEBPACK_IMPORTED_MODULE_1__/* .signLastScan */ .lU)(digest);
158
+ att.signatureScope = 'per-install-hmac';
159
+ } catch { /* signing is best-effort; an unsigned attestation is still valid */ }
160
+ }
161
+ return att;
162
+ }
163
+
164
+ /**
165
+ * Re-derive the attestation from a finding set and compare.
166
+ * @returns {{ok: boolean, reason: string}}
167
+ */
168
+ function verifyRunAttestation(attestation, inputs = {}) {
169
+ if (!attestation || typeof attestation !== 'object') {
170
+ return { ok: false, reason: 'no attestation supplied' };
171
+ }
172
+ if (attestation.canonicalisation !== ATTESTATION_CANONICALISATION) {
173
+ return {
174
+ ok: false,
175
+ reason: `unknown canonicalisation "${_str(attestation.canonicalisation)}" — ` +
176
+ `this build verifies "${ATTESTATION_CANONICALISATION}" only`,
177
+ };
178
+ }
179
+ if (typeof attestation.digest !== 'string' || !/^[0-9a-f]{64}$/.test(attestation.digest)) {
180
+ return { ok: false, reason: 'attestation carries no sha256 digest' };
181
+ }
182
+ for (const k of ['engineVersion', 'rulesetVersion', 'bundleSha']) {
183
+ if (_str(inputs[k]) !== _str(attestation[k])) {
184
+ return { ok: false, reason: `${k} mismatch: attested "${_str(attestation[k])}", got "${_str(inputs[k])}"` };
185
+ }
186
+ }
187
+ const recomputed = computeRunAttestation({
188
+ findings: inputs.findings,
189
+ engineVersion: inputs.engineVersion,
190
+ rulesetVersion: inputs.rulesetVersion,
191
+ bundleSha: inputs.bundleSha,
192
+ root: inputs.root,
193
+ });
194
+ if (recomputed.findingCount !== attestation.findingCount) {
195
+ return {
196
+ ok: false,
197
+ reason: `finding count mismatch: attested ${attestation.findingCount}, got ${recomputed.findingCount}`,
198
+ };
199
+ }
200
+ if (recomputed.digest !== attestation.digest) {
201
+ return { ok: false, reason: 'digest mismatch — the finding set is not the one attested' };
202
+ }
203
+ if (attestation.signature) {
204
+ let expected;
205
+ try { expected = signLastScan(attestation.digest); }
206
+ catch { return { ok: false, reason: 'signature present but no key material available to check it' }; }
207
+ const a = Buffer.from(String(attestation.signature), 'hex');
208
+ const b = Buffer.from(expected, 'hex');
209
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
210
+ return { ok: false, reason: 'signature does not verify under this install key' };
211
+ }
212
+ return { ok: true, reason: 'digest and per-install signature verify' };
213
+ }
214
+ return { ok: true, reason: 'digest verifies (unsigned attestation)' };
215
+ }
216
+
217
+
218
+ /***/ })
219
+
220
+ };
@@ -0,0 +1,218 @@
1
+ export const id = 238;
2
+ export const ids = [238];
3
+ export const modules = {
4
+
5
+ /***/ 2238:
6
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
+
8
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
9
+ /* harmony export */ I3: () => (/* binding */ recordFixAttempt),
10
+ /* harmony export */ fixDurationReport: () => (/* binding */ fixDurationReport),
11
+ /* harmony export */ renderFixDurationSummary: () => (/* binding */ renderFixDurationSummary)
12
+ /* harmony export */ });
13
+ /* unused harmony exports FIX_STAGES, loadFixAttempts, bucketOf, summarizeFixDurations, _internals */
14
+ /* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(3024);
15
+ /* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(6760);
16
+ /* harmony import */ var _state_dir_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(1174);
17
+ // Time-to-validated-fix (R5, the reporting half).
18
+ //
19
+ // `verifyFix` already RUNS the stages and `test-runner.js` already times the
20
+ // slowest one. What did not exist was anything durable to read afterwards, so
21
+ // "how long does a fix actually take to validate" had no answer from real runs
22
+ // — only an estimate (`time-to-fix.js` guesses engineering hours from family
23
+ // and patch shape, before anything runs). This module is the opposite: it
24
+ // records what the pipeline observed and reports the distribution.
25
+ //
26
+ // THE HONESTY RULES, which are most of why this file is longer than a mean:
27
+ //
28
+ // 1. A failed attempt is NOT a data point about how long a fix takes. Fixes
29
+ // that fail verification fail fast (a re-scan that still sees the finding
30
+ // never reaches the test suite), so blending them into one average makes
31
+ // the pipeline look faster the worse it performs. Validated and failed
32
+ // attempts are summarised separately and never merged.
33
+ //
34
+ // 2. "Tests skipped" is not "tests passed". A project with no detectable
35
+ // suite can reach `ok:true` having run only the re-scan and the linter.
36
+ // That is a weaker claim than a fix whose suite executed, and it is also
37
+ // much faster, so counting the two together would quietly deflate the
38
+ // headline. They get their own bucket: `validated` means the suite ran and
39
+ // passed, `validatedWithoutTests` means there was no suite to run.
40
+ //
41
+ // 3. Every figure carries its `n`, and a percentile computed from too few
42
+ // samples is labelled unreliable rather than omitted or silently reported.
43
+ // Same precedent as the accuracy scorecard's `{n, d}` rates: a number
44
+ // without its denominator is not a measurement.
45
+ //
46
+ // Storage is append-only JSONL at `<scanRoot>/.agentic-security/fix-metrics.jsonl`,
47
+ // one record per verification attempt. Nothing here throws (posture
48
+ // convention) — an unwritable or corrupt log degrades to "no metrics", never
49
+ // to a failed verification.
50
+
51
+
52
+
53
+
54
+
55
+ const STATE_DIR = '.agentic-security';
56
+ const LOG_FILE = 'fix-metrics.jsonl';
57
+
58
+ // Below this many samples a percentile is an artifact of the sample, not a
59
+ // property of the pipeline. Reported anyway (hiding it invites re-deriving it
60
+ // wrong downstream) but flagged, so a caller cannot quote it as settled.
61
+ const RELIABLE_N = 10;
62
+
63
+ // The stages verifyFix runs, in execution order. Kept here so the recorder and
64
+ // the summariser cannot drift apart on stage naming.
65
+ const FIX_STAGES = Object.freeze(['rescan', 'lint', 'tests', 'honesty', 'poc']);
66
+
67
+ function _logPath(scanRoot) {
68
+ return node_path__WEBPACK_IMPORTED_MODULE_1__.join(scanRoot, STATE_DIR, LOG_FILE);
69
+ }
70
+
71
+ /**
72
+ * Append one verification attempt. Best-effort and silent on failure: metrics
73
+ * must never be able to fail a fix that otherwise verified.
74
+ *
75
+ * @returns {boolean} whether the record was written (for tests, not callers).
76
+ */
77
+ function recordFixAttempt(scanRoot, record) {
78
+ if (!scanRoot || !record || typeof record !== 'object') return false;
79
+ try {
80
+ const dir = node_path__WEBPACK_IMPORTED_MODULE_1__.join(scanRoot, STATE_DIR);
81
+ if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.isSafeStateDir)(dir)) return false;
82
+ node_fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(dir, { recursive: true });
83
+ // One writeSync of one newline-terminated line: a concurrent reader sees
84
+ // whole records or nothing, and a torn tail is dropped on read.
85
+ node_fs__WEBPACK_IMPORTED_MODULE_0__.appendFileSync(_logPath(scanRoot), JSON.stringify(record) + '\n', 'utf8');
86
+ return true;
87
+ } catch { return false; }
88
+ }
89
+
90
+ /**
91
+ * Read every well-formed attempt. A line that does not parse is skipped, not
92
+ * fatal — the last line of an interrupted write is the expected case.
93
+ */
94
+ function loadFixAttempts(scanRoot) {
95
+ try {
96
+ const raw = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(_logPath(scanRoot), 'utf8');
97
+ const out = [];
98
+ for (const line of raw.split('\n')) {
99
+ if (!line.trim()) continue;
100
+ try {
101
+ const rec = JSON.parse(line);
102
+ if (rec && typeof rec === 'object' && typeof rec.totalMs === 'number') out.push(rec);
103
+ } catch { /* torn or hand-edited line — drop it, keep the rest */ }
104
+ }
105
+ return out;
106
+ } catch { return []; }
107
+ }
108
+
109
+ // Nearest-rank percentile over an ascending array. Nearest-rank rather than
110
+ // interpolated because these are observed durations, and an interpolated p50
111
+ // reports a duration that no run actually took.
112
+ function _pct(sorted, p) {
113
+ if (!sorted.length) return null;
114
+ const rank = Math.ceil((p / 100) * sorted.length);
115
+ return sorted[Math.min(sorted.length - 1, Math.max(0, rank - 1))];
116
+ }
117
+
118
+ function _dist(values) {
119
+ const v = values.filter(x => typeof x === 'number' && Number.isFinite(x) && x >= 0).sort((a, b) => a - b);
120
+ if (!v.length) return { n: 0, minMs: null, p50Ms: null, p90Ms: null, maxMs: null, meanMs: null, reliable: false };
121
+ const sum = v.reduce((a, b) => a + b, 0);
122
+ return {
123
+ n: v.length,
124
+ minMs: v[0],
125
+ p50Ms: _pct(v, 50),
126
+ p90Ms: _pct(v, 90),
127
+ maxMs: v[v.length - 1],
128
+ meanMs: Math.round(sum / v.length),
129
+ // Says whether the percentiles above may be quoted, not whether the count
130
+ // is real. n and min/max/mean are exact at any sample size.
131
+ reliable: v.length >= RELIABLE_N,
132
+ };
133
+ }
134
+
135
+ // Which bucket an attempt belongs to. Deliberately total: every attempt lands
136
+ // in exactly one, so the bucket counts always sum to the attempt count and a
137
+ // mis-shaped record cannot silently vanish from the denominator.
138
+ function bucketOf(a) {
139
+ if (!a?.ok) return 'failed';
140
+ return a.testsRan ? 'validated' : 'validatedWithoutTests';
141
+ }
142
+
143
+ /**
144
+ * Summarise a set of attempts into the reported distribution.
145
+ *
146
+ * `validated` is the headline: attempts that verified AND whose test suite
147
+ * actually ran and passed. The other two buckets exist so that headline cannot
148
+ * be inflated by counting weaker or faster outcomes inside it.
149
+ */
150
+ function summarizeFixDurations(attempts) {
151
+ const all = Array.isArray(attempts) ? attempts : [];
152
+ const buckets = { validated: [], validatedWithoutTests: [], failed: [] };
153
+ for (const a of all) buckets[bucketOf(a)].push(a);
154
+
155
+ const byStage = {};
156
+ for (const stage of FIX_STAGES) {
157
+ // Per-stage timings come from validated runs only. A stage's duration in a
158
+ // failed run is truncated by the failure (the pipeline stops), so mixing
159
+ // them in would understate every stage after the first failure point.
160
+ byStage[stage] = _dist(buckets.validated.map(a => a?.stages?.[stage]));
161
+ }
162
+
163
+ return {
164
+ attempts: all.length,
165
+ counts: {
166
+ validated: buckets.validated.length,
167
+ validatedWithoutTests: buckets.validatedWithoutTests.length,
168
+ failed: buckets.failed.length,
169
+ },
170
+ timeToValidatedFix: _dist(buckets.validated.map(a => a.totalMs)),
171
+ timeToValidatedFixWithoutTests: _dist(buckets.validatedWithoutTests.map(a => a.totalMs)),
172
+ timeToFailure: _dist(buckets.failed.map(a => a.totalMs)),
173
+ byStage,
174
+ reliableAtOrAbove: RELIABLE_N,
175
+ };
176
+ }
177
+
178
+ /** Read + summarise in one step. */
179
+ function fixDurationReport(scanRoot) {
180
+ return summarizeFixDurations(loadFixAttempts(scanRoot));
181
+ }
182
+
183
+ function _ms(v) {
184
+ if (v == null) return '—';
185
+ return v >= 1000 ? `${(v / 1000).toFixed(1)}s` : `${v}ms`;
186
+ }
187
+
188
+ /**
189
+ * One-paragraph human summary. Returns null when there is nothing measured —
190
+ * callers print nothing rather than printing an empty table.
191
+ */
192
+ function renderFixDurationSummary(sum) {
193
+ if (!sum || !sum.attempts) return null;
194
+ const d = sum.timeToValidatedFix;
195
+ const parts = [];
196
+ if (d.n) {
197
+ parts.push(
198
+ `time-to-validated-fix: median ${_ms(d.p50Ms)}, p90 ${_ms(d.p90Ms)} `
199
+ + `(n=${d.n}${d.reliable ? '' : `, below ${sum.reliableAtOrAbove} — percentiles not yet reliable`})`,
200
+ );
201
+ } else {
202
+ parts.push('time-to-validated-fix: no fix has both verified and had its test suite run yet');
203
+ }
204
+ if (sum.counts.validatedWithoutTests) {
205
+ parts.push(`${sum.counts.validatedWithoutTests} verified with no detectable test suite (excluded from the median above)`);
206
+ }
207
+ if (sum.counts.failed) {
208
+ parts.push(`${sum.counts.failed} failed verification, median ${_ms(sum.timeToFailure.p50Ms)} (counted separately)`);
209
+ }
210
+ return parts.join('; ') + '.';
211
+ }
212
+
213
+ const _internals = { _dist, _pct, RELIABLE_N };
214
+
215
+
216
+ /***/ })
217
+
218
+ };