ccqa 1.40.7 → 1.41.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
@@ -6422,6 +6422,7 @@ var FrontendResolution = class {
6422
6422
  coverageDir;
6423
6423
  roots;
6424
6424
  fetchText;
6425
+ fetchStoredMap;
6425
6426
  warn;
6426
6427
  files = /* @__PURE__ */ new Set();
6427
6428
  /**
@@ -6448,6 +6449,7 @@ var FrontendResolution = class {
6448
6449
  this.coverageDir = opts.coverageDir;
6449
6450
  this.roots = opts.roots;
6450
6451
  this.fetchText = opts.fetchText;
6452
+ this.fetchStoredMap = opts.fetchStoredMap;
6451
6453
  this.warn = opts.warn;
6452
6454
  }
6453
6455
  async absorb(script) {
@@ -6538,24 +6540,46 @@ var FrontendResolution = class {
6538
6540
  async fetchSourceMap(script) {
6539
6541
  const source = await script.source();
6540
6542
  if (source === void 0) return void 0;
6543
+ for (const load of this.sourceMapLoaders(script, source)) {
6544
+ const json = await load();
6545
+ if (json === void 0) continue;
6546
+ const map = parseSourceMap(json);
6547
+ if (map !== void 0) return prepareSourceMap(map, source);
6548
+ }
6549
+ }
6550
+ /**
6551
+ * Ways to get a map for `script`, most authoritative first and none of them
6552
+ * taken until the one before it fails. A map the script points at describes
6553
+ * the code that ran, so the stored copy is only asked for when that is
6554
+ * absent or turns out not to be a map — a catch-all route answers a missing
6555
+ * `.map` with an HTML page, which is a body but not a map. Reading it
6556
+ * eagerly would put a request on the wire for every script of every spec,
6557
+ * nearly all of them misses on a deployment that pushes nothing.
6558
+ */
6559
+ sourceMapLoaders(script, source) {
6560
+ const loaders = [];
6541
6561
  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;
6562
+ if (reference !== void 0) {
6563
+ const inline = decodeInlineSourceMap(reference);
6564
+ if (inline !== void 0) loaders.push(() => Promise.resolve(inline));
6565
+ else {
6566
+ const target = absoluteOrUndefined(reference, script.url);
6567
+ if (target !== void 0) loaders.push(() => this.fetchText(target));
6550
6568
  }
6551
- json = await this.fetchText(target);
6552
- if (json === void 0) return void 0;
6553
6569
  }
6554
- const map = parseSourceMap(json);
6555
- if (map === void 0) return void 0;
6556
- return prepareSourceMap(map, source);
6570
+ const stored = this.fetchStoredMap;
6571
+ if (stored !== void 0) loaders.push(() => stored(script.url));
6572
+ return loaders;
6557
6573
  }
6558
6574
  };
6575
+ /** `reference` resolved against the script's URL, or undefined when it is not a URL. */
6576
+ function absoluteOrUndefined(reference, scriptUrl) {
6577
+ try {
6578
+ return new URL(reference, scriptUrl).toString();
6579
+ } catch {
6580
+ return;
6581
+ }
6582
+ }
6559
6583
  function message$2(error) {
6560
6584
  return error instanceof Error ? error.message : String(error);
6561
6585
  }
@@ -7149,6 +7173,7 @@ var Engine = class {
7149
7173
  coverageDir: opts.coverageDir,
7150
7174
  roots: opts.roots,
7151
7175
  fetchText: (url) => this.fetchThroughBrowser(url),
7176
+ fetchStoredMap: (scriptUrl) => this.storedMapFor(scriptUrl),
7152
7177
  warn: opts.warn
7153
7178
  });
7154
7179
  }
@@ -7414,6 +7439,23 @@ var Engine = class {
7414
7439
  return;
7415
7440
  }
7416
7441
  }
7442
+ /**
7443
+ * The map a deploy stored for `scriptUrl`, if this run knows where to ask.
7444
+ * Addressed by the path under the asset origin rather than the URL, so the
7445
+ * push and the read agree without either knowing the other's host.
7446
+ */
7447
+ async storedMapFor(scriptUrl) {
7448
+ const stored = this.opts.fetchStoredSourceMap;
7449
+ if (stored === void 0) return void 0;
7450
+ const assetPath = assetPathOf(scriptUrl, [...this.opts.origins, ...this.opts.assetOrigins ?? []]);
7451
+ if (assetPath === void 0) return void 0;
7452
+ try {
7453
+ return await stored(`${assetPath}.map`);
7454
+ } catch (err) {
7455
+ this.opts.warn(`could not read the stored source map for ${assetPath}: ${message(err)}`);
7456
+ return;
7457
+ }
7458
+ }
7417
7459
  async readStream(handle, sessionId) {
7418
7460
  const parts = [];
7419
7461
  for (;;) {
@@ -7428,6 +7470,25 @@ var Engine = class {
7428
7470
  function message(error) {
7429
7471
  return error instanceof Error ? error.message : String(error);
7430
7472
  }
7473
+ /**
7474
+ * The path a stored map is filed under: the URL with its origin removed, and
7475
+ * with any deploy-scoped prefix (`/assets/<sha>/`) left in place — that prefix
7476
+ * is part of what the browser asked for, so the push has to have used it too.
7477
+ * A URL from an origin the run never declared is not ours to look up.
7478
+ */
7479
+ function assetPathOf(url, origins) {
7480
+ let parsed;
7481
+ try {
7482
+ parsed = new URL(url);
7483
+ } catch {
7484
+ return;
7485
+ }
7486
+ const path = `${parsed.origin}${parsed.pathname}`;
7487
+ for (const origin of origins) {
7488
+ const base = origin.replace(/\/+$/, "");
7489
+ if (path.startsWith(`${base}/`)) return path.slice(base.length + 1);
7490
+ }
7491
+ }
7431
7492
  //#endregion
7432
7493
  //#region src/coverage/universe.ts
7433
7494
  /**
@@ -7503,8 +7564,20 @@ async function walk(abs, rel, out, warn) {
7503
7564
  const SETTLE_POLL_MS = 250;
7504
7565
  const SETTLE_QUIET_POLLS = 10;
7505
7566
  const SETTLE_CAP_MS = 1e4;
7567
+ /**
7568
+ * Origins with their `${VAR}`s resolved, refusing anything that is not an
7569
+ * absolute http(s) URL. An unset variable resolves to the empty string, which
7570
+ * would otherwise match nothing and take the feature down without a word.
7571
+ */
7572
+ function resolveAbsoluteOrigins(origins, label) {
7573
+ const resolved = origins.map((origin) => resolveEnvRefs(origin));
7574
+ const unresolved = resolved.filter((origin) => !/^https?:\/\//i.test(origin));
7575
+ if (unresolved.length > 0) throw new Error(`${label} must be absolute http(s) URLs after variable substitution; got ${unresolved.join(", ")}`);
7576
+ return resolved;
7577
+ }
7506
7578
  var CoverageSession = class CoverageSession {
7507
7579
  existing = /* @__PURE__ */ new Map();
7580
+ fetchStoredSourceMap;
7508
7581
  /**
7509
7582
  * Undefined in hub mode: nothing binds on the runner, and every read-side
7510
7583
  * answer here stays empty — interpretation lives in the hub's resolve
@@ -7525,13 +7598,15 @@ var CoverageSession = class CoverageSession {
7525
7598
  cwd;
7526
7599
  actors;
7527
7600
  origins;
7601
+ /** Where this project's assets come from — the cookie never goes here. */
7602
+ assetOrigins;
7528
7603
  /**
7529
7604
  * The denominator, enumerated once at start, or undefined when
7530
7605
  * `coverage.include` is unset — and always in hub mode, where it travels as
7531
7606
  * a `universe` event instead of riding the report envelope.
7532
7607
  */
7533
7608
  universe;
7534
- constructor(sink, inbox, runId, root, cwd, actors, origins, universe) {
7609
+ constructor(sink, inbox, runId, root, cwd, actors, origins, assetOrigins, universe, fetchStoredSourceMap) {
7535
7610
  this.sink = sink;
7536
7611
  this.inbox = inbox;
7537
7612
  this.runId = runId;
@@ -7539,12 +7614,12 @@ var CoverageSession = class CoverageSession {
7539
7614
  this.cwd = cwd;
7540
7615
  this.actors = actors;
7541
7616
  this.origins = origins;
7617
+ this.assetOrigins = assetOrigins;
7542
7618
  this.universe = universe;
7619
+ this.fetchStoredSourceMap = fetchStoredSourceMap;
7543
7620
  }
7544
7621
  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(", ")}`);
7622
+ const origins = resolveAbsoluteOrigins(options.config.instrumentedOrigins, "coverage.instrumentedOrigins");
7548
7623
  const actors = options.actors ?? NO_ACTORS;
7549
7624
  let sink;
7550
7625
  if (options.inbox === void 0) {
@@ -7560,7 +7635,7 @@ var CoverageSession = class CoverageSession {
7560
7635
  include: [...universe.include],
7561
7636
  files: [...universe.files]
7562
7637
  });
7563
- return new CoverageSession(sink, options.inbox, options.runId, root, options.cwd, actors, origins, options.inbox === void 0 ? universe : void 0);
7638
+ 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
7639
  }
7565
7640
  /**
7566
7641
  * Ties the stream's run id to the hub's run record. The session starts
@@ -7635,12 +7710,14 @@ var CoverageSession = class CoverageSession {
7635
7710
  cdpUrl,
7636
7711
  specId: specIdFor(this.runId, ref),
7637
7712
  origins: this.origins,
7713
+ assetOrigins: this.assetOrigins,
7638
7714
  coverageDir,
7639
7715
  roots: {
7640
7716
  base: this.cwd,
7641
7717
  root: this.root
7642
7718
  },
7643
- warn: (text) => warn(`coverage: ${text}`)
7719
+ warn: (text) => warn(`coverage: ${text}`),
7720
+ fetchStoredSourceMap: this.fetchStoredSourceMap
7644
7721
  });
7645
7722
  }
7646
7723
  /**
@@ -10843,6 +10920,177 @@ function readOctal(buf, offset, fieldLen) {
10843
10920
  return str === "" ? 0 : parseInt(str, 8);
10844
10921
  }
10845
10922
  //#endregion
10923
+ //#region src/hub/core/storage/file/fs-helpers.ts
10924
+ /**
10925
+ * Defense-in-depth for a name this layer joins into a file path. The API's
10926
+ * `SAFE_SEGMENT` (api/validate.ts) is deliberately stricter — it also fixes a
10927
+ * charset and a length — and stays the rule for what a client may name; this
10928
+ * only refuses traversal, so it also covers callers that never came through
10929
+ * HTTP (tests, a library embedding the hub).
10930
+ */
10931
+ function assertSafeName(value, label) {
10932
+ if (value.length === 0 || value === "." || value === ".." || value.includes("/") || value.includes("\\")) throw new Error(`invalid ${label}: must be a bare name without path separators or '..'`);
10933
+ }
10934
+ /** Read and JSON-parse a file, returning `null` when it doesn't exist. Malformed JSON throws. */
10935
+ async function readJson(path) {
10936
+ let raw;
10937
+ try {
10938
+ raw = await readFile(path, "utf8");
10939
+ } catch (err) {
10940
+ if (isNotFound(err)) return null;
10941
+ throw err;
10942
+ }
10943
+ try {
10944
+ return JSON.parse(raw);
10945
+ } catch (err) {
10946
+ throw new Error(`corrupt JSON at ${path}: ${err instanceof Error ? err.message : String(err)}`);
10947
+ }
10948
+ }
10949
+ /**
10950
+ * Write `data` to a temp file in the same directory, then atomically rename it
10951
+ * into place, so a concurrent reader only ever observes the old file or the
10952
+ * fully-written new one — never the empty/partial window a plain
10953
+ * truncate-then-write leaves open (e.g. a `putActualCause` writing a run's
10954
+ * triage file while an API request reads it). The temp file lives in the same
10955
+ * directory as its target to keep the rename on one filesystem, where it is
10956
+ * atomic.
10957
+ */
10958
+ async function atomicWrite(path, data) {
10959
+ await mkdir(dirname(path), { recursive: true });
10960
+ const tmp = `${path}.${randomUUID()}.tmp`;
10961
+ await writeFile(tmp, data);
10962
+ await rename(tmp, path);
10963
+ }
10964
+ /** Write `value` as pretty JSON, creating parent directories as needed. */
10965
+ async function writeJson(path, value) {
10966
+ await atomicWrite(path, JSON.stringify(value, null, 2) + "\n");
10967
+ }
10968
+ /**
10969
+ * Per-path serialization for read-modify-write JSON updates. Two concurrent
10970
+ * `updateJson` calls on the same path (e.g. two `putActualCause` calls racing
10971
+ * on the same run's triage file) would otherwise both read the same starting
10972
+ * state and the second writer's change would silently clobber the first's —
10973
+ * this queues the second call's read until the first's write has landed, so
10974
+ * updates apply in the order they were issued rather than racing. Scoped by
10975
+ * path (a `Map` of chained promises), not globally, so unrelated records still
10976
+ * update concurrently.
10977
+ */
10978
+ const updateChains = /* @__PURE__ */ new Map();
10979
+ /**
10980
+ * Queue `work` behind whatever is already in flight for `path`. A delete takes
10981
+ * the same chain as the updates it removes: `writeJson` recreates the parent
10982
+ * directory, so an unordered delete would be silently undone by an update that
10983
+ * was already queued.
10984
+ */
10985
+ async function serialize(path, work) {
10986
+ const next = (updateChains.get(path) ?? Promise.resolve()).catch(() => {}).then(work);
10987
+ updateChains.set(path, next);
10988
+ try {
10989
+ return await next;
10990
+ } finally {
10991
+ if (updateChains.get(path) === next) updateChains.delete(path);
10992
+ }
10993
+ }
10994
+ async function updateJson(path, mutate) {
10995
+ return await serialize(path, async () => {
10996
+ const updated = mutate(await readJson(path));
10997
+ await writeJson(path, updated);
10998
+ return updated;
10999
+ });
11000
+ }
11001
+ /** Read a raw file, returning `null` when it doesn't exist. */
11002
+ async function readBytesOrNull(path) {
11003
+ try {
11004
+ const buf = await readFile(path);
11005
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
11006
+ } catch (err) {
11007
+ if (isNotFound(err)) return null;
11008
+ throw err;
11009
+ }
11010
+ }
11011
+ async function writeBytes(path, bytes) {
11012
+ await atomicWrite(path, bytes);
11013
+ }
11014
+ /** List entry names (files or dirs) directly under `dir`, or `[]` when it doesn't exist. */
11015
+ async function listDirOrEmpty(dir) {
11016
+ try {
11017
+ return await readdir(dir);
11018
+ } catch (err) {
11019
+ if (isNotFound(err)) return [];
11020
+ throw err;
11021
+ }
11022
+ }
11023
+ /**
11024
+ * Subdirectory names directly under `dir`, or `[]` when it doesn't exist.
11025
+ * Skips files and dot-entries so stray filesystem litter (e.g. a Finder
11026
+ * `.DS_Store`) never surfaces as a record id or project name.
11027
+ */
11028
+ async function listSubdirsOrEmpty(dir) {
11029
+ try {
11030
+ return (await readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
11031
+ } catch (err) {
11032
+ if (isNotFound(err)) return [];
11033
+ throw err;
11034
+ }
11035
+ }
11036
+ /** Every file path under `dir`, relative to `dir`, posix-separated. */
11037
+ async function listFilesRecursive(dir) {
11038
+ const out = [];
11039
+ async function walk(current) {
11040
+ let entries;
11041
+ try {
11042
+ entries = await readdir(current, { withFileTypes: true });
11043
+ } catch (err) {
11044
+ if (isNotFound(err)) return;
11045
+ throw err;
11046
+ }
11047
+ for (const entry of entries) {
11048
+ const abs = join(current, entry.name);
11049
+ if (entry.isDirectory()) await walk(abs);
11050
+ else if (entry.isFile()) out.push(relative(dir, abs).split(sep).join("/"));
11051
+ }
11052
+ }
11053
+ await walk(dir);
11054
+ return out;
11055
+ }
11056
+ async function removePath(path) {
11057
+ await rm(path, {
11058
+ recursive: true,
11059
+ force: true
11060
+ });
11061
+ }
11062
+ function isNotFound(err) {
11063
+ return err instanceof Error && "code" in err && err.code === "ENOENT";
11064
+ }
11065
+ //#endregion
11066
+ //#region src/coverage/frontend/reduce-map.ts
11067
+ /**
11068
+ * `json` reduced to the readable fields, or undefined when it is not a source
11069
+ * map this side can use — the same refusals `parseSourceMap` makes, applied at
11070
+ * push time so an unusable map is never stored.
11071
+ */
11072
+ function reduceSourceMap(json) {
11073
+ let parsed;
11074
+ try {
11075
+ parsed = JSON.parse(json);
11076
+ } catch {
11077
+ return;
11078
+ }
11079
+ if (typeof parsed !== "object" || parsed === null) return void 0;
11080
+ const map = parsed;
11081
+ if (map.sections !== void 0) return void 0;
11082
+ if (map.version !== 3) return void 0;
11083
+ if (typeof map.mappings !== "string") return void 0;
11084
+ if (!Array.isArray(map.sources)) return void 0;
11085
+ return {
11086
+ version: 3,
11087
+ sources: map.sources,
11088
+ mappings: map.mappings,
11089
+ ...typeof map.sourceRoot === "string" ? { sourceRoot: map.sourceRoot } : {},
11090
+ ...typeof map.file === "string" ? { file: map.file } : {}
11091
+ };
11092
+ }
11093
+ //#endregion
10846
11094
  //#region src/runtime/session-state.ts
10847
11095
  /** Default per-profile sessions root, relative to the project (`--cwd`). */
10848
11096
  const SESSIONS_SUBDIR = ".ccqa/sessions";
@@ -11401,6 +11649,7 @@ const CoverageActorsSchema = z.record(z.string().regex(/^[a-z0-9][a-z0-9._-]*$/i
11401
11649
  */
11402
11650
  const CoverageConfigSchema = z.object({
11403
11651
  instrumentedOrigins: z.array(z.string().min(1)).min(1),
11652
+ assetOrigins: z.array(z.string().min(1)).optional(),
11404
11653
  sink: z.string().min(1).default("http://127.0.0.1:4757"),
11405
11654
  projectRoot: z.string().min(1).optional(),
11406
11655
  include: z.array(z.string().min(1)).optional(),
@@ -12307,6 +12556,49 @@ const varRm = new Command("rm").description("Delete a variable from the hub.").a
12307
12556
  info(`deleted variable "${name}" from the hub`);
12308
12557
  }));
12309
12558
  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);
12559
+ 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) => {
12560
+ const project = resolveProject(opts);
12561
+ const hub = connect$2(opts);
12562
+ const dir = resolve(resolveCwd(opts.cwd), dirArg);
12563
+ if (!existsSync(dir)) throw new Error(`no such directory: ${dir}`);
12564
+ const maps = (await listFilesRecursive(dir)).filter((f) => f.endsWith(".map"));
12565
+ const prefix = opts.assetPrefix.replace(/^\/+|\/+$/g, "");
12566
+ header("hub sourcemap push", `${project}@${opts.sha.slice(0, 12)}`);
12567
+ meta("dir", dir);
12568
+ if (maps.length === 0) {
12569
+ warn(`no *.map under ${dir} — nothing to push (was the build run with source maps enabled?)`);
12570
+ return;
12571
+ }
12572
+ let pushed = 0;
12573
+ let bytes = 0;
12574
+ const unusable = [];
12575
+ for (const rel of maps) {
12576
+ const reduced = reduceSourceMap(await readFile(join(dir, rel), "utf8"));
12577
+ if (reduced === void 0) {
12578
+ unusable.push(rel);
12579
+ continue;
12580
+ }
12581
+ const body = new TextEncoder().encode(JSON.stringify(reduced));
12582
+ await hub.putSourceMap(project, opts.sha, prefix ? `${prefix}/${rel}` : rel, body);
12583
+ pushed++;
12584
+ bytes += body.length;
12585
+ }
12586
+ if (unusable.length > 0) warn(`${unusable.length} map(s) were not source maps this side can read, e.g. ${unusable[0]}`);
12587
+ await hub.sweepSourceMaps(project);
12588
+ info(`pushed ${pushed} source map(s), ${Math.round(bytes / 1024)} KiB`);
12589
+ }));
12590
+ 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) => {
12591
+ const project = resolveProject(opts);
12592
+ const paths = await connect$2(opts).listSourceMaps(project, opts.sha);
12593
+ header("hub sourcemap ls", `${project}@${opts.sha.slice(0, 12)}`);
12594
+ if (paths.length === 0) {
12595
+ info("no source maps stored for this commit");
12596
+ return;
12597
+ }
12598
+ for (const path of paths) meta("", path);
12599
+ info(`${paths.length} source map(s)`);
12600
+ }));
12601
+ 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
12602
  function validatePromptName(rawName) {
12311
12603
  if (!isPromptName(rawName)) {
12312
12604
  error(`invalid prompt name "${rawName}"`);
@@ -12595,7 +12887,7 @@ async function runCoverageInspect(opts) {
12595
12887
  const outside = Object.entries(h.outsideWindowEvents);
12596
12888
  if (outside.length > 0) warn(`outside-window events (identity was driven while unclaimed): ${outside.map(([key, count]) => `${key}: ${count}`).join(", ")}`);
12597
12889
  }
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);
12890
+ 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
12891
  /** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
12600
12892
  function isStorageStateShape(state) {
12601
12893
  return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
@@ -16360,6 +16652,53 @@ async function collectChangedSpecs(specs, opts) {
16360
16652
  };
16361
16653
  }
16362
16654
  //#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
16363
16702
  //#region src/run/pipeline.ts
16364
16703
  async function resolveVitestConfig(cwd) {
16365
16704
  const userConfig = resolve(cwd, ".ccqa/vitest.config.ts");
@@ -16538,6 +16877,10 @@ async function executeRun(targets, opts) {
16538
16877
  if (rerunProfile !== null && hubCtx == null) throw new RunUsageError(needsHubConnection("--only-hub-rerun-needed"));
16539
16878
  if (opts.reportToHub && hubCtx == null) throw new RunUsageError(REPORT_TO_HUB_NEEDS_CONNECTION);
16540
16879
  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
+ }
16541
16884
  let coverageInbox;
16542
16885
  if (opts.coverageInbox === "hub") {
16543
16886
  if (opts.coverage !== true) throw new RunUsageError("--coverage-inbox hub does nothing without --coverage — there is no measurement to stream");
@@ -16586,6 +16929,7 @@ async function executeRun(targets, opts) {
16586
16929
  };
16587
16930
  const enumerateAll = () => listAllSpecsWithSpecFile(cwd);
16588
16931
  let specs = dedupeSpecs((await Promise.all((targets.length ? targets : [void 0]).map((t) => resolveSpecTargets(t, enumerateAll, cwd)))).flat());
16932
+ const inventory = specs;
16589
16933
  if (filtering) {
16590
16934
  const before = specs.length;
16591
16935
  let inProgress = 0;
@@ -16616,6 +16960,14 @@ async function executeRun(targets, opts) {
16616
16960
  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");
16617
16961
  }
16618
16962
  }
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
+ }
16619
16971
  if (specs.length === 0) {
16620
16972
  warn("no specs to run");
16621
16973
  return {
@@ -16670,7 +17022,7 @@ async function executeRun(targets, opts) {
16670
17022
  const liveSpecs = withMode.filter((s) => s.mode === "live");
16671
17023
  meta("modes", `${detSpecs.length} deterministic / ${liveSpecs.length} live`);
16672
17024
  if (dispatch.external.length > 0) meta("external", dispatch.external.map((g) => `${g.targetId} ${g.specs.length}`).join(" / "));
16673
- const coverage = opts.coverage && forExecution ? await startCoverage(cwd, projectConfig.coverage, actors, dispatch, opts.teardown, coverageInbox) : void 0;
17025
+ const coverage = opts.coverage && forExecution ? await startCoverage(cwd, projectConfig.coverage, actors, dispatch, opts.teardown, coverageInbox, storedSourceMapReader(hubCtx, deployedSha), hubCtx !== null) : void 0;
16674
17026
  if (liveSpecs.length === 0) {
16675
17027
  const why = "it only applies to agent-browser 'mode: live' specs, and this run has none";
16676
17028
  if (typeof opts.liveStepRetry === "number" && opts.liveStepRetry > 0) warn(`--live-step-retry is ignored: ${why}`);
@@ -16873,13 +17225,7 @@ async function executeRun(targets, opts) {
16873
17225
  reportDir
16874
17226
  };
16875
17227
  }
16876
- /**
16877
- * Starts the run's coverage measurement before any spec runs: the sink has to
16878
- * be listening before the first request reaches the application, and the set
16879
- * of spec ids it will accept is only known once dispatch has resolved. With
16880
- * an `inbox`, nothing binds — the run streams its events to the hub instead.
16881
- */
16882
- async function startCoverage(cwd, config, actors, dispatch, teardown, inbox) {
17228
+ async function startCoverage(cwd, config, actors, dispatch, teardown, inbox, fetchStoredSourceMap, hubConfigured) {
16883
17229
  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");
16884
17230
  let session;
16885
17231
  try {
@@ -16889,11 +17235,13 @@ async function startCoverage(cwd, config, actors, dispatch, teardown, inbox) {
16889
17235
  config,
16890
17236
  actors,
16891
17237
  specs: dispatch.external.flatMap((g) => g.specs),
16892
- ...inbox ? { inbox } : {}
17238
+ ...inbox ? { inbox } : {},
17239
+ ...fetchStoredSourceMap ? { fetchStoredSourceMap } : {}
16893
17240
  });
16894
17241
  } catch (err) {
16895
17242
  throw new RunUsageError(`could not start coverage collection: ${errMessage(err)}`);
16896
17243
  }
17244
+ 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.");
16897
17245
  if (inbox !== void 0) meta("coverage", `streaming to hub inbox → ${session.origins.join(", ")}`);
16898
17246
  else meta("coverage", `sink ${session.sinkUrl} → ${session.origins.join(", ")}`);
16899
17247
  const unmeasured = dispatch.external.filter((g) => g.browserCoverage.browser === "none").length;
@@ -16902,6 +17250,28 @@ async function startCoverage(cwd, config, actors, dispatch, teardown, inbox) {
16902
17250
  return session;
16903
17251
  }
16904
17252
  /**
17253
+ * Starts the run's coverage measurement before any spec runs: the sink has to
17254
+ * be listening before the first request reaches the application, and the set
17255
+ * of spec ids it will accept is only known once dispatch has resolved. With
17256
+ * an `inbox`, nothing binds — the run streams its events to the hub instead.
17257
+ */
17258
+ /**
17259
+ * Needs both a hub and the commit under test: without either there is nothing
17260
+ * to address a stored map by, and reading some other commit's maps would name
17261
+ * the wrong files. See `SourceMapStore`.
17262
+ */
17263
+ function storedSourceMapReader(hubCtx, deployedSha) {
17264
+ if (hubCtx === null || deployedSha === null) return void 0;
17265
+ const seen = /* @__PURE__ */ new Map();
17266
+ return async (assetPath) => {
17267
+ const cached = seen.get(assetPath);
17268
+ if (cached !== void 0 || seen.has(assetPath)) return cached;
17269
+ const map = await hubCtx.hub.getSourceMap(hubCtx.project, deployedSha, assetPath) ?? void 0;
17270
+ seen.set(assetPath, map);
17271
+ return map;
17272
+ };
17273
+ }
17274
+ /**
16905
17275
  * A run that measured coverage still leaves rows without any — a spec on a
16906
17276
  * target that declares no browser, or one that never executed. Saying why keeps
16907
17277
  * the reader from reading a blank as "this spec reached nothing".
@@ -17570,7 +17940,11 @@ const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command(
17570
17940
  }, "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) => {
17571
17941
  if (COVERAGE_INBOX_MODES.includes(raw)) return raw;
17572
17942
  throw new Error(`--coverage-inbox must be one of ${COVERAGE_INBOX_MODES.join(" | ")}`);
17573
- }, "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) => {
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) => {
17574
17948
  await runCliAction(targets, opts);
17575
17949
  });
17576
17950
  /** Parse --concurrency: a positive integer. Rejects 0, negatives, non-integers. */
@@ -21402,6 +21776,20 @@ function mergeBucket(into, from) {
21402
21776
  //#endregion
21403
21777
  //#region src/hub/core/retention.ts
21404
21778
  /**
21779
+ * Drop the source maps of every commit past the newest `maxCommits`.
21780
+ *
21781
+ * Hung off a push for the same reason run retention hangs off a terminal run:
21782
+ * the hub whose disk grows is the one written to constantly and never
21783
+ * restarted. Best-effort — a lost sweep costs disk, while failing the push it
21784
+ * runs beside would cost the deploy.
21785
+ */
21786
+ async function sweepSourceMapRetention(storage, project, maxCommits) {
21787
+ try {
21788
+ const commits = await storage.sourceMaps.listCommits(project);
21789
+ for (const commit of commits.slice(maxCommits)) await storage.sourceMaps.delete(project, commit);
21790
+ } catch {}
21791
+ }
21792
+ /**
21405
21793
  * Drop everything past the newest `maxRuns` of the (project, branch) that
21406
21794
  * `run` belongs to, taking each evicted run's artifacts and triage records
21407
21795
  * with it.
@@ -22338,6 +22726,58 @@ function createGetDriftLedgerHandler(storage) {
22338
22726
  };
22339
22727
  }
22340
22728
  //#endregion
22729
+ //#region src/hub/api/handlers/sourcemaps.ts
22730
+ /**
22731
+ * A source map is roughly the size of the code it describes, and a single
22732
+ * bundle chunk can be a few MB. Generous, but still a ceiling: a push is many
22733
+ * small requests rather than one archive, so no single body should approach it.
22734
+ */
22735
+ const MAX_MAP_BYTES = 32 * 1024 * 1024;
22736
+ function createPutSourceMapHandler(storage) {
22737
+ return async (ctx) => {
22738
+ const project = requireSafeSegment(ctx.params.project, "project");
22739
+ const commit = requireSafeSegment(ctx.params.commit, "commit");
22740
+ const assetPath = requireSafeRelPath(ctx.params.path, "source map path");
22741
+ const body = await readBody(ctx.req, MAX_MAP_BYTES);
22742
+ await storage.sourceMaps.put(project, commit, assetPath, body);
22743
+ ctx.res.statusCode = 204;
22744
+ ctx.res.end();
22745
+ };
22746
+ }
22747
+ function createGetSourceMapHandler(storage) {
22748
+ return async (ctx) => {
22749
+ const project = requireSafeSegment(ctx.params.project, "project");
22750
+ const commit = requireSafeSegment(ctx.params.commit, "commit");
22751
+ const assetPath = requireSafeRelPath(ctx.params.path, "source map path");
22752
+ const bytes = await storage.sourceMaps.read(project, commit, assetPath);
22753
+ if (!bytes) throw new HttpError(404, "not_found", `no source map stored for "${assetPath}" at ${commit}`);
22754
+ sendBytes(ctx.res, 200, bytes, "application/json; charset=utf-8");
22755
+ };
22756
+ }
22757
+ /**
22758
+ * Ends a push: everything for this commit has landed, so older commits can go.
22759
+ * Separate from the PUTs because a push is hundreds of them, and sweeping on
22760
+ * each would walk the whole project every time.
22761
+ */
22762
+ function createSweepSourceMapsHandler(storage) {
22763
+ return async (ctx) => {
22764
+ await sweepSourceMapRetention(storage, requireSafeSegment(ctx.params.project, "project"), 10);
22765
+ ctx.res.statusCode = 204;
22766
+ ctx.res.end();
22767
+ };
22768
+ }
22769
+ function createListSourceMapsHandler(storage) {
22770
+ return async (ctx) => {
22771
+ const project = requireSafeSegment(ctx.params.project, "project");
22772
+ const commit = requireSafeSegment(ctx.params.commit, "commit");
22773
+ sendJson(ctx.res, 200, {
22774
+ project,
22775
+ commit,
22776
+ paths: await storage.sourceMaps.list(project, commit)
22777
+ });
22778
+ };
22779
+ }
22780
+ //#endregion
22341
22781
  //#region src/hub/api/handlers/deploys.ts
22342
22782
  /** `changedPaths` for a wide refactor can run to tens of thousands of entries. */
22343
22783
  const MAX_DEPLOY_BODY_BYTES = 8 * 1024 * 1024;
@@ -30300,6 +30740,10 @@ function registerRoutes(router, config, queue) {
30300
30740
  router.get("/api/v1/projects/:project/deploys", createGetDeployLogHandler(storage));
30301
30741
  router.get("/api/v1/projects/:project/rerun", createGetRerunHandler(storage));
30302
30742
  router.get("/api/v1/projects/:project/audit-needed", createGetAuditNeedHandler(storage));
30743
+ router.put("/api/v1/projects/:project/sourcemaps/:commit/*path", createPutSourceMapHandler(storage));
30744
+ router.post("/api/v1/projects/:project/sourcemaps/sweep", createSweepSourceMapsHandler(storage));
30745
+ router.get("/api/v1/projects/:project/sourcemaps/:commit", createListSourceMapsHandler(storage));
30746
+ router.get("/api/v1/projects/:project/sourcemaps/:commit/*path", createGetSourceMapHandler(storage));
30303
30747
  router.post("/api/v1/projects/:project/locks", createAcquireLocksHandler(storage));
30304
30748
  router.delete("/api/v1/projects/:project/locks", createReleaseLocksHandler(storage));
30305
30749
  router.get("/api/v1/projects/:project/attestations", createGetAttestationsHandler(storage));
@@ -30354,149 +30798,6 @@ function registerRoutes(router, config, queue) {
30354
30798
  router.get("/api/v1/projects/:project/learning-jobs/:jobId", createGetLearningJobHandler(storage));
30355
30799
  }
30356
30800
  //#endregion
30357
- //#region src/hub/core/storage/file/fs-helpers.ts
30358
- /**
30359
- * Defense-in-depth for a name this layer joins into a file path. The API's
30360
- * `SAFE_SEGMENT` (api/validate.ts) is deliberately stricter — it also fixes a
30361
- * charset and a length — and stays the rule for what a client may name; this
30362
- * only refuses traversal, so it also covers callers that never came through
30363
- * HTTP (tests, a library embedding the hub).
30364
- */
30365
- function assertSafeName(value, label) {
30366
- if (value.length === 0 || value === "." || value === ".." || value.includes("/") || value.includes("\\")) throw new Error(`invalid ${label}: must be a bare name without path separators or '..'`);
30367
- }
30368
- /** Read and JSON-parse a file, returning `null` when it doesn't exist. Malformed JSON throws. */
30369
- async function readJson(path) {
30370
- let raw;
30371
- try {
30372
- raw = await readFile(path, "utf8");
30373
- } catch (err) {
30374
- if (isNotFound(err)) return null;
30375
- throw err;
30376
- }
30377
- try {
30378
- return JSON.parse(raw);
30379
- } catch (err) {
30380
- throw new Error(`corrupt JSON at ${path}: ${err instanceof Error ? err.message : String(err)}`);
30381
- }
30382
- }
30383
- /**
30384
- * Write `data` to a temp file in the same directory, then atomically rename it
30385
- * into place, so a concurrent reader only ever observes the old file or the
30386
- * fully-written new one — never the empty/partial window a plain
30387
- * truncate-then-write leaves open (e.g. a `putActualCause` writing a run's
30388
- * triage file while an API request reads it). The temp file lives in the same
30389
- * directory as its target to keep the rename on one filesystem, where it is
30390
- * atomic.
30391
- */
30392
- async function atomicWrite(path, data) {
30393
- await mkdir(dirname(path), { recursive: true });
30394
- const tmp = `${path}.${randomUUID()}.tmp`;
30395
- await writeFile(tmp, data);
30396
- await rename(tmp, path);
30397
- }
30398
- /** Write `value` as pretty JSON, creating parent directories as needed. */
30399
- async function writeJson(path, value) {
30400
- await atomicWrite(path, JSON.stringify(value, null, 2) + "\n");
30401
- }
30402
- /**
30403
- * Per-path serialization for read-modify-write JSON updates. Two concurrent
30404
- * `updateJson` calls on the same path (e.g. two `putActualCause` calls racing
30405
- * on the same run's triage file) would otherwise both read the same starting
30406
- * state and the second writer's change would silently clobber the first's —
30407
- * this queues the second call's read until the first's write has landed, so
30408
- * updates apply in the order they were issued rather than racing. Scoped by
30409
- * path (a `Map` of chained promises), not globally, so unrelated records still
30410
- * update concurrently.
30411
- */
30412
- const updateChains = /* @__PURE__ */ new Map();
30413
- /**
30414
- * Queue `work` behind whatever is already in flight for `path`. A delete takes
30415
- * the same chain as the updates it removes: `writeJson` recreates the parent
30416
- * directory, so an unordered delete would be silently undone by an update that
30417
- * was already queued.
30418
- */
30419
- async function serialize(path, work) {
30420
- const next = (updateChains.get(path) ?? Promise.resolve()).catch(() => {}).then(work);
30421
- updateChains.set(path, next);
30422
- try {
30423
- return await next;
30424
- } finally {
30425
- if (updateChains.get(path) === next) updateChains.delete(path);
30426
- }
30427
- }
30428
- async function updateJson(path, mutate) {
30429
- return await serialize(path, async () => {
30430
- const updated = mutate(await readJson(path));
30431
- await writeJson(path, updated);
30432
- return updated;
30433
- });
30434
- }
30435
- /** Read a raw file, returning `null` when it doesn't exist. */
30436
- async function readBytesOrNull(path) {
30437
- try {
30438
- const buf = await readFile(path);
30439
- return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
30440
- } catch (err) {
30441
- if (isNotFound(err)) return null;
30442
- throw err;
30443
- }
30444
- }
30445
- async function writeBytes(path, bytes) {
30446
- await atomicWrite(path, bytes);
30447
- }
30448
- /** List entry names (files or dirs) directly under `dir`, or `[]` when it doesn't exist. */
30449
- async function listDirOrEmpty(dir) {
30450
- try {
30451
- return await readdir(dir);
30452
- } catch (err) {
30453
- if (isNotFound(err)) return [];
30454
- throw err;
30455
- }
30456
- }
30457
- /**
30458
- * Subdirectory names directly under `dir`, or `[]` when it doesn't exist.
30459
- * Skips files and dot-entries so stray filesystem litter (e.g. a Finder
30460
- * `.DS_Store`) never surfaces as a record id or project name.
30461
- */
30462
- async function listSubdirsOrEmpty(dir) {
30463
- try {
30464
- return (await readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
30465
- } catch (err) {
30466
- if (isNotFound(err)) return [];
30467
- throw err;
30468
- }
30469
- }
30470
- /** Every file path under `dir`, relative to `dir`, posix-separated. */
30471
- async function listFilesRecursive(dir) {
30472
- const out = [];
30473
- async function walk(current) {
30474
- let entries;
30475
- try {
30476
- entries = await readdir(current, { withFileTypes: true });
30477
- } catch (err) {
30478
- if (isNotFound(err)) return;
30479
- throw err;
30480
- }
30481
- for (const entry of entries) {
30482
- const abs = join(current, entry.name);
30483
- if (entry.isDirectory()) await walk(abs);
30484
- else if (entry.isFile()) out.push(relative(dir, abs).split(sep).join("/"));
30485
- }
30486
- }
30487
- await walk(dir);
30488
- return out;
30489
- }
30490
- async function removePath(path) {
30491
- await rm(path, {
30492
- recursive: true,
30493
- force: true
30494
- });
30495
- }
30496
- function isNotFound(err) {
30497
- return err instanceof Error && "code" in err && err.code === "ENOENT";
30498
- }
30499
- //#endregion
30500
30801
  //#region src/hub/core/storage/file/paths.ts
30501
30802
  /**
30502
30803
  * On-disk layout for the local-directory `HubStorage` backend, all rooted
@@ -30534,6 +30835,12 @@ function runMetaPath(root, id) {
30534
30835
  function artifactsRunDir(root, runId) {
30535
30836
  return join(root, "artifacts", runId);
30536
30837
  }
30838
+ function sourceMapProjectDir(root, project) {
30839
+ return join(root, "sourcemaps", project);
30840
+ }
30841
+ function sourceMapCommitDir(root, project, commit) {
30842
+ return join(sourceMapProjectDir(root, project), commit);
30843
+ }
30537
30844
  function jobsDir(root) {
30538
30845
  return join(root, "jobs");
30539
30846
  }
@@ -30902,6 +31209,56 @@ function parseLine(rawLine) {
30902
31209
  };
30903
31210
  }
30904
31211
  //#endregion
31212
+ //#region src/hub/core/storage/file/sourcemap-store.ts
31213
+ /**
31214
+ * Defense-in-depth: the HTTP layer validates the asset path before it gets
31215
+ * here, but this store joins it onto a directory, so it re-checks rather than
31216
+ * trusting the caller.
31217
+ */
31218
+ function assertSafeAssetPath(assetPath) {
31219
+ const segments = assetPath.split("/");
31220
+ 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");
31221
+ }
31222
+ /** Marks when a commit last received a push; see `listCommits`. */
31223
+ const PUSHED_AT = "pushed-at.json";
31224
+ function createFileSourceMapStore(root) {
31225
+ return {
31226
+ async put(project, commit, assetPath, bytes) {
31227
+ assertSafeName(project, "project");
31228
+ assertSafeName(commit, "commit");
31229
+ assertSafeAssetPath(assetPath);
31230
+ const dir = sourceMapCommitDir(root, project, commit);
31231
+ await writeBytes(join(dir, assetPath), bytes);
31232
+ await writeJson(join(dir, PUSHED_AT), { at: Date.now() });
31233
+ },
31234
+ async read(project, commit, assetPath) {
31235
+ assertSafeName(project, "project");
31236
+ assertSafeName(commit, "commit");
31237
+ assertSafeAssetPath(assetPath);
31238
+ return readBytesOrNull(join(sourceMapCommitDir(root, project, commit), assetPath));
31239
+ },
31240
+ async list(project, commit) {
31241
+ assertSafeName(project, "project");
31242
+ assertSafeName(commit, "commit");
31243
+ return (await listFilesRecursive(sourceMapCommitDir(root, project, commit))).filter((file) => file !== PUSHED_AT);
31244
+ },
31245
+ async listCommits(project) {
31246
+ assertSafeName(project, "project");
31247
+ const dir = sourceMapProjectDir(root, project);
31248
+ const commits = await listSubdirsOrEmpty(dir);
31249
+ return (await Promise.all(commits.map(async (commit) => ({
31250
+ commit,
31251
+ at: (await readJson(join(dir, commit, PUSHED_AT)).catch(() => null))?.at ?? 0
31252
+ })))).sort((a, b) => b.at - a.at).map((entry) => entry.commit);
31253
+ },
31254
+ async delete(project, commit) {
31255
+ assertSafeName(project, "project");
31256
+ assertSafeName(commit, "commit");
31257
+ await removePath(sourceMapCommitDir(root, project, commit));
31258
+ }
31259
+ };
31260
+ }
31261
+ //#endregion
30905
31262
  //#region src/hub/core/storage/file/deploy-store.ts
30906
31263
  function createFileDeployStore(root) {
30907
31264
  const readLog = async (project, profile) => await readJson(deployLogPath(root, project, profile)) ?? emptyDeployLog();
@@ -31345,7 +31702,8 @@ function createFileHubStorage(dataDir) {
31345
31702
  spend: createFileSpendStore(dataDir),
31346
31703
  attestations: createFileAttestationStore(dataDir),
31347
31704
  auditDismissals: createFileAuditDismissalStore(dataDir),
31348
- coverageEvents: createFileCoverageEventStore(dataDir)
31705
+ coverageEvents: createFileCoverageEventStore(dataDir),
31706
+ sourceMaps: createFileSourceMapStore(dataDir)
31349
31707
  };
31350
31708
  }
31351
31709
  //#endregion
@@ -1159,6 +1159,16 @@ interface HubClient {
1159
1159
  getCoverage(project: string, q?: {
1160
1160
  runId?: string;
1161
1161
  }): Promise<HubCoverageAnswer>;
1162
+ /**
1163
+ * Source maps for what a commit deployed, addressed by the asset path the
1164
+ * browser requests. Coverage falls back to these when the build keeps its
1165
+ * maps out of the CDN, which is the usual choice — a map hands out source.
1166
+ */
1167
+ putSourceMap(project: string, commit: string, assetPath: string, map: Uint8Array): Promise<void>;
1168
+ getSourceMap(project: string, commit: string, assetPath: string): Promise<string | null>;
1169
+ listSourceMaps(project: string, commit: string): Promise<string[]>;
1170
+ /** Ends a push, letting the hub drop the commits it no longer keeps. */
1171
+ sweepSourceMaps(project: string): Promise<void>;
1162
1172
  getTriage(id: string): Promise<RunTriage>;
1163
1173
  putActualCause(id: string, c: {
1164
1174
  feature: string;
@@ -153,6 +153,28 @@ function createHubClient(opts) {
153
153
  runId: q.runId
154
154
  })}`);
155
155
  },
156
+ putSourceMap(project, commit, assetPath, map) {
157
+ return request(`${sourceMapPath(project, commit)}/${encodeAssetPath(assetPath)}`, {
158
+ method: "PUT",
159
+ headers: { "Content-Type": "application/json" },
160
+ body: toBodyInit(map)
161
+ }).then(() => void 0);
162
+ },
163
+ async getSourceMap(project, commit, assetPath) {
164
+ try {
165
+ return await text(`${sourceMapPath(project, commit)}/${encodeAssetPath(assetPath)}`);
166
+ } catch (err) {
167
+ if (err instanceof HubApiError && err.status === 404) return null;
168
+ throw err;
169
+ }
170
+ },
171
+ sweepSourceMaps(project) {
172
+ return noBody(`/api/v1/projects/${encodeURIComponent(project)}/sourcemaps/sweep`, "POST");
173
+ },
174
+ async listSourceMaps(project, commit) {
175
+ const { paths } = await json(sourceMapPath(project, commit));
176
+ return paths;
177
+ },
156
178
  getTriage(id) {
157
179
  return json(`/api/v1/runs/${encodeURIComponent(id)}/triage`);
158
180
  },
@@ -366,6 +388,17 @@ function auditDismissalsPath(project) {
366
388
  function perspectivesPath(project) {
367
389
  return `/api/v1/projects/${encodeURIComponent(project)}/perspectives`;
368
390
  }
391
+ /** `/api/v1/projects/<project>/sourcemaps/<commit>` — the scope a push and a read share. */
392
+ function sourceMapPath(project, commit) {
393
+ return `/api/v1/projects/${encodeURIComponent(project)}/sourcemaps/${encodeURIComponent(commit)}`;
394
+ }
395
+ /**
396
+ * Asset paths keep their separators — the route matches the rest of the URL as
397
+ * one wildcard segment — so only the parts between them are escaped.
398
+ */
399
+ function encodeAssetPath(assetPath) {
400
+ return assetPath.split("/").map(encodeURIComponent).join("/");
401
+ }
369
402
  function queryString(params) {
370
403
  const out = new URLSearchParams();
371
404
  for (const [key, value] of Object.entries(params)) if (value !== void 0) out.set(key, String(value));
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.40.7",
3
+ "version": "1.41.0",
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.40.7",
3
+ "version": "1.41.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {