@vijaypjavvadi/bdd2pw 3.8.1 → 3.10.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
+ "use strict";
2
+ /**
3
+ * v3.9.0 — LLM telemetry collector.
4
+ *
5
+ * Subscribes to the existing `LLMLogEvent` stream that AnthropicLLMClient
6
+ * emits during `generateBinding` / `generateBatchBindings`, accumulates
7
+ * per-call stats, and exposes an aggregated summary suitable for writing
8
+ * to `<repo>/artefacts/llm-stats.json`.
9
+ *
10
+ * Why this exists: v3.5.0 introduced per-scenario batching which can cut
11
+ * Anthropic spend by ~75% on unmatched-heavy scaffolds. Until this
12
+ * release, the savings were invisible — operators had no per-scaffold
13
+ * record of cache hit rate, batch size distribution, token counts, or
14
+ * estimated cost. The sidecar makes the ROI measurable per run.
15
+ *
16
+ * Design constraints:
17
+ * - Zero overhead when disabled. The collector is constructed
18
+ * conditionally; the log-event fan-out is a single `if` check at
19
+ * the bdd2pw orchestrator layer.
20
+ * - Per-call detail PLUS aggregates. Operators want both "this
21
+ * scaffold cost $0.07" and "the batch of 5 unmatched steps in
22
+ * scenario X took 2.3s and consumed 6700 input tokens".
23
+ * - Cost is an ESTIMATE. Provider prices change; the sidecar
24
+ * records the per-million rates used so a reviewer can re-derive
25
+ * the number against current pricing.
26
+ * - Backwards compatible: no change to LLMClient interface, no
27
+ * change to existing log consumers. The collector is a regular
28
+ * `log: (e) => {...}` subscriber.
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.LLMTelemetry = exports.DEFAULT_PRICING = void 0;
32
+ /**
33
+ * Per-million-token pricing snapshot used to estimate cost. Prices
34
+ * change; the sidecar records what we used so a reviewer can re-derive.
35
+ *
36
+ * Update when you know providers raised / lowered prices, OR pass an
37
+ * override via `LLMTelemetry`'s constructor.
38
+ */
39
+ exports.DEFAULT_PRICING = {
40
+ // Anthropic Claude Sonnet 4.6 — Q4 2025 published pricing.
41
+ "claude-sonnet-4-6": { inputUsdPerMillion: 3, outputUsdPerMillion: 15 },
42
+ // Older Sonnet generation (still used by some clients) — same pricing.
43
+ "claude-3-7-sonnet-latest": {
44
+ inputUsdPerMillion: 3,
45
+ outputUsdPerMillion: 15,
46
+ },
47
+ // Anthropic Opus — when used.
48
+ "claude-opus-4-6": { inputUsdPerMillion: 15, outputUsdPerMillion: 75 },
49
+ // Anthropic Haiku — when used.
50
+ "claude-haiku-4-5-20251001": {
51
+ inputUsdPerMillion: 0.8,
52
+ outputUsdPerMillion: 4,
53
+ },
54
+ };
55
+ /**
56
+ * Telemetry collector. One instance per scaffold call. Subscribe via
57
+ * `.handleEvent` (typed to match the existing `LLMLogEvent` shape);
58
+ * read aggregates via `.toSummary()`; write to disk via
59
+ * `.writeSidecar(path)`.
60
+ */
61
+ class LLMTelemetry {
62
+ constructor(scaffoldId, version,
63
+ /** Override DEFAULT_PRICING when known per model. */
64
+ pricingOverride) {
65
+ this.scaffoldId = scaffoldId;
66
+ this.version = version;
67
+ this.pricingOverride = pricingOverride;
68
+ this.callsCounter = 0;
69
+ /**
70
+ * cache_miss events fire BEFORE provider_call_start in
71
+ * AnthropicLLMClient.generateBatchBindings (cache lookup is step 1,
72
+ * provider call is step 4). We accumulate misses here and snapshot
73
+ * the counter into the in-flight record when the call starts, so
74
+ * batchSize reports correctly regardless of event ordering.
75
+ */
76
+ this.pendingMisses = 0;
77
+ /**
78
+ * The call we're currently accumulating into. provider_call_start
79
+ * opens a record (capturing pendingMisses as batchSize); provider_call_done
80
+ * fills in latency + tokens and appends to completedCalls.
81
+ */
82
+ this.inFlight = null;
83
+ this.completedCalls = [];
84
+ this.cacheHits = 0;
85
+ this.cacheMisses = 0;
86
+ this.bindingsGenerated = 0;
87
+ this.callsAttempted = 0;
88
+ this.modelName = "unknown";
89
+ /**
90
+ * Subscribe this method to the AnthropicLLMClient log callback:
91
+ *
92
+ * new AnthropicLLMClient({ ..., log: telemetry.handleEvent }, ...)
93
+ *
94
+ * Or compose with the existing pino logger by fanning out:
95
+ *
96
+ * log: (e) => { pinoLog(e); telemetry.handleEvent(e); }
97
+ *
98
+ * Multiple events arrive per call. The collector reads the shape
99
+ * to advance its internal state machine.
100
+ */
101
+ this.handleEvent = (event) => {
102
+ switch (event.kind) {
103
+ case "cache_hit":
104
+ this.cacheHits += 1;
105
+ this.bindingsGenerated += 1;
106
+ break;
107
+ case "cache_miss":
108
+ this.cacheMisses += 1;
109
+ // Real flow: cache_miss fires BEFORE provider_call_start
110
+ // (cache lookup is step 1, provider call is step 4). Buffer
111
+ // the misses in `pendingMisses` and snapshot them into the
112
+ // record at start time. Also support the case where misses
113
+ // fire AFTER start (older code paths / future refactors) by
114
+ // adding to inFlight.batchSize too.
115
+ if (this.inFlight)
116
+ this.inFlight.batchSize += 1;
117
+ else
118
+ this.pendingMisses += 1;
119
+ break;
120
+ case "provider_call_start":
121
+ this.callsAttempted += 1;
122
+ this.inFlight = {
123
+ model: event.model,
124
+ latencyMs: 0,
125
+ inputTokens: 0,
126
+ outputTokens: 0,
127
+ batchSize: this.pendingMisses,
128
+ startTime: Date.now(),
129
+ };
130
+ // Consume the buffered misses so a subsequent call doesn't
131
+ // double-count them.
132
+ this.pendingMisses = 0;
133
+ // The pricing snapshot tracks whichever model we see first.
134
+ // Most scaffolds use one model start-to-finish.
135
+ this.modelName = event.model;
136
+ const pricing = (this.pricingOverride && this.pricingOverride[event.model]) ??
137
+ exports.DEFAULT_PRICING[event.model];
138
+ if (pricing)
139
+ this.modelPricing = pricing;
140
+ break;
141
+ case "provider_call_done":
142
+ if (this.inFlight) {
143
+ this.inFlight.latencyMs = event.latencyMs;
144
+ this.inFlight.inputTokens = event.inputTokens;
145
+ this.inFlight.outputTokens = event.outputTokens;
146
+ // batchSize may have been incremented by cache_miss events
147
+ // between start and done. If somehow 0, default to 1 (single-
148
+ // step generateBinding case where the orchestrator goes
149
+ // through the per-step path that doesn't emit cache_miss in
150
+ // the start→done window).
151
+ if (this.inFlight.batchSize === 0)
152
+ this.inFlight.batchSize = 1;
153
+ this.callsCounter += 1;
154
+ this.completedCalls.push({
155
+ index: this.callsCounter,
156
+ model: this.inFlight.model,
157
+ inputTokens: this.inFlight.inputTokens,
158
+ outputTokens: this.inFlight.outputTokens,
159
+ latencyMs: this.inFlight.latencyMs,
160
+ fromCache: false,
161
+ batchSize: this.inFlight.batchSize,
162
+ });
163
+ this.inFlight = null;
164
+ }
165
+ break;
166
+ case "binding_parsed":
167
+ this.bindingsGenerated += 1;
168
+ break;
169
+ // error events don't affect aggregates; they're already
170
+ // captured by the surrounding scaffold log.
171
+ default:
172
+ break;
173
+ }
174
+ };
175
+ }
176
+ toSummary() {
177
+ const latencies = this.completedCalls
178
+ .map((c) => c.latencyMs)
179
+ .sort((a, b) => a - b);
180
+ const inputTokens = this.completedCalls.reduce((acc, c) => acc + c.inputTokens, 0);
181
+ const outputTokens = this.completedCalls.reduce((acc, c) => acc + c.outputTokens, 0);
182
+ const pricing = this.modelPricing ?? {
183
+ inputUsdPerMillion: 0,
184
+ outputUsdPerMillion: 0,
185
+ };
186
+ const estimatedCostUsd = (inputTokens / 1000000) * pricing.inputUsdPerMillion +
187
+ (outputTokens / 1000000) * pricing.outputUsdPerMillion;
188
+ const totalCacheChecks = this.cacheHits + this.cacheMisses;
189
+ return {
190
+ version: this.version,
191
+ scaffoldId: this.scaffoldId,
192
+ generatedAt: new Date().toISOString(),
193
+ totals: {
194
+ callsAttempted: this.callsAttempted,
195
+ callsSuccessful: this.callsCounter,
196
+ bindingsGenerated: this.bindingsGenerated,
197
+ cacheHits: this.cacheHits,
198
+ cacheMisses: this.cacheMisses,
199
+ cacheHitRate: totalCacheChecks === 0 ? 0 : this.cacheHits / totalCacheChecks,
200
+ inputTokens,
201
+ outputTokens,
202
+ estimatedCostUsd: Number(estimatedCostUsd.toFixed(6)),
203
+ },
204
+ calls: this.completedCalls,
205
+ latencyMs: {
206
+ p50: percentile(latencies, 0.5),
207
+ p95: percentile(latencies, 0.95),
208
+ min: latencies.length ? latencies[0] : 0,
209
+ max: latencies.length ? latencies[latencies.length - 1] : 0,
210
+ },
211
+ pricing: {
212
+ model: this.modelName,
213
+ inputUsdPerMillion: pricing.inputUsdPerMillion,
214
+ outputUsdPerMillion: pricing.outputUsdPerMillion,
215
+ },
216
+ };
217
+ }
218
+ }
219
+ exports.LLMTelemetry = LLMTelemetry;
220
+ /**
221
+ * Index-based percentile. Returns 0 for an empty array. Sorted input.
222
+ * Picks the ceiling index so p95 of [10, 20, 30, 40, 50] is 50, not 40.
223
+ */
224
+ function percentile(sortedAsc, p) {
225
+ if (sortedAsc.length === 0)
226
+ return 0;
227
+ const idx = Math.ceil(sortedAsc.length * p) - 1;
228
+ return sortedAsc[Math.max(0, Math.min(idx, sortedAsc.length - 1))];
229
+ }
230
+ //# sourceMappingURL=telemetry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"telemetry.js","sourceRoot":"","sources":["../../src/llm/telemetry.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,132 @@
1
+ /**
2
+ * v3.10.0 — heal-events.jsonl → heal-stats.json analyzer.
3
+ *
4
+ * Same flavor as v3.9.0's `LLMTelemetry` sidecar but for the
5
+ * self-healing pipeline. Where v3.9.0 measures the LLM batching ROI
6
+ * per scaffold, v3.10.0 measures the self-healing ROI per TEST RUN.
7
+ *
8
+ * Input: `<repo>/artefacts/heal-events.jsonl` — one JSON event per
9
+ * line, written by the runtime `healOrThrow` helper from
10
+ * `templates/heal.ts.tmpl`. Event kinds:
11
+ * - `register` — locator constructed
12
+ * - `heal_attempt` — action failed; asking the heal
13
+ * service for a candidate
14
+ * - `healed` — candidate worked; subsequent
15
+ * calls use it transparently
16
+ * - `heal_unavailable` — no candidate / candidate failed
17
+ *
18
+ * Output: `<repo>/artefacts/heal-stats.json` — stable shape:
19
+ *
20
+ * {
21
+ * "version": "3.10.0",
22
+ * "generatedAt": "2026-...",
23
+ * "source": "<events file>",
24
+ * "totals": {
25
+ * "registrations": 47,
26
+ * "healAttempts": 6,
27
+ * "healed": 4,
28
+ * "healUnavailable": 2,
29
+ * "healRate": 0.6667, // healed / healAttempts
30
+ * "uniqueFields": 23,
31
+ * "uniquePages": 3
32
+ * },
33
+ * "topFailingFields": [
34
+ * { "page": "LoginPage", "name": "submitButton", "attempts": 3, "healed": 2 },
35
+ * ...
36
+ * ],
37
+ * "topErrors": [
38
+ * { "error": "Timeout 30000ms exceeded", "count": 4 },
39
+ * ...
40
+ * ],
41
+ * "topCandidates": [
42
+ * { "selector": "[data-testid='submit']", "promotions": 2,
43
+ * "averageConfidence": 0.87 },
44
+ * ...
45
+ * ],
46
+ * "retryLatencyMs": { "p50": 1240, "p95": 3010, "min": 880, "max": 3010 },
47
+ * "perScenario": [
48
+ * { "scenario": "User can log in", "attempts": 1, "healed": 1 },
49
+ * ...
50
+ * ]
51
+ * }
52
+ *
53
+ * Why a separate command (not part of scaffold): scaffold GENERATES the
54
+ * test repo; the heal events are produced when the user RUNS the
55
+ * generated tests. So this analyzer is a post-test command, run from
56
+ * CI after `npx playwright test`. Same pattern as `bdd2pw propose-rules`
57
+ * which runs after the LLM has populated `candidate-rules.jsonl`.
58
+ */
59
+ export interface HealStatsOptions {
60
+ /** Path to heal-events.jsonl OR a repo dir containing it. */
61
+ inputPath: string;
62
+ /** Override output path. Default: <input dir>/heal-stats.json. */
63
+ outputPath?: string;
64
+ /** How many top entries to keep in each ranked list. Default 10. */
65
+ topN?: number;
66
+ }
67
+ export interface HealStatsResult {
68
+ outputPath: string;
69
+ totalEvents: number;
70
+ }
71
+ /** One event line in heal-events.jsonl. Shape tracks `templates/heal.ts.tmpl`. */
72
+ interface HealEvent {
73
+ ts: string;
74
+ event: "register" | "heal_attempt" | "healed" | "heal_unavailable";
75
+ page: string;
76
+ name: string;
77
+ scenario_name?: string | null;
78
+ method?: string;
79
+ original?: string | null;
80
+ healed?: string | null;
81
+ confidence?: number | null;
82
+ error?: string | null;
83
+ }
84
+ export interface HealStats {
85
+ version: string;
86
+ generatedAt: string;
87
+ source: string;
88
+ totals: {
89
+ registrations: number;
90
+ healAttempts: number;
91
+ healed: number;
92
+ healUnavailable: number;
93
+ healRate: number;
94
+ uniqueFields: number;
95
+ uniquePages: number;
96
+ };
97
+ topFailingFields: {
98
+ page: string;
99
+ name: string;
100
+ attempts: number;
101
+ healed: number;
102
+ }[];
103
+ topErrors: {
104
+ error: string;
105
+ count: number;
106
+ }[];
107
+ topCandidates: {
108
+ selector: string;
109
+ promotions: number;
110
+ averageConfidence: number | null;
111
+ }[];
112
+ retryLatencyMs: {
113
+ p50: number;
114
+ p95: number;
115
+ min: number;
116
+ max: number;
117
+ };
118
+ perScenario: {
119
+ scenario: string;
120
+ attempts: number;
121
+ healed: number;
122
+ }[];
123
+ }
124
+ /** Public entry point. */
125
+ export declare function analyseHealStats(opts: HealStatsOptions): Promise<HealStatsResult>;
126
+ /**
127
+ * Compute a HealStats summary from an in-memory event list. Pure
128
+ * function — no I/O. Used both by the public CLI entry and the unit
129
+ * tests.
130
+ */
131
+ export declare function aggregate(events: HealEvent[], source: string, topN: number): HealStats;
132
+ export {};
@@ -0,0 +1,303 @@
1
+ "use strict";
2
+ /**
3
+ * v3.10.0 — heal-events.jsonl → heal-stats.json analyzer.
4
+ *
5
+ * Same flavor as v3.9.0's `LLMTelemetry` sidecar but for the
6
+ * self-healing pipeline. Where v3.9.0 measures the LLM batching ROI
7
+ * per scaffold, v3.10.0 measures the self-healing ROI per TEST RUN.
8
+ *
9
+ * Input: `<repo>/artefacts/heal-events.jsonl` — one JSON event per
10
+ * line, written by the runtime `healOrThrow` helper from
11
+ * `templates/heal.ts.tmpl`. Event kinds:
12
+ * - `register` — locator constructed
13
+ * - `heal_attempt` — action failed; asking the heal
14
+ * service for a candidate
15
+ * - `healed` — candidate worked; subsequent
16
+ * calls use it transparently
17
+ * - `heal_unavailable` — no candidate / candidate failed
18
+ *
19
+ * Output: `<repo>/artefacts/heal-stats.json` — stable shape:
20
+ *
21
+ * {
22
+ * "version": "3.10.0",
23
+ * "generatedAt": "2026-...",
24
+ * "source": "<events file>",
25
+ * "totals": {
26
+ * "registrations": 47,
27
+ * "healAttempts": 6,
28
+ * "healed": 4,
29
+ * "healUnavailable": 2,
30
+ * "healRate": 0.6667, // healed / healAttempts
31
+ * "uniqueFields": 23,
32
+ * "uniquePages": 3
33
+ * },
34
+ * "topFailingFields": [
35
+ * { "page": "LoginPage", "name": "submitButton", "attempts": 3, "healed": 2 },
36
+ * ...
37
+ * ],
38
+ * "topErrors": [
39
+ * { "error": "Timeout 30000ms exceeded", "count": 4 },
40
+ * ...
41
+ * ],
42
+ * "topCandidates": [
43
+ * { "selector": "[data-testid='submit']", "promotions": 2,
44
+ * "averageConfidence": 0.87 },
45
+ * ...
46
+ * ],
47
+ * "retryLatencyMs": { "p50": 1240, "p95": 3010, "min": 880, "max": 3010 },
48
+ * "perScenario": [
49
+ * { "scenario": "User can log in", "attempts": 1, "healed": 1 },
50
+ * ...
51
+ * ]
52
+ * }
53
+ *
54
+ * Why a separate command (not part of scaffold): scaffold GENERATES the
55
+ * test repo; the heal events are produced when the user RUNS the
56
+ * generated tests. So this analyzer is a post-test command, run from
57
+ * CI after `npx playwright test`. Same pattern as `bdd2pw propose-rules`
58
+ * which runs after the LLM has populated `candidate-rules.jsonl`.
59
+ */
60
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
61
+ if (k2 === undefined) k2 = k;
62
+ var desc = Object.getOwnPropertyDescriptor(m, k);
63
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
64
+ desc = { enumerable: true, get: function() { return m[k]; } };
65
+ }
66
+ Object.defineProperty(o, k2, desc);
67
+ }) : (function(o, m, k, k2) {
68
+ if (k2 === undefined) k2 = k;
69
+ o[k2] = m[k];
70
+ }));
71
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
72
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
73
+ }) : function(o, v) {
74
+ o["default"] = v;
75
+ });
76
+ var __importStar = (this && this.__importStar) || (function () {
77
+ var ownKeys = function(o) {
78
+ ownKeys = Object.getOwnPropertyNames || function (o) {
79
+ var ar = [];
80
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
81
+ return ar;
82
+ };
83
+ return ownKeys(o);
84
+ };
85
+ return function (mod) {
86
+ if (mod && mod.__esModule) return mod;
87
+ var result = {};
88
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
89
+ __setModuleDefault(result, mod);
90
+ return result;
91
+ };
92
+ })();
93
+ Object.defineProperty(exports, "__esModule", { value: true });
94
+ exports.analyseHealStats = analyseHealStats;
95
+ exports.aggregate = aggregate;
96
+ const fs = __importStar(require("fs-extra"));
97
+ const path = __importStar(require("path"));
98
+ const PACKAGE_VERSION = "3.10.0";
99
+ /** Public entry point. */
100
+ async function analyseHealStats(opts) {
101
+ const jsonlPath = await resolveJsonlPath(opts.inputPath);
102
+ const events = await readEvents(jsonlPath);
103
+ const summary = aggregate(events, jsonlPath, opts.topN ?? 10);
104
+ const outputPath = opts.outputPath ?? path.join(path.dirname(jsonlPath), "heal-stats.json");
105
+ await fs.ensureDir(path.dirname(outputPath));
106
+ await fs.writeFile(outputPath, JSON.stringify(summary, null, 2), "utf8");
107
+ return { outputPath, totalEvents: events.length };
108
+ }
109
+ /* ─── pure aggregation (testable without fs) ──────────────────────── */
110
+ /**
111
+ * Compute a HealStats summary from an in-memory event list. Pure
112
+ * function — no I/O. Used both by the public CLI entry and the unit
113
+ * tests.
114
+ */
115
+ function aggregate(events, source, topN) {
116
+ const fieldKey = (e) => `${e.page}::${e.name}`;
117
+ const fieldAttempts = new Map();
118
+ const errorCounts = new Map();
119
+ const candidateStats = new Map();
120
+ const scenarioStats = new Map();
121
+ const uniquePages = new Set();
122
+ const uniqueFields = new Set();
123
+ const retryLatencies = [];
124
+ /**
125
+ * Match heal_attempt events with their matching `healed` /
126
+ * `heal_unavailable` outcome by (page, name, method). The keying
127
+ * assumes a heal attempt resolves before another one fires for the
128
+ * same field+method combo, which holds in practice — actions run
129
+ * serially per test.
130
+ */
131
+ const openAttempts = new Map();
132
+ const attemptKey = (e) => `${e.page}::${e.name}::${e.method ?? ""}`;
133
+ let registrations = 0;
134
+ let healAttempts = 0;
135
+ let healed = 0;
136
+ let healUnavailable = 0;
137
+ for (const e of events) {
138
+ uniquePages.add(e.page);
139
+ uniqueFields.add(fieldKey(e));
140
+ if (e.event === "register") {
141
+ registrations += 1;
142
+ continue;
143
+ }
144
+ if (e.event === "heal_attempt") {
145
+ healAttempts += 1;
146
+ const field = fieldAttempts.get(fieldKey(e)) ?? {
147
+ page: e.page,
148
+ name: e.name,
149
+ attempts: 0,
150
+ healed: 0,
151
+ };
152
+ field.attempts += 1;
153
+ fieldAttempts.set(fieldKey(e), field);
154
+ if (e.error) {
155
+ const msg = truncateError(e.error);
156
+ errorCounts.set(msg, (errorCounts.get(msg) ?? 0) + 1);
157
+ }
158
+ const scenario = e.scenario_name ?? "(no scenario context)";
159
+ const ss = scenarioStats.get(scenario) ?? { attempts: 0, healed: 0 };
160
+ ss.attempts += 1;
161
+ scenarioStats.set(scenario, ss);
162
+ openAttempts.set(attemptKey(e), Date.parse(e.ts));
163
+ continue;
164
+ }
165
+ if (e.event === "healed") {
166
+ healed += 1;
167
+ const field = fieldAttempts.get(fieldKey(e)) ?? {
168
+ page: e.page,
169
+ name: e.name,
170
+ attempts: 0,
171
+ healed: 0,
172
+ };
173
+ field.healed += 1;
174
+ fieldAttempts.set(fieldKey(e), field);
175
+ if (e.healed) {
176
+ const cs = candidateStats.get(e.healed) ?? {
177
+ promotions: 0,
178
+ confidenceSum: 0,
179
+ confidenceCount: 0,
180
+ };
181
+ cs.promotions += 1;
182
+ if (typeof e.confidence === "number") {
183
+ cs.confidenceSum += e.confidence;
184
+ cs.confidenceCount += 1;
185
+ }
186
+ candidateStats.set(e.healed, cs);
187
+ }
188
+ const scenario = e.scenario_name ?? "(no scenario context)";
189
+ const ss = scenarioStats.get(scenario) ?? { attempts: 0, healed: 0 };
190
+ ss.healed += 1;
191
+ scenarioStats.set(scenario, ss);
192
+ const startTs = openAttempts.get(attemptKey(e));
193
+ if (startTs !== undefined) {
194
+ retryLatencies.push(Date.parse(e.ts) - startTs);
195
+ openAttempts.delete(attemptKey(e));
196
+ }
197
+ continue;
198
+ }
199
+ if (e.event === "heal_unavailable") {
200
+ healUnavailable += 1;
201
+ openAttempts.delete(attemptKey(e));
202
+ continue;
203
+ }
204
+ }
205
+ const sortedFailingFields = Array.from(fieldAttempts.values())
206
+ .sort((a, b) => b.attempts - a.attempts)
207
+ .slice(0, topN);
208
+ const sortedErrors = Array.from(errorCounts.entries())
209
+ .map(([error, count]) => ({ error, count }))
210
+ .sort((a, b) => b.count - a.count)
211
+ .slice(0, topN);
212
+ const sortedCandidates = Array.from(candidateStats.entries())
213
+ .map(([selector, s]) => ({
214
+ selector,
215
+ promotions: s.promotions,
216
+ averageConfidence: s.confidenceCount > 0 ? s.confidenceSum / s.confidenceCount : null,
217
+ }))
218
+ .sort((a, b) => b.promotions - a.promotions)
219
+ .slice(0, topN);
220
+ const sortedScenarios = Array.from(scenarioStats.entries())
221
+ .map(([scenario, s]) => ({ scenario, ...s }))
222
+ .sort((a, b) => b.attempts - a.attempts)
223
+ .slice(0, topN);
224
+ const latenciesSorted = [...retryLatencies].sort((a, b) => a - b);
225
+ return {
226
+ version: PACKAGE_VERSION,
227
+ generatedAt: new Date().toISOString(),
228
+ source,
229
+ totals: {
230
+ registrations,
231
+ healAttempts,
232
+ healed,
233
+ healUnavailable,
234
+ healRate: healAttempts === 0 ? 0 : healed / healAttempts,
235
+ uniqueFields: uniqueFields.size,
236
+ uniquePages: uniquePages.size,
237
+ },
238
+ topFailingFields: sortedFailingFields,
239
+ topErrors: sortedErrors,
240
+ topCandidates: sortedCandidates,
241
+ retryLatencyMs: {
242
+ p50: percentile(latenciesSorted, 0.5),
243
+ p95: percentile(latenciesSorted, 0.95),
244
+ min: latenciesSorted.length ? latenciesSorted[0] : 0,
245
+ max: latenciesSorted.length
246
+ ? latenciesSorted[latenciesSorted.length - 1]
247
+ : 0,
248
+ },
249
+ perScenario: sortedScenarios,
250
+ };
251
+ }
252
+ function percentile(sortedAsc, p) {
253
+ if (sortedAsc.length === 0)
254
+ return 0;
255
+ const idx = Math.ceil(sortedAsc.length * p) - 1;
256
+ return sortedAsc[Math.max(0, Math.min(idx, sortedAsc.length - 1))];
257
+ }
258
+ /**
259
+ * Normalise an error message so different timestamps / IDs collapse to
260
+ * the same bucket: lowercase, strip leading "Error: " / "TimeoutError",
261
+ * collapse runs of digits.
262
+ */
263
+ function truncateError(s) {
264
+ return s
265
+ .toLowerCase()
266
+ .replace(/^(?:error|timeouterror|protocolerror):\s*/i, "")
267
+ .replace(/\b\d{3,}\b/g, "<n>")
268
+ .slice(0, 120);
269
+ }
270
+ /* ─── filesystem glue ─────────────────────────────────────────────── */
271
+ async function resolveJsonlPath(inputPath) {
272
+ const stat = await fs.stat(inputPath).catch(() => undefined);
273
+ if (stat?.isFile())
274
+ return inputPath;
275
+ if (stat?.isDirectory()) {
276
+ const direct = path.join(inputPath, "heal-events.jsonl");
277
+ if (await fs.pathExists(direct))
278
+ return direct;
279
+ const nested = path.join(inputPath, "artefacts", "heal-events.jsonl");
280
+ if (await fs.pathExists(nested))
281
+ return nested;
282
+ return nested; // analyser will handle 'no events' gracefully
283
+ }
284
+ return path.join(inputPath, "artefacts", "heal-events.jsonl");
285
+ }
286
+ async function readEvents(jsonlPath) {
287
+ if (!(await fs.pathExists(jsonlPath)))
288
+ return [];
289
+ const raw = await fs.readFile(jsonlPath, "utf8");
290
+ const out = [];
291
+ for (const line of raw.split(/\r?\n/)) {
292
+ if (!line.trim())
293
+ continue;
294
+ try {
295
+ out.push(JSON.parse(line));
296
+ }
297
+ catch {
298
+ /* skip malformed line */
299
+ }
300
+ }
301
+ return out;
302
+ }
303
+ //# sourceMappingURL=healStats.js.map