canary-test-cli 6.8.1 → 7.1.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/engine/analysis/cli.js +261 -89
- package/dist/engine/analysis/engine.js +39 -21
- package/dist/engine/analysis/reports.js +0 -0
- package/dist/engine/cli-commands.js +251 -43
- package/dist/engine/cli-common.js +15 -24
- package/dist/engine/cli.core.js +37 -11
- package/dist/engine/cli.js +2 -2
- package/dist/engine/company-knowledge-cli.js +2 -2
- package/dist/engine/core/adoption.js +408 -0
- package/dist/engine/core/framework-probes.js +7 -7
- package/dist/engine/core/fs-glob.js +2 -2
- package/dist/engine/core/gate-result.js +17 -0
- package/dist/engine/core/migrator.js +151 -48
- package/dist/engine/core/pattern-matcher.js +23 -5
- package/dist/engine/core/persona.js +421 -0
- package/dist/engine/core/promotion-verdict.js +261 -0
- package/dist/engine/core/reporter.js +1 -9
- package/dist/engine/core/skill-examples.js +292 -0
- package/dist/engine/core/skill-surfaces.js +307 -0
- package/dist/engine/core/static-linter.js +312 -40
- package/dist/engine/core/ticket-updater.js +1 -7
- package/dist/engine/core/vacuity-scanner.js +556 -0
- package/dist/engine/core/workflow-discovery.js +2 -8
- package/dist/engine/core/workspace-detect.js +0 -0
- package/dist/engine/data/personas/registry.json +36 -0
- package/dist/engine/guardian/adjudication.js +6 -6
- package/dist/engine/guardian/agent-tier.js +3 -3
- package/dist/engine/guardian/analysis-emit.js +13 -27
- package/dist/engine/guardian/cli.js +30 -43
- package/dist/engine/guardian/coverage.js +15 -1420
- package/dist/engine/guardian/diff-coverage/formats/cobertura.js +130 -0
- package/dist/engine/guardian/diff-coverage/formats/coverage-json-lint.js +197 -0
- package/dist/engine/guardian/diff-coverage/formats/coverage-json.js +107 -0
- package/dist/engine/guardian/diff-coverage/formats/xml.js +151 -0
- package/dist/engine/guardian/diff-coverage/graph-tier.js +223 -0
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +150 -0
- package/dist/engine/guardian/diff-coverage/orchestrator.js +125 -0
- package/dist/engine/guardian/diff-coverage/paths.js +164 -0
- package/dist/engine/guardian/diff-coverage/report-tier.js +153 -0
- package/dist/engine/guardian/diff-coverage/type-only.js +150 -0
- package/dist/engine/guardian/diff-coverage/types.js +115 -0
- package/dist/engine/guardian/pr-check.js +10 -20
- package/dist/engine/guardian/pr-comment.js +4 -3
- package/dist/engine/history/cli.js +210 -6
- package/dist/engine/history/ndjson-store.js +9 -5
- package/dist/engine/history/record.js +34 -5
- package/dist/engine/history/run-recorder.js +165 -0
- package/dist/engine/history/schema.js +25 -7
- package/dist/engine/history/store.js +9 -0
- package/dist/engine/mcp-server.js +35 -13
- package/dist/engine/skills-cli.js +133 -11
- package/dist/engine/util/ensure-ascii.js +37 -0
- package/dist/engine/workflow-cli.js +6 -6
- package/dist/engine-checks.d.ts +15 -0
- package/dist/engine-checks.js +92 -1
- package/dist/gate-result.d.ts +11 -0
- package/dist/gate-result.js +18 -0
- package/dist/overlay-commands.d.ts +12 -1
- package/dist/overlay-commands.js +28 -2
- package/dist/router.js +17 -5
- package/dist/uninstall-render.d.ts +11 -0
- package/dist/uninstall-render.js +60 -0
- package/dist/uninstall-scan.d.ts +14 -0
- package/dist/uninstall-scan.js +273 -0
- package/dist/uninstall-types.d.ts +46 -0
- package/dist/uninstall-types.js +91 -0
- package/dist/uninstall.d.ts +13 -0
- package/dist/uninstall.js +181 -0
- package/package.json +1 -1
|
@@ -13,45 +13,38 @@
|
|
|
13
13
|
* - `json.dumps(x, indent=2)` -> {@link jsonIndent2} (byte-exact + ensure_ascii).
|
|
14
14
|
* - The report builders are byte-exact ports (Markdown), so the human-readable
|
|
15
15
|
* paths match the oracle exactly.
|
|
16
|
-
* -
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* store.
|
|
16
|
+
* - `--db-url` / `CANARY_HISTORY_DB_URL` are HONOURED as of #711 (ADR 0013
|
|
17
|
+
* Decision 4): the engine is async, so analyze selects its backend through
|
|
18
|
+
* the shared `makeStore` factory like every other history consumer. Three
|
|
19
|
+
* reports (spikes, common-failures, regression-candidates) are computed by
|
|
20
|
+
* walking raw run records, which only the local backend exposes; against a
|
|
21
|
+
* remote backend each names itself as unverifiable instead of rendering an
|
|
22
|
+
* empty report. No Python analyze test exercised a remote store.
|
|
23
23
|
* - `area-health` accepts `--json` but ignores it -- faithful to the Python
|
|
24
24
|
* command, which never branches on `output_json`.
|
|
25
25
|
*/
|
|
26
26
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
27
27
|
import { join } from 'node:path';
|
|
28
|
-
import { Command, Option } from 'commander';
|
|
28
|
+
import { Command, InvalidArgumentError, Option } from 'commander';
|
|
29
29
|
import { jsonIndent2, normalizeUsageExit } from '../cli-common.js';
|
|
30
30
|
import { gateOutcome } from '../core/gate-result.js';
|
|
31
31
|
import { AnalysisEngine } from './engine.js';
|
|
32
|
-
import { buildCommonFailuresReport,
|
|
33
|
-
import {
|
|
32
|
+
import { buildCommonFailuresReport, buildFlakyTestsReport, buildRegressionCandidatesReport, buildFailureSpikesReport, } from './reports.js';
|
|
33
|
+
import { makeStore } from '../history/store.js';
|
|
34
34
|
const DEFAULT_HISTORY_PATH = 'test-results/reports/history-v2.jsonl';
|
|
35
35
|
/** Process-backed defaults for production. */
|
|
36
36
|
export function defaultAnalyzeDeps() {
|
|
37
|
-
const err = (s) => {
|
|
38
|
-
process.stderr.write(`${s}\n`);
|
|
39
|
-
};
|
|
40
37
|
return {
|
|
41
38
|
out: (s) => process.stdout.write(`${s}\n`),
|
|
42
|
-
err,
|
|
39
|
+
err: (s) => process.stderr.write(`${s}\n`),
|
|
43
40
|
env: process.env,
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
err('note: --db-url is ignored by analyze; it reads local NDJSON only.');
|
|
52
|
-
}
|
|
53
|
-
return new NdjsonHistoryStore(DEFAULT_HISTORY_PATH);
|
|
54
|
-
},
|
|
41
|
+
// #711: analyze now goes through the shared factory, so --db-url and
|
|
42
|
+
// CANARY_HISTORY_DB_URL select the backend for real. Until the engine went
|
|
43
|
+
// async this could not be done -- the engine held the synchronous store
|
|
44
|
+
// contract -- and the flag was accepted with a printed apology instead.
|
|
45
|
+
// Where a remote backend cannot answer a given section, the command says so
|
|
46
|
+
// by name (see `cannotVerifyRawRecords`) rather than rendering an empty one.
|
|
47
|
+
makeStore: (dbUrl) => makeStore(dbUrl, DEFAULT_HISTORY_PATH),
|
|
55
48
|
};
|
|
56
49
|
}
|
|
57
50
|
/**
|
|
@@ -72,8 +65,14 @@ export function defaultAnalyzeDeps() {
|
|
|
72
65
|
*
|
|
73
66
|
* Returns true when the caller should stop (the store was empty).
|
|
74
67
|
*/
|
|
75
|
-
function abstainOnEmptyHistory(store, deps, json, what) {
|
|
76
|
-
|
|
68
|
+
async function abstainOnEmptyHistory(store, deps, json, what) {
|
|
69
|
+
// #711: `countRuns` is an OPTIONAL capability, and a backend that cannot
|
|
70
|
+
// report its denominator has an UNKNOWN one, not a zero one (ADR 0013
|
|
71
|
+
// Decision 3). Abstaining here would fire on every remote query and mute the
|
|
72
|
+
// doctrine -- the katana lesson again, from the other direction.
|
|
73
|
+
if (!store.countRuns)
|
|
74
|
+
return false;
|
|
75
|
+
if ((await store.countRuns()) > 0)
|
|
77
76
|
return false;
|
|
78
77
|
const outcome = gateOutcome({ checked: 0, findings: [] }, 'advisory');
|
|
79
78
|
const notice = `${outcome.summaryLine} No run history to analyze, so "${what}" is ` +
|
|
@@ -89,32 +88,169 @@ function abstainOnEmptyHistory(store, deps, json, what) {
|
|
|
89
88
|
}
|
|
90
89
|
return true;
|
|
91
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* The capability guard for the sections built on raw run records (#711).
|
|
93
|
+
*
|
|
94
|
+
* `spikes`, `common-failures` and `regression-candidates` are computed by
|
|
95
|
+
* walking whole run records rather than by any aggregate query, so they exist
|
|
96
|
+
* only where the backend implements the optional `readAll()`. The local NDJSON
|
|
97
|
+
* store does; the remote Supabase store does not.
|
|
98
|
+
*
|
|
99
|
+
* Against a backend without it these commands used to emit an empty report,
|
|
100
|
+
* which is indistinguishable on screen from a measured all-clear — the exact
|
|
101
|
+
* false-green shape #508 exists to kill, and one that only became reachable
|
|
102
|
+
* once `--db-url` started being honoured. So the command names the section it
|
|
103
|
+
* could not compute, and names the ones that DO work against this backend so
|
|
104
|
+
* the answer is actionable rather than just a refusal.
|
|
105
|
+
*
|
|
106
|
+
* Advisory, matching `abstainOnEmptyHistory`: the exit stays 0, the human path
|
|
107
|
+
* gets the notice instead of the report, and `--json` keeps stdout a parseable
|
|
108
|
+
* empty array with the notice on stderr.
|
|
109
|
+
*
|
|
110
|
+
* Returns true when the caller should stop.
|
|
111
|
+
*/
|
|
112
|
+
function cannotVerifyRawRecords(store, deps, json, what) {
|
|
113
|
+
if (store.readAll)
|
|
114
|
+
return false;
|
|
115
|
+
const outcome = gateOutcome({ checked: 0, findings: [] }, 'advisory');
|
|
116
|
+
const notice = `${outcome.summaryLine} cannot verify: this history backend does not ` +
|
|
117
|
+
`expose raw run records, so "${what}" is UNKNOWN rather than clean. ` +
|
|
118
|
+
`\`analyze flaky\` works against this backend today; drop --db-url ` +
|
|
119
|
+
`(and CANARY_HISTORY_DB_URL) to analyze the local ${DEFAULT_HISTORY_PATH} ` +
|
|
120
|
+
`instead.`;
|
|
121
|
+
if (json) {
|
|
122
|
+
deps.out(jsonIndent2([]));
|
|
123
|
+
deps.err(notice);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
deps.out(notice);
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
// --- unit-bearing flags (#673) -----------------------------------------------
|
|
131
|
+
//
|
|
132
|
+
// #670 put the unit in the NAME one layer down -- `analysis/reports.ts` takes
|
|
133
|
+
// `windowRuns`, `deltaPp`, `minRatePct` -- and stopped at the CLI boundary
|
|
134
|
+
// because renaming a flag is user-visible. The flags carried the same silence
|
|
135
|
+
// they always had: the report prints `window: 30 runs`, `20.0pp increase` and
|
|
136
|
+
// `>= 10.0%`, so the unit only ever arrived AFTER the user had guessed. This
|
|
137
|
+
// section closes that gap using the same convention: `<measure><Unit>` in
|
|
138
|
+
// camelCase becomes `--<measure>-<unit>` on the command line.
|
|
139
|
+
//
|
|
140
|
+
// `--delta` is the sharpest case. It is a percentage-POINT threshold that looks
|
|
141
|
+
// like a percentage, so `--delta 20` against a failure rate moving 5% -> 6% is
|
|
142
|
+
// the difference between firing and staying silent, with nothing on screen to
|
|
143
|
+
// say which reading applied.
|
|
144
|
+
/** One decimal or integer, no sign: the only shape these thresholds accept. */
|
|
145
|
+
const UNSIGNED_NUMBER = /^(\d+(\.\d+)?|\.\d+)$/;
|
|
146
|
+
/**
|
|
147
|
+
* A whole count of runs, >= 1.
|
|
148
|
+
*
|
|
149
|
+
* The bare `Number.parseInt` this replaces mapped `--window seven` to `NaN`,
|
|
150
|
+
* which the query layer happily compared against and matched nothing -- a
|
|
151
|
+
* silent abstention wearing a clean fleet's clothes. A value that cannot mean
|
|
152
|
+
* what the flag says is a usage error instead.
|
|
153
|
+
*/
|
|
154
|
+
function parseRuns(flag) {
|
|
155
|
+
return (raw) => {
|
|
156
|
+
if (!/^\d+$/.test(raw.trim()) || Number.parseInt(raw, 10) < 1) {
|
|
157
|
+
throw new InvalidArgumentError(`${flag} takes a whole number of RUNS, at least 1 (e.g. 30); ` +
|
|
158
|
+
`got "${raw}".`);
|
|
159
|
+
}
|
|
160
|
+
return Number.parseInt(raw, 10);
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* A 0-100 value on a percentage scale (`percent` for a rate, `percentage
|
|
165
|
+
* points` for a delta). Rejecting >100 is what catches the fraction habit from
|
|
166
|
+
* the other direction; `0.1` is still legal (a tenth of a percent), so the help
|
|
167
|
+
* text carries that half of the warning.
|
|
168
|
+
*/
|
|
169
|
+
function parsePercentScale(flag, unit) {
|
|
170
|
+
return (raw) => {
|
|
171
|
+
const n = Number.parseFloat(raw);
|
|
172
|
+
if (!UNSIGNED_NUMBER.test(raw.trim()) || !Number.isFinite(n) || n > 100) {
|
|
173
|
+
throw new InvalidArgumentError(`${flag} takes ${unit} between 0 and 100 (e.g. 10 means ten ${unit}, ` +
|
|
174
|
+
`not 0.1); got "${raw}".`);
|
|
175
|
+
}
|
|
176
|
+
return n;
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const WINDOW_ALIAS = {
|
|
180
|
+
canonical: 'windowRuns',
|
|
181
|
+
legacy: 'window',
|
|
182
|
+
canonicalFlag: '--window-runs',
|
|
183
|
+
legacyFlag: '--window',
|
|
184
|
+
unit: 'a count of runs, not days',
|
|
185
|
+
};
|
|
186
|
+
const DELTA_ALIAS = {
|
|
187
|
+
canonical: 'deltaPp',
|
|
188
|
+
legacy: 'delta',
|
|
189
|
+
canonicalFlag: '--delta-pp',
|
|
190
|
+
legacyFlag: '--delta',
|
|
191
|
+
unit: 'percentage points, not percent',
|
|
192
|
+
};
|
|
193
|
+
const MIN_RATE_ALIAS = {
|
|
194
|
+
canonical: 'minRatePct',
|
|
195
|
+
legacy: 'minRate',
|
|
196
|
+
canonicalFlag: '--min-rate-pct',
|
|
197
|
+
legacyFlag: '--min-rate',
|
|
198
|
+
unit: 'a percentage, 0-100',
|
|
199
|
+
};
|
|
200
|
+
/**
|
|
201
|
+
* Fold any deprecated spellings the user typed onto their canonical keys.
|
|
202
|
+
*
|
|
203
|
+
* The canonical flag wins when BOTH are given -- the alias is the legacy path,
|
|
204
|
+
* so an explicit new-style flag is the more deliberate statement of intent.
|
|
205
|
+
* `getOptionValueSource` is what separates "the user typed it" from "commander
|
|
206
|
+
* filled in the default"; comparing values would let a canonical default that
|
|
207
|
+
* happens to equal the alias silently discard the alias.
|
|
208
|
+
*/
|
|
209
|
+
function resolveUnitFlags(opts, cmd, deps, aliases) {
|
|
210
|
+
// Commander hands option bags back as plain objects keyed by camelCase flag
|
|
211
|
+
// name; the per-command interfaces describe them but carry no index
|
|
212
|
+
// signature, so the alias keys are reached through one local widening.
|
|
213
|
+
const merged = { ...opts };
|
|
214
|
+
for (const alias of aliases) {
|
|
215
|
+
if (merged[alias.legacy] === undefined)
|
|
216
|
+
continue;
|
|
217
|
+
deps.err(`note: ${alias.legacyFlag} is deprecated; use ${alias.canonicalFlag} ` +
|
|
218
|
+
`(the value is ${alias.unit}).`);
|
|
219
|
+
if (cmd.getOptionValueSource(alias.canonical) !== 'cli') {
|
|
220
|
+
merged[alias.canonical] = merged[alias.legacy];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return merged;
|
|
224
|
+
}
|
|
92
225
|
function writeArtifacts(artifacts, output) {
|
|
93
226
|
mkdirSync(output, { recursive: true });
|
|
94
227
|
for (const [name, content] of Object.entries(artifacts)) {
|
|
95
228
|
writeFileSync(join(output, name), content, 'utf-8');
|
|
96
229
|
}
|
|
97
230
|
}
|
|
98
|
-
function flakyCmd(opts, deps) {
|
|
231
|
+
async function flakyCmd(opts, deps) {
|
|
99
232
|
const store = deps.makeStore(opts.dbUrl);
|
|
100
|
-
if (abstainOnEmptyHistory(store, deps, opts.json === true, 'flake rate')) {
|
|
233
|
+
if (await abstainOnEmptyHistory(store, deps, opts.json === true, 'flake rate')) {
|
|
101
234
|
return;
|
|
102
235
|
}
|
|
103
|
-
const rows = store.queryFlaky(opts.
|
|
236
|
+
const rows = await store.queryFlaky(opts.windowRuns, opts.suite ?? null, opts.minRatePct);
|
|
104
237
|
if (opts.json) {
|
|
105
238
|
deps.out(jsonIndent2(rows));
|
|
106
239
|
}
|
|
107
240
|
else {
|
|
108
|
-
deps.out(
|
|
241
|
+
deps.out(buildFlakyTestsReport(rows, opts.windowRuns, opts.minRatePct));
|
|
109
242
|
}
|
|
110
243
|
}
|
|
111
|
-
function spikesCmd(opts, deps) {
|
|
244
|
+
async function spikesCmd(opts, deps) {
|
|
112
245
|
const store = deps.makeStore(opts.dbUrl);
|
|
113
|
-
if (abstainOnEmptyHistory(store, deps, opts.json === true, 'failure spikes')) {
|
|
246
|
+
if (await abstainOnEmptyHistory(store, deps, opts.json === true, 'failure spikes')) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (cannotVerifyRawRecords(store, deps, opts.json === true, 'failure spikes')) {
|
|
114
250
|
return;
|
|
115
251
|
}
|
|
116
252
|
const rows = [];
|
|
117
|
-
for (const r of store.readAll()) {
|
|
253
|
+
for (const r of await store.readAll()) {
|
|
118
254
|
if (opts.since && (r.timestamp ?? '') < opts.since)
|
|
119
255
|
continue;
|
|
120
256
|
rows.push({
|
|
@@ -130,7 +266,7 @@ function spikesCmd(opts, deps) {
|
|
|
130
266
|
deps.out(jsonIndent2(rows));
|
|
131
267
|
}
|
|
132
268
|
else {
|
|
133
|
-
deps.out(
|
|
269
|
+
deps.out(buildFailureSpikesReport(rows, opts.deltaPp));
|
|
134
270
|
}
|
|
135
271
|
}
|
|
136
272
|
function areaHealthCmd(opts, deps) {
|
|
@@ -148,13 +284,16 @@ function areaHealthCmd(opts, deps) {
|
|
|
148
284
|
`result here would be a fiction. Use \`analyze digest\` for the reports ` +
|
|
149
285
|
`that are wired, and track the area-health row set as unimplemented.`);
|
|
150
286
|
}
|
|
151
|
-
function commonFailuresCmd(opts, deps) {
|
|
287
|
+
async function commonFailuresCmd(opts, deps) {
|
|
152
288
|
const store = deps.makeStore(opts.dbUrl);
|
|
153
|
-
if (abstainOnEmptyHistory(store, deps, opts.json === true, 'common failures')) {
|
|
289
|
+
if (await abstainOnEmptyHistory(store, deps, opts.json === true, 'common failures')) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (cannotVerifyRawRecords(store, deps, opts.json === true, 'common failures')) {
|
|
154
293
|
return;
|
|
155
294
|
}
|
|
156
295
|
const rows = [];
|
|
157
|
-
for (const record of store.readAll()) {
|
|
296
|
+
for (const record of await store.readAll()) {
|
|
158
297
|
if (opts.since && (record.timestamp ?? '') < opts.since)
|
|
159
298
|
continue;
|
|
160
299
|
for (const t of record.tests ?? []) {
|
|
@@ -176,13 +315,16 @@ function commonFailuresCmd(opts, deps) {
|
|
|
176
315
|
deps.out(buildCommonFailuresReport(rows, opts.minSuites));
|
|
177
316
|
}
|
|
178
317
|
}
|
|
179
|
-
function regressionCandidatesCmd(opts, deps) {
|
|
318
|
+
async function regressionCandidatesCmd(opts, deps) {
|
|
180
319
|
const store = deps.makeStore(opts.dbUrl);
|
|
181
|
-
if (abstainOnEmptyHistory(store, deps, opts.json === true, 'regression candidates')) {
|
|
320
|
+
if (await abstainOnEmptyHistory(store, deps, opts.json === true, 'regression candidates')) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (cannotVerifyRawRecords(store, deps, opts.json === true, 'regression candidates')) {
|
|
182
324
|
return;
|
|
183
325
|
}
|
|
184
326
|
const engine = new AnalysisEngine(store);
|
|
185
|
-
const candidates = engine.detectRegressionCandidates(null, opts.minGreen, opts.recentFailures);
|
|
327
|
+
const candidates = await engine.detectRegressionCandidates(null, opts.minGreen, opts.recentFailures);
|
|
186
328
|
if (opts.json) {
|
|
187
329
|
deps.out(jsonIndent2(candidates));
|
|
188
330
|
}
|
|
@@ -190,20 +332,33 @@ function regressionCandidatesCmd(opts, deps) {
|
|
|
190
332
|
deps.out(buildRegressionCandidatesReport(candidates));
|
|
191
333
|
}
|
|
192
334
|
}
|
|
193
|
-
function digestCmd(opts, deps) {
|
|
335
|
+
async function digestCmd(opts, deps) {
|
|
194
336
|
const store = deps.makeStore(opts.dbUrl);
|
|
195
|
-
if (abstainOnEmptyHistory(store, deps, opts.json === true, 'fleet health')) {
|
|
337
|
+
if (await abstainOnEmptyHistory(store, deps, opts.json === true, 'fleet health')) {
|
|
196
338
|
return;
|
|
197
339
|
}
|
|
198
340
|
const engine = new AnalysisEngine(store);
|
|
199
|
-
const result = engine.run({
|
|
200
|
-
|
|
201
|
-
|
|
341
|
+
const result = await engine.run({
|
|
342
|
+
windowRuns: opts.windowRuns,
|
|
343
|
+
deltaPp: opts.deltaPp,
|
|
202
344
|
weeks: opts.weeks,
|
|
203
345
|
minSuites: opts.minSuites,
|
|
204
346
|
suite: opts.suite ?? null,
|
|
205
347
|
});
|
|
206
348
|
writeArtifacts(result.artifacts, opts.output);
|
|
349
|
+
// Unlike the single-section commands, digest can partially succeed: the flaky
|
|
350
|
+
// leaderboard rides a contract method and is real even on a backend with no
|
|
351
|
+
// raw-record access. So it renders what it measured and names what it could
|
|
352
|
+
// not -- a digest silently missing three of its five sections would be the
|
|
353
|
+
// worst of both worlds. The notice rides stderr so --json and the Slack path
|
|
354
|
+
// stay byte-clean for their consumers.
|
|
355
|
+
if (result.degraded.length > 0) {
|
|
356
|
+
const outcome = gateOutcome({ checked: 0, findings: [] }, 'advisory');
|
|
357
|
+
deps.err(`${outcome.summaryLine} cannot verify: this history backend does not ` +
|
|
358
|
+
`expose raw run records, so ${result.degraded.join(', ')} ` +
|
|
359
|
+
`${result.degraded.length === 1 ? 'is' : 'are'} UNKNOWN rather than ` +
|
|
360
|
+
`clean in this digest. The flake leaderboard above is measured.`);
|
|
361
|
+
}
|
|
207
362
|
if (opts.json) {
|
|
208
363
|
deps.out(jsonIndent2({
|
|
209
364
|
flaky_count: result.flaky.length,
|
|
@@ -229,6 +384,18 @@ function printSlack(flakyCount, regCount, deps) {
|
|
|
229
384
|
deps.out(lines.join('\n'));
|
|
230
385
|
}
|
|
231
386
|
// --- assembly ----------------------------------------------------------------
|
|
387
|
+
// Option help shared across subcommands. Every analyze option carries a
|
|
388
|
+
// description: a blank one is how the silent unit survived this long, since the
|
|
389
|
+
// value's meaning lived only in the report it eventually printed.
|
|
390
|
+
const DB_URL_ENV = 'CANARY_HISTORY_DB_URL';
|
|
391
|
+
const DB_URL_DESC = 'History store URL. Defaults to the local NDJSON store; a remote backend ' +
|
|
392
|
+
'cannot answer the spikes, common-failures or regression-candidates ' +
|
|
393
|
+
'reports, which say so rather than reporting zero.';
|
|
394
|
+
const JSON_DESC = 'Emit the rows as JSON instead of a Markdown report.';
|
|
395
|
+
const WINDOW_RUNS_DESC = 'Rolling window measured in RUNS, not days.';
|
|
396
|
+
const MIN_RATE_PCT_DESC = 'Minimum flake rate to report, in PERCENT 0-100 (10 means 10%, not 0.1).';
|
|
397
|
+
const DELTA_PP_DESC = 'Spike threshold as a PERCENTAGE-POINT rise in failure rate ' +
|
|
398
|
+
'(20 means 5% -> 25%, not 5% -> 6%).';
|
|
232
399
|
/** Build a fresh `analyze` command wired to `depsInit`. */
|
|
233
400
|
export function createAnalyzeCommand(depsInit = {}) {
|
|
234
401
|
const deps = { ...defaultAnalyzeDeps(), ...depsInit };
|
|
@@ -239,89 +406,94 @@ export function createAnalyzeCommand(depsInit = {}) {
|
|
|
239
406
|
program
|
|
240
407
|
.command('flaky')
|
|
241
408
|
.description('Fleet-wide flake leaderboard.')
|
|
242
|
-
.addOption(new Option('-w, --window <
|
|
409
|
+
.addOption(new Option('-w, --window-runs <runs>', WINDOW_RUNS_DESC)
|
|
243
410
|
.default(30)
|
|
244
|
-
.argParser((
|
|
245
|
-
.
|
|
246
|
-
.
|
|
411
|
+
.argParser(parseRuns('--window-runs')))
|
|
412
|
+
.addOption(new Option('--window <runs>', 'Deprecated alias for --window-runs.').argParser(parseRuns('--window')))
|
|
413
|
+
.option('-s, --suite <suite>', 'Filter to a specific suite.')
|
|
414
|
+
.addOption(new Option('--min-rate-pct <percent>', MIN_RATE_PCT_DESC)
|
|
247
415
|
.default(10.0)
|
|
248
|
-
.argParser((
|
|
249
|
-
.addOption(new Option('--
|
|
250
|
-
.
|
|
251
|
-
.
|
|
252
|
-
|
|
416
|
+
.argParser(parsePercentScale('--min-rate-pct', 'percent')))
|
|
417
|
+
.addOption(new Option('--min-rate <percent>', 'Deprecated alias for --min-rate-pct.').argParser(parsePercentScale('--min-rate', 'percent')))
|
|
418
|
+
.addOption(new Option('--db-url <url>', DB_URL_DESC).env(DB_URL_ENV))
|
|
419
|
+
.option('--json', JSON_DESC)
|
|
420
|
+
.action(async (opts, cmd) => {
|
|
421
|
+
await flakyCmd(resolveUnitFlags(opts, cmd, deps, [WINDOW_ALIAS, MIN_RATE_ALIAS]), deps);
|
|
253
422
|
});
|
|
254
423
|
program
|
|
255
424
|
.command('spikes')
|
|
256
425
|
.description('Recent failure spikes across suites.')
|
|
257
426
|
.option('--since <date>', 'ISO date filter, e.g. 2026-06-01')
|
|
258
|
-
.addOption(new Option('--delta <
|
|
427
|
+
.addOption(new Option('--delta-pp <points>', DELTA_PP_DESC)
|
|
259
428
|
.default(20.0)
|
|
260
|
-
.argParser((
|
|
261
|
-
.addOption(new Option('--
|
|
262
|
-
.
|
|
263
|
-
.
|
|
264
|
-
|
|
429
|
+
.argParser(parsePercentScale('--delta-pp', 'percentage points')))
|
|
430
|
+
.addOption(new Option('--delta <points>', 'Deprecated alias for --delta-pp.').argParser(parsePercentScale('--delta', 'percentage points')))
|
|
431
|
+
.addOption(new Option('--db-url <url>', DB_URL_DESC).env(DB_URL_ENV))
|
|
432
|
+
.option('--json', JSON_DESC)
|
|
433
|
+
.action(async (opts, cmd) => {
|
|
434
|
+
await spikesCmd(resolveUnitFlags(opts, cmd, deps, [DELTA_ALIAS]), deps);
|
|
265
435
|
});
|
|
266
436
|
program
|
|
267
437
|
.command('area-health')
|
|
268
438
|
.description('Area degradation trends over time.')
|
|
269
|
-
.addOption(new Option('--weeks <
|
|
439
|
+
.addOption(new Option('--weeks <weeks>', 'Trend window, in whole weeks.')
|
|
270
440
|
.default(4)
|
|
271
441
|
.argParser((v) => Number.parseInt(v, 10)))
|
|
272
|
-
.addOption(new Option('--db-url <url>').env(
|
|
273
|
-
.option('--json')
|
|
442
|
+
.addOption(new Option('--db-url <url>', DB_URL_DESC).env(DB_URL_ENV))
|
|
443
|
+
.option('--json', JSON_DESC)
|
|
274
444
|
.action((opts) => {
|
|
275
445
|
areaHealthCmd(opts, deps);
|
|
276
446
|
});
|
|
277
447
|
program
|
|
278
448
|
.command('common-failures')
|
|
279
449
|
.description('Cross-suite failure fingerprinting.')
|
|
280
|
-
.option('--since <date>')
|
|
281
|
-
.addOption(new Option('--min-suites <
|
|
450
|
+
.option('--since <date>', 'ISO date filter, e.g. 2026-06-01')
|
|
451
|
+
.addOption(new Option('--min-suites <suites>', 'Report a failure only once it appears in this many SUITES.')
|
|
282
452
|
.default(2)
|
|
283
453
|
.argParser((v) => Number.parseInt(v, 10)))
|
|
284
|
-
.addOption(new Option('--db-url <url>').env(
|
|
285
|
-
.option('--json')
|
|
286
|
-
.action((opts) => {
|
|
287
|
-
commonFailuresCmd(opts, deps);
|
|
454
|
+
.addOption(new Option('--db-url <url>', DB_URL_DESC).env(DB_URL_ENV))
|
|
455
|
+
.option('--json', JSON_DESC)
|
|
456
|
+
.action(async (opts) => {
|
|
457
|
+
await commonFailuresCmd(opts, deps);
|
|
288
458
|
});
|
|
289
459
|
program
|
|
290
460
|
.command('regression-candidates')
|
|
291
461
|
.description('Tests newly and consistently broken after a green streak.')
|
|
292
|
-
.addOption(new Option('--min-green <
|
|
462
|
+
.addOption(new Option('--min-green <runs>', 'Length of the prior green streak, in RUNS.')
|
|
293
463
|
.default(5)
|
|
294
464
|
.argParser((v) => Number.parseInt(v, 10)))
|
|
295
|
-
.addOption(new Option('--recent-failures <
|
|
465
|
+
.addOption(new Option('--recent-failures <runs>', 'Consecutive failing RUNS required after the streak.')
|
|
296
466
|
.default(3)
|
|
297
467
|
.argParser((v) => Number.parseInt(v, 10)))
|
|
298
|
-
.addOption(new Option('--db-url <url>').env(
|
|
299
|
-
.option('--json')
|
|
300
|
-
.action((opts) => {
|
|
301
|
-
regressionCandidatesCmd(opts, deps);
|
|
468
|
+
.addOption(new Option('--db-url <url>', DB_URL_DESC).env(DB_URL_ENV))
|
|
469
|
+
.option('--json', JSON_DESC)
|
|
470
|
+
.action(async (opts) => {
|
|
471
|
+
await regressionCandidatesCmd(opts, deps);
|
|
302
472
|
});
|
|
303
473
|
program
|
|
304
474
|
.command('digest')
|
|
305
475
|
.description('Combined digest of all five report types.')
|
|
306
|
-
.addOption(new Option('--window <
|
|
476
|
+
.addOption(new Option('--window-runs <runs>', WINDOW_RUNS_DESC)
|
|
307
477
|
.default(30)
|
|
308
|
-
.argParser((
|
|
309
|
-
.addOption(new Option('--
|
|
478
|
+
.argParser(parseRuns('--window-runs')))
|
|
479
|
+
.addOption(new Option('--window <runs>', 'Deprecated alias for --window-runs.').argParser(parseRuns('--window')))
|
|
480
|
+
.addOption(new Option('--delta-pp <points>', DELTA_PP_DESC)
|
|
310
481
|
.default(20.0)
|
|
311
|
-
.argParser((
|
|
312
|
-
.addOption(new Option('--
|
|
482
|
+
.argParser(parsePercentScale('--delta-pp', 'percentage points')))
|
|
483
|
+
.addOption(new Option('--delta <points>', 'Deprecated alias for --delta-pp.').argParser(parsePercentScale('--delta', 'percentage points')))
|
|
484
|
+
.addOption(new Option('--weeks <weeks>', 'Area-health trend window, in whole weeks.')
|
|
313
485
|
.default(4)
|
|
314
486
|
.argParser((v) => Number.parseInt(v, 10)))
|
|
315
|
-
.addOption(new Option('--min-suites <
|
|
487
|
+
.addOption(new Option('--min-suites <suites>', 'Report a failure only once it appears in this many SUITES.')
|
|
316
488
|
.default(2)
|
|
317
489
|
.argParser((v) => Number.parseInt(v, 10)))
|
|
318
|
-
.option('--suite <suite>')
|
|
319
|
-
.addOption(new Option('--output <dir>').default('test-results/analysis'))
|
|
320
|
-
.option('--json')
|
|
321
|
-
.option('--slack')
|
|
322
|
-
.addOption(new Option('--db-url <url>').env(
|
|
323
|
-
.action((opts) => {
|
|
324
|
-
digestCmd(opts, deps);
|
|
490
|
+
.option('--suite <suite>', 'Filter to a specific suite.')
|
|
491
|
+
.addOption(new Option('--output <dir>', 'Directory the Markdown artifacts are ' + 'written to.').default('test-results/analysis'))
|
|
492
|
+
.option('--json', 'Emit per-section counts as JSON instead of the digest.')
|
|
493
|
+
.option('--slack', 'Emit a short Slack-formatted summary.')
|
|
494
|
+
.addOption(new Option('--db-url <url>', DB_URL_DESC).env(DB_URL_ENV))
|
|
495
|
+
.action(async (opts, cmd) => {
|
|
496
|
+
await digestCmd(resolveUnitFlags(opts, cmd, deps, [WINDOW_ALIAS, DELTA_ALIAS]), deps);
|
|
325
497
|
});
|
|
326
498
|
for (const sub of program.commands) {
|
|
327
499
|
sub.exitOverride(normalizeUsageExit);
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* `run()` (the Python engine initialises `area_rows = []` and never appends), so
|
|
10
10
|
* the area-health artifact always renders the empty-data message.
|
|
11
11
|
*/
|
|
12
|
-
import { buildAreaHealthReport, buildCommonFailuresReport, buildDigest,
|
|
12
|
+
import { buildAreaHealthReport, buildCommonFailuresReport, buildDigest, buildFlakyTestsReport, buildRegressionCandidatesReport, buildFailureSpikesReport, } from './reports.js';
|
|
13
13
|
import { detectRegressions } from '../history/detector.js';
|
|
14
14
|
import { def } from '../util/coalesce.js';
|
|
15
15
|
function isReadable(store) {
|
|
@@ -26,25 +26,38 @@ function toCommonFailureRow(record, t) {
|
|
|
26
26
|
error_text: t.error_text,
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
+
/** Report-language names for the sections that need raw-record access. */
|
|
30
|
+
const SECTION_SPIKES = 'failure spikes';
|
|
31
|
+
const SECTION_COMMON_FAILURES = 'common failures';
|
|
32
|
+
const SECTION_REGRESSIONS = 'regression candidates';
|
|
29
33
|
export class AnalysisEngine {
|
|
30
34
|
store;
|
|
31
35
|
constructor(store) {
|
|
32
36
|
this.store = store;
|
|
33
37
|
}
|
|
34
|
-
run(opts = {}) {
|
|
35
|
-
const
|
|
36
|
-
const
|
|
38
|
+
async run(opts = {}) {
|
|
39
|
+
const windowRuns = opts.windowRuns ?? 30;
|
|
40
|
+
const deltaPp = opts.deltaPp ?? 20.0;
|
|
37
41
|
const weeks = opts.weeks ?? 4;
|
|
38
42
|
const minSuites = opts.minSuites ?? 2;
|
|
39
|
-
const
|
|
43
|
+
const minFlakeRatePct = opts.minFlakeRatePct ?? 10.0;
|
|
40
44
|
const minGreen = opts.minGreen ?? 5;
|
|
41
45
|
const recentFailures = opts.recentFailures ?? 3;
|
|
42
46
|
const suite = opts.suite ?? null;
|
|
43
|
-
const flaky = this.store.queryFlaky(
|
|
44
|
-
|
|
47
|
+
const flaky = (await this.store.queryFlaky(windowRuns, suite, minFlakeRatePct));
|
|
48
|
+
// Sections that need raw-record access are UNKNOWN, not empty, on a backend
|
|
49
|
+
// that does not offer it (#711). `flaky` above and a suite-scoped `spikes`
|
|
50
|
+
// below ride real contract methods, so they stay measured either way.
|
|
51
|
+
const degraded = [];
|
|
52
|
+
const readable = isReadable(this.store);
|
|
53
|
+
const suitesToQuery = suite ? [suite] : await this.discoverSuites();
|
|
54
|
+
// With no explicit --suite, the suite list itself comes from raw records —
|
|
55
|
+
// so spikes is only degraded in that case, not whenever readAll is absent.
|
|
56
|
+
if (!readable && !suite)
|
|
57
|
+
degraded.push(SECTION_SPIKES);
|
|
45
58
|
const spikesRows = [];
|
|
46
59
|
for (const s of suitesToQuery) {
|
|
47
|
-
const summary = this.store.querySummary(s,
|
|
60
|
+
const summary = await this.store.querySummary(s, windowRuns * 2);
|
|
48
61
|
for (const row of def(summary.runs, [])) {
|
|
49
62
|
// query_summary rows omit suite; the spikes builder groups by it, so
|
|
50
63
|
// tag each pooled row with the suite it came from (matches Python).
|
|
@@ -59,22 +72,26 @@ export class AnalysisEngine {
|
|
|
59
72
|
}
|
|
60
73
|
// Faithful to Python: area rows are never populated by run().
|
|
61
74
|
const areaRows = [];
|
|
62
|
-
const commonRows = this.queryCommonFailures(suite);
|
|
63
|
-
|
|
75
|
+
const commonRows = await this.queryCommonFailures(suite);
|
|
76
|
+
if (!readable)
|
|
77
|
+
degraded.push(SECTION_COMMON_FAILURES);
|
|
78
|
+
const regressionCandidates = await this.detectRegressionCandidates(suite, minGreen, recentFailures);
|
|
79
|
+
if (!readable)
|
|
80
|
+
degraded.push(SECTION_REGRESSIONS);
|
|
64
81
|
const digest = buildDigest({
|
|
65
82
|
flaky,
|
|
66
83
|
spikes: spikesRows,
|
|
67
84
|
areaHealth: areaRows,
|
|
68
85
|
commonFailures: commonRows,
|
|
69
86
|
regressionCandidates,
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
windowRuns,
|
|
88
|
+
deltaPp,
|
|
72
89
|
weeks,
|
|
73
90
|
minSuites,
|
|
74
91
|
});
|
|
75
92
|
const artifacts = {
|
|
76
|
-
'flaky.md':
|
|
77
|
-
'spikes.md':
|
|
93
|
+
'flaky.md': buildFlakyTestsReport(flaky, windowRuns, minFlakeRatePct),
|
|
94
|
+
'spikes.md': buildFailureSpikesReport(spikesRows, deltaPp),
|
|
78
95
|
'area-health.md': buildAreaHealthReport(areaRows, weeks),
|
|
79
96
|
'common-failures.md': buildCommonFailuresReport(commonRows, minSuites),
|
|
80
97
|
'regression-candidates.md': buildRegressionCandidatesReport(regressionCandidates),
|
|
@@ -88,23 +105,24 @@ export class AnalysisEngine {
|
|
|
88
105
|
regressionCandidates,
|
|
89
106
|
digestMd: digest,
|
|
90
107
|
artifacts,
|
|
108
|
+
degraded,
|
|
91
109
|
};
|
|
92
110
|
}
|
|
93
|
-
discoverSuites() {
|
|
111
|
+
async discoverSuites() {
|
|
94
112
|
if (!isReadable(this.store))
|
|
95
113
|
return [];
|
|
96
114
|
const suites = new Set();
|
|
97
|
-
for (const r of this.store.readAll()) {
|
|
115
|
+
for (const r of await this.store.readAll()) {
|
|
98
116
|
if (r.suite)
|
|
99
117
|
suites.add(r.suite);
|
|
100
118
|
}
|
|
101
119
|
return [...suites];
|
|
102
120
|
}
|
|
103
|
-
queryCommonFailures(suite) {
|
|
121
|
+
async queryCommonFailures(suite) {
|
|
104
122
|
if (!isReadable(this.store))
|
|
105
123
|
return [];
|
|
106
124
|
const rows = [];
|
|
107
|
-
for (const record of this.store.readAll()) {
|
|
125
|
+
for (const record of await this.store.readAll()) {
|
|
108
126
|
if (suite && record.suite !== suite)
|
|
109
127
|
continue;
|
|
110
128
|
for (const t of def(record.tests, [])) {
|
|
@@ -114,11 +132,11 @@ export class AnalysisEngine {
|
|
|
114
132
|
}
|
|
115
133
|
return rows;
|
|
116
134
|
}
|
|
117
|
-
detectRegressionCandidates(suite, minGreen, recentFailures) {
|
|
135
|
+
async detectRegressionCandidates(suite, minGreen, recentFailures) {
|
|
118
136
|
if (!isReadable(this.store))
|
|
119
137
|
return [];
|
|
120
138
|
const testNames = new Set();
|
|
121
|
-
for (const record of this.store.readAll()) {
|
|
139
|
+
for (const record of await this.store.readAll()) {
|
|
122
140
|
if (suite && record.suite !== suite)
|
|
123
141
|
continue;
|
|
124
142
|
for (const t of def(record.tests, []))
|
|
@@ -126,7 +144,7 @@ export class AnalysisEngine {
|
|
|
126
144
|
}
|
|
127
145
|
const candidates = [];
|
|
128
146
|
for (const name of testNames) {
|
|
129
|
-
const timeline = this.store.queryTimeline(name);
|
|
147
|
+
const timeline = await this.store.queryTimeline(name);
|
|
130
148
|
const result = detectRegressions(timeline, minGreen, recentFailures);
|
|
131
149
|
if (result.is_regression) {
|
|
132
150
|
candidates.push({
|
|
Binary file
|