ccqa 1.41.0 → 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 +247 -142
- package/dist/hub-client/index.d.mts +27 -0
- package/dist/hub-client/index.mjs +15 -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.
|
|
@@ -7905,14 +7923,6 @@ async function resolveRoot(cwd, declared) {
|
|
|
7905
7923
|
function specCoverageDir(reportDir, feature, spec) {
|
|
7906
7924
|
return join(reportDir, "coverage", feature, spec);
|
|
7907
7925
|
}
|
|
7908
|
-
/**
|
|
7909
|
-
* `<runId>.<feature>/<spec>`. The run id keeps a stale cookie from an earlier
|
|
7910
|
-
* run out; the spec half is `specKey`, so an id here and a report row name the
|
|
7911
|
-
* same spec the same way.
|
|
7912
|
-
*/
|
|
7913
|
-
function specIdFor(runId, ref) {
|
|
7914
|
-
return `${runId}.${specKey(ref)}`;
|
|
7915
|
-
}
|
|
7916
7926
|
async function readFrontend(coverageDir, specId) {
|
|
7917
7927
|
let raw;
|
|
7918
7928
|
try {
|
|
@@ -10321,6 +10331,28 @@ z.object({
|
|
|
10321
10331
|
totalUsd: z.number(),
|
|
10322
10332
|
entries: z.array(SpendEntrySchema)
|
|
10323
10333
|
});
|
|
10334
|
+
/**
|
|
10335
|
+
* The coverage-edge ledger: per spec, the most recent measured reach — the
|
|
10336
|
+
* input measured spec selection intersects a diff with (ADR-0026). One
|
|
10337
|
+
* document per project; entries never expire, they are only replaced by a
|
|
10338
|
+
* newer measurement. `measuredAt` is stamped by the hub on merge, so entries
|
|
10339
|
+
* written by different runs stay comparable on one clock.
|
|
10340
|
+
*/
|
|
10341
|
+
const CoverageEdgeEntrySchema = z.object({
|
|
10342
|
+
files: z.array(z.string()),
|
|
10343
|
+
measuredAt: z.number(),
|
|
10344
|
+
runId: z.string().optional()
|
|
10345
|
+
});
|
|
10346
|
+
const CoverageEdgesDocSchema = z.object({ specs: z.record(z.string(), CoverageEdgeEntrySchema) });
|
|
10347
|
+
/**
|
|
10348
|
+
* Body of `PUT /projects/:project/coverage-edges` — the specs one run
|
|
10349
|
+
* measured. Merged into the stored document, never replacing other specs'
|
|
10350
|
+
* entries; an empty file set is not a measurement and is rejected per entry.
|
|
10351
|
+
*/
|
|
10352
|
+
const CoverageEdgesUpsertSchema = z.object({ specs: z.record(z.string(), z.object({
|
|
10353
|
+
files: z.array(z.string()).min(1),
|
|
10354
|
+
runId: z.string().optional()
|
|
10355
|
+
})).refine((specs) => Object.keys(specs).length > 0, "at least one spec entry is required") });
|
|
10324
10356
|
//#endregion
|
|
10325
10357
|
//#region src/run/hub-selection.ts
|
|
10326
10358
|
/**
|
|
@@ -11719,8 +11751,9 @@ function enrichZodError(error, source) {
|
|
|
11719
11751
|
* means that spec must re-run — reach cannot see the test's own definition,
|
|
11720
11752
|
* so no measurement is consulted for it. Everything else intersects the diff
|
|
11721
11753
|
* with the files the spec's last measured run actually reached (ADR-0024);
|
|
11722
|
-
* a spec with no measurement
|
|
11723
|
-
*
|
|
11754
|
+
* a spec with no measurement is `needed` — it runs until a measurement
|
|
11755
|
+
* lands, which is also what records its first edge (ADR-0026). Only when
|
|
11756
|
+
* the measurements could not be read does absence degrade to `unknown`.
|
|
11724
11757
|
*/
|
|
11725
11758
|
async function selectSpecs(input) {
|
|
11726
11759
|
const { changed, specs, cwd, base, head, edges } = input;
|
|
@@ -11815,27 +11848,37 @@ function isCcqaPath(path) {
|
|
|
11815
11848
|
/**
|
|
11816
11849
|
* Hold each undecided spec's last measured reach against the diff.
|
|
11817
11850
|
*
|
|
11818
|
-
* Three outcomes, and only
|
|
11819
|
-
*
|
|
11820
|
-
* the
|
|
11821
|
-
*
|
|
11822
|
-
* `
|
|
11823
|
-
*
|
|
11851
|
+
* Three outcomes, and only one is a positive claim: a non-empty intersection
|
|
11852
|
+
* means `needed`, with the intersecting paths as the reason; an empty one
|
|
11853
|
+
* means `notNeeded` — the measurement accounts for everything the spec
|
|
11854
|
+
* reached, and the diff missed all of it; no measurement at all is also
|
|
11855
|
+
* `needed` — the spec runs until a measurement records its reach (ADR-0026)
|
|
11856
|
+
* — unless the read was degraded, in which case absence proves nothing and
|
|
11857
|
+
* the spec is left `unknown`. Changes outside the measured root fall out
|
|
11824
11858
|
* of the comparison entirely: the root is the declared boundary of what
|
|
11825
11859
|
* measurement governs, so what lies beyond it clears specs quietly — one
|
|
11826
11860
|
* warning names the dropped paths, because a root configured too narrow
|
|
11827
11861
|
* looks exactly like this and hides real reach (see docs/coverage.md).
|
|
11828
11862
|
*/
|
|
11829
11863
|
async function judgeWithCoverage(input) {
|
|
11830
|
-
const { pending, productChanges, cwd
|
|
11831
|
-
const
|
|
11832
|
-
|
|
11864
|
+
const { pending, productChanges, cwd } = input;
|
|
11865
|
+
const { edges, degraded } = input.edges;
|
|
11866
|
+
const unreadable = "the hub's measured reach could not be read; not guessing";
|
|
11833
11867
|
const measuredChanges = rerootChangesForCoverage(productChanges, await resolveCoverageRoots(productChanges, cwd));
|
|
11834
11868
|
const dropped = productChanges.length - measuredChanges.length;
|
|
11835
11869
|
if (dropped > 0) warn(`select-specs: ${dropped} of ${productChanges.length} changed files fall outside coverage.projectRoot and cannot be compared against measured reach`);
|
|
11836
11870
|
return pending.map((spec) => {
|
|
11837
11871
|
const edge = edges.get(specKey(spec));
|
|
11838
|
-
if (!edge)
|
|
11872
|
+
if (!edge) {
|
|
11873
|
+
if (degraded) return unknownSelection(spec, unreadable);
|
|
11874
|
+
return {
|
|
11875
|
+
featureName: spec.featureName,
|
|
11876
|
+
specName: spec.specName,
|
|
11877
|
+
verdict: "needed",
|
|
11878
|
+
source: "coverage",
|
|
11879
|
+
reason: "never measured: the spec runs until a measurement records its reach"
|
|
11880
|
+
};
|
|
11881
|
+
}
|
|
11839
11882
|
const touchedBy = measuredChanges.filter((c) => edge.files.has(c.measured)).map((c) => c.original);
|
|
11840
11883
|
if (touchedBy.length > 0) return {
|
|
11841
11884
|
featureName: spec.featureName,
|
|
@@ -11911,46 +11954,74 @@ async function resolveCoverageRoots(changed, cwd) {
|
|
|
11911
11954
|
//#endregion
|
|
11912
11955
|
//#region src/select/coverage-edges.ts
|
|
11913
11956
|
/**
|
|
11914
|
-
* How many recent hub runs are probed for report-row coverage.
|
|
11915
|
-
* because every probe downloads a whole report.json;
|
|
11916
|
-
*
|
|
11917
|
-
* spec runs — is the safer answer anyway. The stream side carries its own
|
|
11918
|
-
* bound: the hub lists at most its newest twenty measured runs.
|
|
11957
|
+
* How many recent hub runs are probed for legacy report-row coverage.
|
|
11958
|
+
* Bounded because every probe downloads a whole report.json; anything this
|
|
11959
|
+
* far back has usually been superseded by the ledger anyway.
|
|
11919
11960
|
*/
|
|
11920
11961
|
const MAX_REPORT_RUNS = 20;
|
|
11921
11962
|
/**
|
|
11922
|
-
*
|
|
11923
|
-
*
|
|
11924
|
-
*
|
|
11925
|
-
*
|
|
11926
|
-
*/
|
|
11927
|
-
const EDGE_MAX_AGE_MS = 336 * 60 * 60 * 1e3;
|
|
11928
|
-
/**
|
|
11929
|
-
* Read every spec's most recent measured reach from the hub.
|
|
11930
|
-
*
|
|
11931
|
-
* Never throws: a hub that cannot be read yields an empty map (warned), which
|
|
11932
|
-
* the selection degrades to `unknown` across the board — the caller runs
|
|
11933
|
-
* those specs, so an unreadable hub costs runs, never a skipped regression.
|
|
11963
|
+
* Read every spec's most recent measured reach from the hub. Never throws: a
|
|
11964
|
+
* source that cannot be read warns, and `degraded` flips when the failure
|
|
11965
|
+
* leaves absence ambiguous (the ledger itself, or the legacy sources while
|
|
11966
|
+
* no ledger answers).
|
|
11934
11967
|
*/
|
|
11935
11968
|
async function loadCoverageEdges(input) {
|
|
11936
|
-
|
|
11937
|
-
|
|
11938
|
-
|
|
11939
|
-
|
|
11940
|
-
|
|
11941
|
-
|
|
11942
|
-
|
|
11943
|
-
|
|
11969
|
+
if (input == null) return {
|
|
11970
|
+
edges: /* @__PURE__ */ new Map(),
|
|
11971
|
+
degraded: true
|
|
11972
|
+
};
|
|
11973
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
11974
|
+
const merge = (key, candidate) => {
|
|
11975
|
+
const existing = candidates.get(key);
|
|
11976
|
+
if (!existing || candidate.measuredAt > existing.measuredAt) candidates.set(key, candidate);
|
|
11977
|
+
};
|
|
11978
|
+
const [ledger, ...legacy] = await Promise.allSettled([
|
|
11979
|
+
collectLedgerEdges(input, merge),
|
|
11980
|
+
collectStreamEdges(input, merge),
|
|
11981
|
+
collectReportEdges(input, merge)
|
|
11982
|
+
]);
|
|
11944
11983
|
let skipped = 0;
|
|
11945
|
-
|
|
11946
|
-
|
|
11947
|
-
|
|
11948
|
-
|
|
11984
|
+
let legacyBroken = false;
|
|
11985
|
+
for (const result of legacy) if (result.status === "rejected") {
|
|
11986
|
+
legacyBroken = true;
|
|
11987
|
+
warn(`select-specs: could not read coverage measurements from the hub (${errMessage(result.reason)})`);
|
|
11988
|
+
} else skipped += result.value;
|
|
11989
|
+
if (skipped > 0) {
|
|
11990
|
+
legacyBroken = true;
|
|
11991
|
+
warn(`select-specs: ${skipped} measured run(s) on the hub could not be read; treating the measurements as unreadable rather than absent`);
|
|
11992
|
+
}
|
|
11993
|
+
if (ledger.status === "rejected") warn(`select-specs: could not read the hub's coverage-edge ledger (${errMessage(ledger.reason)})`);
|
|
11994
|
+
const ledgerAnswered = ledger.status === "fulfilled" && ledger.value;
|
|
11995
|
+
const degraded = ledger.status === "rejected" || !ledgerAnswered && legacyBroken;
|
|
11996
|
+
return {
|
|
11997
|
+
edges: new Map([...candidates].map(([key, c]) => [key, {
|
|
11998
|
+
files: new Set(c.files),
|
|
11999
|
+
measuredAt: c.measuredAt
|
|
12000
|
+
}])),
|
|
12001
|
+
degraded
|
|
12002
|
+
};
|
|
12003
|
+
}
|
|
12004
|
+
/**
|
|
12005
|
+
* The ledger itself; true when a document answered. A 404 is an older hub or
|
|
12006
|
+
* a project that never wrote one, not a failure: the legacy sources answer.
|
|
12007
|
+
*/
|
|
12008
|
+
async function collectLedgerEdges(input, merge) {
|
|
12009
|
+
const doc = await input.hub.getCoverageEdges(input.project);
|
|
12010
|
+
if (doc === null) return false;
|
|
12011
|
+
for (const [key, entry] of Object.entries(doc.specs)) {
|
|
12012
|
+
if (entry.files.length === 0) continue;
|
|
12013
|
+
merge(key, {
|
|
12014
|
+
files: entry.files,
|
|
12015
|
+
measuredAt: entry.measuredAt
|
|
12016
|
+
});
|
|
12017
|
+
}
|
|
12018
|
+
return true;
|
|
11949
12019
|
}
|
|
11950
12020
|
/**
|
|
11951
|
-
*
|
|
11952
|
-
* recently measured run and lists every run the stream retains;
|
|
11953
|
-
* 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.
|
|
11954
12025
|
*/
|
|
11955
12026
|
async function collectStreamEdges(input, merge) {
|
|
11956
12027
|
const { hub, project } = input;
|
|
@@ -11962,24 +12033,16 @@ async function collectStreamEdges(input, merge) {
|
|
|
11962
12033
|
function ingestResolved(resolved, merge) {
|
|
11963
12034
|
if (!resolved) return;
|
|
11964
12035
|
for (const spec of resolved.specs) {
|
|
11965
|
-
const key =
|
|
12036
|
+
const key = specKeyFromSpecId(spec.specId, resolved.runId);
|
|
11966
12037
|
if (key === null) continue;
|
|
11967
12038
|
if (spec.files.length === 0) continue;
|
|
11968
12039
|
merge(key, {
|
|
11969
|
-
files:
|
|
12040
|
+
files: spec.files,
|
|
11970
12041
|
measuredAt: resolved.asOf
|
|
11971
12042
|
});
|
|
11972
12043
|
}
|
|
11973
12044
|
}
|
|
11974
12045
|
/**
|
|
11975
|
-
* A stream specId is `<runId>.<feature>/<spec>` (src/coverage/session.ts).
|
|
11976
|
-
* The runId itself may contain `.`, so the known prefix is stripped by
|
|
11977
|
-
* length, never by splitting on the dot.
|
|
11978
|
-
*/
|
|
11979
|
-
function stripRunIdPrefix(specId, runId) {
|
|
11980
|
-
return specId.startsWith(`${runId}.`) ? specId.slice(runId.length + 1) : null;
|
|
11981
|
-
}
|
|
11982
|
-
/**
|
|
11983
12046
|
* The one slice of report.json this consumer reads. Parsed with its own
|
|
11984
12047
|
* narrow schema rather than the full report schema so a report from another
|
|
11985
12048
|
* ccqa version still yields its edges as long as this shape holds.
|
|
@@ -11990,10 +12053,10 @@ const ReportCoverageRowsSchema = z.object({ results: z.array(z.object({
|
|
|
11990
12053
|
coverage: z.object({ files: z.array(z.string()) }).optional()
|
|
11991
12054
|
})) });
|
|
11992
12055
|
/**
|
|
11993
|
-
*
|
|
11994
|
-
* probed — audits and recordings execute no specs
|
|
11995
|
-
*
|
|
11996
|
-
*
|
|
12056
|
+
* Legacy: edges from pushed run reports, newest first. Only `kind: run` runs
|
|
12057
|
+
* are probed — audits and recordings execute no specs — and a still-`running`
|
|
12058
|
+
* run is skipped: its rows are still arriving. Returns how many reports
|
|
12059
|
+
* could not be read.
|
|
11997
12060
|
*/
|
|
11998
12061
|
async function collectReportEdges(input, merge) {
|
|
11999
12062
|
const { hub, project } = input;
|
|
@@ -12016,7 +12079,7 @@ async function collectReportEdges(input, merge) {
|
|
|
12016
12079
|
for (const row of parsed.data.results) {
|
|
12017
12080
|
if (!row.coverage || row.coverage.files.length === 0) continue;
|
|
12018
12081
|
merge(`${row.feature}/${row.spec}`, {
|
|
12019
|
-
files:
|
|
12082
|
+
files: row.coverage.files,
|
|
12020
12083
|
measuredAt
|
|
12021
12084
|
});
|
|
12022
12085
|
}
|
|
@@ -16626,22 +16689,21 @@ async function collectChangedSpecs(specs, opts) {
|
|
|
16626
16689
|
specs: [],
|
|
16627
16690
|
base: resolved
|
|
16628
16691
|
};
|
|
16692
|
+
if (!hub) warn(`${flag}: no hub connection, so coverage measurements cannot be consulted — undecided specs will run`);
|
|
16693
|
+
const edgesPromise = loadCoverageEdges(hub);
|
|
16629
16694
|
let inventory;
|
|
16630
16695
|
try {
|
|
16631
16696
|
inventory = await loadSpecInventory(cwd);
|
|
16632
16697
|
} catch (e) {
|
|
16633
16698
|
throw new RunUsageError(e.message);
|
|
16634
16699
|
}
|
|
16635
|
-
let edges = /* @__PURE__ */ new Map();
|
|
16636
|
-
if (hub) edges = await loadCoverageEdges(hub);
|
|
16637
|
-
else warn(`${flag}: no hub connection, so coverage measurements cannot be consulted — undecided specs will run`);
|
|
16638
16700
|
const report = await selectSpecs({
|
|
16639
16701
|
changed,
|
|
16640
16702
|
specs: inventory,
|
|
16641
16703
|
cwd,
|
|
16642
16704
|
base: resolved.sha,
|
|
16643
16705
|
head: "HEAD",
|
|
16644
|
-
edges
|
|
16706
|
+
edges: await edgesPromise
|
|
16645
16707
|
});
|
|
16646
16708
|
const toRun = new Set(specsToRun(report).map(specKey));
|
|
16647
16709
|
const undecided = report.specs.filter((s) => s.verdict === "unknown").length;
|
|
@@ -16652,53 +16714,6 @@ async function collectChangedSpecs(specs, opts) {
|
|
|
16652
16714
|
};
|
|
16653
16715
|
}
|
|
16654
16716
|
//#endregion
|
|
16655
|
-
//#region src/run/measure-backfill.ts
|
|
16656
|
-
/**
|
|
16657
|
-
* `ccqa run --measure-backfill <n>`: keep the measured-reach edges alive.
|
|
16658
|
-
*
|
|
16659
|
-
* Selection (ADR-0024) consumes each spec's most recent measured reach, and
|
|
16660
|
-
* an edge expires after `EDGE_MAX_AGE_MS`. Nothing else re-measures: an
|
|
16661
|
-
* unmeasured spec answers `unknown`, `unknown` marks nothing due (ADR-0023),
|
|
16662
|
-
* and a suite can settle into a state where no run ever fires — the seed
|
|
16663
|
-
* deadlock. Appending a few unmeasured-or-aging specs to every selected run
|
|
16664
|
-
* breaks that loop and keeps the whole suite inside the freshness window
|
|
16665
|
-
* without a scheduled full sweep.
|
|
16666
|
-
*/
|
|
16667
|
-
/**
|
|
16668
|
-
* Re-measure once an edge has spent half its lifetime. Half, not "expired":
|
|
16669
|
-
* a spec re-measured only after expiry answers `unknown` for the gap between
|
|
16670
|
-
* expiry and the next run, which is exactly the window this flag exists to
|
|
16671
|
-
* close.
|
|
16672
|
-
*/
|
|
16673
|
-
const REMEASURE_AFTER_MS = EDGE_MAX_AGE_MS / 2;
|
|
16674
|
-
/**
|
|
16675
|
-
* Picks up to `limit` specs from `inventory` worth re-measuring: ones with no
|
|
16676
|
-
* edge at all first (they cost an `unknown` verdict today), then the oldest
|
|
16677
|
-
* measured ones. Specs already selected for this run are never doubled.
|
|
16678
|
-
*/
|
|
16679
|
-
function chooseMeasureBackfill(inventory, selected, edges, limit, now) {
|
|
16680
|
-
const alreadyRunning = new Set(selected.map(specKey));
|
|
16681
|
-
const missing = [];
|
|
16682
|
-
const aging = [];
|
|
16683
|
-
for (const spec of inventory) {
|
|
16684
|
-
if (alreadyRunning.has(specKey(spec))) continue;
|
|
16685
|
-
const edge = edges.get(specKey(spec));
|
|
16686
|
-
if (edge === void 0) missing.push(spec);
|
|
16687
|
-
else if (now - edge.measuredAt > REMEASURE_AFTER_MS) aging.push({
|
|
16688
|
-
spec,
|
|
16689
|
-
measuredAt: edge.measuredAt
|
|
16690
|
-
});
|
|
16691
|
-
}
|
|
16692
|
-
aging.sort((a, b) => a.measuredAt - b.measuredAt);
|
|
16693
|
-
const specs = [...missing, ...aging.map((entry) => entry.spec)].slice(0, limit);
|
|
16694
|
-
const missingTaken = Math.min(missing.length, specs.length);
|
|
16695
|
-
return {
|
|
16696
|
-
specs,
|
|
16697
|
-
missing: missingTaken,
|
|
16698
|
-
aging: specs.length - missingTaken
|
|
16699
|
-
};
|
|
16700
|
-
}
|
|
16701
|
-
//#endregion
|
|
16702
16717
|
//#region src/run/pipeline.ts
|
|
16703
16718
|
async function resolveVitestConfig(cwd) {
|
|
16704
16719
|
const userConfig = resolve(cwd, ".ccqa/vitest.config.ts");
|
|
@@ -16877,10 +16892,6 @@ async function executeRun(targets, opts) {
|
|
|
16877
16892
|
if (rerunProfile !== null && hubCtx == null) throw new RunUsageError(needsHubConnection("--only-hub-rerun-needed"));
|
|
16878
16893
|
if (opts.reportToHub && hubCtx == null) throw new RunUsageError(REPORT_TO_HUB_NEEDS_CONNECTION);
|
|
16879
16894
|
if (opts.learnHubLivePrompt && hubCtx == null) throw new RunUsageError(needsHubConnection("--learn-hub-live-prompt"));
|
|
16880
|
-
if ((opts.measureBackfill ?? 0) > 0) {
|
|
16881
|
-
if (opts.coverage !== true) throw new RunUsageError("--measure-backfill does nothing without --coverage — there is no measurement to keep fresh");
|
|
16882
|
-
if (!filtering) throw new RunUsageError("--measure-backfill needs a selection flag (--only-hub-rerun-needed / --only-affected-by); an explicit spec list runs exactly what was asked");
|
|
16883
|
-
}
|
|
16884
16895
|
let coverageInbox;
|
|
16885
16896
|
if (opts.coverageInbox === "hub") {
|
|
16886
16897
|
if (opts.coverage !== true) throw new RunUsageError("--coverage-inbox hub does nothing without --coverage — there is no measurement to stream");
|
|
@@ -16929,7 +16940,6 @@ async function executeRun(targets, opts) {
|
|
|
16929
16940
|
};
|
|
16930
16941
|
const enumerateAll = () => listAllSpecsWithSpecFile(cwd);
|
|
16931
16942
|
let specs = dedupeSpecs((await Promise.all((targets.length ? targets : [void 0]).map((t) => resolveSpecTargets(t, enumerateAll, cwd)))).flat());
|
|
16932
|
-
const inventory = specs;
|
|
16933
16943
|
if (filtering) {
|
|
16934
16944
|
const before = specs.length;
|
|
16935
16945
|
let inProgress = 0;
|
|
@@ -16960,14 +16970,6 @@ async function executeRun(targets, opts) {
|
|
|
16960
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");
|
|
16961
16971
|
}
|
|
16962
16972
|
}
|
|
16963
|
-
if (filtering && (opts.measureBackfill ?? 0) > 0 && hubCtx != null) {
|
|
16964
|
-
const edges = await loadCoverageEdges(hubCtx);
|
|
16965
|
-
const pick = chooseMeasureBackfill(inventory, specs, edges, opts.measureBackfill ?? 0, Date.now());
|
|
16966
|
-
if (pick.specs.length > 0) {
|
|
16967
|
-
specs = [...specs, ...pick.specs];
|
|
16968
|
-
meta("measure-backfill", `${pick.specs.length} spec(s) appended (${pick.missing} unmeasured / ${pick.aging} aging)`);
|
|
16969
|
-
}
|
|
16970
|
-
}
|
|
16971
16973
|
if (specs.length === 0) {
|
|
16972
16974
|
warn("no specs to run");
|
|
16973
16975
|
return {
|
|
@@ -17134,8 +17136,9 @@ async function executeRun(targets, opts) {
|
|
|
17134
17136
|
report: incrementalReport
|
|
17135
17137
|
};
|
|
17136
17138
|
const live = await runLiveSpecs(liveSpecs, liveOpts);
|
|
17139
|
+
let streamedEdges = {};
|
|
17137
17140
|
if (coverage && !coverage.streamsToHub) reportCoverageHealth(coverage, [...externalRows, ...live.reportResults]);
|
|
17138
|
-
else if (coverage && hubCtx != null) await reportStreamedCoverageHealth(coverage, hubCtx);
|
|
17141
|
+
else if (coverage && hubCtx != null) streamedEdges = await reportStreamedCoverageHealth(coverage, hubCtx);
|
|
17139
17142
|
let overallExitCode = det.exitCode !== 0 ? 1 : 0;
|
|
17140
17143
|
if (live.failedCount > 0) overallExitCode = 1;
|
|
17141
17144
|
if (externalRows.some((r) => r.status === "failed")) overallExitCode = 1;
|
|
@@ -17179,6 +17182,10 @@ async function executeRun(targets, opts) {
|
|
|
17179
17182
|
coverage
|
|
17180
17183
|
});
|
|
17181
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
|
+
}
|
|
17182
17189
|
if (hubRunId) {
|
|
17183
17190
|
const finalStatus = overallExitCode === 0 ? "passed" : "failed";
|
|
17184
17191
|
const reportMeta = buildReportEnvelope({
|
|
@@ -17289,6 +17296,10 @@ function explainMissingCoverage(row) {
|
|
|
17289
17296
|
* Best-effort — the measurement already left as events, so a failed read-out
|
|
17290
17297
|
* loses visibility, never data. Application pushes may still land for
|
|
17291
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.
|
|
17292
17303
|
*/
|
|
17293
17304
|
async function reportStreamedCoverageHealth(coverage, hubCtx) {
|
|
17294
17305
|
await new Promise((resolve) => setTimeout(resolve, 3e3));
|
|
@@ -17297,11 +17308,11 @@ async function reportStreamedCoverageHealth(coverage, hubCtx) {
|
|
|
17297
17308
|
resolved = (await hubCtx.hub.getCoverage(hubCtx.project, { runId: coverage.streamRunId })).resolved;
|
|
17298
17309
|
} catch (error) {
|
|
17299
17310
|
warn(`coverage: could not read this run's resolve from the hub (${errMessage(error)})`);
|
|
17300
|
-
return;
|
|
17311
|
+
return {};
|
|
17301
17312
|
}
|
|
17302
17313
|
if (resolved == null) {
|
|
17303
17314
|
warn("coverage: the hub resolved nothing for this run — its events never reached the stream, so every spec's measured reach is absent");
|
|
17304
|
-
return;
|
|
17315
|
+
return {};
|
|
17305
17316
|
}
|
|
17306
17317
|
const measured = resolved.specs.filter((spec) => spec.files.length > 0);
|
|
17307
17318
|
const empty = resolved.specs.filter((spec) => spec.files.length === 0);
|
|
@@ -17320,6 +17331,30 @@ async function reportStreamedCoverageHealth(coverage, hubCtx) {
|
|
|
17320
17331
|
const outside = Object.entries(h.outsideWindowEvents);
|
|
17321
17332
|
if (outside.length > 0) trouble.push(`outside-window=${outside.map(([key, count]) => `${key}:${count}`).join(",")}`);
|
|
17322
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
|
+
}
|
|
17323
17358
|
}
|
|
17324
17359
|
/** Everything the measurement could not place; silence here reads as "never reached". */
|
|
17325
17360
|
function reportCoverageHealth(coverage, rows) {
|
|
@@ -17940,11 +17975,7 @@ const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command(
|
|
|
17940
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) => {
|
|
17941
17976
|
if (COVERAGE_INBOX_MODES.includes(raw)) return raw;
|
|
17942
17977
|
throw new Error(`--coverage-inbox must be one of ${COVERAGE_INBOX_MODES.join(" | ")}`);
|
|
17943
|
-
}, "local").option("--
|
|
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) => {
|
|
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) => {
|
|
17948
17979
|
await runCliAction(targets, opts);
|
|
17949
17980
|
});
|
|
17950
17981
|
/** Parse --concurrency: a positive integer. Rejects 0, negatives, non-integers. */
|
|
@@ -21503,7 +21534,7 @@ async function runSelectSpecs(opts) {
|
|
|
21503
21534
|
project: opts.project,
|
|
21504
21535
|
cwd: opts.cwd
|
|
21505
21536
|
});
|
|
21506
|
-
const [specsResult, changedResult,
|
|
21537
|
+
const [specsResult, changedResult, edgesReadout] = await Promise.all([
|
|
21507
21538
|
loadSpecInventory(cwd).then((specs) => ({
|
|
21508
21539
|
ok: true,
|
|
21509
21540
|
specs
|
|
@@ -21543,7 +21574,7 @@ async function runSelectSpecs(opts) {
|
|
|
21543
21574
|
meta("project", project);
|
|
21544
21575
|
meta("changed-files", changed.length);
|
|
21545
21576
|
meta("specs", specs.length);
|
|
21546
|
-
meta("measured-specs", edges.size);
|
|
21577
|
+
meta("measured-specs", edgesReadout.edges.size);
|
|
21547
21578
|
}
|
|
21548
21579
|
const report = await selectSpecs({
|
|
21549
21580
|
changed,
|
|
@@ -21551,7 +21582,7 @@ async function runSelectSpecs(opts) {
|
|
|
21551
21582
|
cwd,
|
|
21552
21583
|
base: opts.base,
|
|
21553
21584
|
head,
|
|
21554
|
-
edges
|
|
21585
|
+
edges: edgesReadout
|
|
21555
21586
|
});
|
|
21556
21587
|
process.stdout.write(format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderText(report));
|
|
21557
21588
|
process.exit(0);
|
|
@@ -23471,6 +23502,32 @@ function requireSinceSeqParam(url) {
|
|
|
23471
23502
|
if (!Number.isInteger(value) || value < 0) throw new HttpError(400, "invalid_param", "invalid sinceSeq: must be a non-negative integer");
|
|
23472
23503
|
return value;
|
|
23473
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
|
+
}
|
|
23474
23531
|
//#endregion
|
|
23475
23532
|
//#region src/hub/core/rerun.ts
|
|
23476
23533
|
/**
|
|
@@ -30781,6 +30838,9 @@ function registerRoutes(router, config, queue) {
|
|
|
30781
30838
|
router.get("/api/v1/projects/:project/perspectives", createGetPerspectivesHandler(perspectivesConfig));
|
|
30782
30839
|
router.patch("/api/v1/projects/:project/perspectives", createPatchPerspectivesNoteHandler(perspectivesConfig));
|
|
30783
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));
|
|
30784
30844
|
const coverageConfig = {
|
|
30785
30845
|
store: storage.coverageEvents,
|
|
30786
30846
|
encryptionKey: config.encryptionKey,
|
|
@@ -30886,6 +30946,9 @@ function perspectivesKindDir(root) {
|
|
|
30886
30946
|
function perspectivesPath(root, project) {
|
|
30887
30947
|
return join(perspectivesKindDir(root), `${project}.json`);
|
|
30888
30948
|
}
|
|
30949
|
+
function coverageEdgesPath(root, project) {
|
|
30950
|
+
return join(root, "coverage-edges", `${project}.json`);
|
|
30951
|
+
}
|
|
30889
30952
|
function ledgerProfileDir(root, project, profile) {
|
|
30890
30953
|
return join(root, "last-green", project, profile);
|
|
30891
30954
|
}
|
|
@@ -31387,6 +31450,47 @@ function createFileSpecLedgerStore(root) {
|
|
|
31387
31450
|
};
|
|
31388
31451
|
}
|
|
31389
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
|
|
31390
31494
|
//#region src/hub/core/storage/file/perspectives-store.ts
|
|
31391
31495
|
/**
|
|
31392
31496
|
* Perspectives storage: one JSON document per project, plain UTF-8 with no
|
|
@@ -31693,6 +31797,7 @@ function createFileHubStorage(dataDir) {
|
|
|
31693
31797
|
triage: createFileTriageStore(dataDir),
|
|
31694
31798
|
prompts: createFilePromptStore(dataDir),
|
|
31695
31799
|
perspectives: createFilePerspectivesStore(dataDir),
|
|
31800
|
+
coverageEdges: createFileCoverageEdgeStore(dataDir),
|
|
31696
31801
|
jobs: createFileJobStore(dataDir),
|
|
31697
31802
|
ledger: createFileSpecLedgerStore(dataDir),
|
|
31698
31803
|
driftLedger: createFileDriftLedgerStore(dataDir),
|
|
@@ -547,6 +547,26 @@ declare const SpendLogResponseSchema: z.ZodObject<{
|
|
|
547
547
|
}, z.core.$strip>>;
|
|
548
548
|
}, z.core.$strip>;
|
|
549
549
|
type SpendLogResponse = z.infer<typeof SpendLogResponseSchema>;
|
|
550
|
+
declare const CoverageEdgesDocSchema: z.ZodObject<{
|
|
551
|
+
specs: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
552
|
+
files: z.ZodArray<z.ZodString>;
|
|
553
|
+
measuredAt: z.ZodNumber;
|
|
554
|
+
runId: z.ZodOptional<z.ZodString>;
|
|
555
|
+
}, z.core.$strip>>;
|
|
556
|
+
}, z.core.$strip>;
|
|
557
|
+
type CoverageEdgesDoc = z.infer<typeof CoverageEdgesDocSchema>;
|
|
558
|
+
/**
|
|
559
|
+
* Body of `PUT /projects/:project/coverage-edges` — the specs one run
|
|
560
|
+
* measured. Merged into the stored document, never replacing other specs'
|
|
561
|
+
* entries; an empty file set is not a measurement and is rejected per entry.
|
|
562
|
+
*/
|
|
563
|
+
declare const CoverageEdgesUpsertSchema: z.ZodObject<{
|
|
564
|
+
specs: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
565
|
+
files: z.ZodArray<z.ZodString>;
|
|
566
|
+
runId: z.ZodOptional<z.ZodString>;
|
|
567
|
+
}, z.core.$strip>>;
|
|
568
|
+
}, z.core.$strip>;
|
|
569
|
+
type CoverageEdgesUpsert = z.infer<typeof CoverageEdgesUpsertSchema>;
|
|
550
570
|
//#endregion
|
|
551
571
|
//#region src/coverage/resolve-stream.d.ts
|
|
552
572
|
/**
|
|
@@ -1159,6 +1179,13 @@ interface HubClient {
|
|
|
1159
1179
|
getCoverage(project: string, q?: {
|
|
1160
1180
|
runId?: string;
|
|
1161
1181
|
}): Promise<HubCoverageAnswer>;
|
|
1182
|
+
/**
|
|
1183
|
+
* Merge one run's measured reach into the project's coverage-edge ledger
|
|
1184
|
+
* (ADR-0026). The hub stamps `measuredAt` with its own clock.
|
|
1185
|
+
*/
|
|
1186
|
+
putCoverageEdges(project: string, upsert: CoverageEdgesUpsert): Promise<void>;
|
|
1187
|
+
/** The coverage-edge ledger, or `null` when nothing is stored (or the hub predates it). */
|
|
1188
|
+
getCoverageEdges(project: string): Promise<CoverageEdgesDoc | null>;
|
|
1162
1189
|
/**
|
|
1163
1190
|
* Source maps for what a commit deployed, addressed by the asset path the
|
|
1164
1191
|
* browser requests. Coverage falls back to these when the build keeps its
|
|
@@ -153,6 +153,21 @@ function createHubClient(opts) {
|
|
|
153
153
|
runId: q.runId
|
|
154
154
|
})}`);
|
|
155
155
|
},
|
|
156
|
+
putCoverageEdges(project, upsert) {
|
|
157
|
+
return request(`/api/v1/projects/${encodeURIComponent(project)}/coverage-edges`, {
|
|
158
|
+
method: "PUT",
|
|
159
|
+
headers: { "Content-Type": "application/json" },
|
|
160
|
+
body: JSON.stringify(upsert)
|
|
161
|
+
}).then(() => void 0);
|
|
162
|
+
},
|
|
163
|
+
async getCoverageEdges(project) {
|
|
164
|
+
try {
|
|
165
|
+
return await json(`/api/v1/projects/${encodeURIComponent(project)}/coverage-edges`);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (err instanceof HubApiError && err.status === 404) return null;
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
},
|
|
156
171
|
putSourceMap(project, commit, assetPath, map) {
|
|
157
172
|
return request(`${sourceMapPath(project, commit)}/${encodeAssetPath(assetPath)}`, {
|
|
158
173
|
method: "PUT",
|
package/dist/package.json
CHANGED