canary-test-cli 7.0.0 → 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.
Files changed (47) hide show
  1. package/dist/engine/analysis/cli.js +116 -54
  2. package/dist/engine/analysis/engine.js +34 -16
  3. package/dist/engine/analysis/reports.js +5 -4
  4. package/dist/engine/cli-commands.js +249 -41
  5. package/dist/engine/cli-common.js +15 -24
  6. package/dist/engine/cli.core.js +37 -11
  7. package/dist/engine/cli.js +2 -2
  8. package/dist/engine/company-knowledge-cli.js +2 -2
  9. package/dist/engine/core/adoption.js +408 -0
  10. package/dist/engine/core/framework-probes.js +7 -7
  11. package/dist/engine/core/fs-glob.js +2 -2
  12. package/dist/engine/core/gate-result.js +17 -0
  13. package/dist/engine/core/migrator.js +9 -17
  14. package/dist/engine/core/pattern-matcher.js +23 -5
  15. package/dist/engine/core/persona.js +421 -0
  16. package/dist/engine/core/promotion-verdict.js +261 -0
  17. package/dist/engine/core/reporter.js +1 -9
  18. package/dist/engine/core/skill-examples.js +292 -0
  19. package/dist/engine/core/skill-surfaces.js +307 -0
  20. package/dist/engine/core/static-linter.js +310 -38
  21. package/dist/engine/core/ticket-updater.js +1 -7
  22. package/dist/engine/core/vacuity-scanner.js +556 -0
  23. package/dist/engine/core/workflow-discovery.js +2 -8
  24. package/dist/engine/core/workspace-detect.js +7 -6
  25. package/dist/engine/data/personas/registry.json +36 -0
  26. package/dist/engine/guardian/adjudication.js +5 -5
  27. package/dist/engine/guardian/analysis-emit.js +13 -27
  28. package/dist/engine/guardian/cli.js +30 -43
  29. package/dist/engine/guardian/coverage.js +1 -1
  30. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
  31. package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
  32. package/dist/engine/guardian/pr-check.js +5 -15
  33. package/dist/engine/guardian/pr-comment.js +4 -3
  34. package/dist/engine/history/cli.js +210 -6
  35. package/dist/engine/history/ndjson-store.js +9 -5
  36. package/dist/engine/history/record.js +34 -5
  37. package/dist/engine/history/run-recorder.js +165 -0
  38. package/dist/engine/history/schema.js +25 -7
  39. package/dist/engine/history/store.js +9 -0
  40. package/dist/engine/mcp-server.js +35 -13
  41. package/dist/engine/skills-cli.js +133 -11
  42. package/dist/engine/util/ensure-ascii.js +37 -0
  43. package/dist/engine/workflow-cli.js +6 -6
  44. package/dist/gate-result.d.ts +11 -0
  45. package/dist/gate-result.js +18 -0
  46. package/dist/uninstall.js +12 -5
  47. package/package.json +1 -1
@@ -13,13 +13,13 @@
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
- * - INTENTIONAL DEVIATION: the TS analysis engine (`engine.ts`) operates on the
17
- * LOCAL NDJSON store only -- the JS Supabase SDK is async and the engine's
18
- * query surface is synchronous. `--db-url` is still accepted (faithful CLI
19
- * surface) but the TS port always reads the local store, exactly as the
20
- * Python `isinstance(store, LocalHistoryStore)` read-path does for the
21
- * spikes/common-failures commands. No Python analyze test exercises a remote
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
  */
@@ -29,29 +29,22 @@ 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, buildFlakyReport, buildRegressionCandidatesReport, buildSpikesReport, } from './reports.js';
33
- import { NdjsonHistoryStore } from '../history/ndjson-store.js';
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
- // The ported analysis engine's query surface is synchronous, so it cannot
45
- // drive the async Supabase store; analyze reads local NDJSON only. Python
46
- // honors --db-url / CANARY_HISTORY_DB_URL via make_store, so warn (to stderr,
47
- // not stdout -- keeps --json clean) rather than SILENTLY reading a different
48
- // data source. Full remote support is deferred with the async engine port.
49
- makeStore: (dbUrl) => {
50
- if (dbUrl) {
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
- if (store.countRuns() > 0)
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,6 +88,45 @@ 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
+ }
92
130
  // --- unit-bearing flags (#673) -----------------------------------------------
93
131
  //
94
132
  // #670 put the unit in the NAME one layer down -- `analysis/reports.ts` takes
@@ -190,26 +228,29 @@ function writeArtifacts(artifacts, output) {
190
228
  writeFileSync(join(output, name), content, 'utf-8');
191
229
  }
192
230
  }
193
- function flakyCmd(opts, deps) {
231
+ async function flakyCmd(opts, deps) {
194
232
  const store = deps.makeStore(opts.dbUrl);
195
- if (abstainOnEmptyHistory(store, deps, opts.json === true, 'flake rate')) {
233
+ if (await abstainOnEmptyHistory(store, deps, opts.json === true, 'flake rate')) {
196
234
  return;
197
235
  }
198
- const rows = store.queryFlaky(opts.windowRuns, opts.suite ?? null, opts.minRatePct);
236
+ const rows = await store.queryFlaky(opts.windowRuns, opts.suite ?? null, opts.minRatePct);
199
237
  if (opts.json) {
200
238
  deps.out(jsonIndent2(rows));
201
239
  }
202
240
  else {
203
- deps.out(buildFlakyReport(rows, opts.windowRuns, opts.minRatePct));
241
+ deps.out(buildFlakyTestsReport(rows, opts.windowRuns, opts.minRatePct));
204
242
  }
205
243
  }
206
- function spikesCmd(opts, deps) {
244
+ async function spikesCmd(opts, deps) {
207
245
  const store = deps.makeStore(opts.dbUrl);
208
- 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')) {
209
250
  return;
210
251
  }
211
252
  const rows = [];
212
- for (const r of store.readAll()) {
253
+ for (const r of await store.readAll()) {
213
254
  if (opts.since && (r.timestamp ?? '') < opts.since)
214
255
  continue;
215
256
  rows.push({
@@ -225,7 +266,7 @@ function spikesCmd(opts, deps) {
225
266
  deps.out(jsonIndent2(rows));
226
267
  }
227
268
  else {
228
- deps.out(buildSpikesReport(rows, opts.deltaPp));
269
+ deps.out(buildFailureSpikesReport(rows, opts.deltaPp));
229
270
  }
230
271
  }
231
272
  function areaHealthCmd(opts, deps) {
@@ -243,13 +284,16 @@ function areaHealthCmd(opts, deps) {
243
284
  `result here would be a fiction. Use \`analyze digest\` for the reports ` +
244
285
  `that are wired, and track the area-health row set as unimplemented.`);
245
286
  }
246
- function commonFailuresCmd(opts, deps) {
287
+ async function commonFailuresCmd(opts, deps) {
247
288
  const store = deps.makeStore(opts.dbUrl);
248
- 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')) {
249
293
  return;
250
294
  }
251
295
  const rows = [];
252
- for (const record of store.readAll()) {
296
+ for (const record of await store.readAll()) {
253
297
  if (opts.since && (record.timestamp ?? '') < opts.since)
254
298
  continue;
255
299
  for (const t of record.tests ?? []) {
@@ -271,13 +315,16 @@ function commonFailuresCmd(opts, deps) {
271
315
  deps.out(buildCommonFailuresReport(rows, opts.minSuites));
272
316
  }
273
317
  }
274
- function regressionCandidatesCmd(opts, deps) {
318
+ async function regressionCandidatesCmd(opts, deps) {
275
319
  const store = deps.makeStore(opts.dbUrl);
276
- 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')) {
277
324
  return;
278
325
  }
279
326
  const engine = new AnalysisEngine(store);
280
- const candidates = engine.detectRegressionCandidates(null, opts.minGreen, opts.recentFailures);
327
+ const candidates = await engine.detectRegressionCandidates(null, opts.minGreen, opts.recentFailures);
281
328
  if (opts.json) {
282
329
  deps.out(jsonIndent2(candidates));
283
330
  }
@@ -285,13 +332,13 @@ function regressionCandidatesCmd(opts, deps) {
285
332
  deps.out(buildRegressionCandidatesReport(candidates));
286
333
  }
287
334
  }
288
- function digestCmd(opts, deps) {
335
+ async function digestCmd(opts, deps) {
289
336
  const store = deps.makeStore(opts.dbUrl);
290
- if (abstainOnEmptyHistory(store, deps, opts.json === true, 'fleet health')) {
337
+ if (await abstainOnEmptyHistory(store, deps, opts.json === true, 'fleet health')) {
291
338
  return;
292
339
  }
293
340
  const engine = new AnalysisEngine(store);
294
- const result = engine.run({
341
+ const result = await engine.run({
295
342
  windowRuns: opts.windowRuns,
296
343
  deltaPp: opts.deltaPp,
297
344
  weeks: opts.weeks,
@@ -299,6 +346,19 @@ function digestCmd(opts, deps) {
299
346
  suite: opts.suite ?? null,
300
347
  });
301
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
+ }
302
362
  if (opts.json) {
303
363
  deps.out(jsonIndent2({
304
364
  flaky_count: result.flaky.length,
@@ -328,7 +388,9 @@ function printSlack(flakyCount, regCount, deps) {
328
388
  // description: a blank one is how the silent unit survived this long, since the
329
389
  // value's meaning lived only in the report it eventually printed.
330
390
  const DB_URL_ENV = 'CANARY_HISTORY_DB_URL';
331
- const DB_URL_DESC = 'History store URL (accepted, but analyze reads local NDJSON).';
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.';
332
394
  const JSON_DESC = 'Emit the rows as JSON instead of a Markdown report.';
333
395
  const WINDOW_RUNS_DESC = 'Rolling window measured in RUNS, not days.';
334
396
  const MIN_RATE_PCT_DESC = 'Minimum flake rate to report, in PERCENT 0-100 (10 means 10%, not 0.1).';
@@ -355,8 +417,8 @@ export function createAnalyzeCommand(depsInit = {}) {
355
417
  .addOption(new Option('--min-rate <percent>', 'Deprecated alias for --min-rate-pct.').argParser(parsePercentScale('--min-rate', 'percent')))
356
418
  .addOption(new Option('--db-url <url>', DB_URL_DESC).env(DB_URL_ENV))
357
419
  .option('--json', JSON_DESC)
358
- .action((opts, cmd) => {
359
- flakyCmd(resolveUnitFlags(opts, cmd, deps, [WINDOW_ALIAS, MIN_RATE_ALIAS]), deps);
420
+ .action(async (opts, cmd) => {
421
+ await flakyCmd(resolveUnitFlags(opts, cmd, deps, [WINDOW_ALIAS, MIN_RATE_ALIAS]), deps);
360
422
  });
361
423
  program
362
424
  .command('spikes')
@@ -368,8 +430,8 @@ export function createAnalyzeCommand(depsInit = {}) {
368
430
  .addOption(new Option('--delta <points>', 'Deprecated alias for --delta-pp.').argParser(parsePercentScale('--delta', 'percentage points')))
369
431
  .addOption(new Option('--db-url <url>', DB_URL_DESC).env(DB_URL_ENV))
370
432
  .option('--json', JSON_DESC)
371
- .action((opts, cmd) => {
372
- spikesCmd(resolveUnitFlags(opts, cmd, deps, [DELTA_ALIAS]), deps);
433
+ .action(async (opts, cmd) => {
434
+ await spikesCmd(resolveUnitFlags(opts, cmd, deps, [DELTA_ALIAS]), deps);
373
435
  });
374
436
  program
375
437
  .command('area-health')
@@ -391,8 +453,8 @@ export function createAnalyzeCommand(depsInit = {}) {
391
453
  .argParser((v) => Number.parseInt(v, 10)))
392
454
  .addOption(new Option('--db-url <url>', DB_URL_DESC).env(DB_URL_ENV))
393
455
  .option('--json', JSON_DESC)
394
- .action((opts) => {
395
- commonFailuresCmd(opts, deps);
456
+ .action(async (opts) => {
457
+ await commonFailuresCmd(opts, deps);
396
458
  });
397
459
  program
398
460
  .command('regression-candidates')
@@ -405,8 +467,8 @@ export function createAnalyzeCommand(depsInit = {}) {
405
467
  .argParser((v) => Number.parseInt(v, 10)))
406
468
  .addOption(new Option('--db-url <url>', DB_URL_DESC).env(DB_URL_ENV))
407
469
  .option('--json', JSON_DESC)
408
- .action((opts) => {
409
- regressionCandidatesCmd(opts, deps);
470
+ .action(async (opts) => {
471
+ await regressionCandidatesCmd(opts, deps);
410
472
  });
411
473
  program
412
474
  .command('digest')
@@ -430,8 +492,8 @@ export function createAnalyzeCommand(depsInit = {}) {
430
492
  .option('--json', 'Emit per-section counts as JSON instead of the digest.')
431
493
  .option('--slack', 'Emit a short Slack-formatted summary.')
432
494
  .addOption(new Option('--db-url <url>', DB_URL_DESC).env(DB_URL_ENV))
433
- .action((opts, cmd) => {
434
- digestCmd(resolveUnitFlags(opts, cmd, deps, [WINDOW_ALIAS, DELTA_ALIAS]), deps);
495
+ .action(async (opts, cmd) => {
496
+ await digestCmd(resolveUnitFlags(opts, cmd, deps, [WINDOW_ALIAS, DELTA_ALIAS]), deps);
435
497
  });
436
498
  for (const sub of program.commands) {
437
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, buildFlakyReport, buildRegressionCandidatesReport, buildSpikesReport, } from './reports.js';
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,12 +26,16 @@ 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 = {}) {
38
+ async run(opts = {}) {
35
39
  const windowRuns = opts.windowRuns ?? 30;
36
40
  const deltaPp = opts.deltaPp ?? 20.0;
37
41
  const weeks = opts.weeks ?? 4;
@@ -40,11 +44,20 @@ export class AnalysisEngine {
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(windowRuns, suite, minFlakeRatePct);
44
- const suitesToQuery = suite ? [suite] : this.discoverSuites();
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, windowRuns * 2);
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,8 +72,12 @@ 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
- const regressionCandidates = this.detectRegressionCandidates(suite, minGreen, recentFailures);
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,
@@ -73,8 +90,8 @@ export class AnalysisEngine {
73
90
  minSuites,
74
91
  });
75
92
  const artifacts = {
76
- 'flaky.md': buildFlakyReport(flaky, windowRuns, minFlakeRatePct),
77
- 'spikes.md': buildSpikesReport(spikesRows, deltaPp),
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({
@@ -16,7 +16,7 @@ export { round1 };
16
16
  // ---------------------------------------------------------------------------
17
17
  // Flaky report
18
18
  // ---------------------------------------------------------------------------
19
- export function buildFlakyReport(rows, windowRuns, minRatePct, limit = 20) {
19
+ export function buildFlakyTestsReport(rows, windowRuns, minRatePct, limit = 20) {
20
20
  if (rows.length === 0) {
21
21
  return `No tests above ${pyFloat(minRatePct)}% flake rate in the last ${windowRuns} runs.\n`;
22
22
  }
@@ -75,7 +75,7 @@ function detectSpikes(rows, deltaPp) {
75
75
  }
76
76
  return spikes;
77
77
  }
78
- export function buildSpikesReport(rows, deltaPp) {
78
+ export function buildFailureSpikesReport(rows, deltaPp) {
79
79
  if (rows.length === 0) {
80
80
  return 'No run data available for spike detection.\n';
81
81
  }
@@ -210,8 +210,9 @@ function regressionRow(r) {
210
210
  export function buildDigest(args) {
211
211
  const sections = [
212
212
  '# Fleet Health Digest\n',
213
- '## Flaky Tests\n\n' + buildFlakyReport(args.flaky, args.windowRuns, 10.0),
214
- '## Spikes\n\n' + buildSpikesReport(args.spikes, args.deltaPp),
213
+ '## Flaky Tests\n\n' +
214
+ buildFlakyTestsReport(args.flaky, args.windowRuns, 10.0),
215
+ '## Spikes\n\n' + buildFailureSpikesReport(args.spikes, args.deltaPp),
215
216
  '## Area Health\n\n' + buildAreaHealthReport(args.areaHealth, args.weeks),
216
217
  '## Common Failures\n\n' +
217
218
  buildCommonFailuresReport(args.commonFailures, args.minSuites),