ccqa 1.41.0 → 1.42.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
@@ -5661,6 +5661,24 @@ function actorGroups(plan) {
5661
5661
  return (ref) => (plan.windowsForSpec.get(specKey(ref)) ?? []).map((window) => window.key);
5662
5662
  }
5663
5663
  //#endregion
5664
+ //#region src/coverage/spec-id.ts
5665
+ /**
5666
+ * `<runId>.<feature>/<spec>`. The run id keeps a stale cookie from an earlier
5667
+ * run out; the spec half is `specKey`, so an id here and a report row name the
5668
+ * same spec the same way.
5669
+ */
5670
+ function specIdFor(runId, ref) {
5671
+ return `${runId}.${specKey(ref)}`;
5672
+ }
5673
+ /**
5674
+ * Inverse of `specIdFor`: the `feature/spec` half, or null when the id was
5675
+ * minted under another run. The runId itself may contain `.`, so the known
5676
+ * prefix is stripped by length, never by splitting on the dot.
5677
+ */
5678
+ function specKeyFromSpecId(specId, runId) {
5679
+ return specId.startsWith(`${runId}.`) ? specId.slice(runId.length + 1) : null;
5680
+ }
5681
+ //#endregion
5664
5682
  //#region src/coverage/contract.ts
5665
5683
  /**
5666
5684
  * The names and shapes ccqa agrees on with the instrumented application.
@@ -7905,14 +7923,6 @@ async function resolveRoot(cwd, declared) {
7905
7923
  function specCoverageDir(reportDir, feature, spec) {
7906
7924
  return join(reportDir, "coverage", feature, spec);
7907
7925
  }
7908
- /**
7909
- * `<runId>.<feature>/<spec>`. The run id keeps a stale cookie from an earlier
7910
- * run out; the spec half is `specKey`, so an id here and a report row name the
7911
- * same spec the same way.
7912
- */
7913
- function specIdFor(runId, ref) {
7914
- return `${runId}.${specKey(ref)}`;
7915
- }
7916
7926
  async function readFrontend(coverageDir, specId) {
7917
7927
  let raw;
7918
7928
  try {
@@ -10321,6 +10331,28 @@ z.object({
10321
10331
  totalUsd: z.number(),
10322
10332
  entries: z.array(SpendEntrySchema)
10323
10333
  });
10334
+ /**
10335
+ * The coverage-edge ledger: per spec, the most recent measured reach — the
10336
+ * input measured spec selection intersects a diff with (ADR-0026). One
10337
+ * document per project; entries never expire, they are only replaced by a
10338
+ * newer measurement. `measuredAt` is stamped by the hub on merge, so entries
10339
+ * written by different runs stay comparable on one clock.
10340
+ */
10341
+ const CoverageEdgeEntrySchema = z.object({
10342
+ files: z.array(z.string()),
10343
+ measuredAt: z.number(),
10344
+ runId: z.string().optional()
10345
+ });
10346
+ const CoverageEdgesDocSchema = z.object({ specs: z.record(z.string(), CoverageEdgeEntrySchema) });
10347
+ /**
10348
+ * Body of `PUT /projects/:project/coverage-edges` — the specs one run
10349
+ * measured. Merged into the stored document, never replacing other specs'
10350
+ * entries; an empty file set is not a measurement and is rejected per entry.
10351
+ */
10352
+ const CoverageEdgesUpsertSchema = z.object({ specs: z.record(z.string(), z.object({
10353
+ files: z.array(z.string()).min(1),
10354
+ runId: z.string().optional()
10355
+ })).refine((specs) => Object.keys(specs).length > 0, "at least one spec entry is required") });
10324
10356
  //#endregion
10325
10357
  //#region src/run/hub-selection.ts
10326
10358
  /**
@@ -11719,8 +11751,9 @@ function enrichZodError(error, source) {
11719
11751
  * means that spec must re-run — reach cannot see the test's own definition,
11720
11752
  * so no measurement is consulted for it. Everything else intersects the diff
11721
11753
  * with the files the spec's last measured run actually reached (ADR-0024);
11722
- * a spec with no measurement stays `unknown`, because an unmeasured edge is
11723
- * not an unreached one.
11754
+ * a spec with no measurement is `needed` it runs until a measurement
11755
+ * lands, which is also what records its first edge (ADR-0026). Only when
11756
+ * the measurements could not be read does absence degrade to `unknown`.
11724
11757
  */
11725
11758
  async function selectSpecs(input) {
11726
11759
  const { changed, specs, cwd, base, head, edges } = input;
@@ -11815,27 +11848,37 @@ function isCcqaPath(path) {
11815
11848
  /**
11816
11849
  * Hold each undecided spec's last measured reach against the diff.
11817
11850
  *
11818
- * Three outcomes, and only the middle one is a positive claim: no
11819
- * measurement means `unknown` (an unmeasured edge is not an unreached one
11820
- * the absence of evidence runs the spec); a non-empty intersection means
11821
- * `needed`, with the intersecting paths as the reason; an empty one means
11822
- * `notNeeded` — the measurement accounts for everything the spec reached,
11823
- * and the diff missed all of it. Changes outside the measured root fall out
11851
+ * Three outcomes, and only one is a positive claim: a non-empty intersection
11852
+ * means `needed`, with the intersecting paths as the reason; an empty one
11853
+ * means `notNeeded` — the measurement accounts for everything the spec
11854
+ * reached, and the diff missed all of it; no measurement at all is also
11855
+ * `needed` — the spec runs until a measurement records its reach (ADR-0026)
11856
+ * unless the read was degraded, in which case absence proves nothing and
11857
+ * the spec is left `unknown`. Changes outside the measured root fall out
11824
11858
  * of the comparison entirely: the root is the declared boundary of what
11825
11859
  * measurement governs, so what lies beyond it clears specs quietly — one
11826
11860
  * warning names the dropped paths, because a root configured too narrow
11827
11861
  * looks exactly like this and hides real reach (see docs/coverage.md).
11828
11862
  */
11829
11863
  async function judgeWithCoverage(input) {
11830
- const { pending, productChanges, cwd, edges } = input;
11831
- const noMeasurement = "no measurement to consult: the hub holds no measured reach for this spec";
11832
- if (edges.size === 0) return pending.map((s) => unknownSelection(s, noMeasurement));
11864
+ const { pending, productChanges, cwd } = input;
11865
+ const { edges, degraded } = input.edges;
11866
+ const unreadable = "the hub's measured reach could not be read; not guessing";
11833
11867
  const measuredChanges = rerootChangesForCoverage(productChanges, await resolveCoverageRoots(productChanges, cwd));
11834
11868
  const dropped = productChanges.length - measuredChanges.length;
11835
11869
  if (dropped > 0) warn(`select-specs: ${dropped} of ${productChanges.length} changed files fall outside coverage.projectRoot and cannot be compared against measured reach`);
11836
11870
  return pending.map((spec) => {
11837
11871
  const edge = edges.get(specKey(spec));
11838
- if (!edge) return unknownSelection(spec, noMeasurement);
11872
+ if (!edge) {
11873
+ if (degraded) return unknownSelection(spec, unreadable);
11874
+ return {
11875
+ featureName: spec.featureName,
11876
+ specName: spec.specName,
11877
+ verdict: "needed",
11878
+ source: "coverage",
11879
+ reason: "never measured: the spec runs until a measurement records its reach"
11880
+ };
11881
+ }
11839
11882
  const touchedBy = measuredChanges.filter((c) => edge.files.has(c.measured)).map((c) => c.original);
11840
11883
  if (touchedBy.length > 0) return {
11841
11884
  featureName: spec.featureName,
@@ -11911,46 +11954,74 @@ async function resolveCoverageRoots(changed, cwd) {
11911
11954
  //#endregion
11912
11955
  //#region src/select/coverage-edges.ts
11913
11956
  /**
11914
- * How many recent hub runs are probed for report-row coverage. Bounded
11915
- * because every probe downloads a whole report.json; past this many runs a
11916
- * measurement is old enough that treating it as absent — `unknown`, so the
11917
- * spec runs — is the safer answer anyway. The stream side carries its own
11918
- * bound: the hub lists at most its newest twenty measured runs.
11957
+ * How many recent hub runs are probed for legacy report-row coverage.
11958
+ * Bounded because every probe downloads a whole report.json; anything this
11959
+ * far back has usually been superseded by the ledger anyway.
11919
11960
  */
11920
11961
  const MAX_REPORT_RUNS = 20;
11921
11962
  /**
11922
- * How old a measurement may be and still decide a spec. The same fourteen
11923
- * days the stream store retains events for (`COVERAGE_RETENTION_DAYS`): past
11924
- * it an edge is too stale to clear a spec with confidence, so it is not
11925
- * adopted and the spec degrades to `unknown` — which runs.
11926
- */
11927
- const EDGE_MAX_AGE_MS = 336 * 60 * 60 * 1e3;
11928
- /**
11929
- * Read every spec's most recent measured reach from the hub.
11930
- *
11931
- * Never throws: a hub that cannot be read yields an empty map (warned), which
11932
- * the selection degrades to `unknown` across the board — the caller runs
11933
- * those specs, so an unreadable hub costs runs, never a skipped regression.
11963
+ * Read every spec's most recent measured reach from the hub. Never throws: a
11964
+ * source that cannot be read warns, and `degraded` flips when the failure
11965
+ * leaves absence ambiguous (the ledger itself, or the legacy sources while
11966
+ * no ledger answers).
11934
11967
  */
11935
11968
  async function loadCoverageEdges(input) {
11936
- const edges = /* @__PURE__ */ new Map();
11937
- const freshAfter = Date.now() - EDGE_MAX_AGE_MS;
11938
- const merge = (key, edge) => {
11939
- if (edge.measuredAt < freshAfter) return;
11940
- const existing = edges.get(key);
11941
- if (!existing || edge.measuredAt > existing.measuredAt) edges.set(key, edge);
11942
- };
11943
- const results = await Promise.allSettled([collectStreamEdges(input, merge), collectReportEdges(input, merge)]);
11969
+ if (input == null) return {
11970
+ edges: /* @__PURE__ */ new Map(),
11971
+ degraded: true
11972
+ };
11973
+ const candidates = /* @__PURE__ */ new Map();
11974
+ const merge = (key, candidate) => {
11975
+ const existing = candidates.get(key);
11976
+ if (!existing || candidate.measuredAt > existing.measuredAt) candidates.set(key, candidate);
11977
+ };
11978
+ const [ledger, ...legacy] = await Promise.allSettled([
11979
+ collectLedgerEdges(input, merge),
11980
+ collectStreamEdges(input, merge),
11981
+ collectReportEdges(input, merge)
11982
+ ]);
11944
11983
  let skipped = 0;
11945
- for (const result of results) if (result.status === "rejected") warn(`select-specs: could not read coverage measurements from the hub (${errMessage(result.reason)})`);
11946
- else skipped += result.value;
11947
- if (skipped > 0) warn(`select-specs: ${skipped} measured run(s) on the hub could not be read; their reach is treated as absent`);
11948
- return edges;
11984
+ let legacyBroken = false;
11985
+ for (const result of legacy) if (result.status === "rejected") {
11986
+ legacyBroken = true;
11987
+ warn(`select-specs: could not read coverage measurements from the hub (${errMessage(result.reason)})`);
11988
+ } else skipped += result.value;
11989
+ if (skipped > 0) {
11990
+ legacyBroken = true;
11991
+ warn(`select-specs: ${skipped} measured run(s) on the hub could not be read; treating the measurements as unreadable rather than absent`);
11992
+ }
11993
+ if (ledger.status === "rejected") warn(`select-specs: could not read the hub's coverage-edge ledger (${errMessage(ledger.reason)})`);
11994
+ const ledgerAnswered = ledger.status === "fulfilled" && ledger.value;
11995
+ const degraded = ledger.status === "rejected" || !ledgerAnswered && legacyBroken;
11996
+ return {
11997
+ edges: new Map([...candidates].map(([key, c]) => [key, {
11998
+ files: new Set(c.files),
11999
+ measuredAt: c.measuredAt
12000
+ }])),
12001
+ degraded
12002
+ };
12003
+ }
12004
+ /**
12005
+ * The ledger itself; true when a document answered. A 404 is an older hub or
12006
+ * a project that never wrote one, not a failure: the legacy sources answer.
12007
+ */
12008
+ async function collectLedgerEdges(input, merge) {
12009
+ const doc = await input.hub.getCoverageEdges(input.project);
12010
+ if (doc === null) return false;
12011
+ for (const [key, entry] of Object.entries(doc.specs)) {
12012
+ if (entry.files.length === 0) continue;
12013
+ merge(key, {
12014
+ files: entry.files,
12015
+ measuredAt: entry.measuredAt
12016
+ });
12017
+ }
12018
+ return true;
11949
12019
  }
11950
12020
  /**
11951
- * Edges from the coverage event stream. The plain read answers for the most
11952
- * recently measured run and lists every run the stream retains; each older
11953
- * run is then resolved individually. Returns how many runs could not be read.
12021
+ * Legacy: edges from the coverage event stream. The plain read answers for
12022
+ * the most recently measured run and lists every run the stream retains;
12023
+ * each older run is then resolved individually. Returns how many runs could
12024
+ * not be read.
11954
12025
  */
11955
12026
  async function collectStreamEdges(input, merge) {
11956
12027
  const { hub, project } = input;
@@ -11962,24 +12033,16 @@ async function collectStreamEdges(input, merge) {
11962
12033
  function ingestResolved(resolved, merge) {
11963
12034
  if (!resolved) return;
11964
12035
  for (const spec of resolved.specs) {
11965
- const key = stripRunIdPrefix(spec.specId, resolved.runId);
12036
+ const key = specKeyFromSpecId(spec.specId, resolved.runId);
11966
12037
  if (key === null) continue;
11967
12038
  if (spec.files.length === 0) continue;
11968
12039
  merge(key, {
11969
- files: new Set(spec.files),
12040
+ files: spec.files,
11970
12041
  measuredAt: resolved.asOf
11971
12042
  });
11972
12043
  }
11973
12044
  }
11974
12045
  /**
11975
- * A stream specId is `<runId>.<feature>/<spec>` (src/coverage/session.ts).
11976
- * The runId itself may contain `.`, so the known prefix is stripped by
11977
- * length, never by splitting on the dot.
11978
- */
11979
- function stripRunIdPrefix(specId, runId) {
11980
- return specId.startsWith(`${runId}.`) ? specId.slice(runId.length + 1) : null;
11981
- }
11982
- /**
11983
12046
  * The one slice of report.json this consumer reads. Parsed with its own
11984
12047
  * narrow schema rather than the full report schema so a report from another
11985
12048
  * ccqa version still yields its edges as long as this shape holds.
@@ -11990,10 +12053,10 @@ const ReportCoverageRowsSchema = z.object({ results: z.array(z.object({
11990
12053
  coverage: z.object({ files: z.array(z.string()) }).optional()
11991
12054
  })) });
11992
12055
  /**
11993
- * Edges from pushed run reports, newest first. Only `kind: run` runs are
11994
- * probed — audits and recordings execute no specs, so they carry no reach —
11995
- * and a still-`running` run is skipped: its rows are still arriving, so its
11996
- * measurement is not settled. Returns how many reports could not be read.
12056
+ * Legacy: edges from pushed run reports, newest first. Only `kind: run` runs
12057
+ * are probed — audits and recordings execute no specs and a still-`running`
12058
+ * run is skipped: its rows are still arriving. Returns how many reports
12059
+ * could not be read.
11997
12060
  */
11998
12061
  async function collectReportEdges(input, merge) {
11999
12062
  const { hub, project } = input;
@@ -12016,7 +12079,7 @@ async function collectReportEdges(input, merge) {
12016
12079
  for (const row of parsed.data.results) {
12017
12080
  if (!row.coverage || row.coverage.files.length === 0) continue;
12018
12081
  merge(`${row.feature}/${row.spec}`, {
12019
- files: new Set(row.coverage.files),
12082
+ files: row.coverage.files,
12020
12083
  measuredAt
12021
12084
  });
12022
12085
  }
@@ -15641,7 +15704,7 @@ const CONFIG_NAMES = [
15641
15704
  "playwright.config.cjs"
15642
15705
  ];
15643
15706
  async function acquirePlaywrightBrowser(ctx) {
15644
- await sweepStaleWrappers(ctx.cwd);
15707
+ await sweepStaleWrappersOnce(ctx.cwd);
15645
15708
  const chromium = await resolveChromium(ctx.cwd);
15646
15709
  const port = await freePort();
15647
15710
  const server = await chromium.launchServer({ args: [`--remote-debugging-port=${port}`] });
@@ -15672,9 +15735,26 @@ async function acquirePlaywrightBrowser(ctx) {
15672
15735
  };
15673
15736
  }
15674
15737
  /**
15675
- * Wrappers a killed earlier run left behind. Deleted on the next acquire, not
15676
- * only guarded against: a stray one is git-status dirt in somebody's repo.
15738
+ * Wrappers a killed earlier run left behind. Deleted before this process
15739
+ * writes its first one, not only guarded against: a stray one is git-status
15740
+ * dirt in somebody's repo.
15741
+ *
15742
+ * Once per cwd per process, never per acquire: every live wrapper matches
15743
+ * the stale pattern, so under `--concurrency` a per-acquire sweep deletes
15744
+ * the wrapper a parallel spec just wrote and is about to hand to
15745
+ * `playwright test`, killing that spec before it starts. The memo makes
15746
+ * concurrent first acquires share one sweep that finished before either of
15747
+ * them wrote anything.
15677
15748
  */
15749
+ const sweptCwds = /* @__PURE__ */ new Map();
15750
+ function sweepStaleWrappersOnce(cwd) {
15751
+ let sweep = sweptCwds.get(cwd);
15752
+ if (!sweep) {
15753
+ sweep = sweepStaleWrappers(cwd);
15754
+ sweptCwds.set(cwd, sweep);
15755
+ }
15756
+ return sweep;
15757
+ }
15678
15758
  async function sweepStaleWrappers(cwd) {
15679
15759
  const entries = await readdir(cwd).catch(() => []);
15680
15760
  for (const name of entries) if (name.startsWith("ccqa-coverage.") && name.endsWith(".playwright.config.ts")) await unlink(join(cwd, name)).catch(() => void 0);
@@ -16626,22 +16706,21 @@ async function collectChangedSpecs(specs, opts) {
16626
16706
  specs: [],
16627
16707
  base: resolved
16628
16708
  };
16709
+ if (!hub) warn(`${flag}: no hub connection, so coverage measurements cannot be consulted — undecided specs will run`);
16710
+ const edgesPromise = loadCoverageEdges(hub);
16629
16711
  let inventory;
16630
16712
  try {
16631
16713
  inventory = await loadSpecInventory(cwd);
16632
16714
  } catch (e) {
16633
16715
  throw new RunUsageError(e.message);
16634
16716
  }
16635
- let edges = /* @__PURE__ */ new Map();
16636
- if (hub) edges = await loadCoverageEdges(hub);
16637
- else warn(`${flag}: no hub connection, so coverage measurements cannot be consulted — undecided specs will run`);
16638
16717
  const report = await selectSpecs({
16639
16718
  changed,
16640
16719
  specs: inventory,
16641
16720
  cwd,
16642
16721
  base: resolved.sha,
16643
16722
  head: "HEAD",
16644
- edges
16723
+ edges: await edgesPromise
16645
16724
  });
16646
16725
  const toRun = new Set(specsToRun(report).map(specKey));
16647
16726
  const undecided = report.specs.filter((s) => s.verdict === "unknown").length;
@@ -16652,53 +16731,6 @@ async function collectChangedSpecs(specs, opts) {
16652
16731
  };
16653
16732
  }
16654
16733
  //#endregion
16655
- //#region src/run/measure-backfill.ts
16656
- /**
16657
- * `ccqa run --measure-backfill <n>`: keep the measured-reach edges alive.
16658
- *
16659
- * Selection (ADR-0024) consumes each spec's most recent measured reach, and
16660
- * an edge expires after `EDGE_MAX_AGE_MS`. Nothing else re-measures: an
16661
- * unmeasured spec answers `unknown`, `unknown` marks nothing due (ADR-0023),
16662
- * and a suite can settle into a state where no run ever fires — the seed
16663
- * deadlock. Appending a few unmeasured-or-aging specs to every selected run
16664
- * breaks that loop and keeps the whole suite inside the freshness window
16665
- * without a scheduled full sweep.
16666
- */
16667
- /**
16668
- * Re-measure once an edge has spent half its lifetime. Half, not "expired":
16669
- * a spec re-measured only after expiry answers `unknown` for the gap between
16670
- * expiry and the next run, which is exactly the window this flag exists to
16671
- * close.
16672
- */
16673
- const REMEASURE_AFTER_MS = EDGE_MAX_AGE_MS / 2;
16674
- /**
16675
- * Picks up to `limit` specs from `inventory` worth re-measuring: ones with no
16676
- * edge at all first (they cost an `unknown` verdict today), then the oldest
16677
- * measured ones. Specs already selected for this run are never doubled.
16678
- */
16679
- function chooseMeasureBackfill(inventory, selected, edges, limit, now) {
16680
- const alreadyRunning = new Set(selected.map(specKey));
16681
- const missing = [];
16682
- const aging = [];
16683
- for (const spec of inventory) {
16684
- if (alreadyRunning.has(specKey(spec))) continue;
16685
- const edge = edges.get(specKey(spec));
16686
- if (edge === void 0) missing.push(spec);
16687
- else if (now - edge.measuredAt > REMEASURE_AFTER_MS) aging.push({
16688
- spec,
16689
- measuredAt: edge.measuredAt
16690
- });
16691
- }
16692
- aging.sort((a, b) => a.measuredAt - b.measuredAt);
16693
- const specs = [...missing, ...aging.map((entry) => entry.spec)].slice(0, limit);
16694
- const missingTaken = Math.min(missing.length, specs.length);
16695
- return {
16696
- specs,
16697
- missing: missingTaken,
16698
- aging: specs.length - missingTaken
16699
- };
16700
- }
16701
- //#endregion
16702
16734
  //#region src/run/pipeline.ts
16703
16735
  async function resolveVitestConfig(cwd) {
16704
16736
  const userConfig = resolve(cwd, ".ccqa/vitest.config.ts");
@@ -16877,10 +16909,6 @@ async function executeRun(targets, opts) {
16877
16909
  if (rerunProfile !== null && hubCtx == null) throw new RunUsageError(needsHubConnection("--only-hub-rerun-needed"));
16878
16910
  if (opts.reportToHub && hubCtx == null) throw new RunUsageError(REPORT_TO_HUB_NEEDS_CONNECTION);
16879
16911
  if (opts.learnHubLivePrompt && hubCtx == null) throw new RunUsageError(needsHubConnection("--learn-hub-live-prompt"));
16880
- if ((opts.measureBackfill ?? 0) > 0) {
16881
- if (opts.coverage !== true) throw new RunUsageError("--measure-backfill does nothing without --coverage — there is no measurement to keep fresh");
16882
- if (!filtering) throw new RunUsageError("--measure-backfill needs a selection flag (--only-hub-rerun-needed / --only-affected-by); an explicit spec list runs exactly what was asked");
16883
- }
16884
16912
  let coverageInbox;
16885
16913
  if (opts.coverageInbox === "hub") {
16886
16914
  if (opts.coverage !== true) throw new RunUsageError("--coverage-inbox hub does nothing without --coverage — there is no measurement to stream");
@@ -16929,7 +16957,6 @@ async function executeRun(targets, opts) {
16929
16957
  };
16930
16958
  const enumerateAll = () => listAllSpecsWithSpecFile(cwd);
16931
16959
  let specs = dedupeSpecs((await Promise.all((targets.length ? targets : [void 0]).map((t) => resolveSpecTargets(t, enumerateAll, cwd)))).flat());
16932
- const inventory = specs;
16933
16960
  if (filtering) {
16934
16961
  const before = specs.length;
16935
16962
  let inProgress = 0;
@@ -16960,14 +16987,6 @@ async function executeRun(targets, opts) {
16960
16987
  throw new RunUsageError("nothing was selected and no spec was cleared to run: exiting non-zero rather than reporting a green run that verified nothing");
16961
16988
  }
16962
16989
  }
16963
- if (filtering && (opts.measureBackfill ?? 0) > 0 && hubCtx != null) {
16964
- const edges = await loadCoverageEdges(hubCtx);
16965
- const pick = chooseMeasureBackfill(inventory, specs, edges, opts.measureBackfill ?? 0, Date.now());
16966
- if (pick.specs.length > 0) {
16967
- specs = [...specs, ...pick.specs];
16968
- meta("measure-backfill", `${pick.specs.length} spec(s) appended (${pick.missing} unmeasured / ${pick.aging} aging)`);
16969
- }
16970
- }
16971
16990
  if (specs.length === 0) {
16972
16991
  warn("no specs to run");
16973
16992
  return {
@@ -17134,8 +17153,9 @@ async function executeRun(targets, opts) {
17134
17153
  report: incrementalReport
17135
17154
  };
17136
17155
  const live = await runLiveSpecs(liveSpecs, liveOpts);
17156
+ let streamedEdges = {};
17137
17157
  if (coverage && !coverage.streamsToHub) reportCoverageHealth(coverage, [...externalRows, ...live.reportResults]);
17138
- else if (coverage && hubCtx != null) await reportStreamedCoverageHealth(coverage, hubCtx);
17158
+ else if (coverage && hubCtx != null) streamedEdges = await reportStreamedCoverageHealth(coverage, hubCtx);
17139
17159
  let overallExitCode = det.exitCode !== 0 ? 1 : 0;
17140
17160
  if (live.failedCount > 0) overallExitCode = 1;
17141
17161
  if (externalRows.some((r) => r.status === "failed")) overallExitCode = 1;
@@ -17179,6 +17199,10 @@ async function executeRun(targets, opts) {
17179
17199
  coverage
17180
17200
  });
17181
17201
  completedNormally = true;
17202
+ if (coverage && hubCtx != null) {
17203
+ if (coverage.streamsToHub) await upsertMeasuredEdges(hubCtx, streamedEdges);
17204
+ else if (hubRunId != null && coverage.heardFromApplication()) await upsertMeasuredEdges(hubCtx, Object.fromEntries(report.results.filter((row) => (row.coverage?.files.length ?? 0) > 0).map((row) => [`${row.feature}/${row.spec}`, { files: row.coverage.files }])));
17205
+ }
17182
17206
  if (hubRunId) {
17183
17207
  const finalStatus = overallExitCode === 0 ? "passed" : "failed";
17184
17208
  const reportMeta = buildReportEnvelope({
@@ -17289,6 +17313,10 @@ function explainMissingCoverage(row) {
17289
17313
  * Best-effort — the measurement already left as events, so a failed read-out
17290
17314
  * loses visibility, never data. Application pushes may still land for
17291
17315
  * `GRACE_MS` after the last window closed, so the counts here are a floor.
17316
+ *
17317
+ * Returns the specs the resolve measured, keyed `feature/spec` — the caller
17318
+ * merges them into the ledger alongside the local mode's rows, so the "run
17319
+ * end records measured reach" step lives in one place for both modes.
17292
17320
  */
17293
17321
  async function reportStreamedCoverageHealth(coverage, hubCtx) {
17294
17322
  await new Promise((resolve) => setTimeout(resolve, 3e3));
@@ -17297,11 +17325,11 @@ async function reportStreamedCoverageHealth(coverage, hubCtx) {
17297
17325
  resolved = (await hubCtx.hub.getCoverage(hubCtx.project, { runId: coverage.streamRunId })).resolved;
17298
17326
  } catch (error) {
17299
17327
  warn(`coverage: could not read this run's resolve from the hub (${errMessage(error)})`);
17300
- return;
17328
+ return {};
17301
17329
  }
17302
17330
  if (resolved == null) {
17303
17331
  warn("coverage: the hub resolved nothing for this run — its events never reached the stream, so every spec's measured reach is absent");
17304
- return;
17332
+ return {};
17305
17333
  }
17306
17334
  const measured = resolved.specs.filter((spec) => spec.files.length > 0);
17307
17335
  const empty = resolved.specs.filter((spec) => spec.files.length === 0);
@@ -17320,6 +17348,30 @@ async function reportStreamedCoverageHealth(coverage, hubCtx) {
17320
17348
  const outside = Object.entries(h.outsideWindowEvents);
17321
17349
  if (outside.length > 0) trouble.push(`outside-window=${outside.map(([key, count]) => `${key}:${count}`).join(",")}`);
17322
17350
  if (trouble.length > 0) warn(`coverage: stream health flags — ${trouble.join(" ")}`);
17351
+ if (!h.heardFromApplication) return {};
17352
+ return Object.fromEntries(measured.flatMap((spec) => {
17353
+ const key = specKeyFromSpecId(spec.specId, resolved.runId);
17354
+ return key === null ? [] : [[key, {
17355
+ files: spec.files,
17356
+ runId: resolved.runId
17357
+ }]];
17358
+ }));
17359
+ }
17360
+ /**
17361
+ * Merge what this run measured into the hub's coverage-edge ledger
17362
+ * (ADR-0026) — the document selection reads. Best-effort: the measurement
17363
+ * also lives in the stream or the report, so a failed merge loses freshness,
17364
+ * not data. A hub without the endpoint (predating it) warns once and moves on.
17365
+ */
17366
+ async function upsertMeasuredEdges(hubCtx, specs) {
17367
+ const count = Object.keys(specs).length;
17368
+ if (count === 0) return;
17369
+ try {
17370
+ await hubCtx.hub.putCoverageEdges(hubCtx.project, { specs });
17371
+ meta("coverage", `${count} spec edge(s) recorded on the hub's ledger`);
17372
+ } catch (error) {
17373
+ warn(`coverage: could not record measured edges on the hub (${errMessage(error)})`);
17374
+ }
17323
17375
  }
17324
17376
  /** Everything the measurement could not place; silence here reads as "never reached". */
17325
17377
  function reportCoverageHealth(coverage, rows) {
@@ -17940,11 +17992,7 @@ const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command(
17940
17992
  }, "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.").option("--coverage-inbox <where>", "With --coverage: where the measurement's two sides meet. 'local' (default) binds a loopback inbox on this machine for the run's duration; 'hub' appends every event to the hub's durable coverage inbox instead — nothing listens on the runner, report.json carries no coverage, and the hub resolves per-spec results on read (requires a hub connection).", (raw) => {
17941
17993
  if (COVERAGE_INBOX_MODES.includes(raw)) return raw;
17942
17994
  throw new Error(`--coverage-inbox must be one of ${COVERAGE_INBOX_MODES.join(" | ")}`);
17943
- }, "local").option("--measure-backfill <n>", "With --coverage and a selection flag (--only-hub-rerun-needed / --only-affected-by): also run up to <n> specs whose measured reach is missing or past half its freshness window, unmeasured-first then oldest-first. This is what keeps selection's edges alive without it an unmeasured spec answers `unknown` forever, because `unknown` marks nothing due and nothing else re-measures.", (raw) => {
17944
- const n = Number(raw);
17945
- if (!Number.isInteger(n) || n < 0) throw new Error("--measure-backfill must be a non-negative integer");
17946
- return n;
17947
- }).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) => {
17995
+ }, "local").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) => {
17948
17996
  await runCliAction(targets, opts);
17949
17997
  });
17950
17998
  /** Parse --concurrency: a positive integer. Rejects 0, negatives, non-integers. */
@@ -21503,7 +21551,7 @@ async function runSelectSpecs(opts) {
21503
21551
  project: opts.project,
21504
21552
  cwd: opts.cwd
21505
21553
  });
21506
- const [specsResult, changedResult, edges] = await Promise.all([
21554
+ const [specsResult, changedResult, edgesReadout] = await Promise.all([
21507
21555
  loadSpecInventory(cwd).then((specs) => ({
21508
21556
  ok: true,
21509
21557
  specs
@@ -21543,7 +21591,7 @@ async function runSelectSpecs(opts) {
21543
21591
  meta("project", project);
21544
21592
  meta("changed-files", changed.length);
21545
21593
  meta("specs", specs.length);
21546
- meta("measured-specs", edges.size);
21594
+ meta("measured-specs", edgesReadout.edges.size);
21547
21595
  }
21548
21596
  const report = await selectSpecs({
21549
21597
  changed,
@@ -21551,7 +21599,7 @@ async function runSelectSpecs(opts) {
21551
21599
  cwd,
21552
21600
  base: opts.base,
21553
21601
  head,
21554
- edges
21602
+ edges: edgesReadout
21555
21603
  });
21556
21604
  process.stdout.write(format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderText(report));
21557
21605
  process.exit(0);
@@ -23471,6 +23519,32 @@ function requireSinceSeqParam(url) {
23471
23519
  if (!Number.isInteger(value) || value < 0) throw new HttpError(400, "invalid_param", "invalid sinceSeq: must be a non-negative integer");
23472
23520
  return value;
23473
23521
  }
23522
+ const MAX_EDGES_BODY_BYTES = 8 * 1024 * 1024;
23523
+ /**
23524
+ * PUT /api/v1/projects/:project/coverage-edges — the specs one run measured,
23525
+ * merged into the ledger newest-wins. `measuredAt` is stamped here with the
23526
+ * hub's clock, so entries written by different runners stay comparable.
23527
+ * Bearer-authenticated like every project route; the append-only coverage
23528
+ * token cannot write here.
23529
+ */
23530
+ function createPutCoverageEdgesHandler(config) {
23531
+ return async (ctx) => {
23532
+ const project = requireSafeSegment(ctx.params.project, "project");
23533
+ const body = await readJsonBody(ctx.req, MAX_EDGES_BODY_BYTES, CoverageEdgesUpsertSchema, "coverage-edges body");
23534
+ await config.store.merge(project, body.specs, Date.now());
23535
+ ctx.res.statusCode = 204;
23536
+ ctx.res.end();
23537
+ };
23538
+ }
23539
+ /** GET /api/v1/projects/:project/coverage-edges — the ledger, or 404. */
23540
+ function createGetCoverageEdgesHandler(config) {
23541
+ return async (ctx) => {
23542
+ const project = requireSafeSegment(ctx.params.project, "project");
23543
+ const doc = await config.store.get(project);
23544
+ if (doc === null) throw new HttpError(404, "not_found", `no coverage edges stored for project "${project}"`);
23545
+ sendJson(ctx.res, 200, doc);
23546
+ };
23547
+ }
23474
23548
  //#endregion
23475
23549
  //#region src/hub/core/rerun.ts
23476
23550
  /**
@@ -30781,6 +30855,9 @@ function registerRoutes(router, config, queue) {
30781
30855
  router.get("/api/v1/projects/:project/perspectives", createGetPerspectivesHandler(perspectivesConfig));
30782
30856
  router.patch("/api/v1/projects/:project/perspectives", createPatchPerspectivesNoteHandler(perspectivesConfig));
30783
30857
  router.delete("/api/v1/projects/:project/perspectives", createDeletePerspectivesHandler(perspectivesConfig));
30858
+ const coverageEdgesConfig = { store: storage.coverageEdges };
30859
+ router.put("/api/v1/projects/:project/coverage-edges", createPutCoverageEdgesHandler(coverageEdgesConfig));
30860
+ router.get("/api/v1/projects/:project/coverage-edges", createGetCoverageEdgesHandler(coverageEdgesConfig));
30784
30861
  const coverageConfig = {
30785
30862
  store: storage.coverageEvents,
30786
30863
  encryptionKey: config.encryptionKey,
@@ -30886,6 +30963,9 @@ function perspectivesKindDir(root) {
30886
30963
  function perspectivesPath(root, project) {
30887
30964
  return join(perspectivesKindDir(root), `${project}.json`);
30888
30965
  }
30966
+ function coverageEdgesPath(root, project) {
30967
+ return join(root, "coverage-edges", `${project}.json`);
30968
+ }
30889
30969
  function ledgerProfileDir(root, project, profile) {
30890
30970
  return join(root, "last-green", project, profile);
30891
30971
  }
@@ -31387,6 +31467,47 @@ function createFileSpecLedgerStore(root) {
31387
31467
  };
31388
31468
  }
31389
31469
  //#endregion
31470
+ //#region src/hub/core/storage/file/coverage-edge-store.ts
31471
+ /**
31472
+ * Coverage-edge ledger (ADR-0026): one JSON document per project. `merge`
31473
+ * goes through `updateJson`, so two runs finishing at once queue their
31474
+ * read-modify-writes instead of clobbering each other, and each entry only
31475
+ * ever moves forward — a run's measurement replaces a spec's entry, never
31476
+ * deletes another spec's.
31477
+ */
31478
+ function createFileCoverageEdgeStore(root) {
31479
+ return {
31480
+ async get(project) {
31481
+ assertSafeName(project, "project");
31482
+ const raw = await readJson(coverageEdgesPath(root, project));
31483
+ return raw === null ? null : parseDoc(raw, project);
31484
+ },
31485
+ async merge(project, specs, measuredAt) {
31486
+ assertSafeName(project, "project");
31487
+ await updateJson(coverageEdgesPath(root, project), (current) => {
31488
+ const doc = current === null ? { specs: {} } : parseDoc(current, project);
31489
+ for (const [key, entry] of Object.entries(specs)) doc.specs[key] = {
31490
+ files: [...entry.files].sort(),
31491
+ measuredAt,
31492
+ ...entry.runId === void 0 ? {} : { runId: entry.runId }
31493
+ };
31494
+ return doc;
31495
+ });
31496
+ }
31497
+ };
31498
+ }
31499
+ /**
31500
+ * A present document that does not parse is an error, never an empty ledger:
31501
+ * treating it as empty would let the next merge silently discard every other
31502
+ * spec's edge, and the data is regenerated only by running every spec
31503
+ * measured again.
31504
+ */
31505
+ function parseDoc(raw, project) {
31506
+ const parsed = CoverageEdgesDocSchema.safeParse(raw);
31507
+ if (!parsed.success) throw new Error(`coverage-edges document for project "${project}" does not match the schema`);
31508
+ return parsed.data;
31509
+ }
31510
+ //#endregion
31390
31511
  //#region src/hub/core/storage/file/perspectives-store.ts
31391
31512
  /**
31392
31513
  * Perspectives storage: one JSON document per project, plain UTF-8 with no
@@ -31693,6 +31814,7 @@ function createFileHubStorage(dataDir) {
31693
31814
  triage: createFileTriageStore(dataDir),
31694
31815
  prompts: createFilePromptStore(dataDir),
31695
31816
  perspectives: createFilePerspectivesStore(dataDir),
31817
+ coverageEdges: createFileCoverageEdgeStore(dataDir),
31696
31818
  jobs: createFileJobStore(dataDir),
31697
31819
  ledger: createFileSpecLedgerStore(dataDir),
31698
31820
  driftLedger: createFileDriftLedgerStore(dataDir),
@@ -547,6 +547,26 @@ declare const SpendLogResponseSchema: z.ZodObject<{
547
547
  }, z.core.$strip>>;
548
548
  }, z.core.$strip>;
549
549
  type SpendLogResponse = z.infer<typeof SpendLogResponseSchema>;
550
+ declare const CoverageEdgesDocSchema: z.ZodObject<{
551
+ specs: z.ZodRecord<z.ZodString, z.ZodObject<{
552
+ files: z.ZodArray<z.ZodString>;
553
+ measuredAt: z.ZodNumber;
554
+ runId: z.ZodOptional<z.ZodString>;
555
+ }, z.core.$strip>>;
556
+ }, z.core.$strip>;
557
+ type CoverageEdgesDoc = z.infer<typeof CoverageEdgesDocSchema>;
558
+ /**
559
+ * Body of `PUT /projects/:project/coverage-edges` — the specs one run
560
+ * measured. Merged into the stored document, never replacing other specs'
561
+ * entries; an empty file set is not a measurement and is rejected per entry.
562
+ */
563
+ declare const CoverageEdgesUpsertSchema: z.ZodObject<{
564
+ specs: z.ZodRecord<z.ZodString, z.ZodObject<{
565
+ files: z.ZodArray<z.ZodString>;
566
+ runId: z.ZodOptional<z.ZodString>;
567
+ }, z.core.$strip>>;
568
+ }, z.core.$strip>;
569
+ type CoverageEdgesUpsert = z.infer<typeof CoverageEdgesUpsertSchema>;
550
570
  //#endregion
551
571
  //#region src/coverage/resolve-stream.d.ts
552
572
  /**
@@ -1159,6 +1179,13 @@ interface HubClient {
1159
1179
  getCoverage(project: string, q?: {
1160
1180
  runId?: string;
1161
1181
  }): Promise<HubCoverageAnswer>;
1182
+ /**
1183
+ * Merge one run's measured reach into the project's coverage-edge ledger
1184
+ * (ADR-0026). The hub stamps `measuredAt` with its own clock.
1185
+ */
1186
+ putCoverageEdges(project: string, upsert: CoverageEdgesUpsert): Promise<void>;
1187
+ /** The coverage-edge ledger, or `null` when nothing is stored (or the hub predates it). */
1188
+ getCoverageEdges(project: string): Promise<CoverageEdgesDoc | null>;
1162
1189
  /**
1163
1190
  * Source maps for what a commit deployed, addressed by the asset path the
1164
1191
  * browser requests. Coverage falls back to these when the build keeps its
@@ -153,6 +153,21 @@ function createHubClient(opts) {
153
153
  runId: q.runId
154
154
  })}`);
155
155
  },
156
+ putCoverageEdges(project, upsert) {
157
+ return request(`/api/v1/projects/${encodeURIComponent(project)}/coverage-edges`, {
158
+ method: "PUT",
159
+ headers: { "Content-Type": "application/json" },
160
+ body: JSON.stringify(upsert)
161
+ }).then(() => void 0);
162
+ },
163
+ async getCoverageEdges(project) {
164
+ try {
165
+ return await json(`/api/v1/projects/${encodeURIComponent(project)}/coverage-edges`);
166
+ } catch (err) {
167
+ if (err instanceof HubApiError && err.status === 404) return null;
168
+ throw err;
169
+ }
170
+ },
156
171
  putSourceMap(project, commit, assetPath, map) {
157
172
  return request(`${sourceMapPath(project, commit)}/${encodeAssetPath(assetPath)}`, {
158
173
  method: "PUT",
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.41.0",
3
+ "version": "1.42.1",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.41.0",
3
+ "version": "1.42.1",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {