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 +714 -315
- package/dist/hub-client/index.d.mts +37 -0
- package/dist/hub-client/index.mjs +48 -0
- package/dist/package.json +1 -1
- package/package.json +1 -1
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
|
|
6543
|
-
|
|
6544
|
-
|
|
6545
|
-
|
|
6546
|
-
|
|
6547
|
-
target
|
|
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
|
|
6555
|
-
if (
|
|
6556
|
-
return
|
|
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.
|
|
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
|
|
11474
|
-
*
|
|
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
|
|
11570
|
-
*
|
|
11571
|
-
* the
|
|
11572
|
-
*
|
|
11573
|
-
* `
|
|
11574
|
-
*
|
|
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
|
|
11582
|
-
const
|
|
11583
|
-
|
|
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)
|
|
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.
|
|
11666
|
-
* because every probe downloads a whole report.json;
|
|
11667
|
-
*
|
|
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
|
-
*
|
|
11674
|
-
*
|
|
11675
|
-
*
|
|
11676
|
-
*
|
|
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
|
-
|
|
11688
|
-
|
|
11689
|
-
|
|
11690
|
-
|
|
11691
|
-
|
|
11692
|
-
|
|
11693
|
-
|
|
11694
|
-
|
|
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
|
-
|
|
11697
|
-
|
|
11698
|
-
|
|
11699
|
-
|
|
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
|
-
*
|
|
11703
|
-
* recently measured run and lists every run the stream retains;
|
|
11704
|
-
* run is then resolved individually. Returns how many runs could
|
|
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 =
|
|
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:
|
|
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
|
-
*
|
|
11745
|
-
* probed — audits and recordings execute no specs
|
|
11746
|
-
*
|
|
11747
|
-
*
|
|
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:
|
|
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("--
|
|
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,
|
|
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
|