canary-test-cli 6.8.0 → 7.0.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 (36) hide show
  1. package/dist/engine/analysis/cli.js +155 -45
  2. package/dist/engine/analysis/engine.js +9 -9
  3. package/dist/engine/analysis/reports.js +0 -0
  4. package/dist/engine/cli-commands.js +2 -2
  5. package/dist/engine/core/migrator.js +142 -31
  6. package/dist/engine/core/static-linter.js +2 -2
  7. package/dist/engine/core/workspace-detect.js +0 -0
  8. package/dist/engine/guardian/adjudication.js +1 -1
  9. package/dist/engine/guardian/agent-tier.js +3 -3
  10. package/dist/engine/guardian/coverage.js +15 -1397
  11. package/dist/engine/guardian/diff-coverage/formats/cobertura.js +130 -0
  12. package/dist/engine/guardian/diff-coverage/formats/coverage-json-lint.js +197 -0
  13. package/dist/engine/guardian/diff-coverage/formats/coverage-json.js +107 -0
  14. package/dist/engine/guardian/diff-coverage/formats/xml.js +151 -0
  15. package/dist/engine/guardian/diff-coverage/graph-tier.js +223 -0
  16. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +150 -0
  17. package/dist/engine/guardian/diff-coverage/orchestrator.js +125 -0
  18. package/dist/engine/guardian/diff-coverage/paths.js +164 -0
  19. package/dist/engine/guardian/diff-coverage/report-tier.js +153 -0
  20. package/dist/engine/guardian/diff-coverage/type-only.js +150 -0
  21. package/dist/engine/guardian/diff-coverage/types.js +115 -0
  22. package/dist/engine/guardian/pr-check.js +22 -7
  23. package/dist/engine-checks.d.ts +15 -0
  24. package/dist/engine-checks.js +92 -1
  25. package/dist/overlay-commands.d.ts +12 -1
  26. package/dist/overlay-commands.js +28 -2
  27. package/dist/router.js +17 -5
  28. package/dist/uninstall-render.d.ts +11 -0
  29. package/dist/uninstall-render.js +60 -0
  30. package/dist/uninstall-scan.d.ts +14 -0
  31. package/dist/uninstall-scan.js +273 -0
  32. package/dist/uninstall-types.d.ts +46 -0
  33. package/dist/uninstall-types.js +91 -0
  34. package/dist/uninstall.d.ts +13 -0
  35. package/dist/uninstall.js +174 -0
  36. package/package.json +1 -1
@@ -25,7 +25,7 @@
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';
@@ -89,6 +89,101 @@ function abstainOnEmptyHistory(store, deps, json, what) {
89
89
  }
90
90
  return true;
91
91
  }
92
+ // --- unit-bearing flags (#673) -----------------------------------------------
93
+ //
94
+ // #670 put the unit in the NAME one layer down -- `analysis/reports.ts` takes
95
+ // `windowRuns`, `deltaPp`, `minRatePct` -- and stopped at the CLI boundary
96
+ // because renaming a flag is user-visible. The flags carried the same silence
97
+ // they always had: the report prints `window: 30 runs`, `20.0pp increase` and
98
+ // `>= 10.0%`, so the unit only ever arrived AFTER the user had guessed. This
99
+ // section closes that gap using the same convention: `<measure><Unit>` in
100
+ // camelCase becomes `--<measure>-<unit>` on the command line.
101
+ //
102
+ // `--delta` is the sharpest case. It is a percentage-POINT threshold that looks
103
+ // like a percentage, so `--delta 20` against a failure rate moving 5% -> 6% is
104
+ // the difference between firing and staying silent, with nothing on screen to
105
+ // say which reading applied.
106
+ /** One decimal or integer, no sign: the only shape these thresholds accept. */
107
+ const UNSIGNED_NUMBER = /^(\d+(\.\d+)?|\.\d+)$/;
108
+ /**
109
+ * A whole count of runs, >= 1.
110
+ *
111
+ * The bare `Number.parseInt` this replaces mapped `--window seven` to `NaN`,
112
+ * which the query layer happily compared against and matched nothing -- a
113
+ * silent abstention wearing a clean fleet's clothes. A value that cannot mean
114
+ * what the flag says is a usage error instead.
115
+ */
116
+ function parseRuns(flag) {
117
+ return (raw) => {
118
+ if (!/^\d+$/.test(raw.trim()) || Number.parseInt(raw, 10) < 1) {
119
+ throw new InvalidArgumentError(`${flag} takes a whole number of RUNS, at least 1 (e.g. 30); ` +
120
+ `got "${raw}".`);
121
+ }
122
+ return Number.parseInt(raw, 10);
123
+ };
124
+ }
125
+ /**
126
+ * A 0-100 value on a percentage scale (`percent` for a rate, `percentage
127
+ * points` for a delta). Rejecting >100 is what catches the fraction habit from
128
+ * the other direction; `0.1` is still legal (a tenth of a percent), so the help
129
+ * text carries that half of the warning.
130
+ */
131
+ function parsePercentScale(flag, unit) {
132
+ return (raw) => {
133
+ const n = Number.parseFloat(raw);
134
+ if (!UNSIGNED_NUMBER.test(raw.trim()) || !Number.isFinite(n) || n > 100) {
135
+ throw new InvalidArgumentError(`${flag} takes ${unit} between 0 and 100 (e.g. 10 means ten ${unit}, ` +
136
+ `not 0.1); got "${raw}".`);
137
+ }
138
+ return n;
139
+ };
140
+ }
141
+ const WINDOW_ALIAS = {
142
+ canonical: 'windowRuns',
143
+ legacy: 'window',
144
+ canonicalFlag: '--window-runs',
145
+ legacyFlag: '--window',
146
+ unit: 'a count of runs, not days',
147
+ };
148
+ const DELTA_ALIAS = {
149
+ canonical: 'deltaPp',
150
+ legacy: 'delta',
151
+ canonicalFlag: '--delta-pp',
152
+ legacyFlag: '--delta',
153
+ unit: 'percentage points, not percent',
154
+ };
155
+ const MIN_RATE_ALIAS = {
156
+ canonical: 'minRatePct',
157
+ legacy: 'minRate',
158
+ canonicalFlag: '--min-rate-pct',
159
+ legacyFlag: '--min-rate',
160
+ unit: 'a percentage, 0-100',
161
+ };
162
+ /**
163
+ * Fold any deprecated spellings the user typed onto their canonical keys.
164
+ *
165
+ * The canonical flag wins when BOTH are given -- the alias is the legacy path,
166
+ * so an explicit new-style flag is the more deliberate statement of intent.
167
+ * `getOptionValueSource` is what separates "the user typed it" from "commander
168
+ * filled in the default"; comparing values would let a canonical default that
169
+ * happens to equal the alias silently discard the alias.
170
+ */
171
+ function resolveUnitFlags(opts, cmd, deps, aliases) {
172
+ // Commander hands option bags back as plain objects keyed by camelCase flag
173
+ // name; the per-command interfaces describe them but carry no index
174
+ // signature, so the alias keys are reached through one local widening.
175
+ const merged = { ...opts };
176
+ for (const alias of aliases) {
177
+ if (merged[alias.legacy] === undefined)
178
+ continue;
179
+ deps.err(`note: ${alias.legacyFlag} is deprecated; use ${alias.canonicalFlag} ` +
180
+ `(the value is ${alias.unit}).`);
181
+ if (cmd.getOptionValueSource(alias.canonical) !== 'cli') {
182
+ merged[alias.canonical] = merged[alias.legacy];
183
+ }
184
+ }
185
+ return merged;
186
+ }
92
187
  function writeArtifacts(artifacts, output) {
93
188
  mkdirSync(output, { recursive: true });
94
189
  for (const [name, content] of Object.entries(artifacts)) {
@@ -100,12 +195,12 @@ function flakyCmd(opts, deps) {
100
195
  if (abstainOnEmptyHistory(store, deps, opts.json === true, 'flake rate')) {
101
196
  return;
102
197
  }
103
- const rows = store.queryFlaky(opts.window, opts.suite ?? null, opts.minRate);
198
+ const rows = store.queryFlaky(opts.windowRuns, opts.suite ?? null, opts.minRatePct);
104
199
  if (opts.json) {
105
200
  deps.out(jsonIndent2(rows));
106
201
  }
107
202
  else {
108
- deps.out(buildFlakyReport(rows, opts.window, opts.minRate));
203
+ deps.out(buildFlakyReport(rows, opts.windowRuns, opts.minRatePct));
109
204
  }
110
205
  }
111
206
  function spikesCmd(opts, deps) {
@@ -130,7 +225,7 @@ function spikesCmd(opts, deps) {
130
225
  deps.out(jsonIndent2(rows));
131
226
  }
132
227
  else {
133
- deps.out(buildSpikesReport(rows, opts.delta));
228
+ deps.out(buildSpikesReport(rows, opts.deltaPp));
134
229
  }
135
230
  }
136
231
  function areaHealthCmd(opts, deps) {
@@ -197,8 +292,8 @@ function digestCmd(opts, deps) {
197
292
  }
198
293
  const engine = new AnalysisEngine(store);
199
294
  const result = engine.run({
200
- window: opts.window,
201
- delta: opts.delta,
295
+ windowRuns: opts.windowRuns,
296
+ deltaPp: opts.deltaPp,
202
297
  weeks: opts.weeks,
203
298
  minSuites: opts.minSuites,
204
299
  suite: opts.suite ?? null,
@@ -229,6 +324,16 @@ function printSlack(flakyCount, regCount, deps) {
229
324
  deps.out(lines.join('\n'));
230
325
  }
231
326
  // --- assembly ----------------------------------------------------------------
327
+ // Option help shared across subcommands. Every analyze option carries a
328
+ // description: a blank one is how the silent unit survived this long, since the
329
+ // value's meaning lived only in the report it eventually printed.
330
+ const DB_URL_ENV = 'CANARY_HISTORY_DB_URL';
331
+ const DB_URL_DESC = 'History store URL (accepted, but analyze reads local NDJSON).';
332
+ const JSON_DESC = 'Emit the rows as JSON instead of a Markdown report.';
333
+ const WINDOW_RUNS_DESC = 'Rolling window measured in RUNS, not days.';
334
+ const MIN_RATE_PCT_DESC = 'Minimum flake rate to report, in PERCENT 0-100 (10 means 10%, not 0.1).';
335
+ const DELTA_PP_DESC = 'Spike threshold as a PERCENTAGE-POINT rise in failure rate ' +
336
+ '(20 means 5% -> 25%, not 5% -> 6%).';
232
337
  /** Build a fresh `analyze` command wired to `depsInit`. */
233
338
  export function createAnalyzeCommand(depsInit = {}) {
234
339
  const deps = { ...defaultAnalyzeDeps(), ...depsInit };
@@ -239,89 +344,94 @@ export function createAnalyzeCommand(depsInit = {}) {
239
344
  program
240
345
  .command('flaky')
241
346
  .description('Fleet-wide flake leaderboard.')
242
- .addOption(new Option('-w, --window <n>')
347
+ .addOption(new Option('-w, --window-runs <runs>', WINDOW_RUNS_DESC)
243
348
  .default(30)
244
- .argParser((v) => Number.parseInt(v, 10)))
245
- .option('-s, --suite <suite>')
246
- .addOption(new Option('--min-rate <pct>')
349
+ .argParser(parseRuns('--window-runs')))
350
+ .addOption(new Option('--window <runs>', 'Deprecated alias for --window-runs.').argParser(parseRuns('--window')))
351
+ .option('-s, --suite <suite>', 'Filter to a specific suite.')
352
+ .addOption(new Option('--min-rate-pct <percent>', MIN_RATE_PCT_DESC)
247
353
  .default(10.0)
248
- .argParser((v) => Number.parseFloat(v)))
249
- .addOption(new Option('--db-url <url>').env('CANARY_HISTORY_DB_URL'))
250
- .option('--json')
251
- .action((opts) => {
252
- flakyCmd(opts, deps);
354
+ .argParser(parsePercentScale('--min-rate-pct', 'percent')))
355
+ .addOption(new Option('--min-rate <percent>', 'Deprecated alias for --min-rate-pct.').argParser(parsePercentScale('--min-rate', 'percent')))
356
+ .addOption(new Option('--db-url <url>', DB_URL_DESC).env(DB_URL_ENV))
357
+ .option('--json', JSON_DESC)
358
+ .action((opts, cmd) => {
359
+ flakyCmd(resolveUnitFlags(opts, cmd, deps, [WINDOW_ALIAS, MIN_RATE_ALIAS]), deps);
253
360
  });
254
361
  program
255
362
  .command('spikes')
256
363
  .description('Recent failure spikes across suites.')
257
364
  .option('--since <date>', 'ISO date filter, e.g. 2026-06-01')
258
- .addOption(new Option('--delta <pp>')
365
+ .addOption(new Option('--delta-pp <points>', DELTA_PP_DESC)
259
366
  .default(20.0)
260
- .argParser((v) => Number.parseFloat(v)))
261
- .addOption(new Option('--db-url <url>').env('CANARY_HISTORY_DB_URL'))
262
- .option('--json')
263
- .action((opts) => {
264
- spikesCmd(opts, deps);
367
+ .argParser(parsePercentScale('--delta-pp', 'percentage points')))
368
+ .addOption(new Option('--delta <points>', 'Deprecated alias for --delta-pp.').argParser(parsePercentScale('--delta', 'percentage points')))
369
+ .addOption(new Option('--db-url <url>', DB_URL_DESC).env(DB_URL_ENV))
370
+ .option('--json', JSON_DESC)
371
+ .action((opts, cmd) => {
372
+ spikesCmd(resolveUnitFlags(opts, cmd, deps, [DELTA_ALIAS]), deps);
265
373
  });
266
374
  program
267
375
  .command('area-health')
268
376
  .description('Area degradation trends over time.')
269
- .addOption(new Option('--weeks <n>')
377
+ .addOption(new Option('--weeks <weeks>', 'Trend window, in whole weeks.')
270
378
  .default(4)
271
379
  .argParser((v) => Number.parseInt(v, 10)))
272
- .addOption(new Option('--db-url <url>').env('CANARY_HISTORY_DB_URL'))
273
- .option('--json')
380
+ .addOption(new Option('--db-url <url>', DB_URL_DESC).env(DB_URL_ENV))
381
+ .option('--json', JSON_DESC)
274
382
  .action((opts) => {
275
383
  areaHealthCmd(opts, deps);
276
384
  });
277
385
  program
278
386
  .command('common-failures')
279
387
  .description('Cross-suite failure fingerprinting.')
280
- .option('--since <date>')
281
- .addOption(new Option('--min-suites <n>')
388
+ .option('--since <date>', 'ISO date filter, e.g. 2026-06-01')
389
+ .addOption(new Option('--min-suites <suites>', 'Report a failure only once it appears in this many SUITES.')
282
390
  .default(2)
283
391
  .argParser((v) => Number.parseInt(v, 10)))
284
- .addOption(new Option('--db-url <url>').env('CANARY_HISTORY_DB_URL'))
285
- .option('--json')
392
+ .addOption(new Option('--db-url <url>', DB_URL_DESC).env(DB_URL_ENV))
393
+ .option('--json', JSON_DESC)
286
394
  .action((opts) => {
287
395
  commonFailuresCmd(opts, deps);
288
396
  });
289
397
  program
290
398
  .command('regression-candidates')
291
399
  .description('Tests newly and consistently broken after a green streak.')
292
- .addOption(new Option('--min-green <n>')
400
+ .addOption(new Option('--min-green <runs>', 'Length of the prior green streak, in RUNS.')
293
401
  .default(5)
294
402
  .argParser((v) => Number.parseInt(v, 10)))
295
- .addOption(new Option('--recent-failures <n>')
403
+ .addOption(new Option('--recent-failures <runs>', 'Consecutive failing RUNS required after the streak.')
296
404
  .default(3)
297
405
  .argParser((v) => Number.parseInt(v, 10)))
298
- .addOption(new Option('--db-url <url>').env('CANARY_HISTORY_DB_URL'))
299
- .option('--json')
406
+ .addOption(new Option('--db-url <url>', DB_URL_DESC).env(DB_URL_ENV))
407
+ .option('--json', JSON_DESC)
300
408
  .action((opts) => {
301
409
  regressionCandidatesCmd(opts, deps);
302
410
  });
303
411
  program
304
412
  .command('digest')
305
413
  .description('Combined digest of all five report types.')
306
- .addOption(new Option('--window <n>')
414
+ .addOption(new Option('--window-runs <runs>', WINDOW_RUNS_DESC)
307
415
  .default(30)
308
- .argParser((v) => Number.parseInt(v, 10)))
309
- .addOption(new Option('--delta <pp>')
416
+ .argParser(parseRuns('--window-runs')))
417
+ .addOption(new Option('--window <runs>', 'Deprecated alias for --window-runs.').argParser(parseRuns('--window')))
418
+ .addOption(new Option('--delta-pp <points>', DELTA_PP_DESC)
310
419
  .default(20.0)
311
- .argParser((v) => Number.parseFloat(v)))
312
- .addOption(new Option('--weeks <n>')
420
+ .argParser(parsePercentScale('--delta-pp', 'percentage points')))
421
+ .addOption(new Option('--delta <points>', 'Deprecated alias for --delta-pp.').argParser(parsePercentScale('--delta', 'percentage points')))
422
+ .addOption(new Option('--weeks <weeks>', 'Area-health trend window, in whole weeks.')
313
423
  .default(4)
314
424
  .argParser((v) => Number.parseInt(v, 10)))
315
- .addOption(new Option('--min-suites <n>')
425
+ .addOption(new Option('--min-suites <suites>', 'Report a failure only once it appears in this many SUITES.')
316
426
  .default(2)
317
427
  .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('CANARY_HISTORY_DB_URL'))
323
- .action((opts) => {
324
- digestCmd(opts, deps);
428
+ .option('--suite <suite>', 'Filter to a specific suite.')
429
+ .addOption(new Option('--output <dir>', 'Directory the Markdown artifacts are ' + 'written to.').default('test-results/analysis'))
430
+ .option('--json', 'Emit per-section counts as JSON instead of the digest.')
431
+ .option('--slack', 'Emit a short Slack-formatted summary.')
432
+ .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);
325
435
  });
326
436
  for (const sub of program.commands) {
327
437
  sub.exitOverride(normalizeUsageExit);
@@ -32,19 +32,19 @@ export class AnalysisEngine {
32
32
  this.store = store;
33
33
  }
34
34
  run(opts = {}) {
35
- const window = opts.window ?? 30;
36
- const delta = opts.delta ?? 20.0;
35
+ const windowRuns = opts.windowRuns ?? 30;
36
+ const deltaPp = opts.deltaPp ?? 20.0;
37
37
  const weeks = opts.weeks ?? 4;
38
38
  const minSuites = opts.minSuites ?? 2;
39
- const minFlakeRate = opts.minFlakeRate ?? 10.0;
39
+ const minFlakeRatePct = opts.minFlakeRatePct ?? 10.0;
40
40
  const minGreen = opts.minGreen ?? 5;
41
41
  const recentFailures = opts.recentFailures ?? 3;
42
42
  const suite = opts.suite ?? null;
43
- const flaky = this.store.queryFlaky(window, suite, minFlakeRate);
43
+ const flaky = this.store.queryFlaky(windowRuns, suite, minFlakeRatePct);
44
44
  const suitesToQuery = suite ? [suite] : this.discoverSuites();
45
45
  const spikesRows = [];
46
46
  for (const s of suitesToQuery) {
47
- const summary = this.store.querySummary(s, window * 2);
47
+ const summary = this.store.querySummary(s, windowRuns * 2);
48
48
  for (const row of def(summary.runs, [])) {
49
49
  // query_summary rows omit suite; the spikes builder groups by it, so
50
50
  // tag each pooled row with the suite it came from (matches Python).
@@ -67,14 +67,14 @@ export class AnalysisEngine {
67
67
  areaHealth: areaRows,
68
68
  commonFailures: commonRows,
69
69
  regressionCandidates,
70
- window,
71
- delta,
70
+ windowRuns,
71
+ deltaPp,
72
72
  weeks,
73
73
  minSuites,
74
74
  });
75
75
  const artifacts = {
76
- 'flaky.md': buildFlakyReport(flaky, window, minFlakeRate),
77
- 'spikes.md': buildSpikesReport(spikesRows, delta),
76
+ 'flaky.md': buildFlakyReport(flaky, windowRuns, minFlakeRatePct),
77
+ 'spikes.md': buildSpikesReport(spikesRows, deltaPp),
78
78
  'area-health.md': buildAreaHealthReport(areaRows, weeks),
79
79
  'common-failures.md': buildCommonFailuresReport(commonRows, minSuites),
80
80
  'regression-candidates.md': buildRegressionCandidatesReport(regressionCandidates),
Binary file
@@ -19,7 +19,7 @@ import { ckInitCmd } from './company-knowledge-cli.js';
19
19
  import { extractFrameworkHint } from './core/classifier.js';
20
20
  import { VALID_CATEGORIES, buildFeedback } from './core/feedback.js';
21
21
  import { OverlayNotFound, listOverlays, resolveOverlay, } from './core/overlays.js';
22
- import { JS_TEST_EXTENSIONS, lintableFramework } from './core/static-linter.js';
22
+ import { JS_TEST_EXTENSIONS, frameworkForPath } from './core/static-linter.js';
23
23
  import { RunSummary } from './core/ticket-updater.js';
24
24
  import { renderBanner } from './ui/banner.js';
25
25
  import { ARROW, CHECK, CHECK_MARK, CROSS, EM_DASH, HAMMER, NEXT, REDX, ROCKET, WARN, WRENCH, } from './main-deps.js';
@@ -462,7 +462,7 @@ function abstainOnZeroFiles(files, path, deps, json) {
462
462
  * from a scanner that could not parse the input is an abstention, not a pass.
463
463
  */
464
464
  function abstainOnUnlintableFile(path, deps, json) {
465
- if (lintableFramework(path) !== null)
465
+ if (frameworkForPath(path) !== null)
466
466
  return;
467
467
  const ext = extname(path) || basename(path);
468
468
  abstain(`Cannot lint ${ext} — no ruleset parses it, so a clean result would be ` +