ccqa 1.36.0 → 1.37.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.
package/dist/bin/ccqa.mjs CHANGED
@@ -4,8 +4,8 @@ import { t as EVIDENCE_DIR_ENV } from "../evidence-constants-C425F7ZG.mjs";
4
4
  import { a as formatAgentBrowserUnavailableMessage, i as assertAgentBrowserAvailable, n as spawnAB, o as pathWithAgentBrowserShim, r as AgentBrowserUnavailableError, s as resolveAgentBrowserBin$1, t as sleepSync } from "../spawn-ab-CRIVfWpw.mjs";
5
5
  import { createRequire } from "node:module";
6
6
  import { Command } from "commander";
7
- import { accessSync, appendFileSync, createWriteStream, existsSync, readFileSync, rmSync } from "node:fs";
8
- import { fileURLToPath } from "node:url";
7
+ import { accessSync, appendFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
10
10
  import { access, chmod, cp, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
11
11
  import { homedir, tmpdir } from "node:os";
@@ -17,9 +17,10 @@ import { createSdkMcpServer, query, tool } from "@anthropic-ai/claude-agent-sdk"
17
17
  import { AsyncLocalStorage } from "node:async_hooks";
18
18
  import { promisify } from "node:util";
19
19
  import { createInterface } from "node:readline/promises";
20
+ import { createServer } from "node:http";
20
21
  import { gunzipSync, gzipSync } from "node:zlib";
21
22
  import { createInterface as createInterface$1 } from "node:readline";
22
- import { createServer } from "node:http";
23
+ import { createServer as createServer$1 } from "node:net";
23
24
  //#region src/run/report-constants.ts
24
25
  /**
25
26
  * Pure report/run constants with no runtime dependencies. Kept separate from
@@ -185,14 +186,14 @@ const SpecModeSchema = z.enum(["deterministic", "live"]);
185
186
  * A name a spec chooses that ccqa resolves to a path or looks up in a
186
187
  * registry. Restricted to a slug so it cannot escape a directory.
187
188
  */
188
- function slug(what) {
189
+ function slug$1(what) {
189
190
  return z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, `${what} must be a slug (letters, digits, '.', '_', '-'; no path separators)`);
190
191
  }
191
192
  /**
192
193
  * A saved browser session (cookies + localStorage) to restore before the spec
193
194
  * runs, resolved to `.ccqa/sessions/<profile>/<name>.json` at run time.
194
195
  */
195
- const SessionNameSchema = slug("session name");
196
+ const SessionNameSchema = slug$1("session name");
196
197
  /**
197
198
  * Sessions to restore before a `mode: live` spec runs: one name or a list,
198
199
  * always read back as a list. Multiple names are merged (their cookies +
@@ -206,7 +207,7 @@ const SessionFieldSchema = z.union([SessionNameSchema, z.array(SessionNameSchema
206
207
  * registered target is the registry's responsibility, so new targets don't
207
208
  * require a schema change.
208
209
  */
209
- const TargetIdSchema = slug("target");
210
+ const TargetIdSchema = slug$1("target");
210
211
  /** The built-in recorder-backed target. `mode:` / `session:` only apply to it. */
211
212
  const AGENT_BROWSER_TARGET = "agent-browser";
212
213
  /**
@@ -3454,6 +3455,48 @@ const ReportArtifactSchema = z.object({
3454
3455
  sizeBytes: z.number()
3455
3456
  });
3456
3457
  /**
3458
+ * Everything one spec's measurement could not place.
3459
+ *
3460
+ * These are load-bearing, not diagnostics. An execution nobody could attribute
3461
+ * is indistinguishable from one that never happened, and "never happened" is
3462
+ * the answer this measurement exists to produce — so each way of losing one is
3463
+ * counted separately and shown next to the result (ADR-0021).
3464
+ */
3465
+ const CoverageGapsSchema = z.object({
3466
+ unattributed: z.number(),
3467
+ unmappedScripts: z.number(),
3468
+ unmappedRanges: z.number(),
3469
+ outsideProject: z.number(),
3470
+ unresolvedSources: z.number(),
3471
+ uninstrumentedFiles: z.number(),
3472
+ uninstrumentedProcesses: z.number(),
3473
+ droppedPushes: z.number(),
3474
+ unmappedActorEvents: z.number().default(0),
3475
+ outsideWindowEvents: z.number().default(0)
3476
+ });
3477
+ /**
3478
+ * What one spec's execution actually reached: V8's own counters for the
3479
+ * browser and per-request instrumentation for the server, unioned on the spec
3480
+ * id both sides carry (ADR-0021).
3481
+ *
3482
+ * `backendReported` / `frontendReported` separate "reached nothing" from "that
3483
+ * half never answered", which otherwise render identically as zero.
3484
+ */
3485
+ const ReportCoverageSchema = z.object({
3486
+ files: z.array(z.string()),
3487
+ frontendFiles: z.number(),
3488
+ backendFiles: z.number(),
3489
+ backendReported: z.boolean(),
3490
+ frontendReported: z.boolean(),
3491
+ frontendStopped: z.boolean(),
3492
+ actorWindows: z.array(z.object({
3493
+ key: z.string(),
3494
+ events: z.number()
3495
+ })).default([]),
3496
+ excludedDependencies: z.number(),
3497
+ gaps: CoverageGapsSchema
3498
+ });
3499
+ /**
3457
3500
  * Per-step / per-run cost+usage record, pulled from the SDK's `result` message.
3458
3501
  * Every numeric field is nullable so the report can carry partial telemetry
3459
3502
  * (e.g. when the SDK omits a field, or when a step was skipped).
@@ -3552,6 +3595,8 @@ const ReportSpecResultSchema = z.object({
3552
3595
  evidence: z.array(ReportEvidenceSchema).nullable(),
3553
3596
  evidenceUnavailable: z.string().optional(),
3554
3597
  artifacts: z.array(ReportArtifactSchema).optional(),
3598
+ coverage: ReportCoverageSchema.optional(),
3599
+ coverageUnavailable: z.string().optional(),
3555
3600
  liveRun: LiveReportRunSchema.nullable()
3556
3601
  });
3557
3602
  /**
@@ -3575,6 +3620,17 @@ const GitEnvelopeSchema = z.object({
3575
3620
  baseSha: z.string().nullable().optional(),
3576
3621
  baseSource: BaseSourceSchema.nullable().optional()
3577
3622
  });
3623
+ /**
3624
+ * The denominator for the run's coverage measurement: every source file under
3625
+ * `coverage.include`, enumerated by the run from the same checkout the specs
3626
+ * ran against — which is what entitles a viewer to read "absent from every
3627
+ * row's file set" as "uncovered" rather than "unknown". Run-level because it
3628
+ * is a property of the checkout, not of any one spec.
3629
+ */
3630
+ const CoverageUniverseSchema = z.object({
3631
+ include: z.array(z.string()),
3632
+ files: z.array(z.string())
3633
+ });
3578
3634
  const RunReportDataSchema = z.object({
3579
3635
  schemaVersion: z.literal(1),
3580
3636
  kind: ReportKindSchema.default("run"),
@@ -3589,6 +3645,7 @@ const RunReportDataSchema = z.object({
3589
3645
  triageUserPromptHash: z.string().optional(),
3590
3646
  deployedSha: z.string().optional(),
3591
3647
  cost: ReportCostSchema.nullable().default(null),
3648
+ coverageUniverse: CoverageUniverseSchema.optional(),
3592
3649
  results: z.array(ReportSpecResultSchema)
3593
3650
  });
3594
3651
  /** Shape of the "export labels" download produced by the report's client-side JS. */
@@ -3669,9 +3726,19 @@ function scrubOutcome(outcome, scrubMap) {
3669
3726
  }
3670
3727
  };
3671
3728
  }
3729
+ /**
3730
+ * Pause before the single retry of an errored classification call. Long enough
3731
+ * to ride out a transient network/model hiccup, short enough not to stall the
3732
+ * report when the error is persistent.
3733
+ */
3734
+ const RETRY_DELAY_MS = 2e3;
3735
+ function sleep(ms) {
3736
+ return new Promise((resolve) => setTimeout(resolve, ms));
3737
+ }
3672
3738
  async function classifyFailure(input, options) {
3673
- const { result: raw, isError } = await invokeClaudeStreaming({
3674
- prompt: buildFailureAnalysisPrompt(input),
3739
+ const prompt = buildFailureAnalysisPrompt(input);
3740
+ const invoke = () => invokeClaudeStreaming({
3741
+ prompt,
3675
3742
  allowedTools: [
3676
3743
  "Read",
3677
3744
  "Grep",
@@ -3684,11 +3751,22 @@ async function classifyFailure(input, options) {
3684
3751
  ...options.model ? { model: options.model } : {},
3685
3752
  ...options.cwd ? { cwd: options.cwd } : {}
3686
3753
  }, () => {});
3687
- if (isError || !raw) return {
3688
- analysis: unknownAnalysis(isError ? "Claude returned an error result" : "Claude returned no output"),
3689
- raw: raw ?? "",
3690
- sdkError: isError
3691
- };
3754
+ let { result: raw, isError } = await invoke();
3755
+ let retried = false;
3756
+ if (isError) {
3757
+ warn("failure analysis: Claude invocation errored — retrying once");
3758
+ await sleep(RETRY_DELAY_MS);
3759
+ ({result: raw, isError} = await invoke());
3760
+ retried = true;
3761
+ }
3762
+ if (isError || !raw) {
3763
+ const cause = isError ? "Claude returned an error result" : "Claude returned no output";
3764
+ return {
3765
+ analysis: unknownAnalysis(retried ? `${cause} (after 1 retry)` : cause),
3766
+ raw: raw ?? "",
3767
+ sdkError: isError
3768
+ };
3769
+ }
3692
3770
  let sawParseableJson = false;
3693
3771
  for (const candidate of extractJsonCandidates(raw)) {
3694
3772
  let parsed;
@@ -5438,36 +5516,1660 @@ function computeLineDiff(a, b) {
5438
5516
  });
5439
5517
  return out.map((l) => l.kind === "add" ? `+ ${l.text}` : l.kind === "del" ? `- ${l.text}` : ` ${l.text}`);
5440
5518
  }
5441
- function truncate$1(s, n) {
5442
- if (s.length <= n) return s;
5443
- return s.slice(s.length - n);
5519
+ function truncate$1(s, n) {
5520
+ if (s.length <= n) return s;
5521
+ return s.slice(s.length - n);
5522
+ }
5523
+ //#endregion
5524
+ //#region src/run/output-tail.ts
5525
+ /** Cap on the per-spec output tail kept for the report / analysis prompt. */
5526
+ const OUTPUT_TAIL_CAP = 64 * 1024;
5527
+ /**
5528
+ * Keeps the LAST `cap` characters appended — test runners put the failure
5529
+ * summary at the end of their output, so the tail is what's worth keeping on
5530
+ * overflow. Dependency-free so both the vitest pipeline and the runCommand
5531
+ * runner (src/targets/run-command-runner.ts) can use it without importing the
5532
+ * whole pipeline.
5533
+ */
5534
+ var TailBuffer = class {
5535
+ buf = "";
5536
+ cap;
5537
+ constructor(cap) {
5538
+ this.cap = cap;
5539
+ }
5540
+ append(s) {
5541
+ this.buf += s;
5542
+ if (this.buf.length > this.cap * 2) this.buf = this.buf.slice(-this.cap);
5543
+ }
5544
+ toString() {
5545
+ if (this.buf.length <= this.cap) return this.buf;
5546
+ return `[...output truncated...]\n${this.buf.slice(-this.cap)}`;
5547
+ }
5548
+ };
5549
+ //#endregion
5550
+ //#region src/coverage/actors.ts
5551
+ /**
5552
+ * Attribution by who acted, for the requests that cannot carry a spec id.
5553
+ *
5554
+ * The measurement's normal carrier is the request itself — a cookie the browser
5555
+ * holds, a baggage header, a Temporal header. A webhook from a chat platform
5556
+ * has none of them: the browser only ever talked to the platform, and what
5557
+ * reaches the application was sent by the platform's servers. Everything such a
5558
+ * flow runs would be unattributed, which for a suite whose majority is chat
5559
+ * flows means the measurement misses its main subject.
5560
+ *
5561
+ * What the webhook does carry is who caused it. If exactly one spec may act as
5562
+ * that identity at a time, "who" plus "when" identifies the spec — so the
5563
+ * application records only the fact (`this identity acted, at this instant`)
5564
+ * and every judgement about which spec that belongs to is made here and in the
5565
+ * sink. Nothing flows the other way: the application is never told which
5566
+ * identities matter or which windows are open, so there is no table to
5567
+ * distribute, go stale, or leak one project's identities into another's logs.
5568
+ */
5569
+ /**
5570
+ * Quiet gap enforced between two specs that act as the same identity.
5571
+ *
5572
+ * The application stamps events with its own clock and the sink judges them
5573
+ * against its own, so an event near a boundary could fall on either side. Three
5574
+ * seconds is over two push intervals, which also means the window being closed
5575
+ * has had time to receive everything still in flight for it.
5576
+ */
5577
+ const ACTOR_DRAIN_MS = 3e3;
5578
+ ACTOR_DRAIN_MS / 2;
5579
+ const NO_ACTORS = {
5580
+ windows: [],
5581
+ tagToKey: /* @__PURE__ */ new Map(),
5582
+ windowsForSpec: /* @__PURE__ */ new Map()
5583
+ };
5584
+ /**
5585
+ * Reads the config's actors into the plan the run plays out, refusing anything
5586
+ * ambiguous rather than measuring under a guess.
5587
+ *
5588
+ * Every rejection here is one that would otherwise surface as "this spec
5589
+ * reached nothing": an identity that resolved to nothing matches no event, and
5590
+ * two entries resolving alike make each other's events unattributable.
5591
+ */
5592
+ async function resolveActors(actors, cwd) {
5593
+ const providers = Object.keys(actors);
5594
+ if (providers.length === 0) return NO_ACTORS;
5595
+ const known = new Set((await listAllSpecsWithSpecFile(cwd)).map(specKey));
5596
+ const windows = [];
5597
+ const tagToKey = /* @__PURE__ */ new Map();
5598
+ const windowsForSpec = /* @__PURE__ */ new Map();
5599
+ for (const provider of providers) for (const [identity, specs] of Object.entries(actors[provider] ?? {})) {
5600
+ const key = `${provider}:${identity}`;
5601
+ const refs = [...iterEnvRefNames(identity)];
5602
+ if (refs.length === 0) throw new RunUsageError(`coverage.actors.${key} names an identity directly — write it as a variable (e.g. \${TEST_USER_ID}) so the value stays out of reports and the hub`);
5603
+ const missing = refs.filter((name) => process.env[name] === void 0 || process.env[name] === "");
5604
+ if (missing.length > 0) throw new RunUsageError(`coverage.actors.${key} needs ${missing.join(", ")}, which ${missing.length === 1 ? "is" : "are"} not set — an identity that resolves to nothing matches no event, and the specs under it would report reaching nothing`);
5605
+ const tag = `${provider}:${resolveEnvRefs(identity)}`;
5606
+ const clash = tagToKey.get(tag);
5607
+ if (clash !== void 0) throw new RunUsageError(`coverage.actors.${key} and coverage.actors.${clash} name the same identity — their windows would overlap and neither one's events could be told apart`);
5608
+ for (const member of specs) if (!known.has(member)) throw new RunUsageError(`coverage.actors.${key} lists "${member}", which is not a spec in this project`);
5609
+ const window = {
5610
+ key,
5611
+ tag,
5612
+ specs
5613
+ };
5614
+ windows.push(window);
5615
+ tagToKey.set(tag, key);
5616
+ for (const member of specs) {
5617
+ const owned = windowsForSpec.get(member);
5618
+ if (owned) owned.push(window);
5619
+ else windowsForSpec.set(member, [window]);
5620
+ }
5621
+ }
5622
+ return {
5623
+ windows,
5624
+ tagToKey,
5625
+ windowsForSpec
5626
+ };
5627
+ }
5628
+ /**
5629
+ * The serial groups the plan implies: two specs acting as one identity cannot
5630
+ * overlap, or neither could claim what happened while both were running.
5631
+ */
5632
+ function actorGroups(plan) {
5633
+ if (plan.windowsForSpec.size === 0) return () => [];
5634
+ return (ref) => (plan.windowsForSpec.get(specKey(ref)) ?? []).map((window) => window.key);
5635
+ }
5636
+ //#endregion
5637
+ //#region src/coverage/contract.ts
5638
+ /**
5639
+ * The names and shapes ccqa agrees on with the instrumented application.
5640
+ *
5641
+ * Restated from `ccqa-tools`'s `wire.ts` rather than imported: the CLI must
5642
+ * not depend on the instrumentation SDK, which is installed in the application
5643
+ * under test and versioned separately. `contract.test.ts` asserts the two still
5644
+ * agree — a drift here reports "the spec reached no server code", which is
5645
+ * indistinguishable from the truth it is supposed to measure.
5646
+ *
5647
+ * Not named `wire.ts` like its counterpart, deliberately. The one moment anyone
5648
+ * opens both is while working out why the sink saw nothing, and two tabs with
5649
+ * one name is the wrong thing to hand them.
5650
+ */
5651
+ /** Set on the browser by the acquisition engine, scoped to the instrumented origins. */
5652
+ const COVERAGE_COOKIE = "__ccqa_coverage";
5653
+ /** What the browser engine leaves for the run to read back at collect time. */
5654
+ const FRONTEND_COVERAGE_FILE = "coverage-frontend.json";
5655
+ /** The only spec-id shape this run issues, and the only one the sink accepts. */
5656
+ const SPEC_ID_PATTERN = /^[A-Za-z0-9._\-/]{1,200}$/;
5657
+ //#endregion
5658
+ //#region src/coverage/sink.ts
5659
+ /**
5660
+ * Where instrumented application processes push what they reached. Why they
5661
+ * push rather than being scraped is ADR-0021.
5662
+ *
5663
+ * It authenticates nothing. The gate is the set of spec ids this run issued —
5664
+ * a token would have to be configured on both sides to add anything, and the
5665
+ * sink binds to loopback by default.
5666
+ */
5667
+ /** What an instrumented process pushes, once a second. */
5668
+ const PushSchema = z.object({
5669
+ protocol: z.literal(1),
5670
+ pid: z.number(),
5671
+ startedAt: z.number(),
5672
+ unattributed: z.number(),
5673
+ specs: z.record(z.string(), z.array(z.string())),
5674
+ boot: z.array(z.string()),
5675
+ uninstrumentedFiles: z.number().default(0),
5676
+ uninstrumentedProcess: z.boolean().default(false),
5677
+ droppedPushes: z.number().default(0),
5678
+ actors: z.array(z.object({
5679
+ tag: z.string(),
5680
+ at: z.number(),
5681
+ files: z.array(z.string())
5682
+ })).default([])
5683
+ });
5684
+ const MAX_BODY_BYTES$6 = 8 * 1024 * 1024;
5685
+ var CoverageSink = class CoverageSink {
5686
+ /** Where instrumented processes push to. Known once the socket is bound. */
5687
+ url = "";
5688
+ specs = /* @__PURE__ */ new Map();
5689
+ bootFiles = /* @__PURE__ */ new Set();
5690
+ /** What each reporting process last said about itself, keyed so a restart is a new one. */
5691
+ processes = /* @__PURE__ */ new Map();
5692
+ pushesReceived = 0;
5693
+ rejected = 0;
5694
+ malformed = 0;
5695
+ /** Every turn this run has handed out, oldest first. Kept for the whole run. */
5696
+ windows = [];
5697
+ /** Per spec, per window, the distinct events that landed in it. Drives the row's count. */
5698
+ matched = /* @__PURE__ */ new Map();
5699
+ /** Events from a declared identity that fell outside every turn it was given. */
5700
+ outsideWindow = /* @__PURE__ */ new Map();
5701
+ /**
5702
+ * Events from identities this project never declared — other people using the
5703
+ * same environment. Deduplicated by timestamp alone: the identity is dropped
5704
+ * on arrival, so there is nothing else left to tell two of them apart.
5705
+ */
5706
+ unmappedAt = /* @__PURE__ */ new Set();
5707
+ server;
5708
+ /** Spec ids this run issued. A push naming anything else is dropped. */
5709
+ issued;
5710
+ /** Declared identities to their display key. A tag absent here is somebody else's. */
5711
+ tagToKey;
5712
+ constructor(server, issued, tagToKey) {
5713
+ this.server = server;
5714
+ this.issued = issued;
5715
+ this.tagToKey = tagToKey;
5716
+ }
5717
+ /**
5718
+ * Binds and starts accepting pushes. `issued` is fixed at start: the cookie
5719
+ * is client-controlled, so an id this run never issued is refused here
5720
+ * rather than trusted into a report.
5721
+ */
5722
+ static async start(host, port, issued, tagToKey = /* @__PURE__ */ new Map()) {
5723
+ const sink = new CoverageSink(createServer(), issued, tagToKey);
5724
+ sink.server.on("request", (request, response) => {
5725
+ sink.handle(request, response);
5726
+ });
5727
+ await new Promise((resolve, reject) => {
5728
+ sink.server.once("error", reject);
5729
+ sink.server.listen(port, host, () => {
5730
+ sink.server.removeListener("error", reject);
5731
+ resolve();
5732
+ });
5733
+ });
5734
+ const address = sink.server.address();
5735
+ sink.url = `http://${formatHost(host)}:${address.port}`;
5736
+ return sink;
5737
+ }
5738
+ /** What `specId` reached so far. Reads do not clear: late pushes still land. */
5739
+ filesFor(specId) {
5740
+ return this.specs.get(specId)?.files;
5741
+ }
5742
+ /** Gives `specId` sole claim to `window`'s identity from now until it is closed. */
5743
+ openWindow(window, specId) {
5744
+ this.windows.push({
5745
+ tag: window.tag,
5746
+ key: window.key,
5747
+ specId,
5748
+ openedAt: Date.now(),
5749
+ closedAt: void 0
5750
+ });
5751
+ }
5752
+ /** Ends the open turn on `tag`. Later events from it belong to nobody. */
5753
+ closeWindow(tag) {
5754
+ for (let i = this.windows.length - 1; i >= 0; i--) {
5755
+ const window = this.windows[i];
5756
+ if (window.tag !== tag || window.closedAt !== void 0) continue;
5757
+ window.closedAt = Date.now();
5758
+ return;
5759
+ }
5760
+ }
5761
+ /** When the run may next open a turn on `tag`, given the drain it has to leave. */
5762
+ lastClosedAt(tag) {
5763
+ let latest;
5764
+ for (const window of this.windows) {
5765
+ if (window.tag !== tag || window.closedAt === void 0) continue;
5766
+ latest = window.closedAt;
5767
+ }
5768
+ return latest;
5769
+ }
5770
+ /** Per window key, how many distinct events this spec was credited with. */
5771
+ actorEventsFor(specId) {
5772
+ const counts = /* @__PURE__ */ new Map();
5773
+ for (const [key, events] of this.matched.get(specId) ?? []) counts.set(key, events.size);
5774
+ return counts;
5775
+ }
5776
+ /**
5777
+ * Events from a declared identity that arrived outside its turns.
5778
+ *
5779
+ * Loud rather than silent: it means something other than this run drove that
5780
+ * identity, and whatever it reached is missing from a spec that looks whole.
5781
+ */
5782
+ outsideWindowEvents() {
5783
+ return this.outsideWindow;
5784
+ }
5785
+ /** Events from identities this project never declared. Their reach belongs to nobody. */
5786
+ unmappedActorEvents() {
5787
+ return this.unmappedAt.size;
5788
+ }
5789
+ /** Executions that ran while `specId` was open but outside its context. */
5790
+ unattributedFor(specId) {
5791
+ const spec = this.specs.get(specId);
5792
+ if (spec === void 0) return 0;
5793
+ let total = 0;
5794
+ for (const [process, baseline] of spec.baseline) total += Math.max(0, (this.processes.get(process)?.unattributed ?? 0) - baseline);
5795
+ return total;
5796
+ }
5797
+ /**
5798
+ * Files reached at module top level. Deliberately not folded into any spec:
5799
+ * the first spec to import a module would otherwise own it, which makes a
5800
+ * spec's result depend on the order the run happened to execute.
5801
+ */
5802
+ boot() {
5803
+ return this.bootFiles;
5804
+ }
5805
+ /** True once any instrumented process has reported — i.e. the server half is wired up. */
5806
+ heardFromApplication() {
5807
+ return this.pushesReceived > 0;
5808
+ }
5809
+ /**
5810
+ * Specs some process attributed a file to.
5811
+ *
5812
+ * Distinct from `heardFromApplication`, which a process satisfies with its
5813
+ * boot set alone. An application that reports but attributes nothing has the
5814
+ * instrumentation working and the spec cookie not arriving — and that reads
5815
+ * identically to a server that genuinely ran no code.
5816
+ */
5817
+ attributedSpecs() {
5818
+ return this.specs.size;
5819
+ }
5820
+ /** Pushes refused because they named a spec id this run never issued. */
5821
+ rejectedPushes() {
5822
+ return this.rejected;
5823
+ }
5824
+ /**
5825
+ * Pushes the sink could not read. Counted because the failure is otherwise
5826
+ * invisible from this side and shows up as "the spec reached no server code".
5827
+ */
5828
+ malformedPushes() {
5829
+ return this.malformed;
5830
+ }
5831
+ /**
5832
+ * Files the applications could not instrument — they can never report reach.
5833
+ *
5834
+ * Not baselined, unlike `unattributed` and `droppedPushes`: a file that
5835
+ * failed to rewrite when the process booted is still unrewritten now, so it
5836
+ * is a standing condition of this run and not a past event.
5837
+ */
5838
+ uninstrumentedFiles() {
5839
+ let total = 0;
5840
+ for (const report of this.processes.values()) total += report.uninstrumentedFiles;
5841
+ return total;
5842
+ }
5843
+ /**
5844
+ * Application processes that instrumented nothing at all. Kept apart from
5845
+ * the file count because one of these hides every file the process ran, and
5846
+ * folded together it would read as a single missing file.
5847
+ */
5848
+ uninstrumentedProcesses() {
5849
+ let total = 0;
5850
+ for (const report of this.processes.values()) if (report.blind) total++;
5851
+ return total;
5852
+ }
5853
+ /** Pushes the applications could not deliver during this run. Never seen here. */
5854
+ droppedPushes() {
5855
+ let total = 0;
5856
+ for (const report of this.processes.values()) total += Math.max(0, report.droppedLatest - report.droppedBaseline);
5857
+ return total;
5858
+ }
5859
+ async close() {
5860
+ await new Promise((resolve) => {
5861
+ this.server.close(() => {
5862
+ resolve();
5863
+ });
5864
+ });
5865
+ }
5866
+ async handle(request, response) {
5867
+ if (request.method !== "POST") {
5868
+ response.writeHead(405).end();
5869
+ return;
5870
+ }
5871
+ let body;
5872
+ try {
5873
+ body = await readBody$1(request);
5874
+ } catch {
5875
+ this.malformed++;
5876
+ response.writeHead(413).end();
5877
+ return;
5878
+ }
5879
+ let push;
5880
+ try {
5881
+ push = PushSchema.parse(JSON.parse(body));
5882
+ } catch {
5883
+ this.malformed++;
5884
+ response.writeHead(400).end();
5885
+ return;
5886
+ }
5887
+ this.accept(push);
5888
+ response.writeHead(204).end();
5889
+ }
5890
+ accept(push) {
5891
+ const process = `${push.pid}:${push.startedAt}`;
5892
+ const known = this.processes.get(process);
5893
+ const previous = known?.unattributed ?? push.unattributed;
5894
+ for (const file of push.boot) this.bootFiles.add(file);
5895
+ for (const [specId, files] of Object.entries(push.specs)) {
5896
+ if (!SPEC_ID_PATTERN.test(specId) || !this.issued.has(specId)) {
5897
+ this.rejected++;
5898
+ continue;
5899
+ }
5900
+ let spec = this.specs.get(specId);
5901
+ if (spec === void 0) {
5902
+ spec = {
5903
+ files: /* @__PURE__ */ new Set(),
5904
+ baseline: /* @__PURE__ */ new Map()
5905
+ };
5906
+ this.specs.set(specId, spec);
5907
+ }
5908
+ if (!spec.baseline.has(process)) spec.baseline.set(process, previous);
5909
+ for (const file of files) spec.files.add(file);
5910
+ }
5911
+ for (const event of push.actors) this.attributeActorEvent(event, process, previous);
5912
+ this.processes.set(process, {
5913
+ unattributed: push.unattributed,
5914
+ uninstrumentedFiles: push.uninstrumentedFiles,
5915
+ blind: known?.blind === true || push.uninstrumentedProcess,
5916
+ droppedBaseline: known?.droppedBaseline ?? push.droppedPushes,
5917
+ droppedLatest: push.droppedPushes
5918
+ });
5919
+ this.pushesReceived++;
5920
+ }
5921
+ /**
5922
+ * Decides which spec, if any, an identity's work belongs to.
5923
+ *
5924
+ * `at` is when the work was first asked for, not when it ran — an activity a
5925
+ * queue picks up minutes later still carries the instant that caused it, so a
5926
+ * slow tail lands in the turn that started it rather than the one running now.
5927
+ */
5928
+ attributeActorEvent(event, process, previous) {
5929
+ const key = this.tagToKey.get(event.tag);
5930
+ if (key === void 0) {
5931
+ this.unmappedAt.add(event.at);
5932
+ return;
5933
+ }
5934
+ const window = this.windowAt(event.tag, event.at);
5935
+ if (window === void 0) {
5936
+ this.outsideWindow.set(key, (this.outsideWindow.get(key) ?? 0) + 1);
5937
+ return;
5938
+ }
5939
+ const spec = this.specs.get(window.specId) ?? {
5940
+ files: /* @__PURE__ */ new Set(),
5941
+ baseline: /* @__PURE__ */ new Map()
5942
+ };
5943
+ this.specs.set(window.specId, spec);
5944
+ if (!spec.baseline.has(process)) spec.baseline.set(process, previous);
5945
+ for (const file of event.files) spec.files.add(file);
5946
+ let byKey = this.matched.get(window.specId);
5947
+ if (byKey === void 0) {
5948
+ byKey = /* @__PURE__ */ new Map();
5949
+ this.matched.set(window.specId, byKey);
5950
+ }
5951
+ const events = byKey.get(key) ?? /* @__PURE__ */ new Set();
5952
+ events.add(`${event.tag} ${event.at}`);
5953
+ byKey.set(key, events);
5954
+ }
5955
+ /**
5956
+ * The turn on `tag` that `at` falls in, latest first.
5957
+ *
5958
+ * Both clocks are involved — the application stamped `at`, this process
5959
+ * stamped the bounds — so each bound gives a little. It cannot reach the
5960
+ * neighbouring turn: the run leaves a full drain between two turns on one
5961
+ * identity and this reaches half of it.
5962
+ */
5963
+ windowAt(tag, at) {
5964
+ let found;
5965
+ for (const window of this.windows) {
5966
+ if (window.tag !== tag) continue;
5967
+ if (at < window.openedAt - 1500) continue;
5968
+ if (window.closedAt !== void 0 && at > window.closedAt + 1500) continue;
5969
+ found = window;
5970
+ }
5971
+ return found;
5972
+ }
5973
+ };
5974
+ function formatHost(host) {
5975
+ return host.includes(":") ? `[${host}]` : host;
5976
+ }
5977
+ async function readBody$1(request) {
5978
+ const chunks = [];
5979
+ let size = 0;
5980
+ for await (const chunk of request) {
5981
+ const buffer = chunk;
5982
+ size += buffer.length;
5983
+ if (size > MAX_BODY_BYTES$6) throw new Error("coverage push too large");
5984
+ chunks.push(buffer);
5985
+ }
5986
+ return (chunks.length === 1 ? chunks[0] : Buffer.concat(chunks)).toString("utf8");
5987
+ }
5988
+ //#endregion
5989
+ //#region src/coverage/frontend/source-map.ts
5990
+ /** Parses a source map JSON string. Returns undefined for anything unusable. */
5991
+ function parseSourceMap(json) {
5992
+ let parsed;
5993
+ try {
5994
+ parsed = JSON.parse(json);
5995
+ } catch {
5996
+ return;
5997
+ }
5998
+ if (typeof parsed !== "object" || parsed === null) return void 0;
5999
+ const candidate = parsed;
6000
+ if (candidate.sections !== void 0) return void 0;
6001
+ if (candidate.version !== 3) return void 0;
6002
+ if (typeof candidate.mappings !== "string") return void 0;
6003
+ if (!Array.isArray(candidate.sources)) return void 0;
6004
+ return candidate;
6005
+ }
6006
+ const SOURCE_MAPPING_URL_RE = /\/\/[#@][ \t]*sourceMappingURL=([^\s]+)/g;
6007
+ /** Extracts the `//# sourceMappingURL=` value from generated code, if present. */
6008
+ function readSourceMappingUrl(code) {
6009
+ let last;
6010
+ for (const match of code.matchAll(SOURCE_MAPPING_URL_RE)) last = match[1];
6011
+ return last;
6012
+ }
6013
+ const DATA_URL_RE = /^data:([^,]*),(.*)$/s;
6014
+ /** Decodes a `data:` source map URL into its JSON text. Returns undefined otherwise. */
6015
+ function decodeInlineSourceMap(url) {
6016
+ const match = DATA_URL_RE.exec(url);
6017
+ if (!match) return void 0;
6018
+ const [, meta, payload] = match;
6019
+ if (meta === void 0 || payload === void 0 || !/^application\/json/.test(meta)) return void 0;
6020
+ try {
6021
+ return /;base64$/.test(meta) ? Buffer.from(payload, "base64").toString("utf-8") : decodeURIComponent(payload);
6022
+ } catch {
6023
+ return;
6024
+ }
6025
+ }
6026
+ function joinSourceRoot(root, source) {
6027
+ if (typeof root !== "string" || root === "") return source;
6028
+ return root.endsWith("/") ? `${root}${source}` : `${root}/${source}`;
6029
+ }
6030
+ const VLQ_CONTINUATION_BIT = 32;
6031
+ const VLQ_VALUE_MASK = 31;
6032
+ const VLQ_SHIFT = 5;
6033
+ const BASE64_INDEX = new Map(Array.from("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/").map((ch, i) => [ch, i]));
6034
+ /**
6035
+ * Decodes one VLQ value starting at `pos`. Uses multiplication rather than
6036
+ * bit shifts so values beyond 32 bits (large generated files) decode
6037
+ * correctly — `<<` in JS truncates to a signed 32-bit int.
6038
+ */
6039
+ function decodeVlq(mappings, pos) {
6040
+ let result = 0;
6041
+ let shift = 0;
6042
+ let i = pos;
6043
+ for (;;) {
6044
+ const char = mappings[i];
6045
+ if (char === void 0) throw new Error("truncated VLQ in mappings");
6046
+ const digit = BASE64_INDEX.get(char);
6047
+ if (digit === void 0) throw new Error(`invalid base64 digit in mappings: ${char}`);
6048
+ i += 1;
6049
+ result += (digit & VLQ_VALUE_MASK) * 2 ** shift;
6050
+ if ((digit & VLQ_CONTINUATION_BIT) === 0) break;
6051
+ shift += VLQ_SHIFT;
6052
+ }
6053
+ const negative = result % 2 === 1;
6054
+ const magnitude = Math.floor(result / 2);
6055
+ return {
6056
+ value: negative ? -magnitude : magnitude,
6057
+ next: i
6058
+ };
6059
+ }
6060
+ function isSegmentBoundary(mappings, pos) {
6061
+ return pos >= mappings.length || mappings[pos] === "," || mappings[pos] === ";";
6062
+ }
6063
+ /**
6064
+ * Decodes `mappings` into segments that carry source info (the 4- and
6065
+ * 5-field kind). 1-field segments (generated code with no original source)
6066
+ * are skipped: they can never contribute a source, so keeping them around
6067
+ * would only cost time later.
6068
+ */
6069
+ function decodeMappings(mappings) {
6070
+ const segments = [];
6071
+ const len = mappings.length;
6072
+ let pos = 0;
6073
+ let generatedLine = 0;
6074
+ let generatedColumn = 0;
6075
+ let sourceIndex = 0;
6076
+ let sourceLine = 0;
6077
+ let sourceColumn = 0;
6078
+ while (pos < len) {
6079
+ const char = mappings[pos];
6080
+ if (char === ";") {
6081
+ generatedLine += 1;
6082
+ generatedColumn = 0;
6083
+ pos += 1;
6084
+ continue;
6085
+ }
6086
+ if (char === ",") {
6087
+ pos += 1;
6088
+ continue;
6089
+ }
6090
+ const col = decodeVlq(mappings, pos);
6091
+ generatedColumn += col.value;
6092
+ pos = col.next;
6093
+ if (isSegmentBoundary(mappings, pos)) continue;
6094
+ const srcIndex = decodeVlq(mappings, pos);
6095
+ sourceIndex += srcIndex.value;
6096
+ pos = srcIndex.next;
6097
+ const srcLine = decodeVlq(mappings, pos);
6098
+ sourceLine += srcLine.value;
6099
+ pos = srcLine.next;
6100
+ const srcColumn = decodeVlq(mappings, pos);
6101
+ sourceColumn += srcColumn.value;
6102
+ pos = srcColumn.next;
6103
+ segments.push({
6104
+ generatedLine,
6105
+ generatedColumn,
6106
+ sourceIndex
6107
+ });
6108
+ if (!isSegmentBoundary(mappings, pos)) pos = decodeVlq(mappings, pos).next;
6109
+ }
6110
+ return segments;
6111
+ }
6112
+ function prepareSourceMap(map, generatedCode) {
6113
+ const lineStarts = computeLineStarts(generatedCode);
6114
+ const positions = [];
6115
+ let segments;
6116
+ try {
6117
+ segments = decodeMappings(map.mappings);
6118
+ } catch {
6119
+ return;
6120
+ }
6121
+ for (const segment of segments) {
6122
+ const lineStart = lineStarts[segment.generatedLine];
6123
+ if (lineStart === void 0) continue;
6124
+ positions.push({
6125
+ offset: lineStart + segment.generatedColumn,
6126
+ sourceIndex: segment.sourceIndex
6127
+ });
6128
+ }
6129
+ positions.sort((a, b) => a.offset - b.offset);
6130
+ return {
6131
+ positions,
6132
+ paths: map.sources.map((source) => source === null ? void 0 : joinSourceRoot(map.sourceRoot, source))
6133
+ };
6134
+ }
6135
+ /** Start offset (UTF-16 code units) of each line in `code`, index 0 for line 0. */
6136
+ function computeLineStarts(code) {
6137
+ const starts = [0];
6138
+ for (let i = 0; i < code.length; i++) if (code.charCodeAt(i) === 10) starts.push(i + 1);
6139
+ return starts;
6140
+ }
6141
+ /** Index of the first mapped position at or after `offset`. */
6142
+ function lowerBound(positions, offset) {
6143
+ let lo = 0;
6144
+ let hi = positions.length;
6145
+ while (lo < hi) {
6146
+ const mid = lo + hi >>> 1;
6147
+ if ((positions[mid]?.offset ?? 0) < offset) lo = mid + 1;
6148
+ else hi = mid;
6149
+ }
6150
+ return lo;
6151
+ }
6152
+ /**
6153
+ * Which of `prepared`'s sources the covered ranges touch.
6154
+ *
6155
+ * Every range is walked even once all sources are known, because a range that
6156
+ * maps nowhere has to be counted — dropping it would turn an unknown into a
6157
+ * silent "never reached".
6158
+ */
6159
+ function resolveCovered(prepared, ranges) {
6160
+ const seen = new Uint8Array(prepared.paths.length);
6161
+ const sources = [];
6162
+ let unmappedRanges = 0;
6163
+ for (const range of ranges) {
6164
+ let matched = false;
6165
+ for (let i = lowerBound(prepared.positions, range.startOffset); i < prepared.positions.length; i++) {
6166
+ const position = prepared.positions[i];
6167
+ if (position === void 0 || position.offset >= range.endOffset) break;
6168
+ matched = true;
6169
+ if (seen[position.sourceIndex] === 1) continue;
6170
+ seen[position.sourceIndex] = 1;
6171
+ const path = prepared.paths[position.sourceIndex];
6172
+ if (path !== void 0) sources.push(path);
6173
+ }
6174
+ if (!matched) unmappedRanges += 1;
6175
+ }
6176
+ return {
6177
+ sources,
6178
+ unmappedRanges
6179
+ };
6180
+ }
6181
+ //#endregion
6182
+ //#region src/coverage/frontend/source-path.ts
6183
+ /**
6184
+ * Turns the source names a bundler writes into `sources` back into paths a
6185
+ * reader can find in the project.
6186
+ *
6187
+ * Bundlers namespace those entries — `webpack://_N_E/./src/a.ts`,
6188
+ * `webpack-internal:///(pages-dir-browser)/./src/a.ts` — and some write the
6189
+ * absolute path the build machine used. None of the forms is a project path on
6190
+ * its own.
6191
+ *
6192
+ * Anything that still resolves outside the root is dropped rather than
6193
+ * coerced. A framework's own runtime arrives as `../../../node_modules/...`,
6194
+ * and flattening those leading segments would invent a path that exists
6195
+ * nowhere and report the framework as project code nobody has a test for.
6196
+ *
6197
+ * Why a reason and not just `undefined`: dependency code is dropped on purpose
6198
+ * and a name nobody could resolve is a hole in the measurement. Reported as one
6199
+ * number the two are indistinguishable, and since dependencies dominate it by
6200
+ * orders of magnitude, the number reads as noise — which is how a real hole
6201
+ * goes unnoticed inside it.
6202
+ */
6203
+ /** Dependency code is dropped: an unreached library file is not a missing test. */
6204
+ const VENDOR = /(^|\/)node_modules\//;
6205
+ /** `(rsc)`, `(pages-dir-browser)`, ... — which build layer, not part of the path. */
6206
+ const LAYER = /^\([^)]*\)\//;
6207
+ const DEPENDENCY = { kind: "dependency" };
6208
+ const UNRESOLVED = { kind: "unresolved" };
6209
+ /** `absolute` as a posix path under `root`, or undefined when it is not under it. */
6210
+ function toProjectRelative(root, absolute) {
6211
+ const rel = relative(root, absolute).split(sep).join("/");
6212
+ return rel === "" || rel.startsWith("..") ? void 0 : rel;
6213
+ }
6214
+ function normalizeSourcePath(raw, roots) {
6215
+ if (VENDOR.test(raw)) return DEPENDENCY;
6216
+ let path = raw;
6217
+ const scheme = path.indexOf("://");
6218
+ if (scheme >= 0) {
6219
+ const afterScheme = path.slice(scheme + 3);
6220
+ const slash = afterScheme.indexOf("/");
6221
+ const from = path.startsWith("file:") ? slash : slash + 1;
6222
+ path = slash < 0 ? afterScheme : afterScheme.slice(from);
6223
+ }
6224
+ path = posix.normalize(path.replace(LAYER, ""));
6225
+ if (path === "" || path === ".") return UNRESOLVED;
6226
+ if (path.startsWith("<") || path.startsWith("[")) return UNRESOLVED;
6227
+ const absolute = path.startsWith("/") ? path : resolve(roots.base, path);
6228
+ const rel = toProjectRelative(roots.root, absolute);
6229
+ if (rel === void 0) return UNRESOLVED;
6230
+ return VENDOR.test(rel) ? DEPENDENCY : {
6231
+ kind: "project",
6232
+ path: rel
6233
+ };
6234
+ }
6235
+ //#endregion
6236
+ //#region src/coverage/frontend/build-output.ts
6237
+ /**
6238
+ * Follows a build output back to the source it was compiled from.
6239
+ *
6240
+ * A workspace package is consumed through its published entry, so a bundler
6241
+ * names it `packages/x/dist/index.mjs`. That file exists and is genuinely what
6242
+ * ran, but nobody edits it — reported as-is it becomes an entry in the
6243
+ * untested-file list that no test could ever cover.
6244
+ *
6245
+ * Only a 1:1 build is followed: one output per input, which is what an
6246
+ * unbundled compile produces and what its map states in a single `sources`
6247
+ * entry. A bundle's map lists everything that went into it and cannot say
6248
+ * which of them the file "is", so those are left pointing at the output.
6249
+ *
6250
+ * The server half follows the same 1:1 rule at load time, in `ccqa-tools`'s
6251
+ * `instrument/origin.ts`. It reaches further: it already holds the code, so it
6252
+ * can read an inline map, where this side only reads a sibling `.map`. A
6253
+ * package built with an inline map is therefore reported under its source by
6254
+ * the server and under its build output by the browser.
6255
+ */
6256
+ /**
6257
+ * The project-relative source `path` was built from, or undefined to keep
6258
+ * `path` as it is.
6259
+ *
6260
+ * Only the sibling `<file>.map` convention is read. Finding an inline map means
6261
+ * reading the whole build output on the chance it carries one, which is a lot
6262
+ * of I/O for a case library builds rarely emit.
6263
+ */
6264
+ function sourceBehindBuildOutput(path, root) {
6265
+ const output = join(root, path);
6266
+ let json;
6267
+ try {
6268
+ json = readFileSync(`${output}.map`, "utf8");
6269
+ } catch {
6270
+ return;
6271
+ }
6272
+ const map = parseSourceMap(json);
6273
+ if (map === void 0 || map.sources.length !== 1) return void 0;
6274
+ const source = map.sources[0];
6275
+ if (typeof source !== "string" || source === "") return void 0;
6276
+ const absolute = resolve(dirname(output), map.sourceRoot ?? "", source);
6277
+ const rel = toProjectRelative(root, absolute);
6278
+ if (rel === void 0) return void 0;
6279
+ return existsSync(absolute) ? rel : void 0;
6280
+ }
6281
+ //#endregion
6282
+ //#region src/coverage/frontend/resolve.ts
6283
+ /**
6284
+ * What counts as a source file. Shared with the universe enumeration
6285
+ * (universe.ts): the denominator must use the same notion of "source file"
6286
+ * as the reached side, or "uncovered" drifts as one definition evolves.
6287
+ */
6288
+ const SOURCE_FILE = /\.(?:[cm]?[jt]sx?)$/;
6289
+ var FrontendResolution = class {
6290
+ specId;
6291
+ coverageDir;
6292
+ roots;
6293
+ fetchText;
6294
+ warn;
6295
+ files = /* @__PURE__ */ new Set();
6296
+ /**
6297
+ * Resolution, memoised. `roots` is fixed for the session and V8 re-reports
6298
+ * every script it has seen on each take, so both answers below are otherwise
6299
+ * recomputed for the whole page at every navigation.
6300
+ */
6301
+ classified = /* @__PURE__ */ new Map();
6302
+ /** Project path -> the source it was built from, or itself. */
6303
+ sources = /* @__PURE__ */ new Map();
6304
+ /** Decoded once per script and kept; the raw map is dropped with it. */
6305
+ maps = /* @__PURE__ */ new Map();
6306
+ unmappedScripts = 0;
6307
+ unmappedRanges = 0;
6308
+ unresolvedSources = 0;
6309
+ excludedDependencies = 0;
6310
+ /** Set once collection dies: everything after this point was never seen. */
6311
+ stopped = false;
6312
+ /** What the last write held, so an unchanged flush does not rewrite the file. */
6313
+ written = "";
6314
+ dirReady = false;
6315
+ constructor(opts) {
6316
+ this.specId = opts.specId;
6317
+ this.coverageDir = opts.coverageDir;
6318
+ this.roots = opts.roots;
6319
+ this.fetchText = opts.fetchText;
6320
+ this.warn = opts.warn;
6321
+ }
6322
+ async absorb(script) {
6323
+ if (script.ranges.length === 0) return;
6324
+ const direct = this.bundlerModulePath(script.url);
6325
+ if (direct !== void 0) {
6326
+ this.files.add(this.sourceOf(direct));
6327
+ return;
6328
+ }
6329
+ const prepared = await this.loadSourceMap(script);
6330
+ if (prepared === void 0) {
6331
+ this.unmappedScripts++;
6332
+ return;
6333
+ }
6334
+ const resolved = resolveCovered(prepared, [...script.ranges]);
6335
+ this.unmappedRanges += resolved.unmappedRanges;
6336
+ for (const raw of resolved.sources) {
6337
+ const source = this.classify(raw);
6338
+ if (source.kind === "unresolved") this.unresolvedSources++;
6339
+ else if (source.kind === "dependency") this.excludedDependencies++;
6340
+ else this.files.add(this.sourceOf(source.path));
6341
+ }
6342
+ }
6343
+ /** Collection died mid-spec; the shorter file set must say so. */
6344
+ markStopped() {
6345
+ this.stopped = true;
6346
+ this.flush();
6347
+ }
6348
+ /**
6349
+ * Written after every batch that changed something, not only at the end: a
6350
+ * spec that fails mid-way still leaves everything it reached, and a failing
6351
+ * spec is exactly when the reader wants to know what ran.
6352
+ */
6353
+ flush() {
6354
+ const payload = {
6355
+ specId: this.specId,
6356
+ files: [...this.files].sort(),
6357
+ unmappedScripts: this.unmappedScripts,
6358
+ unmappedRanges: this.unmappedRanges,
6359
+ unresolvedSources: this.unresolvedSources,
6360
+ excludedDependencies: this.excludedDependencies,
6361
+ stopped: this.stopped
6362
+ };
6363
+ const text = `${JSON.stringify(payload, null, 2)}\n`;
6364
+ if (text === this.written) return;
6365
+ try {
6366
+ if (!this.dirReady) {
6367
+ mkdirSync(this.coverageDir, { recursive: true });
6368
+ this.dirReady = true;
6369
+ }
6370
+ writeFileSync(join(this.coverageDir, FRONTEND_COVERAGE_FILE), text, "utf8");
6371
+ this.written = text;
6372
+ } catch (error) {
6373
+ this.warn(`could not write ${FRONTEND_COVERAGE_FILE} (${message$2(error)})`);
6374
+ }
6375
+ }
6376
+ classify(raw) {
6377
+ const known = this.classified.get(raw);
6378
+ if (known !== void 0) return known;
6379
+ const source = normalizeSourcePath(raw, this.roots);
6380
+ this.classified.set(raw, source);
6381
+ return source;
6382
+ }
6383
+ sourceOf(path) {
6384
+ const known = this.sources.get(path);
6385
+ if (known !== void 0) return known;
6386
+ const source = sourceBehindBuildOutput(path, this.roots.root) ?? path;
6387
+ this.sources.set(path, source);
6388
+ return source;
6389
+ }
6390
+ /**
6391
+ * Restricted to bundler schemes: a real `http(s)` URL is a built asset, and
6392
+ * its path says nothing about the sources inside it.
6393
+ */
6394
+ bundlerModulePath(url) {
6395
+ if (url === "" || /^https?:/i.test(url) || !url.includes("://")) return void 0;
6396
+ const source = this.classify(url);
6397
+ if (source.kind !== "project" || !SOURCE_FILE.test(source.path)) return void 0;
6398
+ return source.path;
6399
+ }
6400
+ async loadSourceMap(script) {
6401
+ const cached = this.maps.get(script.url);
6402
+ if (cached !== void 0) return cached ?? void 0;
6403
+ const prepared = await this.fetchSourceMap(script);
6404
+ this.maps.set(script.url, prepared ?? null);
6405
+ return prepared;
6406
+ }
6407
+ async fetchSourceMap(script) {
6408
+ const source = await script.source();
6409
+ if (source === void 0) return void 0;
6410
+ const reference = readSourceMappingUrl(source);
6411
+ if (reference === void 0) return void 0;
6412
+ let json = decodeInlineSourceMap(reference);
6413
+ if (json === void 0) {
6414
+ let target;
6415
+ try {
6416
+ target = new URL(reference, script.url).toString();
6417
+ } catch {
6418
+ return;
6419
+ }
6420
+ json = await this.fetchText(target);
6421
+ if (json === void 0) return void 0;
6422
+ }
6423
+ const map = parseSourceMap(json);
6424
+ if (map === void 0) return void 0;
6425
+ return prepareSourceMap(map, source);
6426
+ }
6427
+ };
6428
+ function message$2(error) {
6429
+ return error instanceof Error ? error.message : String(error);
6430
+ }
6431
+ //#endregion
6432
+ //#region src/coverage/browser/cdp.ts
6433
+ /**
6434
+ * Minimal Chrome DevTools Protocol client, dependency-free on purpose.
6435
+ *
6436
+ * Coverage acquisition speaks a handful of domains over one transport, which
6437
+ * is not enough to justify a protocol library in a published CLI. The
6438
+ * transport is the `WebSocket` global — stable since Node 22 — so availability
6439
+ * is gated with an explicit error instead of a package.json engines bump:
6440
+ * everything else in ccqa still runs on 20, and only `--coverage`'s browser
6441
+ * half needs more.
6442
+ */
6443
+ var CdpError = class extends Error {};
6444
+ /**
6445
+ * Wire-level trace, for diagnosing the engine against a live browser:
6446
+ * `CCQA_CDP_TRACE=1` writes to stderr, `CCQA_CDP_TRACE_FILE=<path>` to a file.
6447
+ * The file is the usable one during a live run, whose stderr already carries
6448
+ * the agent's narration.
6449
+ */
6450
+ const TRACE_FILE = process.env.CCQA_CDP_TRACE_FILE;
6451
+ const TRACE = process.env.CCQA_CDP_TRACE === "1" || TRACE_FILE !== void 0;
6452
+ function trace(direction, text) {
6453
+ if (!TRACE) return;
6454
+ const line = `[cdp ${direction}] ${Date.now() % 1e5} ${text}\n`;
6455
+ if (TRACE_FILE === void 0) {
6456
+ process.stderr.write(line);
6457
+ return;
6458
+ }
6459
+ try {
6460
+ appendFileSync(TRACE_FILE, line);
6461
+ } catch {}
6462
+ }
6463
+ /** Throws with the actual requirement when the runtime cannot open the socket. */
6464
+ function requireWebSocket() {
6465
+ if (typeof WebSocket === "undefined") throw new CdpError(`browser coverage needs the WebSocket global (node 22+); this is node ${process.version}`);
6466
+ }
6467
+ /**
6468
+ * Resolves whatever a target hands us — `host:port`, an `http://` endpoint, or
6469
+ * a ws URL — to the **browser-level** ws endpoint. A page-level ws URL is not
6470
+ * enough: auto-attach has to be armed at the browser to see every page and
6471
+ * every popup, so a page URL is reduced to its host and re-resolved through
6472
+ * `/json/version` like the rest.
6473
+ */
6474
+ async function browserWebSocketUrl(endpoint) {
6475
+ const trimmed = endpoint.trim();
6476
+ if (/^wss?:\/\//i.test(trimmed) && trimmed.includes("/devtools/browser/")) return trimmed;
6477
+ let host;
6478
+ try {
6479
+ host = new URL(/^[a-z+]+:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`).host;
6480
+ } catch {
6481
+ throw new CdpError(`not a CDP endpoint: "${endpoint}"`);
6482
+ }
6483
+ const version = await fetch(`http://${host}/json/version`).catch((error) => {
6484
+ throw new CdpError(`CDP endpoint ${host} did not answer /json/version (${message$1(error)})`);
6485
+ });
6486
+ if (!version.ok) throw new CdpError(`CDP endpoint ${host} answered ${version.status}`);
6487
+ const body = await version.json();
6488
+ if (typeof body.webSocketDebuggerUrl !== "string") throw new CdpError(`CDP endpoint ${host} reported no webSocketDebuggerUrl`);
6489
+ return body.webSocketDebuggerUrl;
6490
+ }
6491
+ var CdpClient = class CdpClient {
6492
+ ws;
6493
+ nextId = 1;
6494
+ pending = /* @__PURE__ */ new Map();
6495
+ listeners = /* @__PURE__ */ new Map();
6496
+ closeHandlers = /* @__PURE__ */ new Set();
6497
+ constructor(ws) {
6498
+ this.ws = ws;
6499
+ ws.addEventListener("message", (event) => this.receive(String(event.data)));
6500
+ ws.addEventListener("close", () => this.drop("connection closed"));
6501
+ ws.addEventListener("error", () => this.drop("connection error"));
6502
+ }
6503
+ static async connect(wsUrl) {
6504
+ requireWebSocket();
6505
+ const ws = new WebSocket(wsUrl);
6506
+ await new Promise((resolve, reject) => {
6507
+ ws.addEventListener("open", () => resolve(), { once: true });
6508
+ ws.addEventListener("error", () => reject(new CdpError(`could not connect to ${wsUrl}`)), { once: true });
6509
+ });
6510
+ return new CdpClient(ws);
6511
+ }
6512
+ send(method, params, sessionId) {
6513
+ if (this.ws.readyState !== WebSocket.OPEN) return Promise.reject(new CdpError(`${method}: connection closed`));
6514
+ const id = this.nextId++;
6515
+ const promise = new Promise((resolve, reject) => {
6516
+ this.pending.set(id, {
6517
+ resolve,
6518
+ reject,
6519
+ method
6520
+ });
6521
+ });
6522
+ trace("->", `#${id} ${method} sid:${shortId(sessionId)}`);
6523
+ this.ws.send(JSON.stringify({
6524
+ id,
6525
+ method,
6526
+ params: params ?? {},
6527
+ sessionId
6528
+ }));
6529
+ return promise;
6530
+ }
6531
+ on(method, handler) {
6532
+ let set = this.listeners.get(method);
6533
+ if (set === void 0) {
6534
+ set = /* @__PURE__ */ new Set();
6535
+ this.listeners.set(method, set);
6536
+ }
6537
+ set.add(handler);
6538
+ }
6539
+ onClose(handler) {
6540
+ this.closeHandlers.add(handler);
6541
+ }
6542
+ close() {
6543
+ try {
6544
+ this.ws.close();
6545
+ } catch {}
6546
+ }
6547
+ receive(data) {
6548
+ let parsed;
6549
+ try {
6550
+ parsed = JSON.parse(data);
6551
+ } catch {
6552
+ trace("rx", `unparseable frame: ${data.slice(0, 60)}`);
6553
+ return;
6554
+ }
6555
+ if (parsed.id !== void 0) {
6556
+ const waiting = this.pending.get(parsed.id);
6557
+ if (waiting === void 0) return;
6558
+ this.pending.delete(parsed.id);
6559
+ if (parsed.error !== void 0) {
6560
+ trace("<-", `#${parsed.id} ${waiting.method} ERROR ${parsed.error.message ?? "?"}`);
6561
+ waiting.reject(new CdpError(`${waiting.method}: ${parsed.error.message ?? "CDP error"}`));
6562
+ } else {
6563
+ trace("<-", `#${parsed.id} ${waiting.method} ok`);
6564
+ waiting.resolve(parsed.result ?? {});
6565
+ }
6566
+ return;
6567
+ }
6568
+ if (parsed.method !== void 0) {
6569
+ trace("ev", describeEvent(parsed.method, parsed.params ?? {}, parsed.sessionId));
6570
+ const set = this.listeners.get(parsed.method);
6571
+ if (set === void 0) return;
6572
+ for (const handler of set) try {
6573
+ handler(parsed.params ?? {}, parsed.sessionId);
6574
+ } catch {}
6575
+ }
6576
+ }
6577
+ drop(reason) {
6578
+ for (const waiting of this.pending.values()) waiting.reject(new CdpError(`${waiting.method}: ${reason}`));
6579
+ this.pending.clear();
6580
+ for (const handler of this.closeHandlers) try {
6581
+ handler();
6582
+ } catch {}
6583
+ this.closeHandlers.clear();
6584
+ }
6585
+ };
6586
+ /** Attach events carry the one thing a method name cannot: what was handed over. */
6587
+ function describeEvent(method, params, sessionId) {
6588
+ const base = `${method} sid:${shortId(sessionId)}`;
6589
+ if (method !== "Target.attachedToTarget") return base;
6590
+ const info = params;
6591
+ return `${base} child:${shortId(info.sessionId)} ${info.targetInfo?.type ?? "?"} ${info.targetInfo?.url?.slice(0, 40) ?? "?"} wait:${String(info.waitingForDebugger)}`;
6592
+ }
6593
+ function shortId(sessionId) {
6594
+ return sessionId?.slice(0, 6) ?? "-";
6595
+ }
6596
+ function message$1(error) {
6597
+ return error instanceof Error ? error.message : String(error);
6598
+ }
6599
+ //#endregion
6600
+ //#region src/coverage/browser/engine.ts
6601
+ const TAKE_INTERVAL_MS = 400;
6602
+ /** How long a navigation may hold takes before the guard assumes a lost event. */
6603
+ const NAVIGATION_GUARD_MS = 5e3;
6604
+ /** How long `stop()` waits for the final take before declaring the tail lost. */
6605
+ const STOP_TAKE_TIMEOUT_MS = 2e3;
6606
+ /** The browser's own chrome. Nothing there is the application under test. */
6607
+ const INTERNAL_URL = /^(chrome|chrome-untrusted|chrome-extension|devtools):/;
6608
+ async function startBrowserCoverage(opts) {
6609
+ const client = await (opts.connect ?? ((wsUrl) => CdpClient.connect(wsUrl)))(await browserWebSocketUrl(opts.cdpUrl));
6610
+ const engine = new Engine(client, opts);
6611
+ try {
6612
+ await engine.arm();
6613
+ } catch (error) {
6614
+ client.close();
6615
+ throw error;
6616
+ }
6617
+ return engine;
6618
+ }
6619
+ var Engine = class {
6620
+ client;
6621
+ opts;
6622
+ resolution;
6623
+ pages = /* @__PURE__ */ new Map();
6624
+ /**
6625
+ * Targets already armed, by target id. Browser-level auto-attach reports
6626
+ * existing targets too, so the explicit sweep for them can hand over the
6627
+ * same target a second time, and a page armed through two sessions is taken
6628
+ * twice for one set of counters.
6629
+ */
6630
+ armedTargets = /* @__PURE__ */ new Set();
6631
+ /** Sessions whose take failure was already said; see enqueueTake. */
6632
+ warnedTakeSessions = /* @__PURE__ */ new Set();
6633
+ cookies;
6634
+ timer;
6635
+ stopped = false;
6636
+ constructor(client, opts) {
6637
+ this.client = client;
6638
+ this.opts = opts;
6639
+ this.cookies = opts.origins.map((url) => ({
6640
+ name: COVERAGE_COOKIE,
6641
+ value: opts.specId,
6642
+ url
6643
+ }));
6644
+ this.resolution = new FrontendResolution({
6645
+ specId: opts.specId,
6646
+ coverageDir: opts.coverageDir,
6647
+ roots: opts.roots,
6648
+ fetchText: (url) => this.fetchThroughBrowser(url),
6649
+ warn: opts.warn
6650
+ });
6651
+ }
6652
+ async arm() {
6653
+ this.client.on("Target.attachedToTarget", (params) => {
6654
+ this.onAttached(params);
6655
+ });
6656
+ this.client.on("Target.detachedFromTarget", (params) => {
6657
+ const sessionId = params.sessionId;
6658
+ if (sessionId === void 0) return;
6659
+ const page = this.pages.get(sessionId);
6660
+ this.pages.delete(sessionId);
6661
+ if (page !== void 0) this.armedTargets.delete(page.targetId);
6662
+ });
6663
+ this.client.on("Page.frameStartedNavigating", (params, sessionId) => {
6664
+ if (sessionId === void 0) return;
6665
+ const page = this.pages.get(sessionId);
6666
+ if (page === void 0 || !this.isMainFrame(page, params.frameId)) return;
6667
+ page.navigatingSince = Date.now();
6668
+ });
6669
+ this.client.on("Page.frameNavigated", (params, sessionId) => {
6670
+ if (sessionId === void 0) return;
6671
+ const page = this.pages.get(sessionId);
6672
+ if (page === void 0) return;
6673
+ const frame = params.frame;
6674
+ if (frame?.parentId !== void 0) return;
6675
+ if (frame?.id !== void 0) page.mainFrameId = frame.id;
6676
+ page.navigatingSince = void 0;
6677
+ this.setCookies(page);
6678
+ });
6679
+ this.client.on("Page.frameStoppedLoading", (params, sessionId) => {
6680
+ if (sessionId === void 0) return;
6681
+ const page = this.pages.get(sessionId);
6682
+ if (page === void 0 || !this.isMainFrame(page, params.frameId)) return;
6683
+ page.navigatingSince = void 0;
6684
+ });
6685
+ this.client.onClose(() => {
6686
+ if (!this.stopped && this.pages.size > 0) this.resolution.markStopped();
6687
+ this.pages.clear();
6688
+ if (this.timer !== void 0) clearInterval(this.timer);
6689
+ });
6690
+ await this.client.send("Target.setAutoAttach", {
6691
+ autoAttach: true,
6692
+ waitForDebuggerOnStart: false,
6693
+ flatten: true,
6694
+ filter: [{ type: "tab" }, { exclude: true }]
6695
+ });
6696
+ const existing = await this.client.send("Target.getTargets", { filter: [{ type: "tab" }] });
6697
+ for (const info of existing.targetInfos) {
6698
+ if (this.armedTargets.has(info.targetId)) continue;
6699
+ await this.client.send("Target.attachToTarget", {
6700
+ targetId: info.targetId,
6701
+ flatten: true
6702
+ }).catch(() => void 0);
6703
+ }
6704
+ this.timer = setInterval(() => {
6705
+ for (const page of this.pages.values()) {
6706
+ this.enqueueTake(page.sessionId);
6707
+ if (page.armed) this.setCookies(page);
6708
+ }
6709
+ }, TAKE_INTERVAL_MS);
6710
+ this.timer.unref?.();
6711
+ }
6712
+ async stop() {
6713
+ this.stopped = true;
6714
+ if (this.timer !== void 0) clearInterval(this.timer);
6715
+ let sawEverything = ![...this.pages.values()].some((page) => !page.armed);
6716
+ for (const page of this.pages.values()) page.navigatingSince = void 0;
6717
+ const takes = Promise.all([...this.pages.keys()].map((sessionId) => this.enqueueTake(sessionId)));
6718
+ if (await Promise.race([takes.then(() => false), new Promise((resolve) => setTimeout(resolve, STOP_TAKE_TIMEOUT_MS, true))])) {
6719
+ this.opts.warn("the final coverage take did not answer; the spec's tail went unseen");
6720
+ sawEverything = false;
6721
+ }
6722
+ if (!sawEverything) this.resolution.markStopped();
6723
+ this.resolution.flush();
6724
+ this.client.close();
6725
+ }
6726
+ async onAttached(params) {
6727
+ const { sessionId, targetInfo } = params;
6728
+ const release = () => params.waitingForDebugger ? this.client.send("Runtime.runIfWaitingForDebugger", {}, sessionId).catch(() => void 0) : Promise.resolve();
6729
+ if (this.stopped) {
6730
+ await release();
6731
+ return;
6732
+ }
6733
+ if (targetInfo.type === "tab") {
6734
+ if (!this.armedTargets.has(targetInfo.targetId)) {
6735
+ this.armedTargets.add(targetInfo.targetId);
6736
+ await this.client.send("Target.setAutoAttach", {
6737
+ autoAttach: true,
6738
+ waitForDebuggerOnStart: true,
6739
+ flatten: true
6740
+ }, sessionId).catch(() => void 0);
6741
+ }
6742
+ await release();
6743
+ return;
6744
+ }
6745
+ const measurable = (targetInfo.type === "page" || targetInfo.type === "iframe") && !INTERNAL_URL.test(targetInfo.url);
6746
+ if (!measurable || this.armedTargets.has(targetInfo.targetId)) {
6747
+ await release();
6748
+ if (measurable) await this.client.send("Target.detachFromTarget", { sessionId }).catch(() => void 0);
6749
+ return;
6750
+ }
6751
+ this.armedTargets.add(targetInfo.targetId);
6752
+ const page = {
6753
+ sessionId,
6754
+ targetId: targetInfo.targetId,
6755
+ armed: false,
6756
+ navigatingSince: void 0,
6757
+ mainFrameId: void 0,
6758
+ pending: Promise.resolve()
6759
+ };
6760
+ this.pages.set(sessionId, page);
6761
+ const sent = [
6762
+ this.client.send("Profiler.enable", {}, sessionId),
6763
+ this.client.send("Profiler.startPreciseCoverage", {
6764
+ callCount: true,
6765
+ detailed: true
6766
+ }, sessionId).then(() => {
6767
+ page.armed = true;
6768
+ }),
6769
+ this.client.send("Page.enable", {}, sessionId),
6770
+ this.setCookies(page)
6771
+ ];
6772
+ await release();
6773
+ const failed = (await Promise.allSettled(sent)).find((r) => r.status === "rejected");
6774
+ if (failed !== void 0) this.opts.warn(`could not arm a browser target (${message(failed.reason)})`);
6775
+ }
6776
+ setCookies(page) {
6777
+ if (this.cookies.length === 0) return Promise.resolve();
6778
+ return this.client.send("Network.setCookies", { cookies: this.cookies }, page.sessionId).catch((error) => {
6779
+ this.opts.warn(`could not attach the spec cookie (${message(error)})`);
6780
+ });
6781
+ }
6782
+ enqueueTake(sessionId) {
6783
+ const page = this.pages.get(sessionId);
6784
+ if (page === void 0 || !page.armed) return Promise.resolve();
6785
+ if (page.navigatingSince !== void 0) {
6786
+ if (Date.now() - page.navigatingSince < NAVIGATION_GUARD_MS) return Promise.resolve();
6787
+ page.navigatingSince = void 0;
6788
+ }
6789
+ page.pending = page.pending.then(async () => {
6790
+ const taken = await this.client.send("Profiler.takePreciseCoverage", {}, sessionId);
6791
+ await this.absorbEntries(taken.result);
6792
+ }).catch((error) => {
6793
+ if (this.stopped || this.warnedTakeSessions.has(sessionId)) return;
6794
+ this.warnedTakeSessions.add(sessionId);
6795
+ if (this.pages.has(sessionId)) this.opts.warn(`a coverage take failed and later ones may too (${message(error)})`);
6796
+ });
6797
+ return page.pending;
6798
+ }
6799
+ /** Face value until the main frame's id is known; see the arm() comment. */
6800
+ isMainFrame(page, frameId) {
6801
+ return page.mainFrameId === void 0 || frameId === void 0 || frameId === page.mainFrameId;
6802
+ }
6803
+ async absorbEntries(entries) {
6804
+ let changed = false;
6805
+ for (const entry of entries) {
6806
+ const ranges = [];
6807
+ for (const fn of entry.functions) for (const range of fn.ranges) if (range.count > 0) ranges.push({
6808
+ startOffset: range.startOffset,
6809
+ endOffset: range.endOffset
6810
+ });
6811
+ if (ranges.length === 0) continue;
6812
+ const script = {
6813
+ url: entry.url,
6814
+ ranges,
6815
+ source: async () => /^https?:/i.test(entry.url) ? this.fetchThroughBrowser(entry.url) : void 0
6816
+ };
6817
+ await this.resolution.absorb(script);
6818
+ changed = true;
6819
+ }
6820
+ if (changed) this.resolution.flush();
6821
+ }
6822
+ /**
6823
+ * Fetches through a page where there is one, so the request carries the
6824
+ * session's cookies (see `FrontendResolutionOptions.fetchText`).
6825
+ */
6826
+ async fetchThroughBrowser(url) {
6827
+ const [page] = this.pages.values();
6828
+ if (page !== void 0) try {
6829
+ const tree = await this.client.send("Page.getFrameTree", {}, page.sessionId);
6830
+ const loaded = await this.client.send("Network.loadNetworkResource", {
6831
+ frameId: tree.frameTree.frame.id,
6832
+ url,
6833
+ options: {
6834
+ disableCache: false,
6835
+ includeCredentials: true
6836
+ }
6837
+ }, page.sessionId);
6838
+ if (loaded.resource.success && loaded.resource.stream !== void 0) return await this.readStream(loaded.resource.stream, page.sessionId);
6839
+ } catch {}
6840
+ try {
6841
+ const response = await fetch(url);
6842
+ if (!response.ok) return void 0;
6843
+ return await response.text();
6844
+ } catch {
6845
+ return;
6846
+ }
6847
+ }
6848
+ async readStream(handle, sessionId) {
6849
+ const parts = [];
6850
+ for (;;) {
6851
+ const chunk = await this.client.send("IO.read", { handle }, sessionId);
6852
+ parts.push(chunk.base64Encoded === true ? Buffer.from(chunk.data, "base64").toString("utf8") : chunk.data);
6853
+ if (chunk.eof) break;
6854
+ }
6855
+ await this.client.send("IO.close", { handle }, sessionId).catch(() => void 0);
6856
+ return parts.join("");
6857
+ }
6858
+ };
6859
+ function message(error) {
6860
+ return error instanceof Error ? error.message : String(error);
6861
+ }
6862
+ //#endregion
6863
+ //#region src/coverage/universe.ts
6864
+ /**
6865
+ * Directories that hold generated or vendored code, not sources anyone writes
6866
+ * tests against. Dot-directories (.git, .next, .turbo…) are skipped wholesale.
6867
+ */
6868
+ const SKIP_DIRS = new Set([
6869
+ "node_modules",
6870
+ "dist",
6871
+ "build",
6872
+ "out",
6873
+ "coverage"
6874
+ ]);
6875
+ /**
6876
+ * More files than any human will triage as a gap list — a ceiling this high is
6877
+ * only reached when `coverage.include` points at something like a whole
6878
+ * monorepo, and a truncated universe would silently misreport "uncovered".
6879
+ */
6880
+ const MAX_FILES = 2e4;
6881
+ async function enumerateUniverse(root, include, warn) {
6882
+ const files = [];
6883
+ const dirs = [...new Set(include.map(normalizeDir))];
6884
+ for (const dir of dirs) {
6885
+ await walk(dir === "" ? root : join(root, dir), dir, files, warn);
6886
+ if (files.length > MAX_FILES) {
6887
+ warn(`coverage.include matched more than ${MAX_FILES} files — the universe was omitted rather than truncated. Narrow coverage.include to the directories the measurement covers.`);
6888
+ return;
6889
+ }
6890
+ }
6891
+ if (files.length === 0) {
6892
+ warn("coverage.include matched no files — the universe was omitted. Check that the directories exist relative to coverage.projectRoot.");
6893
+ return;
6894
+ }
6895
+ files.sort();
6896
+ return {
6897
+ include: [...include],
6898
+ files
6899
+ };
6900
+ }
6901
+ function normalizeDir(dir) {
6902
+ const posix = dir.replaceAll("\\", "/").replace(/^(\.\/)+/, "").replace(/\/+$/, "");
6903
+ return posix === "." ? "" : posix;
6904
+ }
6905
+ async function walk(abs, rel, out, warn) {
6906
+ let entries;
6907
+ try {
6908
+ entries = await readdir(abs, { withFileTypes: true });
6909
+ } catch (err) {
6910
+ warn(`coverage universe: cannot read ${abs} (${err.code ?? String(err)}) — its files are not counted.`);
6911
+ return;
6912
+ }
6913
+ for (const entry of entries) if (entry.isDirectory()) {
6914
+ if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue;
6915
+ await walk(join(abs, entry.name), rel === "" ? entry.name : `${rel}/${entry.name}`, out, warn);
6916
+ } else if (entry.isFile() && SOURCE_FILE.test(entry.name)) out.push(rel === "" ? entry.name : `${rel}/${entry.name}`);
6917
+ }
6918
+ //#endregion
6919
+ //#region src/coverage/session.ts
6920
+ /**
6921
+ * One run's coverage measurement: the sink the application pushes to, the
6922
+ * acquisition engine each spec's browser is armed with, and the merge of what
6923
+ * both sides reported into a report row.
6924
+ *
6925
+ * The two sides never talk to each other. The browser writes what it reached
6926
+ * into the spec's coverage directory; instrumented server processes push what
6927
+ * they reached here. They meet on the spec id the cookie carried between them.
6928
+ */
6929
+ /**
6930
+ * The application pushes on a timer, so the last second of a spec is still in
6931
+ * flight when its test returns — and the tail of a spec is where the work it
6932
+ * triggered asynchronously lands.
6933
+ */
6934
+ const SETTLE_POLL_MS = 250;
6935
+ const SETTLE_QUIET_POLLS = 10;
6936
+ const SETTLE_CAP_MS = 1e4;
6937
+ var CoverageSession = class CoverageSession {
6938
+ existing = /* @__PURE__ */ new Map();
6939
+ sink;
6940
+ runId;
6941
+ /** What reported paths are relative to, and what they are checked against. */
6942
+ root;
6943
+ /** Where ccqa runs — the engine's base for resolving bundler-relative paths. */
6944
+ cwd;
6945
+ actors;
6946
+ origins;
6947
+ /** The denominator, enumerated once at start, or undefined when `coverage.include` is unset. */
6948
+ universe;
6949
+ constructor(sink, runId, root, cwd, actors, origins, universe) {
6950
+ this.sink = sink;
6951
+ this.runId = runId;
6952
+ this.root = root;
6953
+ this.cwd = cwd;
6954
+ this.actors = actors;
6955
+ this.origins = origins;
6956
+ this.universe = universe;
6957
+ }
6958
+ static async start(options) {
6959
+ const origins = options.config.instrumentedOrigins.map((origin) => resolveEnvRefs(origin));
6960
+ const unresolved = origins.filter((origin) => !/^https?:\/\//i.test(origin));
6961
+ if (unresolved.length > 0) throw new Error(`coverage.instrumentedOrigins must be absolute http(s) URLs after variable substitution; got ${unresolved.join(", ")}`);
6962
+ const bind = new URL(resolveEnvRefs(options.config.sink));
6963
+ const actors = options.actors ?? NO_ACTORS;
6964
+ const issued = new Set(options.specs.map((spec) => specIdFor(options.runId, spec)));
6965
+ const sink = await CoverageSink.start(bind.hostname, bind.port === "" ? 80 : Number(bind.port), issued, actors.tagToKey);
6966
+ const root = await resolveRoot(options.cwd, options.config.projectRoot) ?? options.cwd;
6967
+ const universe = options.config.include === void 0 ? void 0 : await enumerateUniverse(root, options.config.include, (text) => warn(text));
6968
+ return new CoverageSession(sink, options.runId, root, options.cwd, actors, origins, universe);
6969
+ }
6970
+ get sinkUrl() {
6971
+ return this.sink.url;
6972
+ }
6973
+ /**
6974
+ * Opens the spec's measurement.
6975
+ *
6976
+ * Waits out the drain first, when the spec acts as an identity another spec
6977
+ * just finished acting as: the two clocks involved make an event near the
6978
+ * boundary ambiguous, and a quiet gap is the only thing that resolves it
6979
+ * without either side having to trust the other's time.
6980
+ */
6981
+ async beginSpec(ref) {
6982
+ const specId = specIdFor(this.runId, ref);
6983
+ for (const window of this.actors.windowsForSpec.get(specKey(ref)) ?? []) {
6984
+ const closedAt = this.sink.lastClosedAt(window.tag);
6985
+ const wait = closedAt === void 0 ? 0 : closedAt + ACTOR_DRAIN_MS - Date.now();
6986
+ if (wait > 0) {
6987
+ meta("coverage", `waiting ${Math.ceil(wait / 1e3)}s for ${window.key} to go quiet`);
6988
+ await new Promise((resolve) => setTimeout(resolve, wait));
6989
+ }
6990
+ this.sink.openWindow(window, specId);
6991
+ }
6992
+ }
6993
+ /**
6994
+ * Attaches the acquisition engine to the browser the spec's target drives.
6995
+ * Everything spec-specific the engine needs — the id, the cookie's
6996
+ * destinations, the roots — lives here, so the caller only supplies where
6997
+ * the browser is.
6998
+ */
6999
+ armBrowser(ref, cdpUrl, coverageDir) {
7000
+ return startBrowserCoverage({
7001
+ cdpUrl,
7002
+ specId: specIdFor(this.runId, ref),
7003
+ origins: this.origins,
7004
+ coverageDir,
7005
+ roots: {
7006
+ base: this.cwd,
7007
+ root: this.root
7008
+ },
7009
+ warn: (text) => warn(`coverage: ${text}`)
7010
+ });
7011
+ }
7012
+ /** Merges both sides once the spec's pushes have stopped arriving. */
7013
+ async collect(ref, coverageDir) {
7014
+ const specId = specIdFor(this.runId, ref);
7015
+ await this.settle(specId);
7016
+ const owned = this.actors.windowsForSpec.get(specKey(ref)) ?? [];
7017
+ for (const window of owned) this.sink.closeWindow(window.tag);
7018
+ const matched = this.sink.actorEventsFor(specId);
7019
+ const backend = this.sink.filesFor(specId);
7020
+ const frontend = await readFrontend(coverageDir, specId);
7021
+ const inProject = await this.keepExisting(frontend?.files ?? []);
7022
+ return {
7023
+ files: [...new Set([...backend ?? [], ...inProject])].sort(),
7024
+ frontendFiles: inProject.length,
7025
+ backendFiles: backend?.size ?? 0,
7026
+ backendReported: this.sink.heardFromApplication(),
7027
+ frontendReported: frontend !== void 0,
7028
+ frontendStopped: frontend?.stopped ?? false,
7029
+ actorWindows: owned.map((window) => ({
7030
+ key: window.key,
7031
+ events: matched.get(window.key) ?? 0
7032
+ })),
7033
+ excludedDependencies: frontend?.excludedDependencies ?? 0,
7034
+ gaps: {
7035
+ unattributed: this.sink.unattributedFor(specId),
7036
+ unmappedScripts: frontend?.unmappedScripts ?? 0,
7037
+ unmappedRanges: frontend?.unmappedRanges ?? 0,
7038
+ outsideProject: (frontend?.files.length ?? 0) - inProject.length,
7039
+ unresolvedSources: frontend?.unresolvedSources ?? 0,
7040
+ uninstrumentedFiles: this.sink.uninstrumentedFiles(),
7041
+ uninstrumentedProcesses: this.sink.uninstrumentedProcesses(),
7042
+ droppedPushes: this.sink.droppedPushes(),
7043
+ unmappedActorEvents: this.sink.unmappedActorEvents(),
7044
+ outsideWindowEvents: owned.reduce((sum, window) => sum + (this.sink.outsideWindowEvents().get(window.key) ?? 0), 0)
7045
+ }
7046
+ };
7047
+ }
7048
+ /** Files reached at module top level, across the whole run. */
7049
+ boot() {
7050
+ return [...this.sink.boot()].sort();
7051
+ }
7052
+ /** Whether any instrumented application process reported at all. */
7053
+ heardFromApplication() {
7054
+ return this.sink.heardFromApplication();
7055
+ }
7056
+ /** Specs some application process attributed a file to. */
7057
+ attributedSpecs() {
7058
+ return this.sink.attributedSpecs();
7059
+ }
7060
+ /** Declared identities that acted outside the turns this run gave them. */
7061
+ outsideWindowEvents() {
7062
+ return this.sink.outsideWindowEvents();
7063
+ }
7064
+ /** Events from identities this project never declared. */
7065
+ unmappedActorEvents() {
7066
+ return this.sink.unmappedActorEvents();
7067
+ }
7068
+ /** Pushes naming a spec id this run never issued — a stale or forged cookie. */
7069
+ rejectedPushes() {
7070
+ return this.sink.rejectedPushes();
7071
+ }
7072
+ /** Pushes the sink could not read — the two halves' wire formats disagree. */
7073
+ malformedPushes() {
7074
+ return this.sink.malformedPushes();
7075
+ }
7076
+ /** Application processes that instrumented nothing at all. */
7077
+ uninstrumentedProcesses() {
7078
+ return this.sink.uninstrumentedProcesses();
7079
+ }
7080
+ async close() {
7081
+ await this.sink.close();
7082
+ }
7083
+ /** Keeps the paths that name a file in the working tree, cached per session. */
7084
+ async keepExisting(paths) {
7085
+ const unknown = paths.filter((path) => !this.existing.has(path));
7086
+ await Promise.all(unknown.map(async (path) => {
7087
+ this.existing.set(path, await access(join(this.root, path)).then(() => true, () => false));
7088
+ }));
7089
+ return paths.filter((path) => this.existing.get(path) === true);
7090
+ }
7091
+ async settle(specId) {
7092
+ if (!this.sink.heardFromApplication()) return;
7093
+ const deadline = Date.now() + SETTLE_CAP_MS;
7094
+ let previous = this.sink.filesFor(specId)?.size ?? 0;
7095
+ let quiet = 0;
7096
+ while (Date.now() < deadline && quiet < SETTLE_QUIET_POLLS) {
7097
+ await new Promise((resolve) => setTimeout(resolve, SETTLE_POLL_MS));
7098
+ const size = this.sink.filesFor(specId)?.size ?? 0;
7099
+ quiet = size === previous ? quiet + 1 : 0;
7100
+ previous = size;
7101
+ }
7102
+ }
7103
+ };
7104
+ /**
7105
+ * Ends a spec's measurement, whatever happened to the spec.
7106
+ *
7107
+ * Every caller has to reach this, including the paths that give up before the
7108
+ * spec runs: a turn opened on an identity and never closed swallows every later
7109
+ * event for it, and the next spec on that identity skips the drain it needs.
7110
+ *
7111
+ * Never throws. A measurement that could not be read is not a test result.
7112
+ */
7113
+ async function closeMeasurement(collector, ref, coverageDir) {
7114
+ try {
7115
+ return await collector.collect(ref, coverageDir);
7116
+ } catch (error) {
7117
+ warn(`coverage: could not collect for ${specKey(ref)} (${errMessage(error)})`);
7118
+ return;
7119
+ }
7120
+ }
7121
+ /**
7122
+ * The configured root, checked before a run leans on it.
7123
+ *
7124
+ * Every failure here is otherwise silent and identical to success: a root that
7125
+ * does not exist, or does not contain the project, sends every relative source
7126
+ * outside it, and the run reports a smaller file set with no error at all —
7127
+ * the answer this measurement exists to prevent.
7128
+ */
7129
+ async function resolveRoot(cwd, declared) {
7130
+ if (declared === void 0) return void 0;
7131
+ const substituted = resolveEnvRefs(declared).trim();
7132
+ if (substituted === "") throw new Error(`coverage.projectRoot "${declared}" resolved to nothing — is the variable set?`);
7133
+ const root = resolve(cwd, substituted);
7134
+ if ((await stat(root).catch(() => void 0))?.isDirectory() !== true) throw new Error(`coverage.projectRoot must name an existing directory; "${declared}" resolved to ${root}`);
7135
+ if (relative(root, cwd).startsWith("..")) throw new Error(`coverage.projectRoot must contain the directory ccqa runs in; ${root} does not contain ${cwd}`);
7136
+ return root;
7137
+ }
7138
+ /**
7139
+ * Where a spec's browser-side result lands.
7140
+ *
7141
+ * Not the artifacts directory: the tool a target runs owns that one and may
7142
+ * recreate it on startup, and everything left there is also reported as an
7143
+ * artifact — the same measurement would then ship twice, once structured and
7144
+ * once as a raw blob.
7145
+ */
7146
+ function specCoverageDir(reportDir, feature, spec) {
7147
+ return join(reportDir, "coverage", feature, spec);
5444
7148
  }
5445
- //#endregion
5446
- //#region src/run/output-tail.ts
5447
- /** Cap on the per-spec output tail kept for the report / analysis prompt. */
5448
- const OUTPUT_TAIL_CAP = 64 * 1024;
5449
7149
  /**
5450
- * Keeps the LAST `cap` characters appended test runners put the failure
5451
- * summary at the end of their output, so the tail is what's worth keeping on
5452
- * overflow. Dependency-free so both the vitest pipeline and the runCommand
5453
- * runner (src/targets/run-command-runner.ts) can use it without importing the
5454
- * whole pipeline.
7150
+ * `<runId>.<feature>/<spec>`. The run id keeps a stale cookie from an earlier
7151
+ * run out; the spec half is `specKey`, so an id here and a report row name the
7152
+ * same spec the same way.
5455
7153
  */
5456
- var TailBuffer = class {
5457
- buf = "";
5458
- cap;
5459
- constructor(cap) {
5460
- this.cap = cap;
5461
- }
5462
- append(s) {
5463
- this.buf += s;
5464
- if (this.buf.length > this.cap * 2) this.buf = this.buf.slice(-this.cap);
7154
+ function specIdFor(runId, ref) {
7155
+ return `${runId}.${specKey(ref)}`;
7156
+ }
7157
+ async function readFrontend(coverageDir, specId) {
7158
+ let raw;
7159
+ try {
7160
+ raw = await readFile(join(coverageDir, FRONTEND_COVERAGE_FILE), "utf8");
7161
+ } catch {
7162
+ return;
5465
7163
  }
5466
- toString() {
5467
- if (this.buf.length <= this.cap) return this.buf;
5468
- return `[...output truncated...]\n${this.buf.slice(-this.cap)}`;
7164
+ try {
7165
+ const parsed = JSON.parse(raw);
7166
+ if (!Array.isArray(parsed.files) || parsed.specId !== specId) throw new Error("not this spec");
7167
+ return parsed;
7168
+ } catch (error) {
7169
+ warn(`coverage: ${FRONTEND_COVERAGE_FILE} for ${specId} could not be read (${errMessage(error)})`);
7170
+ return;
5469
7171
  }
5470
- };
7172
+ }
5471
7173
  //#endregion
5472
7174
  //#region src/report/spec-row.ts
5473
7175
  /**
@@ -5600,10 +7302,10 @@ function toPosix(p) {
5600
7302
  */
5601
7303
  function substituteRunCommandFiles(runCommand, testFiles) {
5602
7304
  if (!runCommand.includes("{files}")) return runCommand;
5603
- const joined = testFiles.map(shellQuote).join(" ");
7305
+ const joined = testFiles.map(shellQuote$1).join(" ");
5604
7306
  return runCommand.replaceAll("{files}", joined);
5605
7307
  }
5606
- function shellQuote(s) {
7308
+ function shellQuote$1(s) {
5607
7309
  return /^[A-Za-z0-9_./-]+$/.test(s) ? s : `'${s.replaceAll("'", `'\\''`)}'`;
5608
7310
  }
5609
7311
  /**
@@ -5729,21 +7431,70 @@ async function runOneSpec$1(ref, opts, blocks) {
5729
7431
  });
5730
7432
  await mkdir(evidenceDir, { recursive: true });
5731
7433
  }
5732
- const command = substituteArtifactsDir(substituteRunCommandFiles(runCommand, testFiles), artifactsDir);
7434
+ const measurement = opts.coverage !== void 0 && opts.browserCoverage.browser === "cdp" ? {
7435
+ collector: opts.coverage,
7436
+ cdpEndpoint: opts.browserCoverage.cdpEndpoint
7437
+ } : null;
7438
+ const coverageDir = specCoverageDir(opts.reportDir, featureName, specName);
7439
+ if (measurement) {
7440
+ await rm(coverageDir, {
7441
+ recursive: true,
7442
+ force: true
7443
+ });
7444
+ await mkdir(coverageDir, { recursive: true });
7445
+ }
7446
+ const childEnv = {
7447
+ [ARTIFACTS_DIR_ENV]: artifactsDir,
7448
+ CCQA_RUN_ID: buildRunId(),
7449
+ ...evidenceDir ? { [EVIDENCE_DIR_ENV]: evidenceDir } : {}
7450
+ };
7451
+ let command = substituteArtifactsDir(substituteRunCommandFiles(runCommand, testFiles), artifactsDir);
7452
+ let browserHandle;
7453
+ let browserEngine;
7454
+ let attachError;
7455
+ if (measurement) {
7456
+ await measurement.collector.beginSpec(ref);
7457
+ try {
7458
+ browserHandle = await measurement.cdpEndpoint({
7459
+ cwd: opts.cwd,
7460
+ featureName,
7461
+ specName
7462
+ });
7463
+ const acquired = browserHandle;
7464
+ opts.teardown?.onFinalize(() => acquired.dispose());
7465
+ browserEngine = await measurement.collector.armBrowser(ref, browserHandle.cdpUrl, coverageDir);
7466
+ if (browserHandle.amendCommand) command = browserHandle.amendCommand(command);
7467
+ Object.assign(childEnv, browserHandle.env);
7468
+ } catch (err) {
7469
+ attachError = errMessage(err);
7470
+ warn(`coverage: could not attach to the target's browser (${attachError})`);
7471
+ }
7472
+ }
5733
7473
  meta("command", command);
5734
7474
  blank();
5735
7475
  const started = Date.now();
5736
7476
  let outcome;
7477
+ let spawnFailure;
7478
+ let measured;
5737
7479
  try {
5738
7480
  outcome = await runShellCommand$1(command, {
5739
7481
  cwd: opts.cwd,
5740
7482
  artifactsDir,
5741
- evidenceDir,
5742
- logPath: join(artifactsDir, OUTPUT_LOG_FILE)
7483
+ logPath: join(artifactsDir, OUTPUT_LOG_FILE),
7484
+ env: childEnv
5743
7485
  });
5744
7486
  } catch (err) {
5745
- return didNotExecute(`could not spawn runCommand: ${err instanceof Error ? err.message : String(err)}`, "the runCommand could not be spawned");
7487
+ spawnFailure = err instanceof Error ? err.message : String(err);
7488
+ } finally {
7489
+ if (browserEngine) await browserEngine.stop().catch(() => void 0);
7490
+ if (measurement) measured = await closeMeasurement(measurement.collector, ref, coverageDir);
7491
+ if (browserHandle) await browserHandle.dispose().catch(() => void 0);
5746
7492
  }
7493
+ const coverageFields = coverageRowFields(opts, measured, attachError);
7494
+ if (spawnFailure !== void 0 || outcome === void 0) return {
7495
+ ...didNotExecute(`could not spawn runCommand: ${spawnFailure ?? "unknown error"}`, "the runCommand could not be spawned"),
7496
+ ...coverageFields
7497
+ };
5747
7498
  const durationMs = Date.now() - started;
5748
7499
  blank();
5749
7500
  let artifacts;
@@ -5770,16 +7521,29 @@ async function runOneSpec$1(ref, opts, blocks) {
5770
7521
  target: opts.targetId,
5771
7522
  durationMs,
5772
7523
  ...artifactFields,
5773
- ...evidenceFields
7524
+ ...evidenceFields,
7525
+ ...coverageFields
5774
7526
  };
5775
7527
  return {
5776
7528
  ...failedRow([`command failed (exit ${outcome.exitCode}): ${command}`, outcome.tail.length > 0 ? `--- output (tail) ---\n${outcome.tail}` : null].filter((p) => p !== null).join("\n")),
5777
7529
  durationMs,
5778
7530
  ...artifactFields,
5779
- ...evidenceFields
7531
+ ...evidenceFields,
7532
+ ...coverageFields
5780
7533
  };
5781
7534
  }
5782
7535
  /**
7536
+ * What the row says about measurement. A half-measured row (the server side
7537
+ * only, because the browser never attached) would read as "this spec reached
7538
+ * almost nothing", so a failed attach reports its reason instead of numbers.
7539
+ */
7540
+ function coverageRowFields(opts, measured, attachError) {
7541
+ if (attachError !== void 0) return { coverageUnavailable: `could not attach to the target's browser: ${attachError}` };
7542
+ if (measured !== void 0) return { coverage: measured };
7543
+ if (opts.coverage !== void 0 && opts.browserCoverage.browser === "none") return { coverageUnavailable: opts.browserCoverage.reason };
7544
+ return {};
7545
+ }
7546
+ /**
5783
7547
  * The row's step screenshots, or — when there are none — the reason, so the
5784
7548
  * report never shows an empty evidence section without explanation. A
5785
7549
  * supported target that produced nothing almost always means the generated
@@ -5863,9 +7627,7 @@ async function runShellCommand$1(command, opts) {
5863
7627
  shell: true,
5864
7628
  env: {
5865
7629
  ...process.env,
5866
- [ARTIFACTS_DIR_ENV]: opts.artifactsDir,
5867
- CCQA_RUN_ID: buildRunId(),
5868
- ...opts.evidenceDir ? { [EVIDENCE_DIR_ENV]: opts.evidenceDir } : {}
7630
+ ...opts.env
5869
7631
  },
5870
7632
  stdio: [
5871
7633
  "ignore",
@@ -6406,8 +8168,9 @@ const C$1 = {
6406
8168
  * diff — so report rows and CI logs look the same whichever target a
6407
8169
  * project's specs use.
6408
8170
  *
6409
- * One Claude call per failing spec, which reads the source itself rather than
6410
- * deferring to a drift audit run beforehand. It is still one *phase*:
8171
+ * One Claude call per failing spec (two when the first errors and is retried
8172
+ * once), which reads the source itself rather than deferring to a drift audit
8173
+ * run beforehand. It is still one *phase*:
6411
8174
  * `beginFailureAnalysis` hands back the state every path shares, so a mixed
6412
8175
  * run prints one `failure analysis` banner in one place rather than one per
6413
8176
  * execution path. It runs after every spec has executed, so no Claude turn is
@@ -7198,6 +8961,12 @@ async function resolveSerialGroups(groups, cwd) {
7198
8961
  }
7199
8962
  return (ref) => bySpec.get(specKey(ref)) ?? [];
7200
8963
  }
8964
+ /** Every group either lookup gives a spec. Names from different sources never collide. */
8965
+ function mergeGroups(...lookups) {
8966
+ const present = lookups.filter((lookup) => lookup !== NO_GROUPS);
8967
+ if (present.length <= 1) return present[0] ?? NO_GROUPS;
8968
+ return (ref) => present.flatMap((lookup) => lookup(ref));
8969
+ }
7201
8970
  //#endregion
7202
8971
  //#region src/hub/contract/schema.ts
7203
8972
  /**
@@ -7318,8 +9087,10 @@ z.object({ error: z.object({
7318
9087
  * One spec's record of a single run, as stored in a ledger bucket. Identical
7319
9088
  * to `LastGreenEntry` plus the commit the environment was running at the time
7320
9089
  * — without it a bucket entry can be ordered in wall-clock time but not
7321
- * *positioned* against the deploy log, which is the only ordering re-run
7322
- * selection may use (ADR-0010).
9090
+ * *positioned* against the deploy log, which is the only ordering the
9091
+ * staleness verdict may use (ADR-0010). Wall-clock order is fit only for
9092
+ * scheduling within an already-decided set, where a mis-ranking delays a
9093
+ * spec rather than excusing it.
7323
9094
  */
7324
9095
  const SpecLedgerEntrySchema = z.object({
7325
9096
  gitHead: z.string(),
@@ -7904,7 +9675,10 @@ function selectSpecsNeedingRerun(specs, report) {
7904
9675
  const entry = report.specs[specKey(spec)];
7905
9676
  const verdict = entry?.verdict ?? "inProgress";
7906
9677
  counts.set(verdict, (counts.get(verdict) ?? 0) + 1);
7907
- if (verdict === "rerunNeeded") selected.push(spec);
9678
+ if (verdict === "rerunNeeded") selected.push({
9679
+ spec,
9680
+ lastRunAt: entry?.lastRun?.at ?? ""
9681
+ });
7908
9682
  else if (verdict === "inProgress") {
7909
9683
  excludedInProgress++;
7910
9684
  if (!entry) excludedUnknownToHub++;
@@ -7914,8 +9688,9 @@ function selectSpecsNeedingRerun(specs, report) {
7914
9688
  }
7915
9689
  }
7916
9690
  }
9691
+ selected.sort((a, b) => a.lastRunAt.localeCompare(b.lastRunAt));
7917
9692
  return {
7918
- selected,
9693
+ selected: selected.map((s) => s.spec),
7919
9694
  summary: formatCounts(SUMMARY_ORDER$1, counts),
7920
9695
  excludedInProgress,
7921
9696
  excludedUnknownToHub,
@@ -9836,6 +11611,15 @@ ${stepsText}
9836
11611
  - Do not invent success when blocked: fail honestly with a short reason.
9837
11612
  - **Evidence discipline**: when the assertion target is a specific row / message / banner / URL, scroll it into view (or focus the relevant pane) before letting the step end. The "after" screenshot is captured for you automatically — your job is to make sure that screenshot shows the thing your STEP_RESULT line is talking about.
9838
11613
 
11614
+ ### Waiting for asynchronous responses
11615
+
11616
+ Some expected outcomes arrive asynchronously — an automated reply, a background job finishing, a list refreshing. Waiting for them is fine, but the wait has a budget:
11617
+
11618
+ - Prefer bounded probes (\`agent-browser wait --text "..."\`, or a short pause followed by a fresh \`snapshot\`) over long blind sleeps, and keep a rough running total of how long you have waited within this step.
11619
+ - **The total wait within one step must not exceed 3 minutes**, unless the step's own instruction explicitly names a longer wait. Do not keep adding "one more" sleep past the budget.
11620
+ - When the budget is spent and the expected outcome has still not appeared, STOP waiting and emit \`STEP_RESULT|<stepId>|fail|...\`. **Never end your turn without a STEP_RESULT because you were still waiting** — a silent timeout is recorded as a protocol failure and hides the real cause from failure analysis.
11621
+ - The fail reason must state what you waited for, roughly how long in total, and what you observed instead (e.g. "waited ~3 min for a reply to appear after submitting; none appeared, the view still shows only the submitted item").
11622
+
9839
11623
  ### Output contract (STRICT)
9840
11624
 
9841
11625
  Your final assistant message MUST contain exactly one line of the form:
@@ -10286,6 +12070,46 @@ async function copyEvidenceIntoReport(absPath, evidenceDir, reportDir) {
10286
12070
  }
10287
12071
  }
10288
12072
  //#endregion
12073
+ //#region src/targets/agent-browser/browser-endpoint.ts
12074
+ /**
12075
+ * Where the agent-browser target's browser comes from under `--coverage`.
12076
+ *
12077
+ * agent-browser owns its browser but hands out the keys on request:
12078
+ * `get cdp-url` answers with the browser-level DevTools socket. The daemon
12079
+ * launches a session's browser lazily, so an `open about:blank` forces it up
12080
+ * first — into a session the caller already owns, before the agent starts
12081
+ * driving it. Auth-state restored into the session afterwards lands in a warm
12082
+ * browser, which is the same shape as the executor's mid-run recovery path.
12083
+ *
12084
+ * `dispose` does nothing on purpose: the session's lifecycle belongs to the
12085
+ * caller (the live runner closes it after the engine has stopped), and
12086
+ * closing somebody else's session from here would tear the browser down
12087
+ * while its owner still thinks it is driving it.
12088
+ */
12089
+ async function acquireAgentBrowserEndpoint(ctx) {
12090
+ const session = ctx.driverSession;
12091
+ if (session === void 0) throw new Error("the agent-browser target's browser lives in a driver session, and none was supplied");
12092
+ const warm = spawnAB([
12093
+ "--session",
12094
+ session,
12095
+ "open",
12096
+ "about:blank"
12097
+ ]);
12098
+ if (warm.status !== 0) throw new Error(`could not start the session's browser: ${warm.stderr || warm.stdout}`);
12099
+ const answer = spawnAB([
12100
+ "--session",
12101
+ session,
12102
+ "get",
12103
+ "cdp-url"
12104
+ ]);
12105
+ const cdpUrl = answer.stdout.trim().split("\n").pop()?.trim() ?? "";
12106
+ if (answer.status !== 0 || !/^wss?:\/\//.test(cdpUrl)) throw new Error(`agent-browser did not answer \`get cdp-url\` for session ${session}: ${answer.stderr || answer.stdout}`);
12107
+ return {
12108
+ cdpUrl,
12109
+ dispose: async () => {}
12110
+ };
12111
+ }
12112
+ //#endregion
10289
12113
  //#region src/diagnose/snapshot.ts
10290
12114
  const require = createRequire(import.meta.url);
10291
12115
  const SNAPSHOT_TIMEOUT_MS = 1e4;
@@ -10399,6 +12223,37 @@ async function closeSession(sessionName) {
10399
12223
  * agent-browser) and, when `reportDir` is set, run drift audit + failure
10400
12224
  * analysis to produce report rows. Sibling of `runDeterministicSpecs`.
10401
12225
  */
12226
+ /**
12227
+ * Brackets one live spec's execution with the measurement.
12228
+ *
12229
+ * The bracket has to hold even when the spec does not run: opening a turn on an
12230
+ * identity and never closing it would leave the next spec waiting on a window
12231
+ * that outlived its owner.
12232
+ */
12233
+ async function measureLive(spec, opts, coverageDir, execute) {
12234
+ const collector = opts.coverage;
12235
+ if (collector === void 0) return {
12236
+ outcome: await execute(),
12237
+ coverage: void 0
12238
+ };
12239
+ await rm(coverageDir, {
12240
+ recursive: true,
12241
+ force: true
12242
+ });
12243
+ await mkdir(coverageDir, { recursive: true });
12244
+ await collector.beginSpec(spec);
12245
+ let outcome;
12246
+ try {
12247
+ outcome = await execute();
12248
+ } catch (err) {
12249
+ await closeMeasurement(collector, spec, coverageDir);
12250
+ throw err;
12251
+ }
12252
+ return {
12253
+ outcome,
12254
+ coverage: await closeMeasurement(collector, spec, coverageDir)
12255
+ };
12256
+ }
10402
12257
  async function runLiveSpecs(specs, opts) {
10403
12258
  if (specs.length === 0) return {
10404
12259
  reportResults: [],
@@ -10427,22 +12282,28 @@ async function runLiveSpecs(specs, opts) {
10427
12282
  blank();
10428
12283
  info(`[${i + 1}/${specs.length}] ${label}`);
10429
12284
  }
10430
- const outcome = await runOneSpec({
12285
+ const coverageDir = specCoverageDir(reportDir, spec.featureName, spec.specName);
12286
+ const measured = await measureLive(spec, opts, coverageDir, () => runOneSpec({
10431
12287
  ...spec,
10432
12288
  opts,
10433
12289
  userPromptSuffix,
10434
- cwd
10435
- });
12290
+ cwd,
12291
+ coverageDir
12292
+ }));
12293
+ const { outcome } = measured;
10436
12294
  if (outcome.kind !== "run") return {
10437
12295
  outcome,
10438
12296
  row: null
10439
12297
  };
10440
- const row = await buildLiveReportRow(outcome, {
10441
- auth,
10442
- diffProvider,
10443
- reportDir,
10444
- blocks
10445
- }, opts, cwd);
12298
+ const row = {
12299
+ ...await buildLiveReportRow(outcome, {
12300
+ auth,
12301
+ diffProvider,
12302
+ reportDir,
12303
+ blocks
12304
+ }, opts, cwd),
12305
+ ...outcome.coverageBroken !== void 0 ? { coverageUnavailable: `could not attach to the live browser: ${outcome.coverageBroken}` } : measured.coverage ? { coverage: measured.coverage } : {}
12306
+ };
10446
12307
  await opts.report?.upsert(row);
10447
12308
  return {
10448
12309
  outcome,
@@ -10572,7 +12433,7 @@ async function resolveSessionState(names, hubCtx, profile, verify = verifySessio
10572
12433
  };
10573
12434
  }
10574
12435
  async function runOneSpec(args) {
10575
- const { featureName, specName, opts, userPromptSuffix, cwd } = args;
12436
+ const { featureName, specName, opts, userPromptSuffix, cwd, coverageDir } = args;
10576
12437
  const specDir = getSpecDir(featureName, specName, cwd);
10577
12438
  let specContent;
10578
12439
  try {
@@ -10615,6 +12476,23 @@ async function runOneSpec(args) {
10615
12476
  cleanupSession = resolution.cleanup;
10616
12477
  meta("state", spec.session.join(", "));
10617
12478
  }
12479
+ let browserEngine;
12480
+ let coverageBroken;
12481
+ if (opts.coverage) try {
12482
+ const handle = await acquireAgentBrowserEndpoint({
12483
+ cwd,
12484
+ featureName,
12485
+ specName,
12486
+ driverSession: sessionName
12487
+ });
12488
+ browserEngine = await opts.coverage.armBrowser({
12489
+ featureName,
12490
+ specName
12491
+ }, handle.cdpUrl, coverageDir);
12492
+ } catch (err) {
12493
+ coverageBroken = errMessage(err);
12494
+ warn(`coverage: could not attach to the live browser (${coverageBroken})`);
12495
+ }
10618
12496
  try {
10619
12497
  const runId = buildRunId();
10620
12498
  const envScrubMap = buildProseEnvScrubMap(spec, expanded, { CCQA_RUN_ID: runId });
@@ -10651,9 +12529,11 @@ async function runOneSpec(args) {
10651
12529
  runDir,
10652
12530
  specYaml: specContent,
10653
12531
  envScrubMap,
10654
- result
12532
+ result,
12533
+ ...coverageBroken === void 0 ? {} : { coverageBroken }
10655
12534
  };
10656
12535
  } finally {
12536
+ if (browserEngine) await browserEngine.stop().catch(() => void 0);
10657
12537
  if (cleanupSession) await cleanupSession();
10658
12538
  opts.teardown?.untrackSession(sessionName);
10659
12539
  await closeSession(sessionName);
@@ -10818,6 +12698,39 @@ const TargetConfigSchema = z.object({
10818
12698
  */
10819
12699
  const SerialGroupsSchema = z.record(z.string().regex(/^[a-z0-9][a-z0-9._-]*$/i, "serial group name must be a slug (letters, digits, '.', '_', '-')"), z.array(z.string().min(1)).min(1));
10820
12700
  /**
12701
+ * Which specs act as which external identity, for the flows whose requests
12702
+ * cannot carry a spec id at all.
12703
+ *
12704
+ * A chat platform's webhook is sent by the platform, not the browser, so no
12705
+ * cookie rides along and everything the flow reaches would be unattributed.
12706
+ * What the request does carry is who caused it, and if only one spec is allowed
12707
+ * to act as that identity at a time, "who" plus "when" is enough.
12708
+ *
12709
+ * ```yaml
12710
+ * coverage:
12711
+ * actors:
12712
+ * slack: # the preset's tag prefix
12713
+ * ${TEST_USER_ID}: [chat/create-item, chat/resolve-item]
12714
+ * ```
12715
+ *
12716
+ * The provider name is the prefix the matching preset stamps, and the key is an
12717
+ * identity expression the run's variables resolve. Only the unexpanded text is
12718
+ * ever displayed or used as a lock key, so the identity itself stays out of
12719
+ * reports and the hub.
12720
+ */
12721
+ const CoverageActorsSchema = z.record(z.string().regex(/^[a-z0-9][a-z0-9._-]*$/i, "actor provider must be a slug (letters, digits, '.', '_', '-')"), z.record(z.string().min(1), z.array(z.string().min(1)).min(1)));
12722
+ /**
12723
+ * Settings for `ccqa run --coverage`, which measures what each spec actually
12724
+ * reached in the application under test.
12725
+ */
12726
+ const CoverageConfigSchema = z.object({
12727
+ instrumentedOrigins: z.array(z.string().min(1)).min(1),
12728
+ sink: z.string().min(1).default("http://127.0.0.1:4757"),
12729
+ projectRoot: z.string().min(1).optional(),
12730
+ include: z.array(z.string().min(1)).optional(),
12731
+ actors: CoverageActorsSchema.default({})
12732
+ }).strict();
12733
+ /**
10821
12734
  * Top-level `.ccqa/config.yaml` schema. `defaultTarget` is used by specs
10822
12735
  * with no `target:` of their own. Both defaults make a missing config file
10823
12736
  * equivalent to "agent-browser only, no extra settings".
@@ -10825,7 +12738,8 @@ const SerialGroupsSchema = z.record(z.string().regex(/^[a-z0-9][a-z0-9._-]*$/i,
10825
12738
  const ProjectConfigSchema = z.object({
10826
12739
  defaultTarget: TargetIdSchema.default(AGENT_BROWSER_TARGET),
10827
12740
  targets: z.record(TargetIdSchema, TargetConfigSchema).default({}),
10828
- serialGroups: SerialGroupsSchema.default({})
12741
+ serialGroups: SerialGroupsSchema.default({}),
12742
+ coverage: CoverageConfigSchema.optional()
10829
12743
  }).strict();
10830
12744
  /** Config file location, relative to the project root (`--cwd`). */
10831
12745
  const PROJECT_CONFIG_PATH = ".ccqa/config.yaml";
@@ -11938,13 +13852,17 @@ const agentBrowserTarget = {
11938
13852
  id: AGENT_BROWSER_TARGET,
11939
13853
  input: "recording",
11940
13854
  generate: generateAgentBrowserTest,
11941
- existingOutput: (ref, cwd) => getTestScript(ref.featureName, ref.specName, cwd)
13855
+ existingOutput: (ref, cwd) => getTestScript(ref.featureName, ref.specName, cwd),
13856
+ browserCoverage: {
13857
+ browser: "cdp",
13858
+ cdpEndpoint: acquireAgentBrowserEndpoint
13859
+ }
11942
13860
  };
11943
13861
  //#endregion
11944
13862
  //#region src/targets/playwright/emit-mechanical.ts
11945
13863
  /** Module the emitted step-boundary capture calls import from. */
11946
13864
  const STEP_EVIDENCE_MODULE = "ccqa/step-evidence";
11947
- /** Capture call emitted when a step is entered / closed. Exported for the coverage gate. */
13865
+ /** Capture call emitted when a step is entered / closed. Exported for the generation gate. */
11948
13866
  const STEP_EVIDENCE_BEFORE = "ccqaStepBefore";
11949
13867
  const STEP_EVIDENCE_AFTER = "ccqaStepAfter";
11950
13868
  /** The exact boundary call for one step, as emitted and as the gate greps for it. */
@@ -12162,6 +14080,145 @@ const j = (s) => JSON.stringify(s);
12162
14080
  */
12163
14081
  const jExpr = (s) => envRefsToJsExpression(s);
12164
14082
  //#endregion
14083
+ //#region src/targets/playwright/browser-server.ts
14084
+ /**
14085
+ * Where the playwright target's browser comes from under `--coverage`.
14086
+ *
14087
+ * `playwright test` launches its own browser inside a process ccqa does not
14088
+ * own, and Playwright has no environment knob that would open a debugging
14089
+ * port on it. So the ownership is inverted: ccqa launches a browser server —
14090
+ * with the *consumer's own* Playwright, so the wire protocol matches by
14091
+ * construction — and a generated config makes the tests connect to it via
14092
+ * `use.connectOptions`. The config wrapper imports the project's real config,
14093
+ * so everything else about the run is the project's own; it is written next
14094
+ * to that config because Playwright resolves relative paths against the
14095
+ * config's directory, and a wrapper anywhere else would silently re-root
14096
+ * them.
14097
+ *
14098
+ * The ordering this buys is the point: the browser exists and the engine is
14099
+ * attached before the test process is even spawned, so there is no window in
14100
+ * which a script can run unprofiled or a request can leave uncookied.
14101
+ */
14102
+ const CONNECT_ENV = "CCQA_PW_CONNECT";
14103
+ const CONFIG_NAMES = [
14104
+ "playwright.config.ts",
14105
+ "playwright.config.mts",
14106
+ "playwright.config.cts",
14107
+ "playwright.config.js",
14108
+ "playwright.config.mjs",
14109
+ "playwright.config.cjs"
14110
+ ];
14111
+ async function acquirePlaywrightBrowser(ctx) {
14112
+ await sweepStaleWrappers(ctx.cwd);
14113
+ const chromium = await resolveChromium(ctx.cwd);
14114
+ const port = await freePort();
14115
+ const server = await chromium.launchServer({ args: [`--remote-debugging-port=${port}`] });
14116
+ let wrapperPath;
14117
+ try {
14118
+ await waitForCdp(port);
14119
+ wrapperPath = await writeWrapperConfig(ctx);
14120
+ } catch (error) {
14121
+ await server.close().catch(() => void 0);
14122
+ throw error;
14123
+ }
14124
+ const wrapper = wrapperPath;
14125
+ let disposed = false;
14126
+ return {
14127
+ cdpUrl: `http://127.0.0.1:${port}`,
14128
+ env: { [CONNECT_ENV]: server.wsEndpoint() },
14129
+ amendCommand: (command) => {
14130
+ if (/(^|\s)(--config|-c)[=\s]/.test(command)) throw new Error("the runCommand already passes --config; --coverage needs to supply its own. Drop the flag from targets.playwright.runCommand (the default config resolution still applies) or run without --coverage.");
14131
+ if (/[|;&<>`$]/.test(command)) throw new Error("the runCommand uses shell operators, so --coverage cannot safely append its --config to it. Reduce targets.playwright.runCommand to a plain `playwright test` invocation or run without --coverage.");
14132
+ return `${command} --config=${shellQuote(wrapper)}`;
14133
+ },
14134
+ dispose: async () => {
14135
+ if (disposed) return;
14136
+ disposed = true;
14137
+ await server.close().catch(() => void 0);
14138
+ await unlink(wrapper).catch(() => void 0);
14139
+ }
14140
+ };
14141
+ }
14142
+ /**
14143
+ * Wrappers a killed earlier run left behind. Deleted on the next acquire, not
14144
+ * only guarded against: a stray one is git-status dirt in somebody's repo.
14145
+ */
14146
+ async function sweepStaleWrappers(cwd) {
14147
+ const entries = await readdir(cwd).catch(() => []);
14148
+ for (const name of entries) if (name.startsWith("ccqa-coverage.") && name.endsWith(".playwright.config.ts")) await unlink(join(cwd, name)).catch(() => void 0);
14149
+ }
14150
+ /** Single quotes survive every shell metacharacter except themselves. */
14151
+ function shellQuote(s) {
14152
+ return `'${s.replaceAll("'", `'\\''`)}'`;
14153
+ }
14154
+ /**
14155
+ * The consumer's Playwright, not a dependency of ccqa's: their tests speak
14156
+ * their version's protocol, and the server has to be the same animal. Their
14157
+ * `runCommand` runs `playwright test`, so the package is present — but under
14158
+ * pnpm's isolation it may only be resolvable through `@playwright/test`.
14159
+ */
14160
+ async function resolveChromium(cwd) {
14161
+ const fromProject = createRequire(join(cwd, "package.json"));
14162
+ const origins = [];
14163
+ try {
14164
+ origins.push(fromProject.resolve("@playwright/test/package.json"));
14165
+ } catch {}
14166
+ for (const name of ["playwright", "playwright-core"]) for (const origin of [null, ...origins]) try {
14167
+ const mod = await import(pathToFileURL((origin === null ? fromProject : createRequire(origin)).resolve(name)).href);
14168
+ const chromium = mod.chromium ?? mod.default?.chromium;
14169
+ if (chromium !== void 0) return chromium;
14170
+ } catch {}
14171
+ throw new Error(`could not resolve Playwright from ${cwd} — the playwright target's --coverage launches the browser with the project's own Playwright, which must be installed`);
14172
+ }
14173
+ async function writeWrapperConfig(ctx) {
14174
+ const existing = CONFIG_NAMES.find((name) => existsSync(join(ctx.cwd, name)));
14175
+ const wrapperName = `ccqa-coverage.${slug(ctx.featureName)}--${slug(ctx.specName)}.playwright.config.ts`;
14176
+ const wrapperPath = join(ctx.cwd, wrapperName);
14177
+ const header = "// Written by `ccqa run --coverage` for one spec's run and removed after it.";
14178
+ const connect = `use: { ...(base as { use?: object }).use, connectOptions: { wsEndpoint: process.env.${CONNECT_ENV} ?? "" } }`;
14179
+ await writeFile(wrapperPath, (existing === void 0 ? [
14180
+ header,
14181
+ `export default { use: { connectOptions: { wsEndpoint: process.env.${CONNECT_ENV} ?? "" } } };`,
14182
+ ""
14183
+ ] : [
14184
+ header,
14185
+ "// It only points the browser at the server ccqa launched; everything else",
14186
+ "// is the project's own config, imported unchanged.",
14187
+ `import base from "./${existing}";`,
14188
+ `export default { ...(base as object), ${connect} };`,
14189
+ ""
14190
+ ]).join("\n"), "utf8");
14191
+ return wrapperPath;
14192
+ }
14193
+ function slug(name) {
14194
+ return name.replace(/[^a-zA-Z0-9_-]/g, "-");
14195
+ }
14196
+ function freePort() {
14197
+ return new Promise((resolve, reject) => {
14198
+ const probe = createServer$1();
14199
+ probe.once("error", reject);
14200
+ probe.listen(0, "127.0.0.1", () => {
14201
+ const address = probe.address();
14202
+ const port = typeof address === "object" && address !== null ? address.port : void 0;
14203
+ probe.close(() => {
14204
+ if (port === void 0) reject(/* @__PURE__ */ new Error("could not pick a port"));
14205
+ else resolve(port);
14206
+ });
14207
+ });
14208
+ });
14209
+ }
14210
+ /** The debugging socket opens with the browser; a short poll absorbs the gap. */
14211
+ async function waitForCdp(port) {
14212
+ const deadline = Date.now() + 5e3;
14213
+ for (;;) {
14214
+ try {
14215
+ if ((await fetch(`http://127.0.0.1:${port}/json/version`)).ok) return;
14216
+ } catch {}
14217
+ if (Date.now() > deadline) throw new Error(`the launched browser never opened its debugging port (${port})`);
14218
+ await new Promise((resolve) => setTimeout(resolve, 100));
14219
+ }
14220
+ }
14221
+ //#endregion
12165
14222
  //#region src/targets/playwright/index.ts
12166
14223
  const PLAYWRIGHT_TARGET = "playwright";
12167
14224
  /**
@@ -12183,6 +14240,10 @@ const playwrightTarget = {
12183
14240
  existingOutput: existingPlaywrightOutput,
12184
14241
  runner: runCommandRunner,
12185
14242
  stepEvidence: { supported: true },
14243
+ browserCoverage: {
14244
+ browser: "cdp",
14245
+ cdpEndpoint: acquirePlaywrightBrowser
14246
+ },
12186
14247
  guidanceKind: PLAYWRIGHT_TARGET
12187
14248
  };
12188
14249
  async function generatePlaywrightTest(ctx) {
@@ -12209,7 +14270,7 @@ async function generatePlaywrightTest(ctx) {
12209
14270
  path: draftPath,
12210
14271
  contents: draft
12211
14272
  },
12212
- ...stepMarkers.length > 0 ? { draftInvariant: stepEvidencePreserveRule() } : {}
14273
+ draftInvariant: stepMarkers.length > 0 ? stepEvidencePreserveRule() : ""
12213
14274
  }) : await finalizePreparedFiles({
12214
14275
  ctx,
12215
14276
  target: PLAYWRIGHT_TARGET,
@@ -12221,7 +14282,7 @@ async function generatePlaywrightTest(ctx) {
12221
14282
  summary: `Playwright spec compiled from ${actions.length} recorded action(s)`,
12222
14283
  warnings: []
12223
14284
  });
12224
- const missing = await missingStepEvidence(result, stepMarkers);
14285
+ const missing = await missingInjectedCalls(result, stepMarkers);
12225
14286
  for (const w of missing) warn(w);
12226
14287
  return {
12227
14288
  ...result,
@@ -12229,14 +14290,14 @@ async function generatePlaywrightTest(ctx) {
12229
14290
  };
12230
14291
  }
12231
14292
  /**
12232
- * Per-step warning for any `ccqa/step-evidence` boundary call absent from the
12233
- * generated test files the report would then miss that step's screenshots.
12234
- * Reads the written test files (the LLM pass may have relocated them); a file
12235
- * that can't be read is reported as missing all its steps rather than passing
12236
- * silently.
14293
+ * Warnings for calls the emitter injected that the written test no longer has.
14294
+ * The deterministic emit always has them; the library-rewrite pass can drop
14295
+ * them when it restructures into page objects, which silently costs the spec
14296
+ * its screenshots. Reads the files from disk (the LLM pass may have relocated
14297
+ * them); a file that can't be read is reported as missing everything rather
14298
+ * than passing silently.
12237
14299
  */
12238
- async function missingStepEvidence(result, markers) {
12239
- if (markers.length === 0) return [];
14300
+ async function missingInjectedCalls(result, markers) {
12240
14301
  const corpus = (await Promise.all(result.files.filter((f) => f.kind === "test").map((f) => readFile(f.path, "utf8").catch(() => "")))).join("\n");
12241
14302
  const warnings = [];
12242
14303
  for (const m of markers) {
@@ -12280,6 +14341,10 @@ const runnTarget = {
12280
14341
  supported: false,
12281
14342
  reason: "runn runs API scenarios, which have no screen to capture"
12282
14343
  },
14344
+ browserCoverage: {
14345
+ browser: "none",
14346
+ reason: "runn runs API scenarios; there is no browser to measure"
14347
+ },
12283
14348
  guidanceKind: RUNN_TARGET
12284
14349
  };
12285
14350
  /** Exported with the engine's invoke seam so unit tests can stub Claude. */
@@ -12434,6 +14499,7 @@ function groupSpecsByTarget(specs, catalog, config, resolve = resolveTarget) {
12434
14499
  supported: false,
12435
14500
  reason: `the "${plugin.id}" target does not capture step screenshots`
12436
14501
  },
14502
+ browserCoverage: plugin.browserCoverage,
12437
14503
  specs: []
12438
14504
  };
12439
14505
  group.specs.push(entry);
@@ -12503,6 +14569,9 @@ async function runExternalSpecs(dispatch, ctx) {
12503
14569
  targetId: group.targetId,
12504
14570
  targetConfig: group.targetConfig,
12505
14571
  stepEvidence: group.stepEvidence,
14572
+ browserCoverage: group.browserCoverage,
14573
+ ...ctx.coverage ? { coverage: ctx.coverage } : {},
14574
+ ...ctx.teardown ? { teardown: ctx.teardown } : {},
12506
14575
  onSpecComplete: async (row) => {
12507
14576
  streamed.push(row);
12508
14577
  await ctx.report.upsert(row);
@@ -13268,7 +15337,8 @@ async function executeRun(targets, opts) {
13268
15337
  }
13269
15338
  const catalog = await readSpecs(specs, cwd);
13270
15339
  const projectConfig = await loadProjectConfig(cwd);
13271
- const resources = await resolveSerialGroups(projectConfig.serialGroups, cwd);
15340
+ const actors = opts.coverage === true && forExecution ? await resolveActors(projectConfig.coverage?.actors ?? {}, cwd) : NO_ACTORS;
15341
+ const resources = mergeGroups(await resolveSerialGroups(projectConfig.serialGroups, cwd), actorGroups(actors));
13272
15342
  const declared = [...new Set(specs.flatMap(resources))];
13273
15343
  if (declared.length > 0) meta("serial groups", declared.join(", "));
13274
15344
  let waitingOnGroup = [];
@@ -13311,6 +15381,7 @@ async function executeRun(targets, opts) {
13311
15381
  const liveSpecs = withMode.filter((s) => s.mode === "live");
13312
15382
  meta("modes", `${detSpecs.length} deterministic / ${liveSpecs.length} live`);
13313
15383
  if (dispatch.external.length > 0) meta("external", dispatch.external.map((g) => `${g.targetId} ${g.specs.length}`).join(" / "));
15384
+ const coverage = opts.coverage && forExecution ? await startCoverage(cwd, projectConfig.coverage, actors, dispatch, opts.teardown) : void 0;
13314
15385
  if (liveSpecs.length === 0) {
13315
15386
  const why = "it only applies to agent-browser 'mode: live' specs, and this run has none";
13316
15387
  if (typeof opts.liveStepRetry === "number" && opts.liveStepRetry > 0) warn(`--live-step-retry is ignored: ${why}`);
@@ -13373,7 +15444,8 @@ async function executeRun(targets, opts) {
13373
15444
  customPromptVersion: customPrompt?.customPromptVersion ?? null,
13374
15445
  triageUserPromptHash,
13375
15446
  deployedSha,
13376
- opts
15447
+ opts,
15448
+ coverage
13377
15449
  }), hubSink, currentReportCost);
13378
15450
  let completedNormally = false;
13379
15451
  opts.teardown?.onFinalize(async () => {
@@ -13397,7 +15469,9 @@ async function executeRun(targets, opts) {
13397
15469
  resources,
13398
15470
  ...opts.model ? { model: opts.model } : {},
13399
15471
  ...opts.language ? { language: opts.language } : {},
13400
- report: incrementalReport
15472
+ report: incrementalReport,
15473
+ ...coverage ? { coverage } : {},
15474
+ ...opts.teardown ? { teardown: opts.teardown } : {}
13401
15475
  });
13402
15476
  const liveOpts = {
13403
15477
  ...opts.model ? { model: opts.model } : {},
@@ -13409,6 +15483,7 @@ async function executeRun(targets, opts) {
13409
15483
  concurrency: opts.concurrency ?? 1,
13410
15484
  resources,
13411
15485
  ...opts.hubProfile ? { profile: opts.hubProfile } : {},
15486
+ ...coverage ? { coverage } : {},
13412
15487
  diffProvider,
13413
15488
  hubContext: hubCtx,
13414
15489
  customPrompt,
@@ -13417,6 +15492,7 @@ async function executeRun(targets, opts) {
13417
15492
  report: incrementalReport
13418
15493
  };
13419
15494
  const live = await runLiveSpecs(liveSpecs, liveOpts);
15495
+ if (coverage) reportCoverageHealth(coverage, [...externalRows, ...live.reportResults]);
13420
15496
  let overallExitCode = det.exitCode !== 0 ? 1 : 0;
13421
15497
  if (live.failedCount > 0) overallExitCode = 1;
13422
15498
  if (externalRows.some((r) => r.status === "failed")) overallExitCode = 1;
@@ -13432,30 +15508,32 @@ async function executeRun(targets, opts) {
13432
15508
  }))], analysisDeps);
13433
15509
  const detResults = await analyzeDeterministicSummaries(det.summaries, cwd, reportDir, analysisRun);
13434
15510
  const analyzedExternalRows = await analyzeExternalRows(externalRows, analysisRun);
15511
+ const results = await rerunExplainedFailures([
15512
+ ...detResults,
15513
+ ...analyzedExternalRows,
15514
+ ...live.reportResults
15515
+ ], {
15516
+ mode: rerunMode,
15517
+ maxSpecs: opts.onFailExplainRerunMaxSpecs ?? null,
15518
+ execute: createRerunExecutor({
15519
+ detSpecs,
15520
+ liveSpecs,
15521
+ dispatch,
15522
+ liveOpts,
15523
+ opts,
15524
+ cwd,
15525
+ resources
15526
+ })
15527
+ });
13435
15528
  report = await writeUnifiedReport({
13436
15529
  reportDir,
13437
- results: await rerunExplainedFailures([
13438
- ...detResults,
13439
- ...analyzedExternalRows,
13440
- ...live.reportResults
13441
- ], {
13442
- mode: rerunMode,
13443
- maxSpecs: opts.onFailExplainRerunMaxSpecs ?? null,
13444
- execute: createRerunExecutor({
13445
- detSpecs,
13446
- liveSpecs,
13447
- dispatch,
13448
- liveOpts,
13449
- opts,
13450
- cwd,
13451
- resources
13452
- })
13453
- }),
15530
+ results: coverage ? results.map(explainMissingCoverage) : results,
13454
15531
  git,
13455
15532
  customPromptVersion,
13456
15533
  triageUserPromptHash,
13457
15534
  deployedSha,
13458
- opts
15535
+ opts,
15536
+ coverage
13459
15537
  });
13460
15538
  completedNormally = true;
13461
15539
  if (hubRunId) {
@@ -13465,7 +15543,8 @@ async function executeRun(targets, opts) {
13465
15543
  customPromptVersion,
13466
15544
  triageUserPromptHash,
13467
15545
  deployedSha,
13468
- opts
15546
+ opts,
15547
+ coverage
13469
15548
  });
13470
15549
  const streamedKeys = new Set(incrementalReport.rows().map((r) => `${r.feature}/${r.spec}`));
13471
15550
  const evidence = await readRowsFilesBase64(report.results.filter((r) => !streamedKeys.has(`${r.feature}/${r.spec}`)), reportDir);
@@ -13504,11 +15583,58 @@ async function executeRun(targets, opts) {
13504
15583
  };
13505
15584
  }
13506
15585
  /**
13507
- * Compact, prompt-friendly summary of one ccqa run for the live agent-prompt
13508
- * update step. One section per spec: header line + per-step verdicts (see
13509
- * `liveStepSummaryLine`). Kept to a few KB even with many specs/steps so the
13510
- * prompt cache can absorb the bulk.
15586
+ * Starts the run's coverage measurement before any spec runs: the sink has to
15587
+ * be listening before the first request reaches the application, and the set
15588
+ * of spec ids it will accept is only known once dispatch has resolved.
15589
+ */
15590
+ async function startCoverage(cwd, config, actors, dispatch, teardown) {
15591
+ if (config === void 0) throw new RunUsageError("--coverage needs a `coverage:` block in .ccqa/config.yaml whose `instrumentedOrigins` names the origins the spec cookie may be attached to");
15592
+ let session;
15593
+ try {
15594
+ session = await CoverageSession.start({
15595
+ runId: buildRunId(),
15596
+ cwd,
15597
+ config,
15598
+ actors,
15599
+ specs: dispatch.external.flatMap((g) => g.specs)
15600
+ });
15601
+ } catch (err) {
15602
+ throw new RunUsageError(`could not start coverage collection: ${errMessage(err)}`);
15603
+ }
15604
+ meta("coverage", `sink ${session.sinkUrl} → ${session.origins.join(", ")}`);
15605
+ const unmeasured = dispatch.external.filter((g) => g.browserCoverage.browser === "none").length;
15606
+ if (unmeasured > 0) warn(`${unmeasured} target(s) declare no browser to measure; their specs are reported as unmeasured rather than as reaching nothing`);
15607
+ teardown?.onFinalize(() => session.close());
15608
+ return session;
15609
+ }
15610
+ /**
15611
+ * A run that measured coverage still leaves rows without any — a spec on a
15612
+ * target that declares no browser, or one that never executed. Saying why keeps
15613
+ * the reader from reading a blank as "this spec reached nothing".
13511
15614
  */
15615
+ function explainMissingCoverage(row) {
15616
+ if (row.coverage !== void 0 || row.coverageUnavailable !== void 0) return row;
15617
+ return {
15618
+ ...row,
15619
+ coverageUnavailable: row.status === "skipped" ? "the spec did not execute" : "this target is not measured by --coverage yet"
15620
+ };
15621
+ }
15622
+ /** Everything the measurement could not place; silence here reads as "never reached". */
15623
+ function reportCoverageHealth(coverage, rows) {
15624
+ if (!coverage.heardFromApplication()) warn("no instrumented application process reported — only the browser half was measured. The server needs ccqa-tools and CCQA_COVERAGE_ENDPOINT pointed at this run's sink");
15625
+ if (rows.filter((row) => row.coverage !== void 0).length > 0 && coverage.heardFromApplication() && coverage.attributedSpecs() === 0) warn("instrumented application processes reported, but no spec was attributed to them — the spec cookie is not reaching the application, so every spec's server-side reach is zero for that reason rather than because the server ran nothing. Check coverage.instrumentedOrigins covers every origin the spec's requests go to, and that a server which is not node:http-based installs the middleware itself");
15626
+ const boot = coverage.boot();
15627
+ if (boot.length > 0) meta("coverage", `${boot.length} file(s) reached only at module load, attributed to no spec`);
15628
+ const blind = coverage.uninstrumentedProcesses();
15629
+ if (blind > 0) warn(`${blind} application process(es) instrumented nothing at all — every file they ran is missing from this run, not just some. Load hooks need node 22.15+ (23.5+ on 23.x); bundled code needs the build plugin instead`);
15630
+ for (const [key, count] of coverage.outsideWindowEvents()) warn(`${count} event(s) from ${key} arrived outside this run's turns — another job or a person used that identity while it was being measured, and what they triggered is attributed to nobody`);
15631
+ const unmapped = coverage.unmappedActorEvents();
15632
+ if (unmapped > 0) meta("coverage", `${unmapped} event(s) from identities this project does not declare, attributed to no spec`);
15633
+ const rejected = coverage.rejectedPushes();
15634
+ if (rejected > 0) warn(`${rejected} coverage push(es) named a spec id this run never issued — dropped`);
15635
+ const malformed = coverage.malformedPushes();
15636
+ if (malformed > 0) warn(`${malformed} coverage push(es) could not be read — the application's ccqa-tools and this CLI disagree on the wire format`);
15637
+ }
13512
15638
  function buildLiveRunSummary(results) {
13513
15639
  const sections = [];
13514
15640
  for (const r of results) {
@@ -13780,6 +15906,7 @@ function createRerunExecutor(ctx) {
13780
15906
  targetId: group.targetId,
13781
15907
  targetConfig: group.targetConfig,
13782
15908
  stepEvidence: group.stepEvidence,
15909
+ browserCoverage: group.browserCoverage,
13783
15910
  onSpecComplete: async () => {}
13784
15911
  });
13785
15912
  return row?.status === "passed" ? "passed" : "failed";
@@ -13799,7 +15926,7 @@ function createRerunExecutor(ctx) {
13799
15926
  * final report.json stays byte-identical (existing e2e goldens compare it).
13800
15927
  */
13801
15928
  function buildReportEnvelope(args) {
13802
- const { git, customPromptVersion, triageUserPromptHash, deployedSha, opts } = args;
15929
+ const { git, customPromptVersion, triageUserPromptHash, deployedSha, opts, coverage } = args;
13803
15930
  const runUrl = githubRunUrl();
13804
15931
  return {
13805
15932
  schemaVersion: 1,
@@ -13821,19 +15948,21 @@ function buildReportEnvelope(args) {
13821
15948
  customPromptVersion,
13822
15949
  ...triageUserPromptHash !== null ? { triageUserPromptHash } : {},
13823
15950
  ...deployedSha !== null ? { deployedSha } : {},
13824
- cost: currentReportCost()
15951
+ cost: currentReportCost(),
15952
+ ...coverage?.universe ? { coverageUniverse: coverage.universe } : {}
13825
15953
  };
13826
15954
  }
13827
15955
  /** Write the unified JSON (+ optional GitHub-annotation) report for one run. Returns the report data. */
13828
15956
  async function writeUnifiedReport(args) {
13829
- const { reportDir, results, git, customPromptVersion, triageUserPromptHash, deployedSha, opts } = args;
15957
+ const { reportDir, results, git, customPromptVersion, triageUserPromptHash, deployedSha, opts, coverage } = args;
13830
15958
  const data = {
13831
15959
  ...buildReportEnvelope({
13832
15960
  git,
13833
15961
  customPromptVersion,
13834
15962
  triageUserPromptHash,
13835
15963
  deployedSha,
13836
- opts
15964
+ opts,
15965
+ coverage
13837
15966
  }),
13838
15967
  results
13839
15968
  };
@@ -14106,7 +16235,7 @@ const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command(
14106
16235
  }, "never").option("--on-fail-explain-rerun-max-specs <n>", "Rerun at most N specs, in report order; the rest are named in the run summary and keep the label they were first given. Default: no cap. The knob for an environment having a bad day, where the alternative is turning the reruns off entirely.", parseRerunMaxSpecs).optionsGroup("What to do with the results:").option("--report-dir <dir>", `Directory for the structured run results (report.json + evidence PNGs), which are always written. Default: ${DEFAULT_REPORT_DIR}/.`).option("--report-format <fmt>", "Additional output format alongside HTML: 'text' (default), 'json' (writes report.json), 'github' (GitHub Actions annotations on stdout).", (raw) => {
14107
16236
  if (REPORT_FORMATS.includes(raw)) return raw;
14108
16237
  throw new Error(`--report-format must be one of ${REPORT_FORMATS.join(" | ")}`);
14109
- }, "text").option("--report-to-hub", "Incrementally push the run report to the hub as the run progresses (open → patch per spec → finalize). Requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN). Without it, hub credentials are used only to fetch variables/sessions/prompts, not to push.").optionsGroup("Learning:").option("--learn-hub-live-prompt", "(live only) After the run finishes, ask Claude to refresh the \"live.agent\" prompt on the hub from a summary of the run. Requires a hub connection.").optionsGroup("Environment and connection:").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(async (targets, opts) => {
16238
+ }, "text").option("--report-to-hub", "Incrementally push the run report to the hub as the run progresses (open → patch per spec → finalize). Requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN). Without it, hub credentials are used only to fetch variables/sessions/prompts, not to push.").option("--coverage", "Measure what each spec actually reached in the application under test, and record it on the spec's report row. Needs a `coverage:` block in .ccqa/config.yaml naming the instrumented origins the spec cookie may go to. The browser half attaches to the target's browser from outside (nothing is emitted into generated tests; needs node 22+); the server half needs the application running with ccqa-tools.").optionsGroup("Learning:").option("--learn-hub-live-prompt", "(live only) After the run finishes, ask Claude to refresh the \"live.agent\" prompt on the hub from a summary of the run. Requires a hub connection.").optionsGroup("Environment and connection:").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(async (targets, opts) => {
14110
16239
  await runCliAction(targets, opts);
14111
16240
  });
14112
16241
  /** Parse --concurrency: a positive integer. Rejects 0, negatives, non-integers. */
@@ -18129,7 +20258,8 @@ const PatchRunRequestSchema = z.object({
18129
20258
  customPromptVersion: z.string().nullable().optional(),
18130
20259
  runUrl: z.string().nullable().optional(),
18131
20260
  triageUserPromptHash: z.string().optional(),
18132
- cost: ReportCostSchema.nullable().optional()
20261
+ cost: ReportCostSchema.nullable().optional(),
20262
+ coverageUniverse: CoverageUniverseSchema.optional()
18133
20263
  }).partial().optional()
18134
20264
  });
18135
20265
  /** Insert or replace `rows` into `results`, upserting by feature/spec identity. */
@@ -18345,7 +20475,8 @@ function createPatchRunHandler(config) {
18345
20475
  ...reportMeta?.customPromptVersion !== void 0 ? { customPromptVersion: reportMeta.customPromptVersion } : {},
18346
20476
  ...reportMeta?.runUrl !== void 0 ? { runUrl: reportMeta.runUrl } : {},
18347
20477
  ...reportMeta?.triageUserPromptHash !== void 0 ? { triageUserPromptHash: reportMeta.triageUserPromptHash } : {},
18348
- ...reportMeta?.cost !== void 0 ? { cost: reportMeta.cost } : {}
20478
+ ...reportMeta?.cost !== void 0 ? { cost: reportMeta.cost } : {},
20479
+ ...reportMeta?.coverageUniverse !== void 0 ? { coverageUniverse: reportMeta.coverageUniverse } : {}
18349
20480
  };
18350
20481
  const merged = mergeResults(current?.results ?? [], rows);
18351
20482
  specs = countSpecs(merged);
@@ -20245,6 +22376,7 @@ const HTML_BODY = `
20245
22376
  <nav class="nav">
20246
22377
  <a href="#/runs" class="nav-runs"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 12h4l3 8 4-16 3 8h4"/></svg> <span data-i18n="nav.runs">Runs</span></a>
20247
22378
  <a href="#/perspectives" class="nav-perspectives"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M8 6h13M8 12h13M8 18h13"/><path d="M3 6h.01M3 12h.01M3 18h.01"/></svg> <span data-i18n="nav.perspectives">Perspectives</span></a>
22379
+ <a href="#/coverage" class="nav-coverage"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2"/></svg> <span data-i18n="nav.coverage">Coverage</span></a>
20248
22380
  <a href="#/secrets" class="nav-secrets"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> <span data-i18n="nav.secrets">Secrets</span></a>
20249
22381
  <a href="#/prompts" class="nav-prompts"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 4h11l5 5v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z"/><path d="M14 4v6h6"/><path d="M8 13h6M8 17h6"/></svg> <span data-i18n="nav.prompts">Prompts</span></a>
20250
22382
  <a href="#/jobs" class="nav-jobs"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 2v4M12 18v4M4.9 4.9l2.8 2.8M16.3 16.3l2.8 2.8M2 12h4M18 12h4M4.9 19.1l2.8-2.8M16.3 7.7l2.8-2.8"/></svg> <span data-i18n="nav.learning">Learning</span></a>
@@ -20369,6 +22501,34 @@ const HTML_BODY = `
20369
22501
  </div>
20370
22502
  </section>
20371
22503
 
22504
+ <!-- ===== COVERAGE ===== -->
22505
+ <section id="view-coverage" hidden>
22506
+ <div class="page-bar">
22507
+ <h1 data-i18n="coverage.title">Coverage</h1>
22508
+ <span class="updated" id="cov-asof"></span>
22509
+ <div class="spacer"></div>
22510
+ ${refreshButton("cov-refresh")}
22511
+ </div>
22512
+ <div class="content">
22513
+ <p id="cov-status" class="empty-note" hidden></p>
22514
+ <div id="cov-body" hidden>
22515
+ <div class="ov">
22516
+ <div class="ov-inv" id="cov-inv"></div>
22517
+ <div id="cov-axis"></div>
22518
+ <div class="cov-note" id="cov-note" hidden data-i18n="coverage.noUniverse">This measurement carried no file inventory, so only reached files are shown; nothing can be called uncovered.</div>
22519
+ </div>
22520
+ <div class="toolbar">
22521
+ <label class="search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg><input id="cov-q" type="search" data-i18n-ph="coverage.search" aria-label="Filter files"></label>
22522
+ <button class="fchip" id="cov-unc" aria-pressed="false" type="button"><span data-i18n="coverage.filter.uncovered">Uncovered only</span><span class="fcount" id="cov-unc-n"></span></button>
22523
+ </div>
22524
+ <div class="cov-split">
22525
+ <div class="cov-treewrap"><div id="cov-tree"></div><p class="empty-note" id="cov-no-hit" hidden data-i18n="coverage.noHit">No matching files.</p></div>
22526
+ <div class="cov-detail" id="cov-detail"></div>
22527
+ </div>
22528
+ </div>
22529
+ </div>
22530
+ </section>
22531
+
20372
22532
  <!-- ===== RUN DETAIL ===== -->
20373
22533
  <section id="view-detail" hidden>
20374
22534
  <div class="page-bar">
@@ -20737,6 +22897,8 @@ const CSS = `
20737
22897
  pass/fail ones, and read as a different kind of thing. */
20738
22898
  .badge.dr-found { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
20739
22899
  .badge.dr-found .d { background: var(--amber); }
22900
+ .badge.uncov { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
22901
+ .badge.uncov .d { background: var(--amber); }
20740
22902
  .badge.dr-clean { background: var(--pass-bg); color: var(--pass); border-color: var(--pass-border); }
20741
22903
  .badge.dr-clean .d { background: var(--pass); }
20742
22904
  .badge.dr-unknown { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
@@ -20839,6 +23001,13 @@ const CSS = `
20839
23001
  .artifact-acc > summary { height: 34px; }
20840
23002
  .artifact-pre { margin: 2px 0 8px; padding: 10px 12px; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); font-family: var(--mono); font-size: 11.5px; line-height: 1.5; max-height: 320px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
20841
23003
  .section-label { font-size: 12px; font-weight: 600; color: var(--muted); margin-top: 4px; }
23004
+ /* reached files: what the spec actually executed, plus what could not be placed */
23005
+ .cov-counts { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 0 8px; }
23006
+ .cov-count { font-size: 11px; color: var(--muted); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 1px 7px; white-space: nowrap; }
23007
+ .cov-count b { color: var(--fg-dim); font-variant-numeric: tabular-nums; }
23008
+ .cov-count.cov-warn { color: var(--fail); border-color: var(--fail); }
23009
+ .cov-file { font-family: var(--mono); font-size: 12px; padding: 4px 4px; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
23010
+ .cov-file:last-child { border-bottom: none; }
20842
23011
  /* live run steps: stacked cards with large before/after frames */
20843
23012
  .step-card { border: 1px solid var(--border); border-left: 3px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-2); padding: 12px 14px; margin-top: 10px; }
20844
23013
  .step-card.passed { border-left-color: var(--border-strong); }
@@ -21132,6 +23301,38 @@ const CSS = `
21132
23301
  .d-note { margin-top: 12px; max-width: 900px; }
21133
23302
 
21134
23303
  .tblcard { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
23304
+
23305
+ /* ── coverage: file tree ─────────────────────────────────────────── */
23306
+ .cov-note { font-size: 12.5px; color: var(--amber); background: var(--amber-bg); border: 1px solid var(--amber-border); border-radius: var(--radius-md); padding: 8px 12px; }
23307
+ .cov-split { display: grid; grid-template-columns: minmax(360px, 7fr) 5fr; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); overflow: hidden; }
23308
+ .cov-treewrap { overflow-y: auto; max-height: 560px; padding: 6px 0; border-right: 1px solid var(--border); }
23309
+ .cov-treewrap ul { list-style: none; margin: 0; padding: 0; }
23310
+ .cov-treewrap ul ul { border-left: 1px solid var(--border); margin-left: 19px; }
23311
+ .cov-row { display: flex; align-items: center; gap: 7px; width: 100%; padding: 3px 14px 3px 8px; border: 0; background: none; text-align: left; font-size: 13px; }
23312
+ .cov-row:hover { background: var(--surface-2); }
23313
+ .cov-row.sel { background: var(--info-bg); box-shadow: inset 2px 0 0 var(--info); }
23314
+ .cov-row .chev { width: 12px; height: 12px; flex: none; color: var(--muted-2); transition: transform 0.15s; }
23315
+ .cov-row .chev.open { transform: rotate(90deg); }
23316
+ .cov-row svg.ic { width: 15px; height: 15px; flex: none; color: var(--muted-2); stroke-width: 1.7; fill: none; stroke: currentColor; }
23317
+ .cov-row .nm { font-family: var(--mono); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
23318
+ .cov-row .nm.dim { color: var(--muted); }
23319
+ .cov-row .sp { flex: 1; }
23320
+ .cov-row .dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
23321
+ .cov-row .dot.ok { background: var(--pass); }
23322
+ .cov-row .dot.no { background: var(--amber-fill); }
23323
+ .cov-row .minibar { width: 60px; height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; flex: none; }
23324
+ .cov-row .minibar i { display: block; height: 100%; background: var(--pass); }
23325
+ .cov-row .frac { font-size: 11px; color: var(--muted); font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 52px; text-align: right; flex: none; }
23326
+ .cov-detail { padding: 16px 18px; overflow-y: auto; max-height: 560px; }
23327
+ .cov-detail .ph { color: var(--muted-2); font-size: 13px; padding: 30px 10px; text-align: center; }
23328
+ .cov-detail h4 { font-size: 13px; font-family: var(--mono); font-weight: 600; margin: 0 0 4px; word-break: break-all; }
23329
+ .cov-detail .meta { font-size: 12.5px; color: var(--muted); margin-bottom: 12px; }
23330
+ .cov-caselist { border-top: 1px solid var(--border); }
23331
+ .cov-caselist a { display: flex; align-items: center; gap: 10px; padding: 8px 2px; border-bottom: 1px solid var(--border); text-decoration: none; font-size: 13px; color: inherit; }
23332
+ .cov-caselist a:hover { background: var(--surface-2); }
23333
+ .cov-caselist .cs { font-family: var(--mono); font-size: 12.5px; flex: 1; min-width: 0; word-break: break-all; }
23334
+ .cov-hint { margin-top: 12px; padding: 9px 13px; border-radius: var(--radius-md); background: var(--amber-bg); border: 1px solid var(--amber-border); color: var(--amber); font-size: 12.5px; }
23335
+ @media (max-width: 900px) { .cov-split { grid-template-columns: 1fr; } .cov-treewrap { border-right: 0; border-bottom: 1px solid var(--border); } }
21135
23336
  /* Badges across a case row must land on one line. Some of these cells carry
21136
23337
  only a badge, others a badge plus a sub-line (sha · when) or a two-line
21137
23338
  explanation, so middle-aligning the cells put each badge at a different
@@ -21238,7 +23439,20 @@ const CLIENT_JS = `
21238
23439
  var I18N = {
21239
23440
  en: {
21240
23441
  "nav.projects": "Projects", "nav.runs": "Runs", "nav.perspectives": "Perspectives", "nav.secrets": "Secrets",
21241
- "nav.prompts": "Prompts", "nav.learning": "Learning",
23442
+ "nav.prompts": "Prompts", "nav.learning": "Learning", "nav.coverage": "Coverage",
23443
+ "coverage.title": "Coverage",
23444
+ "coverage.loading": "Loading coverage…",
23445
+ "coverage.none": "No run with coverage yet. Run with --coverage and push the report to this hub.",
23446
+ "coverage.search": "Filter by file path…",
23447
+ "coverage.filter.uncovered": "Uncovered only",
23448
+ "coverage.reached": "Reached", "coverage.uncovered": "Uncovered", "coverage.files": "files",
23449
+ "coverage.measured": "measured", "coverage.specsCombined": "specs combined",
23450
+ "coverage.noUniverse": "This measurement carried no file inventory, so only reached files are shown; nothing can be called uncovered.",
23451
+ "coverage.placeholder": "Select a file to see the cases that reach it.",
23452
+ "coverage.fileUncovered": "No case reached this file in this measurement.",
23453
+ "coverage.casesReach": "case(s) reach this file",
23454
+ "coverage.noHit": "No matching files.",
23455
+ "coverage.case": "case", "coverage.cases": "cases",
21242
23456
  "app.project": "project", "app.profile": "profile", "app.disconnect": "Disconnect", "app.noProject": "no project",
21243
23457
  "app.newProfile": "New profile",
21244
23458
  "login.title": "Connect to your hub", "login.sub": "Enter your bearer token to continue.",
@@ -21267,6 +23481,27 @@ const CLIENT_JS = `
21267
23481
  "acc.reasoning": "Reasoning", "acc.evidence": "Evidence", "acc.steps": "Live run steps",
21268
23482
  "acc.assertions": "Assertions",
21269
23483
  "acc.artifacts": "Artifacts",
23484
+ "acc.coverage": "Reached files",
23485
+ "acc.coverage.hint": "Measured, not inferred: V8's counters in the browser and per-request instrumentation on the server.",
23486
+ "cov.frontend": "browser", "cov.backend": "server",
23487
+ "cov.unattributed": "server executions outside this spec's context",
23488
+ "cov.unmappedScripts": "scripts with no usable source map",
23489
+ "cov.unmappedRanges": "executed ranges that mapped nowhere",
23490
+ "cov.outsideProject": "browser sources outside the project",
23491
+ "cov.unresolvedSources": "browser sources that resolved to no project path",
23492
+ "cov.uninstrumentedFiles": "server files that could not be instrumented",
23493
+ "cov.uninstrumentedProcesses": "server processes that instrumented nothing at all",
23494
+ "cov.droppedPushes": "reports the application could not deliver",
23495
+ "cov.unmappedActorEvents": "events from identities this project does not declare",
23496
+ "cov.outsideWindowEvents": "events from a declared identity outside its turn",
23497
+ "cov.excludedDependencies": "dependency sources, excluded on purpose",
23498
+ "cov.route.carrier": "attributed by carrier",
23499
+ "cov.route.actorWindow": "actor-window",
23500
+ "cov.route.events": "events",
23501
+ "cov.noBackend": "no instrumented server process reported",
23502
+ "cov.noFrontend": "the browser produced no result",
23503
+ "cov.frontendStopped": "browser collection stopped early",
23504
+ "cov.unavailable": "Nothing was measured:",
21270
23505
  "art.open": "Open", "art.loadFailed": "could not load (it may have been omitted from the push)",
21271
23506
  "acc.assertions.hint": "Test cases from the recorded spec run",
21272
23507
  "spec.kind.live": "Live", "spec.kind.det": "Deterministic",
@@ -21427,7 +23662,20 @@ const CLIENT_JS = `
21427
23662
  },
21428
23663
  ja: {
21429
23664
  "nav.projects": "プロジェクト", "nav.runs": "実行", "nav.perspectives": "テスト観点", "nav.secrets": "シークレット",
21430
- "nav.prompts": "プロンプト", "nav.learning": "学習",
23665
+ "nav.prompts": "プロンプト", "nav.learning": "学習", "nav.coverage": "カバレッジ",
23666
+ "coverage.title": "カバレッジ",
23667
+ "coverage.loading": "カバレッジを読み込み中…",
23668
+ "coverage.none": "カバレッジ付きの run がまだありません。--coverage で計測し、レポートをこの hub に push してください。",
23669
+ "coverage.search": "ファイルパスで絞り込み…",
23670
+ "coverage.filter.uncovered": "未到達のみ",
23671
+ "coverage.reached": "到達", "coverage.uncovered": "未到達", "coverage.files": "ファイル",
23672
+ "coverage.measured": "計測", "coverage.specsCombined": "spec 合算",
23673
+ "coverage.noUniverse": "この計測にはファイル台帳が付いていないため、到達したファイルのみ表示しています。未到達は判定できません。",
23674
+ "coverage.placeholder": "ファイルを選択すると、到達しているケースが表示されます",
23675
+ "coverage.fileUncovered": "この計測では、どのケースもこのファイルに到達しませんでした。",
23676
+ "coverage.casesReach": "ケースが到達",
23677
+ "coverage.noHit": "一致するファイルがありません。",
23678
+ "coverage.case": "ケース", "coverage.cases": "ケース",
21431
23679
  "app.project": "プロジェクト", "app.profile": "プロファイル", "app.disconnect": "切断", "app.noProject": "プロジェクト未選択",
21432
23680
  "app.newProfile": "新規プロファイル",
21433
23681
  "login.title": "ハブに接続", "login.sub": "続けるにはベアラートークンを入力してください。",
@@ -21456,6 +23704,27 @@ const CLIENT_JS = `
21456
23704
  "acc.reasoning": "推論", "acc.evidence": "根拠", "acc.steps": "実行ステップ",
21457
23705
  "acc.assertions": "アサーション",
21458
23706
  "acc.artifacts": "成果物",
23707
+ "acc.coverage": "到達ファイル",
23708
+ "acc.coverage.hint": "推定ではなく実測。ブラウザは V8 のカウンタ、サーバはリクエスト単位の計装。",
23709
+ "cov.frontend": "ブラウザ", "cov.backend": "サーバ",
23710
+ "cov.unattributed": "この spec の文脈外で走ったサーバ実行",
23711
+ "cov.unmappedScripts": "source map を辿れなかったスクリプト",
23712
+ "cov.unmappedRanges": "どこにも対応しなかった実行範囲",
23713
+ "cov.outsideProject": "プロジェクト外に解決したブラウザのソース",
23714
+ "cov.unresolvedSources": "プロジェクト内のパスに解決できなかったブラウザのソース",
23715
+ "cov.uninstrumentedFiles": "計装できなかったサーバのファイル",
23716
+ "cov.uninstrumentedProcesses": "何も計装できなかったサーバのプロセス",
23717
+ "cov.droppedPushes": "アプリが送信できなかった報告",
23718
+ "cov.unmappedActorEvents": "このプロジェクトが宣言していない主体のイベント",
23719
+ "cov.outsideWindowEvents": "宣言済みの主体が持ち時間の外で起こしたイベント",
23720
+ "cov.excludedDependencies": "意図的に除外した依存ライブラリのソース",
23721
+ "cov.route.carrier": "carrier で帰属",
23722
+ "cov.route.actorWindow": "actor-window",
23723
+ "cov.route.events": "件",
23724
+ "cov.noBackend": "計装されたサーバプロセスからの報告なし",
23725
+ "cov.noFrontend": "ブラウザ側の結果なし",
23726
+ "cov.frontendStopped": "ブラウザ側の収集が途中で停止",
23727
+ "cov.unavailable": "計測できませんでした:",
21459
23728
  "art.open": "開く", "art.loadFailed": "読み込めませんでした(push時に省略された可能性があります)",
21460
23729
  "acc.assertions.hint": "記録したスペック実行のテストケース",
21461
23730
  "spec.kind.live": "ライブ", "spec.kind.det": "決定的",
@@ -22070,8 +24339,8 @@ const CLIENT_JS = `
22070
24339
 
22071
24340
  // ── view routing ────────────────────────────────────────────────────
22072
24341
 
22073
- var VIEWS = ["projects", "runs", "detail", "perspectives", "secrets", "prompts", "jobs"];
22074
- var NAV_FOR_VIEW = { projects: ".nav-projects", secrets: ".nav-secrets", prompts: ".nav-prompts", runs: ".nav-runs", detail: ".nav-runs", jobs: ".nav-jobs", perspectives: ".nav-perspectives" };
24342
+ var VIEWS = ["projects", "runs", "detail", "perspectives", "coverage", "secrets", "prompts", "jobs"];
24343
+ var NAV_FOR_VIEW = { projects: ".nav-projects", secrets: ".nav-secrets", prompts: ".nav-prompts", runs: ".nav-runs", detail: ".nav-runs", jobs: ".nav-jobs", perspectives: ".nav-perspectives", coverage: ".nav-coverage" };
22075
24344
  function showView(id) {
22076
24345
  // Any in-flight job poll belongs to the view we're leaving — bump the token
22077
24346
  // so its next tick is a no-op (see pollJob).
@@ -22090,6 +24359,7 @@ const CLIENT_JS = `
22090
24359
  var gated = !state.project;
22091
24360
  document.querySelector(".nav-runs").classList.toggle("disabled", gated);
22092
24361
  document.querySelector(".nav-perspectives").classList.toggle("disabled", gated);
24362
+ document.querySelector(".nav-coverage").classList.toggle("disabled", gated);
22093
24363
  document.querySelector(".nav-secrets").classList.toggle("disabled", gated);
22094
24364
  document.querySelector(".nav-prompts").classList.toggle("disabled", gated);
22095
24365
  document.querySelector(".nav-jobs").classList.toggle("disabled", gated);
@@ -22106,6 +24376,7 @@ const CLIENT_JS = `
22106
24376
  var m = location.hash.match(/^#\\/runs\\/(.+)$/);
22107
24377
  if (m) { openRunDetail(decodeURIComponent(m[1])); return; }
22108
24378
  if (location.hash === "#/perspectives") { openPerspectives(); return; }
24379
+ if (location.hash === "#/coverage") { openCoverage(); return; }
22109
24380
  if (location.hash === "#/secrets") { openSecrets(); return; }
22110
24381
  if (location.hash === "#/prompts") { openPrompts(); return; }
22111
24382
  var j = location.hash.match(/^#\\/jobs\\/(.+)$/);
@@ -22327,6 +24598,287 @@ const CLIENT_JS = `
22327
24598
  });
22328
24599
  }
22329
24600
 
24601
+ // == coverage: file tree =============================================
24602
+ // One run's measurement drawn over the enumerated universe. Everything is
24603
+ // display-side aggregation of report.json — the hub computes nothing new.
24604
+ var covState = { q: "", unc: false, model: null, selected: null, openDirs: null, loadToken: 0 };
24605
+
24606
+ function openCoverage() {
24607
+ if (!state.project) { location.hash = "#/projects"; route(); return; }
24608
+ showView("coverage");
24609
+ if (covState.model) { renderCoverage(); return; }
24610
+ loadCoverage();
24611
+ }
24612
+
24613
+ function loadCoverage() {
24614
+ var token = ++covState.loadToken;
24615
+ var status = document.getElementById("cov-status");
24616
+ document.getElementById("cov-body").hidden = true;
24617
+ status.hidden = false;
24618
+ status.textContent = t("coverage.loading");
24619
+ apiFetch("/api/v1/runs?project=" + encodeURIComponent(state.project) + "&kind=run&limit=25")
24620
+ .then(function (data) {
24621
+ // A run still in flight has a report, but a partial one: rows trickle
24622
+ // in per spec and the universe only arrives with the seal.
24623
+ var runs = (data.runs || []).filter(function (r) { return r.status !== "running"; });
24624
+ return covFindReport(runs, 0, token);
24625
+ })
24626
+ .then(function (found) {
24627
+ if (token !== covState.loadToken) return;
24628
+ if (!found) { status.textContent = t("coverage.none"); return; }
24629
+ covState.model = covBuildModel(found.run, found.report);
24630
+ covState.openDirs = null;
24631
+ covState.selected = null;
24632
+ status.hidden = true;
24633
+ document.getElementById("cov-body").hidden = false;
24634
+ renderCoverage();
24635
+ })
24636
+ .catch(function (err) {
24637
+ if (token !== covState.loadToken) return;
24638
+ status.textContent = "Error loading coverage: " + err.message;
24639
+ });
24640
+ }
24641
+
24642
+ // Newest first, stop at the first run whose report actually measured.
24643
+ // Capped: each probe is a full report fetch, and past ten stale runs the
24644
+ // answer is "none recent enough to trust" anyway.
24645
+ function covFindReport(runs, i, token) {
24646
+ if (i >= runs.length || i >= 10 || token !== covState.loadToken) return Promise.resolve(null);
24647
+ var run = runs[i];
24648
+ return apiFetch("/api/v1/runs/" + encodeURIComponent(run.id) + "/report").then(function (report) {
24649
+ var measured = !!report.coverageUniverse || (report.results || []).some(function (r) { return r.coverage; });
24650
+ if (measured) return { run: run, report: report };
24651
+ return covFindReport(runs, i + 1, token);
24652
+ }).catch(function () { return covFindReport(runs, i + 1, token); });
24653
+ }
24654
+
24655
+ function covBuildModel(run, report) {
24656
+ // Null-prototype maps throughout: a path segment named "constructor" or
24657
+ // "__proto__" must stay data, not resolve to an inherited property.
24658
+ var byFile = Object.create(null);
24659
+ var measuredSpecs = 0;
24660
+ (report.results || []).forEach(function (row) {
24661
+ if (!row.coverage) return;
24662
+ measuredSpecs++;
24663
+ var key = row.feature + "/" + row.spec;
24664
+ (row.coverage.files || []).forEach(function (path) {
24665
+ (byFile[path] = byFile[path] || []).push(key);
24666
+ });
24667
+ });
24668
+ // universe ∪ reached: the enumeration's filters can never lose a result.
24669
+ var universe = report.coverageUniverse ? report.coverageUniverse.files : null;
24670
+ var all = Object.create(null);
24671
+ (universe || []).forEach(function (path) { all[path] = true; });
24672
+ Object.keys(byFile).forEach(function (path) { all[path] = true; });
24673
+ var root = { name: "", dirs: Object.create(null), files: [], total: 0, covered: 0 };
24674
+ var fileByPath = Object.create(null);
24675
+ Object.keys(all).sort().forEach(function (path) {
24676
+ var parts = path.split("/");
24677
+ var node = root;
24678
+ for (var i = 0; i < parts.length - 1; i++) {
24679
+ node = node.dirs[parts[i]] = node.dirs[parts[i]] || { name: parts[i], dirs: Object.create(null), files: [], total: 0, covered: 0 };
24680
+ }
24681
+ var f = { name: parts[parts.length - 1], path: path, cases: byFile[path] || [] };
24682
+ node.files.push(f);
24683
+ fileByPath[path] = f;
24684
+ });
24685
+ covAnnotate(root);
24686
+ return { run: run, report: report, root: root, fileByPath: fileByPath, hasUniverse: !!universe, measuredSpecs: measuredSpecs };
24687
+ }
24688
+
24689
+ function covAnnotate(node) {
24690
+ node.total = node.files.length;
24691
+ node.covered = node.files.filter(function (f) { return f.cases.length > 0; }).length;
24692
+ Object.keys(node.dirs).forEach(function (k) {
24693
+ covAnnotate(node.dirs[k]);
24694
+ node.total += node.dirs[k].total;
24695
+ node.covered += node.dirs[k].covered;
24696
+ });
24697
+ }
24698
+
24699
+ function covDefaultOpen(model) {
24700
+ // Root and its first level open by default; deeper stays folded until asked.
24701
+ var open = Object.create(null);
24702
+ open[""] = true;
24703
+ Object.keys(model.root.dirs).forEach(function (k) { open[k] = true; });
24704
+ return open;
24705
+ }
24706
+
24707
+ function covFileVisible(f) {
24708
+ if (covState.unc && f.cases.length > 0) return false;
24709
+ if (covState.q && f.path.toLowerCase().indexOf(covState.q) === -1) return false;
24710
+ return true;
24711
+ }
24712
+
24713
+ function renderCoverage() {
24714
+ var model = covState.model;
24715
+ if (!model) return;
24716
+ if (!covState.openDirs) covState.openDirs = covDefaultOpen(model);
24717
+
24718
+ var uncovered = model.root.total - model.root.covered;
24719
+ var pct = model.root.total ? Math.round((model.root.covered / model.root.total) * 100) : 0;
24720
+ var inv = document.getElementById("cov-inv");
24721
+ clear(inv);
24722
+ inv.appendChild(el("b", null, model.hasUniverse ? pct + "%" : String(model.root.covered)));
24723
+ inv.appendChild(document.createTextNode(
24724
+ model.hasUniverse
24725
+ ? " — " + model.root.covered + " / " + model.root.total + " " + t("coverage.files")
24726
+ : " " + t("coverage.files") + " (" + t("coverage.reached") + ")"
24727
+ ));
24728
+ var axis = document.getElementById("cov-axis");
24729
+ clear(axis);
24730
+ var segments = [{ cls: "sg-verified", state: "reached", count: model.root.covered }];
24731
+ if (model.hasUniverse) segments.push({ cls: "sg-rerunneeded", state: "uncovered", count: uncovered });
24732
+ if (model.root.total > 0) axis.appendChild(ovAxisRow("", segments, "coverage.", model.root.total));
24733
+ document.getElementById("cov-note").hidden = model.hasUniverse;
24734
+ // Without a denominator every shown file is reached — the filter could
24735
+ // only ever produce an empty tree, so it is withdrawn, not just zeroed.
24736
+ var uncChip = document.getElementById("cov-unc");
24737
+ uncChip.hidden = !model.hasUniverse;
24738
+ if (!model.hasUniverse && covState.unc) {
24739
+ covState.unc = false;
24740
+ uncChip.setAttribute("aria-pressed", "false");
24741
+ }
24742
+ document.getElementById("cov-unc-n").textContent = model.hasUniverse ? String(uncovered) : "";
24743
+
24744
+ var asof = document.getElementById("cov-asof");
24745
+ var head = model.report.git && model.report.git.head ? String(model.report.git.head).slice(0, 7) : null;
24746
+ asof.textContent = t("coverage.measured") + ": " + relTime(model.report.createdAt) +
24747
+ (head ? " (" + head + ")" : "") + " - " + model.measuredSpecs + " " + t("coverage.specsCombined");
24748
+
24749
+ // The tree is rebuilt from scratch, which would otherwise snap the pane
24750
+ // back to the top on every toggle deep in the hierarchy.
24751
+ var wrap = document.querySelector(".cov-treewrap");
24752
+ var scroll = wrap ? wrap.scrollTop : 0;
24753
+ var host = document.getElementById("cov-tree");
24754
+ clear(host);
24755
+ var ul = document.createElement("ul");
24756
+ var rootLi = covRenderDir(model.root, "");
24757
+ document.getElementById("cov-no-hit").hidden = !!rootLi;
24758
+ if (rootLi) ul.appendChild(rootLi);
24759
+ host.appendChild(ul);
24760
+ if (wrap) wrap.scrollTop = scroll;
24761
+
24762
+ // The detail pane re-renders with the tree: language toggles, refreshes
24763
+ // and filters would otherwise leave it describing the previous state.
24764
+ var sel = covState.selected ? model.fileByPath[covState.selected] : null;
24765
+ if (sel && covFileVisible(sel)) {
24766
+ covShowDetail(sel);
24767
+ } else {
24768
+ covState.selected = null;
24769
+ var detail = document.getElementById("cov-detail");
24770
+ clear(detail);
24771
+ detail.appendChild(el("div", "ph", t("coverage.placeholder")));
24772
+ }
24773
+ }
24774
+
24775
+ function covRenderDir(node, path) {
24776
+ var filtering = covState.unc || !!covState.q;
24777
+ var isOpen = filtering || !!covState.openDirs[path];
24778
+ // A closed, unfiltered directory renders as a single row \u2014 its counts come
24779
+ // from covAnnotate \u2014 so the subtree is not walked at all. At the 20k-file
24780
+ // ceiling that walk is the whole render cost.
24781
+ var subs = [];
24782
+ var visFiles = [];
24783
+ if (isOpen) {
24784
+ visFiles = node.files.filter(covFileVisible);
24785
+ Object.keys(node.dirs).sort().forEach(function (k) {
24786
+ var sub = covRenderDir(node.dirs[k], path ? path + "/" + k : k);
24787
+ if (sub) subs.push(sub);
24788
+ });
24789
+ if (filtering && visFiles.length === 0 && subs.length === 0) return null;
24790
+ }
24791
+ var li = document.createElement("li");
24792
+ if (path !== "") {
24793
+ var row = el("button", "cov-row");
24794
+ row.type = "button";
24795
+ var chev = chevron();
24796
+ if (isOpen) chev.classList.add("open");
24797
+ row.appendChild(chev);
24798
+ row.appendChild(covIcon(true));
24799
+ row.appendChild(el("span", "nm", node.name + "/"));
24800
+ row.appendChild(el("span", "sp"));
24801
+ var mini = el("span", "minibar");
24802
+ var fill = el("i");
24803
+ fill.style.width = (node.total ? Math.round((node.covered / node.total) * 100) : 0) + "%";
24804
+ mini.appendChild(fill);
24805
+ row.appendChild(mini);
24806
+ row.appendChild(el("span", "frac", node.covered + "/" + node.total));
24807
+ row.addEventListener("click", function () {
24808
+ if (covState.openDirs[path]) delete covState.openDirs[path];
24809
+ else covState.openDirs[path] = true;
24810
+ renderCoverage();
24811
+ });
24812
+ li.appendChild(row);
24813
+ }
24814
+ if (isOpen) {
24815
+ var ul = document.createElement("ul");
24816
+ if (path === "") ul.style.marginLeft = "0";
24817
+ subs.forEach(function (sub) { ul.appendChild(sub); });
24818
+ visFiles.forEach(function (f) { ul.appendChild(covRenderFile(f)); });
24819
+ li.appendChild(ul);
24820
+ }
24821
+ return li;
24822
+ }
24823
+
24824
+ function covRenderFile(f) {
24825
+ var li = document.createElement("li");
24826
+ var row = el("button", "cov-row" + (covState.selected === f.path ? " sel" : ""));
24827
+ row.type = "button";
24828
+ row.appendChild(el("span", "chev"));
24829
+ row.appendChild(covIcon(false));
24830
+ row.appendChild(el("span", "nm" + (f.cases.length > 0 ? " dim" : ""), f.name));
24831
+ row.appendChild(el("span", "sp"));
24832
+ row.appendChild(el("span", "dot " + (f.cases.length > 0 ? "ok" : "no")));
24833
+ row.appendChild(el("span", "frac", f.cases.length > 0
24834
+ ? f.cases.length + " " + (f.cases.length === 1 ? t("coverage.case") : t("coverage.cases"))
24835
+ : "0"));
24836
+ row.addEventListener("click", function () {
24837
+ covState.selected = f.path;
24838
+ renderCoverage();
24839
+ });
24840
+ li.appendChild(row);
24841
+ return li;
24842
+ }
24843
+
24844
+ function covIcon(isDir) {
24845
+ var svg = svgIcon();
24846
+ svg.setAttribute("class", "ic");
24847
+ svg.appendChild(svgPath(isDir
24848
+ ? "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
24849
+ : "M6 2h8l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z"));
24850
+ return svg;
24851
+ }
24852
+
24853
+ function covShowDetail(f) {
24854
+ var host = document.getElementById("cov-detail");
24855
+ clear(host);
24856
+ var title = el("h4", null, f.path);
24857
+ host.appendChild(title);
24858
+ if (f.cases.length === 0) {
24859
+ var meta = el("div", "meta");
24860
+ var badge = el("span", "badge uncov");
24861
+ badge.appendChild(el("span", "d"));
24862
+ badge.appendChild(document.createTextNode(" " + t("coverage.uncovered")));
24863
+ meta.appendChild(badge);
24864
+ host.appendChild(meta);
24865
+ host.appendChild(el("div", "cov-hint", t("coverage.fileUncovered")));
24866
+ return;
24867
+ }
24868
+ host.appendChild(el("div", "meta", f.cases.length + " " + t("coverage.casesReach")));
24869
+ var list = el("div", "cov-caselist");
24870
+ var runId = covState.model.run.id;
24871
+ // No pass/fail here on purpose: this pane answers "what reaches this
24872
+ // file", not "did it pass" — the run page holds the verdicts.
24873
+ f.cases.forEach(function (key) {
24874
+ var a = document.createElement("a");
24875
+ a.href = "#/runs/" + encodeURIComponent(runId);
24876
+ a.appendChild(el("span", "cs", key));
24877
+ list.appendChild(a);
24878
+ });
24879
+ host.appendChild(list);
24880
+ }
24881
+
22330
24882
  // Entering the view or refreshing it. The spend readout is a fixed window,
22331
24883
  // so a filter change reloads the list alone.
22332
24884
  function loadRuns() {
@@ -22656,6 +25208,75 @@ const CLIENT_JS = `
22656
25208
  return wrap;
22657
25209
  }
22658
25210
 
25211
+ // ── run detail: reached files (--coverage) ─────────────────────────
25212
+ //
25213
+ // The gap counters sit above the list on purpose. Every one of them is an
25214
+ // execution the measurement could not place, and an unplaced execution reads
25215
+ // as "never reached" — the exact answer this section exists to give — so they
25216
+ // belong next to the answer rather than in a log nobody opens.
25217
+ function coverageSection(cov) {
25218
+ var wrap = el("div");
25219
+ wrap.appendChild(el("div", "assertions-hint muted", t("acc.coverage.hint")));
25220
+ var gaps = cov.gaps || {};
25221
+ var counts = el("div", "cov-counts");
25222
+ [
25223
+ { label: t("cov.frontend"), value: cov.frontendFiles, always: true },
25224
+ { label: t("cov.backend"), value: cov.backendFiles, always: true },
25225
+ ].forEach(function (count) {
25226
+ var chip = el("span", "cov-count");
25227
+ chip.appendChild(el("b", null, String(count.value || 0)));
25228
+ chip.appendChild(document.createTextNode(" " + count.label));
25229
+ counts.appendChild(chip);
25230
+ });
25231
+ // Which route the attribution came by. The carrier is always live; a
25232
+ // declared identity is shown even at zero events, because a window that
25233
+ // matched nothing is the failure and an omitted chip would hide it.
25234
+ counts.appendChild(el("span", "cov-count muted", t("cov.route.carrier")));
25235
+ (cov.actorWindows || []).forEach(function (w) {
25236
+ var chip = el("span", w.events ? "cov-count" : "cov-count cov-warn");
25237
+ chip.appendChild(document.createTextNode(t("cov.route.actorWindow") + "(" + w.key + ") "));
25238
+ chip.appendChild(el("b", null, String(w.events || 0)));
25239
+ chip.appendChild(document.createTextNode(" " + t("cov.route.events")));
25240
+ counts.appendChild(chip);
25241
+ });
25242
+ // "That half never answered" and "that half reached nothing" render as the
25243
+ // same zero, so the first is said in words.
25244
+ [
25245
+ { shown: cov.backendReported === false, label: t("cov.noBackend") },
25246
+ { shown: cov.frontendReported === false, label: t("cov.noFrontend") },
25247
+ { shown: cov.frontendStopped === true, label: t("cov.frontendStopped") },
25248
+ ].forEach(function (flag) {
25249
+ if (!flag.shown) return;
25250
+ counts.appendChild(el("span", "cov-count cov-warn", flag.label));
25251
+ });
25252
+ [
25253
+ "unattributed", "unmappedScripts", "unmappedRanges",
25254
+ "outsideProject", "unresolvedSources", "uninstrumentedFiles",
25255
+ "uninstrumentedProcesses", "droppedPushes",
25256
+ "unmappedActorEvents", "outsideWindowEvents",
25257
+ ].forEach(function (key) {
25258
+ if (!gaps[key]) return;
25259
+ var chip = el("span", "cov-count");
25260
+ chip.appendChild(el("b", null, String(gaps[key])));
25261
+ chip.appendChild(document.createTextNode(" " + t("cov." + key)));
25262
+ counts.appendChild(chip);
25263
+ });
25264
+ // Last, and outside the gap list: excluded dependencies are not a hole in
25265
+ // the measurement, and next to ones that are they would drown them out.
25266
+ if (cov.excludedDependencies) {
25267
+ var excluded = el("span", "cov-count muted");
25268
+ excluded.appendChild(el("b", null, String(cov.excludedDependencies)));
25269
+ excluded.appendChild(document.createTextNode(" " + t("cov.excludedDependencies")));
25270
+ counts.appendChild(excluded);
25271
+ }
25272
+ wrap.appendChild(counts);
25273
+ // One insertion for a list that can run to thousands of rows.
25274
+ var files = document.createDocumentFragment();
25275
+ (cov.files || []).forEach(function (f) { files.appendChild(el("div", "cov-file", f)); });
25276
+ wrap.appendChild(files);
25277
+ return wrap;
25278
+ }
25279
+
22659
25280
  // The parts a live step and a deterministic step render identically: the
22660
25281
  // status-railed card, a header (#index + instruction + a status badge unless
22661
25282
  // passed), and an optional "expects:"/reasoning meta block. Returns { card,
@@ -22979,6 +25600,18 @@ const CLIENT_JS = `
22979
25600
  any = true;
22980
25601
  }
22981
25602
 
25603
+ if (r.coverage) {
25604
+ var covFiles = r.coverage.files || [];
25605
+ body.appendChild(detailsBlock(t("acc.coverage"), covFiles.length, coverageSection(r.coverage)));
25606
+ any = true;
25607
+ } else if (r.coverageUnavailable) {
25608
+ // The run measured coverage but this spec could not be: say so, rather
25609
+ // than leave a row that looks like it reached nothing.
25610
+ body.appendChild(el("div", "section-label", t("acc.coverage")));
25611
+ body.appendChild(el("div", "muted", t("cov.unavailable") + " " + r.coverageUnavailable));
25612
+ any = true;
25613
+ }
25614
+
22982
25615
  if (any) card.appendChild(body);
22983
25616
  return card;
22984
25617
  }
@@ -25218,6 +27851,15 @@ const CLIENT_JS = `
25218
27851
  // ── project switching ───────────────────────────────────────────────
25219
27852
 
25220
27853
  function setProject(p) {
27854
+ if (p !== state.project) {
27855
+ // The coverage page caches its model per project; a stale one would
27856
+ // draw the previous project's tree under the new project's header.
27857
+ // Only on a real switch: setLang() calls this with the same project
27858
+ // just to refresh labels.
27859
+ covState.model = null;
27860
+ covState.selected = null;
27861
+ covState.openDirs = null;
27862
+ }
25221
27863
  state.project = p;
25222
27864
  document.getElementById("project-current").textContent = p || "none";
25223
27865
  document.getElementById("sidebar-project").textContent = p || t("app.noProject");
@@ -25561,6 +28203,27 @@ const CLIENT_JS = `
25561
28203
  perspState.q = e.target.value.trim().toLowerCase();
25562
28204
  renderPerspectives();
25563
28205
  });
28206
+
28207
+ // Debounced, unlike persp-q: each keystroke rebuilds the whole tree, and
28208
+ // the universe can hold thousands of files.
28209
+ var covQTimer = null;
28210
+ document.getElementById("cov-q").addEventListener("input", function (e) {
28211
+ var value = e.target.value.trim().toLowerCase();
28212
+ clearTimeout(covQTimer);
28213
+ covQTimer = setTimeout(function () {
28214
+ covState.q = value;
28215
+ renderCoverage();
28216
+ }, 150);
28217
+ });
28218
+ document.getElementById("cov-unc").addEventListener("click", function () {
28219
+ covState.unc = !covState.unc;
28220
+ document.getElementById("cov-unc").setAttribute("aria-pressed", String(covState.unc));
28221
+ renderCoverage();
28222
+ });
28223
+ document.getElementById("cov-refresh").addEventListener("click", function () {
28224
+ covState.model = null;
28225
+ loadCoverage();
28226
+ });
25564
28227
  document.querySelectorAll("#view-perspectives .fchip").forEach(function (b) {
25565
28228
  b.addEventListener("click", function () {
25566
28229
  // renderPerspectives -> syncPerspChips repaints aria-pressed from