ccqa 1.40.8 → 1.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.
@@ -6422,6 +6440,7 @@ var FrontendResolution = class {
6422
6440
  coverageDir;
6423
6441
  roots;
6424
6442
  fetchText;
6443
+ fetchStoredMap;
6425
6444
  warn;
6426
6445
  files = /* @__PURE__ */ new Set();
6427
6446
  /**
@@ -6448,6 +6467,7 @@ var FrontendResolution = class {
6448
6467
  this.coverageDir = opts.coverageDir;
6449
6468
  this.roots = opts.roots;
6450
6469
  this.fetchText = opts.fetchText;
6470
+ this.fetchStoredMap = opts.fetchStoredMap;
6451
6471
  this.warn = opts.warn;
6452
6472
  }
6453
6473
  async absorb(script) {
@@ -6538,24 +6558,46 @@ var FrontendResolution = class {
6538
6558
  async fetchSourceMap(script) {
6539
6559
  const source = await script.source();
6540
6560
  if (source === void 0) return void 0;
6561
+ for (const load of this.sourceMapLoaders(script, source)) {
6562
+ const json = await load();
6563
+ if (json === void 0) continue;
6564
+ const map = parseSourceMap(json);
6565
+ if (map !== void 0) return prepareSourceMap(map, source);
6566
+ }
6567
+ }
6568
+ /**
6569
+ * Ways to get a map for `script`, most authoritative first and none of them
6570
+ * taken until the one before it fails. A map the script points at describes
6571
+ * the code that ran, so the stored copy is only asked for when that is
6572
+ * absent or turns out not to be a map — a catch-all route answers a missing
6573
+ * `.map` with an HTML page, which is a body but not a map. Reading it
6574
+ * eagerly would put a request on the wire for every script of every spec,
6575
+ * nearly all of them misses on a deployment that pushes nothing.
6576
+ */
6577
+ sourceMapLoaders(script, source) {
6578
+ const loaders = [];
6541
6579
  const reference = readSourceMappingUrl(source);
6542
- if (reference === void 0) return void 0;
6543
- let json = decodeInlineSourceMap(reference);
6544
- if (json === void 0) {
6545
- let target;
6546
- try {
6547
- target = new URL(reference, script.url).toString();
6548
- } catch {
6549
- return;
6580
+ if (reference !== void 0) {
6581
+ const inline = decodeInlineSourceMap(reference);
6582
+ if (inline !== void 0) loaders.push(() => Promise.resolve(inline));
6583
+ else {
6584
+ const target = absoluteOrUndefined(reference, script.url);
6585
+ if (target !== void 0) loaders.push(() => this.fetchText(target));
6550
6586
  }
6551
- json = await this.fetchText(target);
6552
- if (json === void 0) return void 0;
6553
6587
  }
6554
- const map = parseSourceMap(json);
6555
- if (map === void 0) return void 0;
6556
- return prepareSourceMap(map, source);
6588
+ const stored = this.fetchStoredMap;
6589
+ if (stored !== void 0) loaders.push(() => stored(script.url));
6590
+ return loaders;
6557
6591
  }
6558
6592
  };
6593
+ /** `reference` resolved against the script's URL, or undefined when it is not a URL. */
6594
+ function absoluteOrUndefined(reference, scriptUrl) {
6595
+ try {
6596
+ return new URL(reference, scriptUrl).toString();
6597
+ } catch {
6598
+ return;
6599
+ }
6600
+ }
6559
6601
  function message$2(error) {
6560
6602
  return error instanceof Error ? error.message : String(error);
6561
6603
  }
@@ -7149,6 +7191,7 @@ var Engine = class {
7149
7191
  coverageDir: opts.coverageDir,
7150
7192
  roots: opts.roots,
7151
7193
  fetchText: (url) => this.fetchThroughBrowser(url),
7194
+ fetchStoredMap: (scriptUrl) => this.storedMapFor(scriptUrl),
7152
7195
  warn: opts.warn
7153
7196
  });
7154
7197
  }
@@ -7414,6 +7457,23 @@ var Engine = class {
7414
7457
  return;
7415
7458
  }
7416
7459
  }
7460
+ /**
7461
+ * The map a deploy stored for `scriptUrl`, if this run knows where to ask.
7462
+ * Addressed by the path under the asset origin rather than the URL, so the
7463
+ * push and the read agree without either knowing the other's host.
7464
+ */
7465
+ async storedMapFor(scriptUrl) {
7466
+ const stored = this.opts.fetchStoredSourceMap;
7467
+ if (stored === void 0) return void 0;
7468
+ const assetPath = assetPathOf(scriptUrl, [...this.opts.origins, ...this.opts.assetOrigins ?? []]);
7469
+ if (assetPath === void 0) return void 0;
7470
+ try {
7471
+ return await stored(`${assetPath}.map`);
7472
+ } catch (err) {
7473
+ this.opts.warn(`could not read the stored source map for ${assetPath}: ${message(err)}`);
7474
+ return;
7475
+ }
7476
+ }
7417
7477
  async readStream(handle, sessionId) {
7418
7478
  const parts = [];
7419
7479
  for (;;) {
@@ -7428,6 +7488,25 @@ var Engine = class {
7428
7488
  function message(error) {
7429
7489
  return error instanceof Error ? error.message : String(error);
7430
7490
  }
7491
+ /**
7492
+ * The path a stored map is filed under: the URL with its origin removed, and
7493
+ * with any deploy-scoped prefix (`/assets/<sha>/`) left in place — that prefix
7494
+ * is part of what the browser asked for, so the push has to have used it too.
7495
+ * A URL from an origin the run never declared is not ours to look up.
7496
+ */
7497
+ function assetPathOf(url, origins) {
7498
+ let parsed;
7499
+ try {
7500
+ parsed = new URL(url);
7501
+ } catch {
7502
+ return;
7503
+ }
7504
+ const path = `${parsed.origin}${parsed.pathname}`;
7505
+ for (const origin of origins) {
7506
+ const base = origin.replace(/\/+$/, "");
7507
+ if (path.startsWith(`${base}/`)) return path.slice(base.length + 1);
7508
+ }
7509
+ }
7431
7510
  //#endregion
7432
7511
  //#region src/coverage/universe.ts
7433
7512
  /**
@@ -7503,8 +7582,20 @@ async function walk(abs, rel, out, warn) {
7503
7582
  const SETTLE_POLL_MS = 250;
7504
7583
  const SETTLE_QUIET_POLLS = 10;
7505
7584
  const SETTLE_CAP_MS = 1e4;
7585
+ /**
7586
+ * Origins with their `${VAR}`s resolved, refusing anything that is not an
7587
+ * absolute http(s) URL. An unset variable resolves to the empty string, which
7588
+ * would otherwise match nothing and take the feature down without a word.
7589
+ */
7590
+ function resolveAbsoluteOrigins(origins, label) {
7591
+ const resolved = origins.map((origin) => resolveEnvRefs(origin));
7592
+ const unresolved = resolved.filter((origin) => !/^https?:\/\//i.test(origin));
7593
+ if (unresolved.length > 0) throw new Error(`${label} must be absolute http(s) URLs after variable substitution; got ${unresolved.join(", ")}`);
7594
+ return resolved;
7595
+ }
7506
7596
  var CoverageSession = class CoverageSession {
7507
7597
  existing = /* @__PURE__ */ new Map();
7598
+ fetchStoredSourceMap;
7508
7599
  /**
7509
7600
  * Undefined in hub mode: nothing binds on the runner, and every read-side
7510
7601
  * answer here stays empty — interpretation lives in the hub's resolve
@@ -7525,13 +7616,15 @@ var CoverageSession = class CoverageSession {
7525
7616
  cwd;
7526
7617
  actors;
7527
7618
  origins;
7619
+ /** Where this project's assets come from — the cookie never goes here. */
7620
+ assetOrigins;
7528
7621
  /**
7529
7622
  * The denominator, enumerated once at start, or undefined when
7530
7623
  * `coverage.include` is unset — and always in hub mode, where it travels as
7531
7624
  * a `universe` event instead of riding the report envelope.
7532
7625
  */
7533
7626
  universe;
7534
- constructor(sink, inbox, runId, root, cwd, actors, origins, universe) {
7627
+ constructor(sink, inbox, runId, root, cwd, actors, origins, assetOrigins, universe, fetchStoredSourceMap) {
7535
7628
  this.sink = sink;
7536
7629
  this.inbox = inbox;
7537
7630
  this.runId = runId;
@@ -7539,12 +7632,12 @@ var CoverageSession = class CoverageSession {
7539
7632
  this.cwd = cwd;
7540
7633
  this.actors = actors;
7541
7634
  this.origins = origins;
7635
+ this.assetOrigins = assetOrigins;
7542
7636
  this.universe = universe;
7637
+ this.fetchStoredSourceMap = fetchStoredSourceMap;
7543
7638
  }
7544
7639
  static async start(options) {
7545
- const origins = options.config.instrumentedOrigins.map((origin) => resolveEnvRefs(origin));
7546
- const unresolved = origins.filter((origin) => !/^https?:\/\//i.test(origin));
7547
- if (unresolved.length > 0) throw new Error(`coverage.instrumentedOrigins must be absolute http(s) URLs after variable substitution; got ${unresolved.join(", ")}`);
7640
+ const origins = resolveAbsoluteOrigins(options.config.instrumentedOrigins, "coverage.instrumentedOrigins");
7548
7641
  const actors = options.actors ?? NO_ACTORS;
7549
7642
  let sink;
7550
7643
  if (options.inbox === void 0) {
@@ -7560,7 +7653,7 @@ var CoverageSession = class CoverageSession {
7560
7653
  include: [...universe.include],
7561
7654
  files: [...universe.files]
7562
7655
  });
7563
- return new CoverageSession(sink, options.inbox, options.runId, root, options.cwd, actors, origins, options.inbox === void 0 ? universe : void 0);
7656
+ return new CoverageSession(sink, options.inbox, options.runId, root, options.cwd, actors, origins, resolveAbsoluteOrigins(options.config.assetOrigins ?? [], "coverage.assetOrigins"), options.inbox === void 0 ? universe : void 0, options.fetchStoredSourceMap);
7564
7657
  }
7565
7658
  /**
7566
7659
  * Ties the stream's run id to the hub's run record. The session starts
@@ -7635,12 +7728,14 @@ var CoverageSession = class CoverageSession {
7635
7728
  cdpUrl,
7636
7729
  specId: specIdFor(this.runId, ref),
7637
7730
  origins: this.origins,
7731
+ assetOrigins: this.assetOrigins,
7638
7732
  coverageDir,
7639
7733
  roots: {
7640
7734
  base: this.cwd,
7641
7735
  root: this.root
7642
7736
  },
7643
- warn: (text) => warn(`coverage: ${text}`)
7737
+ warn: (text) => warn(`coverage: ${text}`),
7738
+ fetchStoredSourceMap: this.fetchStoredSourceMap
7644
7739
  });
7645
7740
  }
7646
7741
  /**
@@ -7828,14 +7923,6 @@ async function resolveRoot(cwd, declared) {
7828
7923
  function specCoverageDir(reportDir, feature, spec) {
7829
7924
  return join(reportDir, "coverage", feature, spec);
7830
7925
  }
7831
- /**
7832
- * `<runId>.<feature>/<spec>`. The run id keeps a stale cookie from an earlier
7833
- * run out; the spec half is `specKey`, so an id here and a report row name the
7834
- * same spec the same way.
7835
- */
7836
- function specIdFor(runId, ref) {
7837
- return `${runId}.${specKey(ref)}`;
7838
- }
7839
7926
  async function readFrontend(coverageDir, specId) {
7840
7927
  let raw;
7841
7928
  try {
@@ -10244,6 +10331,28 @@ z.object({
10244
10331
  totalUsd: z.number(),
10245
10332
  entries: z.array(SpendEntrySchema)
10246
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") });
10247
10356
  //#endregion
10248
10357
  //#region src/run/hub-selection.ts
10249
10358
  /**
@@ -10843,6 +10952,177 @@ function readOctal(buf, offset, fieldLen) {
10843
10952
  return str === "" ? 0 : parseInt(str, 8);
10844
10953
  }
10845
10954
  //#endregion
10955
+ //#region src/hub/core/storage/file/fs-helpers.ts
10956
+ /**
10957
+ * Defense-in-depth for a name this layer joins into a file path. The API's
10958
+ * `SAFE_SEGMENT` (api/validate.ts) is deliberately stricter — it also fixes a
10959
+ * charset and a length — and stays the rule for what a client may name; this
10960
+ * only refuses traversal, so it also covers callers that never came through
10961
+ * HTTP (tests, a library embedding the hub).
10962
+ */
10963
+ function assertSafeName(value, label) {
10964
+ if (value.length === 0 || value === "." || value === ".." || value.includes("/") || value.includes("\\")) throw new Error(`invalid ${label}: must be a bare name without path separators or '..'`);
10965
+ }
10966
+ /** Read and JSON-parse a file, returning `null` when it doesn't exist. Malformed JSON throws. */
10967
+ async function readJson(path) {
10968
+ let raw;
10969
+ try {
10970
+ raw = await readFile(path, "utf8");
10971
+ } catch (err) {
10972
+ if (isNotFound(err)) return null;
10973
+ throw err;
10974
+ }
10975
+ try {
10976
+ return JSON.parse(raw);
10977
+ } catch (err) {
10978
+ throw new Error(`corrupt JSON at ${path}: ${err instanceof Error ? err.message : String(err)}`);
10979
+ }
10980
+ }
10981
+ /**
10982
+ * Write `data` to a temp file in the same directory, then atomically rename it
10983
+ * into place, so a concurrent reader only ever observes the old file or the
10984
+ * fully-written new one — never the empty/partial window a plain
10985
+ * truncate-then-write leaves open (e.g. a `putActualCause` writing a run's
10986
+ * triage file while an API request reads it). The temp file lives in the same
10987
+ * directory as its target to keep the rename on one filesystem, where it is
10988
+ * atomic.
10989
+ */
10990
+ async function atomicWrite(path, data) {
10991
+ await mkdir(dirname(path), { recursive: true });
10992
+ const tmp = `${path}.${randomUUID()}.tmp`;
10993
+ await writeFile(tmp, data);
10994
+ await rename(tmp, path);
10995
+ }
10996
+ /** Write `value` as pretty JSON, creating parent directories as needed. */
10997
+ async function writeJson(path, value) {
10998
+ await atomicWrite(path, JSON.stringify(value, null, 2) + "\n");
10999
+ }
11000
+ /**
11001
+ * Per-path serialization for read-modify-write JSON updates. Two concurrent
11002
+ * `updateJson` calls on the same path (e.g. two `putActualCause` calls racing
11003
+ * on the same run's triage file) would otherwise both read the same starting
11004
+ * state and the second writer's change would silently clobber the first's —
11005
+ * this queues the second call's read until the first's write has landed, so
11006
+ * updates apply in the order they were issued rather than racing. Scoped by
11007
+ * path (a `Map` of chained promises), not globally, so unrelated records still
11008
+ * update concurrently.
11009
+ */
11010
+ const updateChains = /* @__PURE__ */ new Map();
11011
+ /**
11012
+ * Queue `work` behind whatever is already in flight for `path`. A delete takes
11013
+ * the same chain as the updates it removes: `writeJson` recreates the parent
11014
+ * directory, so an unordered delete would be silently undone by an update that
11015
+ * was already queued.
11016
+ */
11017
+ async function serialize(path, work) {
11018
+ const next = (updateChains.get(path) ?? Promise.resolve()).catch(() => {}).then(work);
11019
+ updateChains.set(path, next);
11020
+ try {
11021
+ return await next;
11022
+ } finally {
11023
+ if (updateChains.get(path) === next) updateChains.delete(path);
11024
+ }
11025
+ }
11026
+ async function updateJson(path, mutate) {
11027
+ return await serialize(path, async () => {
11028
+ const updated = mutate(await readJson(path));
11029
+ await writeJson(path, updated);
11030
+ return updated;
11031
+ });
11032
+ }
11033
+ /** Read a raw file, returning `null` when it doesn't exist. */
11034
+ async function readBytesOrNull(path) {
11035
+ try {
11036
+ const buf = await readFile(path);
11037
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
11038
+ } catch (err) {
11039
+ if (isNotFound(err)) return null;
11040
+ throw err;
11041
+ }
11042
+ }
11043
+ async function writeBytes(path, bytes) {
11044
+ await atomicWrite(path, bytes);
11045
+ }
11046
+ /** List entry names (files or dirs) directly under `dir`, or `[]` when it doesn't exist. */
11047
+ async function listDirOrEmpty(dir) {
11048
+ try {
11049
+ return await readdir(dir);
11050
+ } catch (err) {
11051
+ if (isNotFound(err)) return [];
11052
+ throw err;
11053
+ }
11054
+ }
11055
+ /**
11056
+ * Subdirectory names directly under `dir`, or `[]` when it doesn't exist.
11057
+ * Skips files and dot-entries so stray filesystem litter (e.g. a Finder
11058
+ * `.DS_Store`) never surfaces as a record id or project name.
11059
+ */
11060
+ async function listSubdirsOrEmpty(dir) {
11061
+ try {
11062
+ return (await readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
11063
+ } catch (err) {
11064
+ if (isNotFound(err)) return [];
11065
+ throw err;
11066
+ }
11067
+ }
11068
+ /** Every file path under `dir`, relative to `dir`, posix-separated. */
11069
+ async function listFilesRecursive(dir) {
11070
+ const out = [];
11071
+ async function walk(current) {
11072
+ let entries;
11073
+ try {
11074
+ entries = await readdir(current, { withFileTypes: true });
11075
+ } catch (err) {
11076
+ if (isNotFound(err)) return;
11077
+ throw err;
11078
+ }
11079
+ for (const entry of entries) {
11080
+ const abs = join(current, entry.name);
11081
+ if (entry.isDirectory()) await walk(abs);
11082
+ else if (entry.isFile()) out.push(relative(dir, abs).split(sep).join("/"));
11083
+ }
11084
+ }
11085
+ await walk(dir);
11086
+ return out;
11087
+ }
11088
+ async function removePath(path) {
11089
+ await rm(path, {
11090
+ recursive: true,
11091
+ force: true
11092
+ });
11093
+ }
11094
+ function isNotFound(err) {
11095
+ return err instanceof Error && "code" in err && err.code === "ENOENT";
11096
+ }
11097
+ //#endregion
11098
+ //#region src/coverage/frontend/reduce-map.ts
11099
+ /**
11100
+ * `json` reduced to the readable fields, or undefined when it is not a source
11101
+ * map this side can use — the same refusals `parseSourceMap` makes, applied at
11102
+ * push time so an unusable map is never stored.
11103
+ */
11104
+ function reduceSourceMap(json) {
11105
+ let parsed;
11106
+ try {
11107
+ parsed = JSON.parse(json);
11108
+ } catch {
11109
+ return;
11110
+ }
11111
+ if (typeof parsed !== "object" || parsed === null) return void 0;
11112
+ const map = parsed;
11113
+ if (map.sections !== void 0) return void 0;
11114
+ if (map.version !== 3) return void 0;
11115
+ if (typeof map.mappings !== "string") return void 0;
11116
+ if (!Array.isArray(map.sources)) return void 0;
11117
+ return {
11118
+ version: 3,
11119
+ sources: map.sources,
11120
+ mappings: map.mappings,
11121
+ ...typeof map.sourceRoot === "string" ? { sourceRoot: map.sourceRoot } : {},
11122
+ ...typeof map.file === "string" ? { file: map.file } : {}
11123
+ };
11124
+ }
11125
+ //#endregion
10846
11126
  //#region src/runtime/session-state.ts
10847
11127
  /** Default per-profile sessions root, relative to the project (`--cwd`). */
10848
11128
  const SESSIONS_SUBDIR = ".ccqa/sessions";
@@ -11401,6 +11681,7 @@ const CoverageActorsSchema = z.record(z.string().regex(/^[a-z0-9][a-z0-9._-]*$/i
11401
11681
  */
11402
11682
  const CoverageConfigSchema = z.object({
11403
11683
  instrumentedOrigins: z.array(z.string().min(1)).min(1),
11684
+ assetOrigins: z.array(z.string().min(1)).optional(),
11404
11685
  sink: z.string().min(1).default("http://127.0.0.1:4757"),
11405
11686
  projectRoot: z.string().min(1).optional(),
11406
11687
  include: z.array(z.string().min(1)).optional(),
@@ -11470,8 +11751,9 @@ function enrichZodError(error, source) {
11470
11751
  * means that spec must re-run — reach cannot see the test's own definition,
11471
11752
  * so no measurement is consulted for it. Everything else intersects the diff
11472
11753
  * with the files the spec's last measured run actually reached (ADR-0024);
11473
- * a spec with no measurement stays `unknown`, because an unmeasured edge is
11474
- * 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`.
11475
11757
  */
11476
11758
  async function selectSpecs(input) {
11477
11759
  const { changed, specs, cwd, base, head, edges } = input;
@@ -11566,27 +11848,37 @@ function isCcqaPath(path) {
11566
11848
  /**
11567
11849
  * Hold each undecided spec's last measured reach against the diff.
11568
11850
  *
11569
- * Three outcomes, and only the middle one is a positive claim: no
11570
- * measurement means `unknown` (an unmeasured edge is not an unreached one
11571
- * the absence of evidence runs the spec); a non-empty intersection means
11572
- * `needed`, with the intersecting paths as the reason; an empty one means
11573
- * `notNeeded` — the measurement accounts for everything the spec reached,
11574
- * 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
11575
11858
  * of the comparison entirely: the root is the declared boundary of what
11576
11859
  * measurement governs, so what lies beyond it clears specs quietly — one
11577
11860
  * warning names the dropped paths, because a root configured too narrow
11578
11861
  * looks exactly like this and hides real reach (see docs/coverage.md).
11579
11862
  */
11580
11863
  async function judgeWithCoverage(input) {
11581
- const { pending, productChanges, cwd, edges } = input;
11582
- const noMeasurement = "no measurement to consult: the hub holds no measured reach for this spec";
11583
- 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";
11584
11867
  const measuredChanges = rerootChangesForCoverage(productChanges, await resolveCoverageRoots(productChanges, cwd));
11585
11868
  const dropped = productChanges.length - measuredChanges.length;
11586
11869
  if (dropped > 0) warn(`select-specs: ${dropped} of ${productChanges.length} changed files fall outside coverage.projectRoot and cannot be compared against measured reach`);
11587
11870
  return pending.map((spec) => {
11588
11871
  const edge = edges.get(specKey(spec));
11589
- 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
+ }
11590
11882
  const touchedBy = measuredChanges.filter((c) => edge.files.has(c.measured)).map((c) => c.original);
11591
11883
  if (touchedBy.length > 0) return {
11592
11884
  featureName: spec.featureName,
@@ -11662,46 +11954,74 @@ async function resolveCoverageRoots(changed, cwd) {
11662
11954
  //#endregion
11663
11955
  //#region src/select/coverage-edges.ts
11664
11956
  /**
11665
- * How many recent hub runs are probed for report-row coverage. Bounded
11666
- * because every probe downloads a whole report.json; past this many runs a
11667
- * measurement is old enough that treating it as absent — `unknown`, so the
11668
- * spec runs — is the safer answer anyway. The stream side carries its own
11669
- * 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.
11670
11960
  */
11671
11961
  const MAX_REPORT_RUNS = 20;
11672
11962
  /**
11673
- * How old a measurement may be and still decide a spec. The same fourteen
11674
- * days the stream store retains events for (`COVERAGE_RETENTION_DAYS`): past
11675
- * it an edge is too stale to clear a spec with confidence, so it is not
11676
- * adopted and the spec degrades to `unknown` — which runs.
11677
- */
11678
- const EDGE_MAX_AGE_MS = 336 * 60 * 60 * 1e3;
11679
- /**
11680
- * Read every spec's most recent measured reach from the hub.
11681
- *
11682
- * Never throws: a hub that cannot be read yields an empty map (warned), which
11683
- * the selection degrades to `unknown` across the board — the caller runs
11684
- * 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).
11685
11967
  */
11686
11968
  async function loadCoverageEdges(input) {
11687
- const edges = /* @__PURE__ */ new Map();
11688
- const freshAfter = Date.now() - EDGE_MAX_AGE_MS;
11689
- const merge = (key, edge) => {
11690
- if (edge.measuredAt < freshAfter) return;
11691
- const existing = edges.get(key);
11692
- if (!existing || edge.measuredAt > existing.measuredAt) edges.set(key, edge);
11693
- };
11694
- 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
+ ]);
11695
11983
  let skipped = 0;
11696
- for (const result of results) if (result.status === "rejected") warn(`select-specs: could not read coverage measurements from the hub (${errMessage(result.reason)})`);
11697
- else skipped += result.value;
11698
- if (skipped > 0) warn(`select-specs: ${skipped} measured run(s) on the hub could not be read; their reach is treated as absent`);
11699
- 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;
11700
12019
  }
11701
12020
  /**
11702
- * Edges from the coverage event stream. The plain read answers for the most
11703
- * recently measured run and lists every run the stream retains; each older
11704
- * 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.
11705
12025
  */
11706
12026
  async function collectStreamEdges(input, merge) {
11707
12027
  const { hub, project } = input;
@@ -11713,24 +12033,16 @@ async function collectStreamEdges(input, merge) {
11713
12033
  function ingestResolved(resolved, merge) {
11714
12034
  if (!resolved) return;
11715
12035
  for (const spec of resolved.specs) {
11716
- const key = stripRunIdPrefix(spec.specId, resolved.runId);
12036
+ const key = specKeyFromSpecId(spec.specId, resolved.runId);
11717
12037
  if (key === null) continue;
11718
12038
  if (spec.files.length === 0) continue;
11719
12039
  merge(key, {
11720
- files: new Set(spec.files),
12040
+ files: spec.files,
11721
12041
  measuredAt: resolved.asOf
11722
12042
  });
11723
12043
  }
11724
12044
  }
11725
12045
  /**
11726
- * A stream specId is `<runId>.<feature>/<spec>` (src/coverage/session.ts).
11727
- * The runId itself may contain `.`, so the known prefix is stripped by
11728
- * length, never by splitting on the dot.
11729
- */
11730
- function stripRunIdPrefix(specId, runId) {
11731
- return specId.startsWith(`${runId}.`) ? specId.slice(runId.length + 1) : null;
11732
- }
11733
- /**
11734
12046
  * The one slice of report.json this consumer reads. Parsed with its own
11735
12047
  * narrow schema rather than the full report schema so a report from another
11736
12048
  * ccqa version still yields its edges as long as this shape holds.
@@ -11741,10 +12053,10 @@ const ReportCoverageRowsSchema = z.object({ results: z.array(z.object({
11741
12053
  coverage: z.object({ files: z.array(z.string()) }).optional()
11742
12054
  })) });
11743
12055
  /**
11744
- * Edges from pushed run reports, newest first. Only `kind: run` runs are
11745
- * probed — audits and recordings execute no specs, so they carry no reach —
11746
- * and a still-`running` run is skipped: its rows are still arriving, so its
11747
- * 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.
11748
12060
  */
11749
12061
  async function collectReportEdges(input, merge) {
11750
12062
  const { hub, project } = input;
@@ -11767,7 +12079,7 @@ async function collectReportEdges(input, merge) {
11767
12079
  for (const row of parsed.data.results) {
11768
12080
  if (!row.coverage || row.coverage.files.length === 0) continue;
11769
12081
  merge(`${row.feature}/${row.spec}`, {
11770
- files: new Set(row.coverage.files),
12082
+ files: row.coverage.files,
11771
12083
  measuredAt
11772
12084
  });
11773
12085
  }
@@ -12307,6 +12619,49 @@ const varRm = new Command("rm").description("Delete a variable from the hub.").a
12307
12619
  info(`deleted variable "${name}" from the hub`);
12308
12620
  }));
12309
12621
  const varCommand = new Command("var").description("Manage environment variables stored on the hub (fetched at run time by `ccqa run` / `ccqa record`).").addCommand(varSet).addCommand(varLs).addCommand(varRm);
12622
+ const sourcemapPush = new Command("push").description("Upload the source maps a build produced, so `ccqa run --coverage` can name frontend files when the build keeps its maps off the CDN. Only the fields coverage reads are sent — the original source each map carries is dropped rather than stored.").argument("<dir>", "Directory holding the build output (e.g. `.next/static`).").requiredOption("--sha <sha>", "Commit these assets were built from — the same one the deploy is recorded under.").option("--asset-prefix <path>", "Path the files are served under, prepended to each stored path (e.g. `_next/static`). Match what the browser requests, minus the origin.", "").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption).option(...cwdOption).action(withHubErrors(async (dirArg, opts) => {
12623
+ const project = resolveProject(opts);
12624
+ const hub = connect$2(opts);
12625
+ const dir = resolve(resolveCwd(opts.cwd), dirArg);
12626
+ if (!existsSync(dir)) throw new Error(`no such directory: ${dir}`);
12627
+ const maps = (await listFilesRecursive(dir)).filter((f) => f.endsWith(".map"));
12628
+ const prefix = opts.assetPrefix.replace(/^\/+|\/+$/g, "");
12629
+ header("hub sourcemap push", `${project}@${opts.sha.slice(0, 12)}`);
12630
+ meta("dir", dir);
12631
+ if (maps.length === 0) {
12632
+ warn(`no *.map under ${dir} — nothing to push (was the build run with source maps enabled?)`);
12633
+ return;
12634
+ }
12635
+ let pushed = 0;
12636
+ let bytes = 0;
12637
+ const unusable = [];
12638
+ for (const rel of maps) {
12639
+ const reduced = reduceSourceMap(await readFile(join(dir, rel), "utf8"));
12640
+ if (reduced === void 0) {
12641
+ unusable.push(rel);
12642
+ continue;
12643
+ }
12644
+ const body = new TextEncoder().encode(JSON.stringify(reduced));
12645
+ await hub.putSourceMap(project, opts.sha, prefix ? `${prefix}/${rel}` : rel, body);
12646
+ pushed++;
12647
+ bytes += body.length;
12648
+ }
12649
+ if (unusable.length > 0) warn(`${unusable.length} map(s) were not source maps this side can read, e.g. ${unusable[0]}`);
12650
+ await hub.sweepSourceMaps(project);
12651
+ info(`pushed ${pushed} source map(s), ${Math.round(bytes / 1024)} KiB`);
12652
+ }));
12653
+ const sourcemapLs = new Command("ls").description("List the source maps stored for a commit.").requiredOption("--sha <sha>", "The commit to list.").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption).option(...cwdOption).action(withHubErrors(async (opts) => {
12654
+ const project = resolveProject(opts);
12655
+ const paths = await connect$2(opts).listSourceMaps(project, opts.sha);
12656
+ header("hub sourcemap ls", `${project}@${opts.sha.slice(0, 12)}`);
12657
+ if (paths.length === 0) {
12658
+ info("no source maps stored for this commit");
12659
+ return;
12660
+ }
12661
+ for (const path of paths) meta("", path);
12662
+ info(`${paths.length} source map(s)`);
12663
+ }));
12664
+ const sourcemapCommand = new Command("sourcemap").description("Manage the source maps coverage reads when a build keeps its maps off the CDN (pushed at deploy time, fetched during `ccqa run --coverage`).").addCommand(sourcemapPush).addCommand(sourcemapLs);
12310
12665
  function validatePromptName(rawName) {
12311
12666
  if (!isPromptName(rawName)) {
12312
12667
  error(`invalid prompt name "${rawName}"`);
@@ -12595,7 +12950,7 @@ async function runCoverageInspect(opts) {
12595
12950
  const outside = Object.entries(h.outsideWindowEvents);
12596
12951
  if (outside.length > 0) warn(`outside-window events (identity was driven while unclaimed): ${outside.map(([key, count]) => `${key}: ${count}`).join(", ")}`);
12597
12952
  }
12598
- const hubCommand = new Command("hub").description("Client for a ccqa hub: push run results and manage sessions/variables/prompts used by `ccqa run`. See docs/hub.md.").addCommand(pushCommand).addCommand(deployCommand).addCommand(costCommand).addCommand(coverageCommand).addCommand(sessionCommand).addCommand(varCommand).addCommand(promptCommand).addCommand(attestCommand).addCommand(dismissCommand);
12953
+ const hubCommand = new Command("hub").description("Client for a ccqa hub: push run results and manage sessions/variables/prompts used by `ccqa run`. See docs/hub.md.").addCommand(pushCommand).addCommand(deployCommand).addCommand(costCommand).addCommand(coverageCommand).addCommand(sessionCommand).addCommand(varCommand).addCommand(sourcemapCommand).addCommand(promptCommand).addCommand(attestCommand).addCommand(dismissCommand);
12599
12954
  /** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
12600
12955
  function isStorageStateShape(state) {
12601
12956
  return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
@@ -16334,22 +16689,21 @@ async function collectChangedSpecs(specs, opts) {
16334
16689
  specs: [],
16335
16690
  base: resolved
16336
16691
  };
16692
+ if (!hub) warn(`${flag}: no hub connection, so coverage measurements cannot be consulted — undecided specs will run`);
16693
+ const edgesPromise = loadCoverageEdges(hub);
16337
16694
  let inventory;
16338
16695
  try {
16339
16696
  inventory = await loadSpecInventory(cwd);
16340
16697
  } catch (e) {
16341
16698
  throw new RunUsageError(e.message);
16342
16699
  }
16343
- let edges = /* @__PURE__ */ new Map();
16344
- if (hub) edges = await loadCoverageEdges(hub);
16345
- else warn(`${flag}: no hub connection, so coverage measurements cannot be consulted — undecided specs will run`);
16346
16700
  const report = await selectSpecs({
16347
16701
  changed,
16348
16702
  specs: inventory,
16349
16703
  cwd,
16350
16704
  base: resolved.sha,
16351
16705
  head: "HEAD",
16352
- edges
16706
+ edges: await edgesPromise
16353
16707
  });
16354
16708
  const toRun = new Set(specsToRun(report).map(specKey));
16355
16709
  const undecided = report.specs.filter((s) => s.verdict === "unknown").length;
@@ -16360,53 +16714,6 @@ async function collectChangedSpecs(specs, opts) {
16360
16714
  };
16361
16715
  }
16362
16716
  //#endregion
16363
- //#region src/run/measure-backfill.ts
16364
- /**
16365
- * `ccqa run --measure-backfill <n>`: keep the measured-reach edges alive.
16366
- *
16367
- * Selection (ADR-0024) consumes each spec's most recent measured reach, and
16368
- * an edge expires after `EDGE_MAX_AGE_MS`. Nothing else re-measures: an
16369
- * unmeasured spec answers `unknown`, `unknown` marks nothing due (ADR-0023),
16370
- * and a suite can settle into a state where no run ever fires — the seed
16371
- * deadlock. Appending a few unmeasured-or-aging specs to every selected run
16372
- * breaks that loop and keeps the whole suite inside the freshness window
16373
- * without a scheduled full sweep.
16374
- */
16375
- /**
16376
- * Re-measure once an edge has spent half its lifetime. Half, not "expired":
16377
- * a spec re-measured only after expiry answers `unknown` for the gap between
16378
- * expiry and the next run, which is exactly the window this flag exists to
16379
- * close.
16380
- */
16381
- const REMEASURE_AFTER_MS = EDGE_MAX_AGE_MS / 2;
16382
- /**
16383
- * Picks up to `limit` specs from `inventory` worth re-measuring: ones with no
16384
- * edge at all first (they cost an `unknown` verdict today), then the oldest
16385
- * measured ones. Specs already selected for this run are never doubled.
16386
- */
16387
- function chooseMeasureBackfill(inventory, selected, edges, limit, now) {
16388
- const alreadyRunning = new Set(selected.map(specKey));
16389
- const missing = [];
16390
- const aging = [];
16391
- for (const spec of inventory) {
16392
- if (alreadyRunning.has(specKey(spec))) continue;
16393
- const edge = edges.get(specKey(spec));
16394
- if (edge === void 0) missing.push(spec);
16395
- else if (now - edge.measuredAt > REMEASURE_AFTER_MS) aging.push({
16396
- spec,
16397
- measuredAt: edge.measuredAt
16398
- });
16399
- }
16400
- aging.sort((a, b) => a.measuredAt - b.measuredAt);
16401
- const specs = [...missing, ...aging.map((entry) => entry.spec)].slice(0, limit);
16402
- const missingTaken = Math.min(missing.length, specs.length);
16403
- return {
16404
- specs,
16405
- missing: missingTaken,
16406
- aging: specs.length - missingTaken
16407
- };
16408
- }
16409
- //#endregion
16410
16717
  //#region src/run/pipeline.ts
16411
16718
  async function resolveVitestConfig(cwd) {
16412
16719
  const userConfig = resolve(cwd, ".ccqa/vitest.config.ts");
@@ -16585,10 +16892,6 @@ async function executeRun(targets, opts) {
16585
16892
  if (rerunProfile !== null && hubCtx == null) throw new RunUsageError(needsHubConnection("--only-hub-rerun-needed"));
16586
16893
  if (opts.reportToHub && hubCtx == null) throw new RunUsageError(REPORT_TO_HUB_NEEDS_CONNECTION);
16587
16894
  if (opts.learnHubLivePrompt && hubCtx == null) throw new RunUsageError(needsHubConnection("--learn-hub-live-prompt"));
16588
- if ((opts.measureBackfill ?? 0) > 0) {
16589
- if (opts.coverage !== true) throw new RunUsageError("--measure-backfill does nothing without --coverage — there is no measurement to keep fresh");
16590
- 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");
16591
- }
16592
16895
  let coverageInbox;
16593
16896
  if (opts.coverageInbox === "hub") {
16594
16897
  if (opts.coverage !== true) throw new RunUsageError("--coverage-inbox hub does nothing without --coverage — there is no measurement to stream");
@@ -16637,7 +16940,6 @@ async function executeRun(targets, opts) {
16637
16940
  };
16638
16941
  const enumerateAll = () => listAllSpecsWithSpecFile(cwd);
16639
16942
  let specs = dedupeSpecs((await Promise.all((targets.length ? targets : [void 0]).map((t) => resolveSpecTargets(t, enumerateAll, cwd)))).flat());
16640
- const inventory = specs;
16641
16943
  if (filtering) {
16642
16944
  const before = specs.length;
16643
16945
  let inProgress = 0;
@@ -16668,14 +16970,6 @@ async function executeRun(targets, opts) {
16668
16970
  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");
16669
16971
  }
16670
16972
  }
16671
- if (filtering && (opts.measureBackfill ?? 0) > 0 && hubCtx != null) {
16672
- const edges = await loadCoverageEdges(hubCtx);
16673
- const pick = chooseMeasureBackfill(inventory, specs, edges, opts.measureBackfill ?? 0, Date.now());
16674
- if (pick.specs.length > 0) {
16675
- specs = [...specs, ...pick.specs];
16676
- meta("measure-backfill", `${pick.specs.length} spec(s) appended (${pick.missing} unmeasured / ${pick.aging} aging)`);
16677
- }
16678
- }
16679
16973
  if (specs.length === 0) {
16680
16974
  warn("no specs to run");
16681
16975
  return {
@@ -16730,7 +17024,7 @@ async function executeRun(targets, opts) {
16730
17024
  const liveSpecs = withMode.filter((s) => s.mode === "live");
16731
17025
  meta("modes", `${detSpecs.length} deterministic / ${liveSpecs.length} live`);
16732
17026
  if (dispatch.external.length > 0) meta("external", dispatch.external.map((g) => `${g.targetId} ${g.specs.length}`).join(" / "));
16733
- const coverage = opts.coverage && forExecution ? await startCoverage(cwd, projectConfig.coverage, actors, dispatch, opts.teardown, coverageInbox) : void 0;
17027
+ const coverage = opts.coverage && forExecution ? await startCoverage(cwd, projectConfig.coverage, actors, dispatch, opts.teardown, coverageInbox, storedSourceMapReader(hubCtx, deployedSha), hubCtx !== null) : void 0;
16734
17028
  if (liveSpecs.length === 0) {
16735
17029
  const why = "it only applies to agent-browser 'mode: live' specs, and this run has none";
16736
17030
  if (typeof opts.liveStepRetry === "number" && opts.liveStepRetry > 0) warn(`--live-step-retry is ignored: ${why}`);
@@ -16842,8 +17136,9 @@ async function executeRun(targets, opts) {
16842
17136
  report: incrementalReport
16843
17137
  };
16844
17138
  const live = await runLiveSpecs(liveSpecs, liveOpts);
17139
+ let streamedEdges = {};
16845
17140
  if (coverage && !coverage.streamsToHub) reportCoverageHealth(coverage, [...externalRows, ...live.reportResults]);
16846
- else if (coverage && hubCtx != null) await reportStreamedCoverageHealth(coverage, hubCtx);
17141
+ else if (coverage && hubCtx != null) streamedEdges = await reportStreamedCoverageHealth(coverage, hubCtx);
16847
17142
  let overallExitCode = det.exitCode !== 0 ? 1 : 0;
16848
17143
  if (live.failedCount > 0) overallExitCode = 1;
16849
17144
  if (externalRows.some((r) => r.status === "failed")) overallExitCode = 1;
@@ -16887,6 +17182,10 @@ async function executeRun(targets, opts) {
16887
17182
  coverage
16888
17183
  });
16889
17184
  completedNormally = true;
17185
+ if (coverage && hubCtx != null) {
17186
+ if (coverage.streamsToHub) await upsertMeasuredEdges(hubCtx, streamedEdges);
17187
+ 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 }])));
17188
+ }
16890
17189
  if (hubRunId) {
16891
17190
  const finalStatus = overallExitCode === 0 ? "passed" : "failed";
16892
17191
  const reportMeta = buildReportEnvelope({
@@ -16933,13 +17232,7 @@ async function executeRun(targets, opts) {
16933
17232
  reportDir
16934
17233
  };
16935
17234
  }
16936
- /**
16937
- * Starts the run's coverage measurement before any spec runs: the sink has to
16938
- * be listening before the first request reaches the application, and the set
16939
- * of spec ids it will accept is only known once dispatch has resolved. With
16940
- * an `inbox`, nothing binds — the run streams its events to the hub instead.
16941
- */
16942
- async function startCoverage(cwd, config, actors, dispatch, teardown, inbox) {
17235
+ async function startCoverage(cwd, config, actors, dispatch, teardown, inbox, fetchStoredSourceMap, hubConfigured) {
16943
17236
  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");
16944
17237
  let session;
16945
17238
  try {
@@ -16949,11 +17242,13 @@ async function startCoverage(cwd, config, actors, dispatch, teardown, inbox) {
16949
17242
  config,
16950
17243
  actors,
16951
17244
  specs: dispatch.external.flatMap((g) => g.specs),
16952
- ...inbox ? { inbox } : {}
17245
+ ...inbox ? { inbox } : {},
17246
+ ...fetchStoredSourceMap ? { fetchStoredSourceMap } : {}
16953
17247
  });
16954
17248
  } catch (err) {
16955
17249
  throw new RunUsageError(`could not start coverage collection: ${errMessage(err)}`);
16956
17250
  }
17251
+ if (fetchStoredSourceMap === void 0 && hubConfigured) warn("coverage: source maps pushed for this deploy cannot be read — the deployed commit is unknown here. Pass --hub-profile so the deploy log can be consulted.");
16957
17252
  if (inbox !== void 0) meta("coverage", `streaming to hub inbox → ${session.origins.join(", ")}`);
16958
17253
  else meta("coverage", `sink ${session.sinkUrl} → ${session.origins.join(", ")}`);
16959
17254
  const unmeasured = dispatch.external.filter((g) => g.browserCoverage.browser === "none").length;
@@ -16962,6 +17257,28 @@ async function startCoverage(cwd, config, actors, dispatch, teardown, inbox) {
16962
17257
  return session;
16963
17258
  }
16964
17259
  /**
17260
+ * Starts the run's coverage measurement before any spec runs: the sink has to
17261
+ * be listening before the first request reaches the application, and the set
17262
+ * of spec ids it will accept is only known once dispatch has resolved. With
17263
+ * an `inbox`, nothing binds — the run streams its events to the hub instead.
17264
+ */
17265
+ /**
17266
+ * Needs both a hub and the commit under test: without either there is nothing
17267
+ * to address a stored map by, and reading some other commit's maps would name
17268
+ * the wrong files. See `SourceMapStore`.
17269
+ */
17270
+ function storedSourceMapReader(hubCtx, deployedSha) {
17271
+ if (hubCtx === null || deployedSha === null) return void 0;
17272
+ const seen = /* @__PURE__ */ new Map();
17273
+ return async (assetPath) => {
17274
+ const cached = seen.get(assetPath);
17275
+ if (cached !== void 0 || seen.has(assetPath)) return cached;
17276
+ const map = await hubCtx.hub.getSourceMap(hubCtx.project, deployedSha, assetPath) ?? void 0;
17277
+ seen.set(assetPath, map);
17278
+ return map;
17279
+ };
17280
+ }
17281
+ /**
16965
17282
  * A run that measured coverage still leaves rows without any — a spec on a
16966
17283
  * target that declares no browser, or one that never executed. Saying why keeps
16967
17284
  * the reader from reading a blank as "this spec reached nothing".
@@ -16979,6 +17296,10 @@ function explainMissingCoverage(row) {
16979
17296
  * Best-effort — the measurement already left as events, so a failed read-out
16980
17297
  * loses visibility, never data. Application pushes may still land for
16981
17298
  * `GRACE_MS` after the last window closed, so the counts here are a floor.
17299
+ *
17300
+ * Returns the specs the resolve measured, keyed `feature/spec` — the caller
17301
+ * merges them into the ledger alongside the local mode's rows, so the "run
17302
+ * end records measured reach" step lives in one place for both modes.
16982
17303
  */
16983
17304
  async function reportStreamedCoverageHealth(coverage, hubCtx) {
16984
17305
  await new Promise((resolve) => setTimeout(resolve, 3e3));
@@ -16987,11 +17308,11 @@ async function reportStreamedCoverageHealth(coverage, hubCtx) {
16987
17308
  resolved = (await hubCtx.hub.getCoverage(hubCtx.project, { runId: coverage.streamRunId })).resolved;
16988
17309
  } catch (error) {
16989
17310
  warn(`coverage: could not read this run's resolve from the hub (${errMessage(error)})`);
16990
- return;
17311
+ return {};
16991
17312
  }
16992
17313
  if (resolved == null) {
16993
17314
  warn("coverage: the hub resolved nothing for this run — its events never reached the stream, so every spec's measured reach is absent");
16994
- return;
17315
+ return {};
16995
17316
  }
16996
17317
  const measured = resolved.specs.filter((spec) => spec.files.length > 0);
16997
17318
  const empty = resolved.specs.filter((spec) => spec.files.length === 0);
@@ -17010,6 +17331,30 @@ async function reportStreamedCoverageHealth(coverage, hubCtx) {
17010
17331
  const outside = Object.entries(h.outsideWindowEvents);
17011
17332
  if (outside.length > 0) trouble.push(`outside-window=${outside.map(([key, count]) => `${key}:${count}`).join(",")}`);
17012
17333
  if (trouble.length > 0) warn(`coverage: stream health flags — ${trouble.join(" ")}`);
17334
+ if (!h.heardFromApplication) return {};
17335
+ return Object.fromEntries(measured.flatMap((spec) => {
17336
+ const key = specKeyFromSpecId(spec.specId, resolved.runId);
17337
+ return key === null ? [] : [[key, {
17338
+ files: spec.files,
17339
+ runId: resolved.runId
17340
+ }]];
17341
+ }));
17342
+ }
17343
+ /**
17344
+ * Merge what this run measured into the hub's coverage-edge ledger
17345
+ * (ADR-0026) — the document selection reads. Best-effort: the measurement
17346
+ * also lives in the stream or the report, so a failed merge loses freshness,
17347
+ * not data. A hub without the endpoint (predating it) warns once and moves on.
17348
+ */
17349
+ async function upsertMeasuredEdges(hubCtx, specs) {
17350
+ const count = Object.keys(specs).length;
17351
+ if (count === 0) return;
17352
+ try {
17353
+ await hubCtx.hub.putCoverageEdges(hubCtx.project, { specs });
17354
+ meta("coverage", `${count} spec edge(s) recorded on the hub's ledger`);
17355
+ } catch (error) {
17356
+ warn(`coverage: could not record measured edges on the hub (${errMessage(error)})`);
17357
+ }
17013
17358
  }
17014
17359
  /** Everything the measurement could not place; silence here reads as "never reached". */
17015
17360
  function reportCoverageHealth(coverage, rows) {
@@ -17630,11 +17975,7 @@ const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command(
17630
17975
  }, "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) => {
17631
17976
  if (COVERAGE_INBOX_MODES.includes(raw)) return raw;
17632
17977
  throw new Error(`--coverage-inbox must be one of ${COVERAGE_INBOX_MODES.join(" | ")}`);
17633
- }, "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) => {
17634
- const n = Number(raw);
17635
- if (!Number.isInteger(n) || n < 0) throw new Error("--measure-backfill must be a non-negative integer");
17636
- return n;
17637
- }).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) => {
17978
+ }, "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) => {
17638
17979
  await runCliAction(targets, opts);
17639
17980
  });
17640
17981
  /** Parse --concurrency: a positive integer. Rejects 0, negatives, non-integers. */
@@ -21193,7 +21534,7 @@ async function runSelectSpecs(opts) {
21193
21534
  project: opts.project,
21194
21535
  cwd: opts.cwd
21195
21536
  });
21196
- const [specsResult, changedResult, edges] = await Promise.all([
21537
+ const [specsResult, changedResult, edgesReadout] = await Promise.all([
21197
21538
  loadSpecInventory(cwd).then((specs) => ({
21198
21539
  ok: true,
21199
21540
  specs
@@ -21233,7 +21574,7 @@ async function runSelectSpecs(opts) {
21233
21574
  meta("project", project);
21234
21575
  meta("changed-files", changed.length);
21235
21576
  meta("specs", specs.length);
21236
- meta("measured-specs", edges.size);
21577
+ meta("measured-specs", edgesReadout.edges.size);
21237
21578
  }
21238
21579
  const report = await selectSpecs({
21239
21580
  changed,
@@ -21241,7 +21582,7 @@ async function runSelectSpecs(opts) {
21241
21582
  cwd,
21242
21583
  base: opts.base,
21243
21584
  head,
21244
- edges
21585
+ edges: edgesReadout
21245
21586
  });
21246
21587
  process.stdout.write(format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderText(report));
21247
21588
  process.exit(0);
@@ -21466,6 +21807,20 @@ function mergeBucket(into, from) {
21466
21807
  //#endregion
21467
21808
  //#region src/hub/core/retention.ts
21468
21809
  /**
21810
+ * Drop the source maps of every commit past the newest `maxCommits`.
21811
+ *
21812
+ * Hung off a push for the same reason run retention hangs off a terminal run:
21813
+ * the hub whose disk grows is the one written to constantly and never
21814
+ * restarted. Best-effort — a lost sweep costs disk, while failing the push it
21815
+ * runs beside would cost the deploy.
21816
+ */
21817
+ async function sweepSourceMapRetention(storage, project, maxCommits) {
21818
+ try {
21819
+ const commits = await storage.sourceMaps.listCommits(project);
21820
+ for (const commit of commits.slice(maxCommits)) await storage.sourceMaps.delete(project, commit);
21821
+ } catch {}
21822
+ }
21823
+ /**
21469
21824
  * Drop everything past the newest `maxRuns` of the (project, branch) that
21470
21825
  * `run` belongs to, taking each evicted run's artifacts and triage records
21471
21826
  * with it.
@@ -22402,6 +22757,58 @@ function createGetDriftLedgerHandler(storage) {
22402
22757
  };
22403
22758
  }
22404
22759
  //#endregion
22760
+ //#region src/hub/api/handlers/sourcemaps.ts
22761
+ /**
22762
+ * A source map is roughly the size of the code it describes, and a single
22763
+ * bundle chunk can be a few MB. Generous, but still a ceiling: a push is many
22764
+ * small requests rather than one archive, so no single body should approach it.
22765
+ */
22766
+ const MAX_MAP_BYTES = 32 * 1024 * 1024;
22767
+ function createPutSourceMapHandler(storage) {
22768
+ return async (ctx) => {
22769
+ const project = requireSafeSegment(ctx.params.project, "project");
22770
+ const commit = requireSafeSegment(ctx.params.commit, "commit");
22771
+ const assetPath = requireSafeRelPath(ctx.params.path, "source map path");
22772
+ const body = await readBody(ctx.req, MAX_MAP_BYTES);
22773
+ await storage.sourceMaps.put(project, commit, assetPath, body);
22774
+ ctx.res.statusCode = 204;
22775
+ ctx.res.end();
22776
+ };
22777
+ }
22778
+ function createGetSourceMapHandler(storage) {
22779
+ return async (ctx) => {
22780
+ const project = requireSafeSegment(ctx.params.project, "project");
22781
+ const commit = requireSafeSegment(ctx.params.commit, "commit");
22782
+ const assetPath = requireSafeRelPath(ctx.params.path, "source map path");
22783
+ const bytes = await storage.sourceMaps.read(project, commit, assetPath);
22784
+ if (!bytes) throw new HttpError(404, "not_found", `no source map stored for "${assetPath}" at ${commit}`);
22785
+ sendBytes(ctx.res, 200, bytes, "application/json; charset=utf-8");
22786
+ };
22787
+ }
22788
+ /**
22789
+ * Ends a push: everything for this commit has landed, so older commits can go.
22790
+ * Separate from the PUTs because a push is hundreds of them, and sweeping on
22791
+ * each would walk the whole project every time.
22792
+ */
22793
+ function createSweepSourceMapsHandler(storage) {
22794
+ return async (ctx) => {
22795
+ await sweepSourceMapRetention(storage, requireSafeSegment(ctx.params.project, "project"), 10);
22796
+ ctx.res.statusCode = 204;
22797
+ ctx.res.end();
22798
+ };
22799
+ }
22800
+ function createListSourceMapsHandler(storage) {
22801
+ return async (ctx) => {
22802
+ const project = requireSafeSegment(ctx.params.project, "project");
22803
+ const commit = requireSafeSegment(ctx.params.commit, "commit");
22804
+ sendJson(ctx.res, 200, {
22805
+ project,
22806
+ commit,
22807
+ paths: await storage.sourceMaps.list(project, commit)
22808
+ });
22809
+ };
22810
+ }
22811
+ //#endregion
22405
22812
  //#region src/hub/api/handlers/deploys.ts
22406
22813
  /** `changedPaths` for a wide refactor can run to tens of thousands of entries. */
22407
22814
  const MAX_DEPLOY_BODY_BYTES = 8 * 1024 * 1024;
@@ -23095,6 +23502,32 @@ function requireSinceSeqParam(url) {
23095
23502
  if (!Number.isInteger(value) || value < 0) throw new HttpError(400, "invalid_param", "invalid sinceSeq: must be a non-negative integer");
23096
23503
  return value;
23097
23504
  }
23505
+ const MAX_EDGES_BODY_BYTES = 8 * 1024 * 1024;
23506
+ /**
23507
+ * PUT /api/v1/projects/:project/coverage-edges — the specs one run measured,
23508
+ * merged into the ledger newest-wins. `measuredAt` is stamped here with the
23509
+ * hub's clock, so entries written by different runners stay comparable.
23510
+ * Bearer-authenticated like every project route; the append-only coverage
23511
+ * token cannot write here.
23512
+ */
23513
+ function createPutCoverageEdgesHandler(config) {
23514
+ return async (ctx) => {
23515
+ const project = requireSafeSegment(ctx.params.project, "project");
23516
+ const body = await readJsonBody(ctx.req, MAX_EDGES_BODY_BYTES, CoverageEdgesUpsertSchema, "coverage-edges body");
23517
+ await config.store.merge(project, body.specs, Date.now());
23518
+ ctx.res.statusCode = 204;
23519
+ ctx.res.end();
23520
+ };
23521
+ }
23522
+ /** GET /api/v1/projects/:project/coverage-edges — the ledger, or 404. */
23523
+ function createGetCoverageEdgesHandler(config) {
23524
+ return async (ctx) => {
23525
+ const project = requireSafeSegment(ctx.params.project, "project");
23526
+ const doc = await config.store.get(project);
23527
+ if (doc === null) throw new HttpError(404, "not_found", `no coverage edges stored for project "${project}"`);
23528
+ sendJson(ctx.res, 200, doc);
23529
+ };
23530
+ }
23098
23531
  //#endregion
23099
23532
  //#region src/hub/core/rerun.ts
23100
23533
  /**
@@ -30364,6 +30797,10 @@ function registerRoutes(router, config, queue) {
30364
30797
  router.get("/api/v1/projects/:project/deploys", createGetDeployLogHandler(storage));
30365
30798
  router.get("/api/v1/projects/:project/rerun", createGetRerunHandler(storage));
30366
30799
  router.get("/api/v1/projects/:project/audit-needed", createGetAuditNeedHandler(storage));
30800
+ router.put("/api/v1/projects/:project/sourcemaps/:commit/*path", createPutSourceMapHandler(storage));
30801
+ router.post("/api/v1/projects/:project/sourcemaps/sweep", createSweepSourceMapsHandler(storage));
30802
+ router.get("/api/v1/projects/:project/sourcemaps/:commit", createListSourceMapsHandler(storage));
30803
+ router.get("/api/v1/projects/:project/sourcemaps/:commit/*path", createGetSourceMapHandler(storage));
30367
30804
  router.post("/api/v1/projects/:project/locks", createAcquireLocksHandler(storage));
30368
30805
  router.delete("/api/v1/projects/:project/locks", createReleaseLocksHandler(storage));
30369
30806
  router.get("/api/v1/projects/:project/attestations", createGetAttestationsHandler(storage));
@@ -30401,6 +30838,9 @@ function registerRoutes(router, config, queue) {
30401
30838
  router.get("/api/v1/projects/:project/perspectives", createGetPerspectivesHandler(perspectivesConfig));
30402
30839
  router.patch("/api/v1/projects/:project/perspectives", createPatchPerspectivesNoteHandler(perspectivesConfig));
30403
30840
  router.delete("/api/v1/projects/:project/perspectives", createDeletePerspectivesHandler(perspectivesConfig));
30841
+ const coverageEdgesConfig = { store: storage.coverageEdges };
30842
+ router.put("/api/v1/projects/:project/coverage-edges", createPutCoverageEdgesHandler(coverageEdgesConfig));
30843
+ router.get("/api/v1/projects/:project/coverage-edges", createGetCoverageEdgesHandler(coverageEdgesConfig));
30404
30844
  const coverageConfig = {
30405
30845
  store: storage.coverageEvents,
30406
30846
  encryptionKey: config.encryptionKey,
@@ -30418,149 +30858,6 @@ function registerRoutes(router, config, queue) {
30418
30858
  router.get("/api/v1/projects/:project/learning-jobs/:jobId", createGetLearningJobHandler(storage));
30419
30859
  }
30420
30860
  //#endregion
30421
- //#region src/hub/core/storage/file/fs-helpers.ts
30422
- /**
30423
- * Defense-in-depth for a name this layer joins into a file path. The API's
30424
- * `SAFE_SEGMENT` (api/validate.ts) is deliberately stricter — it also fixes a
30425
- * charset and a length — and stays the rule for what a client may name; this
30426
- * only refuses traversal, so it also covers callers that never came through
30427
- * HTTP (tests, a library embedding the hub).
30428
- */
30429
- function assertSafeName(value, label) {
30430
- if (value.length === 0 || value === "." || value === ".." || value.includes("/") || value.includes("\\")) throw new Error(`invalid ${label}: must be a bare name without path separators or '..'`);
30431
- }
30432
- /** Read and JSON-parse a file, returning `null` when it doesn't exist. Malformed JSON throws. */
30433
- async function readJson(path) {
30434
- let raw;
30435
- try {
30436
- raw = await readFile(path, "utf8");
30437
- } catch (err) {
30438
- if (isNotFound(err)) return null;
30439
- throw err;
30440
- }
30441
- try {
30442
- return JSON.parse(raw);
30443
- } catch (err) {
30444
- throw new Error(`corrupt JSON at ${path}: ${err instanceof Error ? err.message : String(err)}`);
30445
- }
30446
- }
30447
- /**
30448
- * Write `data` to a temp file in the same directory, then atomically rename it
30449
- * into place, so a concurrent reader only ever observes the old file or the
30450
- * fully-written new one — never the empty/partial window a plain
30451
- * truncate-then-write leaves open (e.g. a `putActualCause` writing a run's
30452
- * triage file while an API request reads it). The temp file lives in the same
30453
- * directory as its target to keep the rename on one filesystem, where it is
30454
- * atomic.
30455
- */
30456
- async function atomicWrite(path, data) {
30457
- await mkdir(dirname(path), { recursive: true });
30458
- const tmp = `${path}.${randomUUID()}.tmp`;
30459
- await writeFile(tmp, data);
30460
- await rename(tmp, path);
30461
- }
30462
- /** Write `value` as pretty JSON, creating parent directories as needed. */
30463
- async function writeJson(path, value) {
30464
- await atomicWrite(path, JSON.stringify(value, null, 2) + "\n");
30465
- }
30466
- /**
30467
- * Per-path serialization for read-modify-write JSON updates. Two concurrent
30468
- * `updateJson` calls on the same path (e.g. two `putActualCause` calls racing
30469
- * on the same run's triage file) would otherwise both read the same starting
30470
- * state and the second writer's change would silently clobber the first's —
30471
- * this queues the second call's read until the first's write has landed, so
30472
- * updates apply in the order they were issued rather than racing. Scoped by
30473
- * path (a `Map` of chained promises), not globally, so unrelated records still
30474
- * update concurrently.
30475
- */
30476
- const updateChains = /* @__PURE__ */ new Map();
30477
- /**
30478
- * Queue `work` behind whatever is already in flight for `path`. A delete takes
30479
- * the same chain as the updates it removes: `writeJson` recreates the parent
30480
- * directory, so an unordered delete would be silently undone by an update that
30481
- * was already queued.
30482
- */
30483
- async function serialize(path, work) {
30484
- const next = (updateChains.get(path) ?? Promise.resolve()).catch(() => {}).then(work);
30485
- updateChains.set(path, next);
30486
- try {
30487
- return await next;
30488
- } finally {
30489
- if (updateChains.get(path) === next) updateChains.delete(path);
30490
- }
30491
- }
30492
- async function updateJson(path, mutate) {
30493
- return await serialize(path, async () => {
30494
- const updated = mutate(await readJson(path));
30495
- await writeJson(path, updated);
30496
- return updated;
30497
- });
30498
- }
30499
- /** Read a raw file, returning `null` when it doesn't exist. */
30500
- async function readBytesOrNull(path) {
30501
- try {
30502
- const buf = await readFile(path);
30503
- return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
30504
- } catch (err) {
30505
- if (isNotFound(err)) return null;
30506
- throw err;
30507
- }
30508
- }
30509
- async function writeBytes(path, bytes) {
30510
- await atomicWrite(path, bytes);
30511
- }
30512
- /** List entry names (files or dirs) directly under `dir`, or `[]` when it doesn't exist. */
30513
- async function listDirOrEmpty(dir) {
30514
- try {
30515
- return await readdir(dir);
30516
- } catch (err) {
30517
- if (isNotFound(err)) return [];
30518
- throw err;
30519
- }
30520
- }
30521
- /**
30522
- * Subdirectory names directly under `dir`, or `[]` when it doesn't exist.
30523
- * Skips files and dot-entries so stray filesystem litter (e.g. a Finder
30524
- * `.DS_Store`) never surfaces as a record id or project name.
30525
- */
30526
- async function listSubdirsOrEmpty(dir) {
30527
- try {
30528
- return (await readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
30529
- } catch (err) {
30530
- if (isNotFound(err)) return [];
30531
- throw err;
30532
- }
30533
- }
30534
- /** Every file path under `dir`, relative to `dir`, posix-separated. */
30535
- async function listFilesRecursive(dir) {
30536
- const out = [];
30537
- async function walk(current) {
30538
- let entries;
30539
- try {
30540
- entries = await readdir(current, { withFileTypes: true });
30541
- } catch (err) {
30542
- if (isNotFound(err)) return;
30543
- throw err;
30544
- }
30545
- for (const entry of entries) {
30546
- const abs = join(current, entry.name);
30547
- if (entry.isDirectory()) await walk(abs);
30548
- else if (entry.isFile()) out.push(relative(dir, abs).split(sep).join("/"));
30549
- }
30550
- }
30551
- await walk(dir);
30552
- return out;
30553
- }
30554
- async function removePath(path) {
30555
- await rm(path, {
30556
- recursive: true,
30557
- force: true
30558
- });
30559
- }
30560
- function isNotFound(err) {
30561
- return err instanceof Error && "code" in err && err.code === "ENOENT";
30562
- }
30563
- //#endregion
30564
30861
  //#region src/hub/core/storage/file/paths.ts
30565
30862
  /**
30566
30863
  * On-disk layout for the local-directory `HubStorage` backend, all rooted
@@ -30598,6 +30895,12 @@ function runMetaPath(root, id) {
30598
30895
  function artifactsRunDir(root, runId) {
30599
30896
  return join(root, "artifacts", runId);
30600
30897
  }
30898
+ function sourceMapProjectDir(root, project) {
30899
+ return join(root, "sourcemaps", project);
30900
+ }
30901
+ function sourceMapCommitDir(root, project, commit) {
30902
+ return join(sourceMapProjectDir(root, project), commit);
30903
+ }
30601
30904
  function jobsDir(root) {
30602
30905
  return join(root, "jobs");
30603
30906
  }
@@ -30643,6 +30946,9 @@ function perspectivesKindDir(root) {
30643
30946
  function perspectivesPath(root, project) {
30644
30947
  return join(perspectivesKindDir(root), `${project}.json`);
30645
30948
  }
30949
+ function coverageEdgesPath(root, project) {
30950
+ return join(root, "coverage-edges", `${project}.json`);
30951
+ }
30646
30952
  function ledgerProfileDir(root, project, profile) {
30647
30953
  return join(root, "last-green", project, profile);
30648
30954
  }
@@ -30966,6 +31272,56 @@ function parseLine(rawLine) {
30966
31272
  };
30967
31273
  }
30968
31274
  //#endregion
31275
+ //#region src/hub/core/storage/file/sourcemap-store.ts
31276
+ /**
31277
+ * Defense-in-depth: the HTTP layer validates the asset path before it gets
31278
+ * here, but this store joins it onto a directory, so it re-checks rather than
31279
+ * trusting the caller.
31280
+ */
31281
+ function assertSafeAssetPath(assetPath) {
31282
+ const segments = assetPath.split("/");
31283
+ if (assetPath.length === 0 || assetPath.startsWith("/") || assetPath.includes("\\") || segments.includes("..") || segments.includes(".")) throw new Error("invalid source map path: must be relative, without '.' or '..' segments");
31284
+ }
31285
+ /** Marks when a commit last received a push; see `listCommits`. */
31286
+ const PUSHED_AT = "pushed-at.json";
31287
+ function createFileSourceMapStore(root) {
31288
+ return {
31289
+ async put(project, commit, assetPath, bytes) {
31290
+ assertSafeName(project, "project");
31291
+ assertSafeName(commit, "commit");
31292
+ assertSafeAssetPath(assetPath);
31293
+ const dir = sourceMapCommitDir(root, project, commit);
31294
+ await writeBytes(join(dir, assetPath), bytes);
31295
+ await writeJson(join(dir, PUSHED_AT), { at: Date.now() });
31296
+ },
31297
+ async read(project, commit, assetPath) {
31298
+ assertSafeName(project, "project");
31299
+ assertSafeName(commit, "commit");
31300
+ assertSafeAssetPath(assetPath);
31301
+ return readBytesOrNull(join(sourceMapCommitDir(root, project, commit), assetPath));
31302
+ },
31303
+ async list(project, commit) {
31304
+ assertSafeName(project, "project");
31305
+ assertSafeName(commit, "commit");
31306
+ return (await listFilesRecursive(sourceMapCommitDir(root, project, commit))).filter((file) => file !== PUSHED_AT);
31307
+ },
31308
+ async listCommits(project) {
31309
+ assertSafeName(project, "project");
31310
+ const dir = sourceMapProjectDir(root, project);
31311
+ const commits = await listSubdirsOrEmpty(dir);
31312
+ return (await Promise.all(commits.map(async (commit) => ({
31313
+ commit,
31314
+ at: (await readJson(join(dir, commit, PUSHED_AT)).catch(() => null))?.at ?? 0
31315
+ })))).sort((a, b) => b.at - a.at).map((entry) => entry.commit);
31316
+ },
31317
+ async delete(project, commit) {
31318
+ assertSafeName(project, "project");
31319
+ assertSafeName(commit, "commit");
31320
+ await removePath(sourceMapCommitDir(root, project, commit));
31321
+ }
31322
+ };
31323
+ }
31324
+ //#endregion
30969
31325
  //#region src/hub/core/storage/file/deploy-store.ts
30970
31326
  function createFileDeployStore(root) {
30971
31327
  const readLog = async (project, profile) => await readJson(deployLogPath(root, project, profile)) ?? emptyDeployLog();
@@ -31094,6 +31450,47 @@ function createFileSpecLedgerStore(root) {
31094
31450
  };
31095
31451
  }
31096
31452
  //#endregion
31453
+ //#region src/hub/core/storage/file/coverage-edge-store.ts
31454
+ /**
31455
+ * Coverage-edge ledger (ADR-0026): one JSON document per project. `merge`
31456
+ * goes through `updateJson`, so two runs finishing at once queue their
31457
+ * read-modify-writes instead of clobbering each other, and each entry only
31458
+ * ever moves forward — a run's measurement replaces a spec's entry, never
31459
+ * deletes another spec's.
31460
+ */
31461
+ function createFileCoverageEdgeStore(root) {
31462
+ return {
31463
+ async get(project) {
31464
+ assertSafeName(project, "project");
31465
+ const raw = await readJson(coverageEdgesPath(root, project));
31466
+ return raw === null ? null : parseDoc(raw, project);
31467
+ },
31468
+ async merge(project, specs, measuredAt) {
31469
+ assertSafeName(project, "project");
31470
+ await updateJson(coverageEdgesPath(root, project), (current) => {
31471
+ const doc = current === null ? { specs: {} } : parseDoc(current, project);
31472
+ for (const [key, entry] of Object.entries(specs)) doc.specs[key] = {
31473
+ files: [...entry.files].sort(),
31474
+ measuredAt,
31475
+ ...entry.runId === void 0 ? {} : { runId: entry.runId }
31476
+ };
31477
+ return doc;
31478
+ });
31479
+ }
31480
+ };
31481
+ }
31482
+ /**
31483
+ * A present document that does not parse is an error, never an empty ledger:
31484
+ * treating it as empty would let the next merge silently discard every other
31485
+ * spec's edge, and the data is regenerated only by running every spec
31486
+ * measured again.
31487
+ */
31488
+ function parseDoc(raw, project) {
31489
+ const parsed = CoverageEdgesDocSchema.safeParse(raw);
31490
+ if (!parsed.success) throw new Error(`coverage-edges document for project "${project}" does not match the schema`);
31491
+ return parsed.data;
31492
+ }
31493
+ //#endregion
31097
31494
  //#region src/hub/core/storage/file/perspectives-store.ts
31098
31495
  /**
31099
31496
  * Perspectives storage: one JSON document per project, plain UTF-8 with no
@@ -31400,6 +31797,7 @@ function createFileHubStorage(dataDir) {
31400
31797
  triage: createFileTriageStore(dataDir),
31401
31798
  prompts: createFilePromptStore(dataDir),
31402
31799
  perspectives: createFilePerspectivesStore(dataDir),
31800
+ coverageEdges: createFileCoverageEdgeStore(dataDir),
31403
31801
  jobs: createFileJobStore(dataDir),
31404
31802
  ledger: createFileSpecLedgerStore(dataDir),
31405
31803
  driftLedger: createFileDriftLedgerStore(dataDir),
@@ -31409,7 +31807,8 @@ function createFileHubStorage(dataDir) {
31409
31807
  spend: createFileSpendStore(dataDir),
31410
31808
  attestations: createFileAttestationStore(dataDir),
31411
31809
  auditDismissals: createFileAuditDismissalStore(dataDir),
31412
- coverageEvents: createFileCoverageEventStore(dataDir)
31810
+ coverageEvents: createFileCoverageEventStore(dataDir),
31811
+ sourceMaps: createFileSourceMapStore(dataDir)
31413
31812
  };
31414
31813
  }
31415
31814
  //#endregion