canary-test-cli 5.15.0 → 6.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 (79) hide show
  1. package/agent/frameworks/registry.json +655 -0
  2. package/bin/canary.js +20 -15
  3. package/dist/doctor-manifest.d.ts +94 -0
  4. package/dist/doctor.d.ts +67 -0
  5. package/dist/engine/analysis/cli.js +270 -0
  6. package/dist/engine/analysis/engine.js +146 -0
  7. package/dist/engine/analysis/reports.js +0 -0
  8. package/dist/engine/analysis/rows.js +9 -0
  9. package/dist/engine/cli-commands.js +618 -0
  10. package/dist/engine/cli-common.js +60 -0
  11. package/dist/engine/cli.core.js +208 -0
  12. package/dist/engine/cli.js +31 -0
  13. package/dist/engine/company-knowledge-cli.js +201 -0
  14. package/dist/engine/core/ci-env.js +33 -0
  15. package/dist/engine/core/classifier.js +192 -0
  16. package/dist/engine/core/company-knowledge.js +765 -0
  17. package/dist/engine/core/config-validation.js +74 -0
  18. package/dist/engine/core/detection.js +48 -0
  19. package/dist/engine/core/domain-scanner.js +212 -0
  20. package/dist/engine/core/environment-detect.js +410 -0
  21. package/dist/engine/core/executor.js +181 -0
  22. package/dist/engine/core/feedback.js +93 -0
  23. package/dist/engine/core/fixture-scanner.js +173 -0
  24. package/dist/engine/core/framework-registry.js +123 -0
  25. package/dist/engine/core/mcp-validator.js +218 -0
  26. package/dist/engine/core/metadata-scanner.js +147 -0
  27. package/dist/engine/core/migrator.js +1112 -0
  28. package/dist/engine/core/overlays.js +176 -0
  29. package/dist/engine/core/pattern-healer.js +147 -0
  30. package/dist/engine/core/pattern-matcher.js +255 -0
  31. package/dist/engine/core/quality-scorer.js +213 -0
  32. package/dist/engine/core/recommender.js +152 -0
  33. package/dist/engine/core/reporter.js +211 -0
  34. package/dist/engine/core/scaffolder.js +236 -0
  35. package/dist/engine/core/skill-registry.js +522 -0
  36. package/dist/engine/core/static-linter.js +237 -0
  37. package/dist/engine/core/ticket-updater.js +639 -0
  38. package/dist/engine/core/workflow-discovery.js +693 -0
  39. package/dist/engine/guardian/agent-tier.js +338 -0
  40. package/dist/engine/guardian/analysis-emit.js +201 -0
  41. package/dist/engine/guardian/cli.js +787 -0
  42. package/dist/engine/guardian/coverage.js +1055 -0
  43. package/dist/engine/guardian/delta-emitter.js +46 -0
  44. package/dist/engine/guardian/diff-extractor.js +257 -0
  45. package/dist/engine/guardian/hard-gate.js +373 -0
  46. package/dist/engine/guardian/impact-mapper.js +121 -0
  47. package/dist/engine/guardian/pr-check.js +975 -0
  48. package/dist/engine/guardian/pr-comment.js +200 -0
  49. package/dist/engine/guardian/summary-emitter.js +94 -0
  50. package/dist/engine/guardian/tier.js +58 -0
  51. package/dist/engine/history/cli.js +303 -0
  52. package/dist/engine/history/detector.js +68 -0
  53. package/dist/engine/history/ndjson-store.js +177 -0
  54. package/dist/engine/history/record.js +14 -0
  55. package/dist/engine/history/schema.js +59 -0
  56. package/dist/engine/history/store.js +47 -0
  57. package/dist/engine/history/supabase-store.js +113 -0
  58. package/dist/engine/main-deps.js +105 -0
  59. package/dist/engine/mcp-server.js +647 -0
  60. package/dist/engine/package.json +4 -0
  61. package/dist/engine/skills-cli.js +181 -0
  62. package/dist/engine/ui/banner.js +50 -0
  63. package/dist/engine/util/coalesce.js +12 -0
  64. package/dist/engine/util/round.js +43 -0
  65. package/dist/engine/workflow-cli.js +242 -0
  66. package/dist/engine-checks.d.ts +49 -0
  67. package/dist/overlay-commands.d.ts +81 -0
  68. package/dist/overlay-conflicts.d.ts +33 -0
  69. package/dist/overlay-lint.d.ts +19 -0
  70. package/dist/overlays-registry.d.ts +74 -0
  71. package/dist/reporters/testtracker.d.ts +89 -0
  72. package/dist/reporters/testtracker.js +195 -0
  73. package/dist/router.d.ts +12 -0
  74. package/dist/router.js +4 -4
  75. package/dist/skill-requirements.d.ts +57 -0
  76. package/dist/source-spec.d.ts +20 -0
  77. package/package.json +30 -6
  78. package/bin/canary +0 -0
  79. package/scripts/install.js +0 -104
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Deterministic GitHub PR comment poster (Tier 0, agent-free).
3
+ *
4
+ * Faithful TypeScript port of `agent/guardian/pr_comment.py`.
5
+ *
6
+ * This module posts/updates the single sticky guardian findings comment on a
7
+ * pull request. It is **deterministic HTTP behind an interface seam** — it
8
+ * imports no agent/LLM module (SC-11).
9
+ *
10
+ * Design:
11
+ *
12
+ * - {@link GitHubClient} is the seam every consumer talks to
13
+ * (`listComments` / `createComment` / `updateComment`).
14
+ * - {@link FakeGitHubClient} is the in-memory implementation used by every unit
15
+ * test — **no network**. It can simulate a fork read-only token via
16
+ * `deny_writes=true` (writes reject with {@link GitHubPermissionError}).
17
+ * - {@link RestGitHubClient} (Python's private `_RestGitHubClient`) is the thin
18
+ * real client. Network lives **only** here and is never exercised in unit
19
+ * tests.
20
+ *
21
+ * Python→TS nuances:
22
+ * - **async**: Python's `urllib` client is synchronous; Node's global `fetch`
23
+ * is async. The seam methods are therefore `Promise`-returning, so the
24
+ * real client can `await fetch`. The fakes satisfy the async interface by
25
+ * being `async` (returning already-resolved values), and
26
+ * {@link upsertStickyComment} becomes `async`. The pure helpers
27
+ * ({@link findSticky}, {@link degradationAnnotation}) stay synchronous.
28
+ * - **error mapping**: `fetch` resolves (does not throw) on a 4xx/5xx status,
29
+ * so the 403→{@link GitHubPermissionError} mapping is done off `resp.status`
30
+ * rather than off a raised `HTTPError`. As in the oracle, ONLY 403 maps to a
31
+ * permission error here; any other non-2xx propagates as a generic error.
32
+ */
33
+ // Single source of truth for the sticky-comment marker. `pr_check.render`
34
+ // emits the identical literal at the head of a `comment`-format body so
35
+ // `findSticky` can locate the guardian comment for in-place upsert.
36
+ export const STICKY_MARKER = '<!-- canary-pr-guardian -->';
37
+ /**
38
+ * A client cannot write (fork read-only token → HTTP 403).
39
+ *
40
+ * Thrown by write methods so {@link upsertStickyComment} can degrade loudly to
41
+ * a `::warning::` annotation instead of crashing the job (OT-4).
42
+ */
43
+ export class GitHubPermissionError extends Error {
44
+ constructor(message) {
45
+ super(message);
46
+ this.name = 'GitHubPermissionError';
47
+ }
48
+ }
49
+ /**
50
+ * In-memory {@link GitHubClient} for unit tests — no network.
51
+ *
52
+ * Seed `comments` to model existing PR comments. Set `deny_writes=true` to
53
+ * simulate a fork read-only token: `createComment`/`updateComment` then reject
54
+ * with {@link GitHubPermissionError}.
55
+ */
56
+ export class FakeGitHubClient {
57
+ comments;
58
+ deny_writes;
59
+ nextId;
60
+ constructor(init = {}) {
61
+ this.comments = init.comments ?? [];
62
+ this.deny_writes = init.deny_writes ?? false;
63
+ this.nextId = 1000;
64
+ }
65
+ async listComments() {
66
+ return this.comments;
67
+ }
68
+ async createComment(body) {
69
+ if (this.deny_writes) {
70
+ throw new GitHubPermissionError('read-only token: cannot create comment');
71
+ }
72
+ this.nextId += 1;
73
+ const row = { id: this.nextId, body };
74
+ this.comments.push(row);
75
+ return row;
76
+ }
77
+ async updateComment(commentId, body) {
78
+ if (this.deny_writes) {
79
+ throw new GitHubPermissionError('read-only token: cannot update comment');
80
+ }
81
+ for (const row of this.comments) {
82
+ if (row.id === commentId) {
83
+ row.body = body;
84
+ return row;
85
+ }
86
+ }
87
+ throw new Error(`no comment with id ${commentId}`);
88
+ }
89
+ }
90
+ /** Return the first comment whose body contains `marker`, else `null`. */
91
+ export function findSticky(comments, marker = STICKY_MARKER) {
92
+ for (const comment of comments) {
93
+ if ((comment.body ?? '').includes(marker)) {
94
+ return comment;
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+ /**
100
+ * Post or update the single sticky guardian comment (SC-9).
101
+ *
102
+ * Locates the existing comment by `marker`; updates it in place when present,
103
+ * otherwise creates a new one. Never stacks duplicates. A read-only token (fork
104
+ * PR?) degrades loudly to a `degraded` result rather than crashing (OT-4).
105
+ */
106
+ export async function upsertStickyComment(client, body, marker = STICKY_MARKER) {
107
+ const existing = findSticky(await client.listComments(), marker);
108
+ try {
109
+ if (existing !== null) {
110
+ const updated = await client.updateComment(existing.id, body);
111
+ return { action: 'updated', comment_id: updated.id, notice: null };
112
+ }
113
+ const created = await client.createComment(body);
114
+ return { action: 'created', comment_id: created.id, notice: null };
115
+ }
116
+ catch (err) {
117
+ if (err instanceof GitHubPermissionError) {
118
+ // OT-4 / SC-1+D6: a read-only token (fork PR?) must degrade loudly, not
119
+ // crash the job. The caller emits `notice` as a `::warning::` annotation.
120
+ return {
121
+ action: 'degraded',
122
+ comment_id: null,
123
+ notice: 'guardian: read-only token (fork PR?) — findings not posted as ' +
124
+ 'a comment',
125
+ };
126
+ }
127
+ throw err;
128
+ }
129
+ }
130
+ /** Return a GitHub Actions `::warning::` annotation line for `notice`. */
131
+ export function degradationAnnotation(notice) {
132
+ return `::warning::${notice}`;
133
+ }
134
+ /**
135
+ * Thin real {@link GitHubClient} over the GitHub REST API (`fetch`).
136
+ *
137
+ * Python's private `_RestGitHubClient`, exported here (public, like
138
+ * {@link RestBranchProtectionClient}). Network lives ONLY here; **no unit test
139
+ * exercises this class**. A 403 (fork read-only token) surfaces as
140
+ * {@link GitHubPermissionError} so the caller degrades loudly rather than
141
+ * crashing.
142
+ *
143
+ * Comments live on the *issues* endpoint (a PR is an issue):
144
+ * `https://api.github.com/repos/{repo}/issues/{pr}/comments`.
145
+ */
146
+ export class RestGitHubClient {
147
+ repo;
148
+ prNumber;
149
+ token;
150
+ static API = 'https://api.github.com';
151
+ constructor(repo, prNumber, token) {
152
+ this.repo = repo;
153
+ this.prNumber = prNumber;
154
+ this.token = token;
155
+ }
156
+ headers() {
157
+ return {
158
+ Authorization: `Bearer ${this.token}`,
159
+ Accept: 'application/vnd.github+json',
160
+ 'X-GitHub-Api-Version': '2022-11-28',
161
+ 'Content-Type': 'application/json',
162
+ 'User-Agent': 'canary-pr-guardian',
163
+ };
164
+ }
165
+ async request(method, url, payload) {
166
+ const init = { method, headers: this.headers() };
167
+ if (payload !== undefined) {
168
+ init.body = JSON.stringify(payload);
169
+ }
170
+ const resp = await fetch(url, init);
171
+ if (!resp.ok) {
172
+ // As in the oracle, ONLY 403 maps to a permission error; any other
173
+ // non-2xx propagates (the analog of urllib's HTTPError re-raise).
174
+ if (resp.status === 403) {
175
+ throw new GitHubPermissionError(`GitHub API 403 (read-only token / fork PR?): ${url}`);
176
+ }
177
+ throw new Error(`GitHub API ${resp.status}: ${url}`);
178
+ }
179
+ return resp.json();
180
+ }
181
+ async listComments() {
182
+ const url = `${RestGitHubClient.API}/repos/${this.repo}/issues/${this.prNumber}/comments`;
183
+ const result = await this.request('GET', url);
184
+ return Array.isArray(result) ? result : [];
185
+ }
186
+ async createComment(body) {
187
+ const url = `${RestGitHubClient.API}/repos/${this.repo}/issues/${this.prNumber}/comments`;
188
+ const result = await this.request('POST', url, { body });
189
+ return isRecord(result) ? result : {};
190
+ }
191
+ async updateComment(commentId, body) {
192
+ const url = `${RestGitHubClient.API}/repos/${this.repo}/issues/comments/${commentId}`;
193
+ const result = await this.request('PATCH', url, { body });
194
+ return isRecord(result) ? result : {};
195
+ }
196
+ }
197
+ function isRecord(value) {
198
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
199
+ }
200
+ //# sourceMappingURL=pr-comment.js.map
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Build the Phase 1 impact summary Markdown.
3
+ *
4
+ * Faithful TypeScript port of `agent/guardian/summary_emitter.py`. This is the
5
+ * content posted as a PR comment on the SUT repo after a merge to main. Pure
6
+ * function: takes gaps and metadata, returns a Markdown string.
7
+ *
8
+ * Note: the emoji below are load-bearing output data (they appear verbatim in
9
+ * the emitted Markdown), so they are retained here despite the usual
10
+ * no-emoji-in-source convention.
11
+ */
12
+ import { ChangeType } from './diff-extractor.js';
13
+ import { Severity } from './impact-mapper.js';
14
+ const SEVERITY_EMOJI = {
15
+ [Severity.CRITICAL]: '🔴',
16
+ [Severity.HIGH]: '🟠',
17
+ [Severity.MEDIUM]: '🟡',
18
+ [Severity.LOW]: '🟢',
19
+ };
20
+ /** Python: `build_summary`. */
21
+ export function buildSummary(gaps, commitSha, suite, healthSnapshot = '') {
22
+ const shortSha = commitSha.slice(0, 8);
23
+ if (gaps.length === 0) {
24
+ return (`## Canary Guardian — Test Impact Summary\n\n` +
25
+ `**Commit:** ${shortSha} \n` +
26
+ `**Suite:** ${suite}\n\n` +
27
+ `✅ No test impact detected — all existing endpoints and coverage are unchanged.\n`);
28
+ }
29
+ const added = gaps.filter((g) => g.change_type === ChangeType.ADDED);
30
+ const removed = gaps.filter((g) => g.change_type === ChangeType.REMOVED);
31
+ const changed = gaps.filter((g) => g.change_type === ChangeType.CHANGED);
32
+ const lines = [
33
+ '## Canary Guardian — Test Impact Summary\n',
34
+ `**Commit:** ${shortSha} \n**Suite:** ${suite}\n`,
35
+ ];
36
+ if (added.length) {
37
+ lines.push('### New endpoints (not yet covered)');
38
+ for (const g of added) {
39
+ const sev = SEVERITY_EMOJI[g.severity];
40
+ const cov = g.affected_tests.length
41
+ ? `${g.affected_tests.length} existing test(s)`
42
+ : '**no existing tests**';
43
+ lines.push(`- ${sev} \`${g.method.toUpperCase()} ${g.path}\` — ${cov}`);
44
+ }
45
+ lines.push('');
46
+ }
47
+ if (removed.length) {
48
+ lines.push('### Removed endpoints');
49
+ for (const g of removed) {
50
+ const sev = SEVERITY_EMOJI[g.severity];
51
+ lines.push(`- ${sev} \`${g.method.toUpperCase()} ${g.path}\``);
52
+ for (const t of g.affected_tests.slice(0, 5)) {
53
+ lines.push(` - Affected test: _${t}_`);
54
+ }
55
+ if (g.affected_tests.length > 5) {
56
+ lines.push(` - … and ${g.affected_tests.length - 5} more`);
57
+ }
58
+ }
59
+ lines.push('');
60
+ }
61
+ if (changed.length) {
62
+ lines.push('### Changed endpoints');
63
+ for (const g of changed) {
64
+ const sev = SEVERITY_EMOJI[g.severity];
65
+ lines.push(`- ${sev} \`${g.method.toUpperCase()} ${g.path}\``);
66
+ for (const t of g.affected_tests.slice(0, 5)) {
67
+ lines.push(` - Affected test: _${t}_`);
68
+ }
69
+ }
70
+ lines.push('');
71
+ }
72
+ if (healthSnapshot) {
73
+ lines.push('### Current health (affected areas)');
74
+ lines.push(healthSnapshot);
75
+ lines.push('');
76
+ }
77
+ lines.push('### Recommended actions');
78
+ gaps.slice(0, 10).forEach((g, index) => {
79
+ const i = index + 1;
80
+ let action;
81
+ if (g.change_type === ChangeType.ADDED) {
82
+ action = `Write test for \`${g.method.toUpperCase()} ${g.path}\` (no coverage)`;
83
+ }
84
+ else if (g.change_type === ChangeType.REMOVED) {
85
+ action = `Remove/update tests for \`${g.method.toUpperCase()} ${g.path}\` (will break)`;
86
+ }
87
+ else {
88
+ action = `Review tests for \`${g.method.toUpperCase()} ${g.path}\` (silent contract drift risk)`;
89
+ }
90
+ lines.push(`${i}. ${action}`);
91
+ });
92
+ return `${lines.join('\n')}\n`;
93
+ }
94
+ //# sourceMappingURL=summary-emitter.js.map
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Tier-resolution seam for canary-pr-guardian (SC-5 core).
3
+ *
4
+ * Faithful TypeScript port of `agent/guardian/tier.py`.
5
+ *
6
+ * Resolves a *requested* guardian tier against the tier an agent runtime can
7
+ * actually serve, and emits a **loud** degradation notice whenever the effective
8
+ * tier drops below the request. The capability probe is an interface; the
9
+ * Phase-3 default ({@link NoAgentProbe}) deterministically reports "no agent"
10
+ * (tier 0 ceiling) **without importing any agent/LLM module** (SC-11). Phase 4
11
+ * supplies a real probe ({@link module:./agent-tier}'s `InSessionAgentProbe`)
12
+ * implementing the same interface -- `resolveTier`'s callers do not change.
13
+ *
14
+ * This is the CLI-wave companion the engine's `pr-check.ts` intentionally
15
+ * deferred (its header notes the tier seam belongs to the CLI wave, alongside
16
+ * `read_diff`). It lives here so `cli.ts` (pr-check/author-plan) can wire it.
17
+ */
18
+ // The degradation notice text carries a warning sign (U+26A0) and an em-dash
19
+ // (U+2014) as load-bearing OUTPUT DATA -- it is rendered verbatim into the PR
20
+ // comment footer and the Actions `::warning::` channel, and asserted byte-exact
21
+ // against the Python oracle. Written as escapes to honor the ASCII-source rule.
22
+ const WARNING_SIGN = '\u{26A0}';
23
+ const EM_DASH = '\u{2014}';
24
+ /**
25
+ * Canonical loud degradation notice (D6 / SC-5).
26
+ *
27
+ * Names the requested tier and stays loud -- a tier>0 result must never be
28
+ * surfaced without this notice when the requested tier is unavailable.
29
+ */
30
+ function degradationNotice(requested, effective) {
31
+ return (`${WARNING_SIGN} degraded: tier ${requested} unavailable ` +
32
+ `(no agent runtime detected) ${EM_DASH} ran tier ${effective}`);
33
+ }
34
+ /**
35
+ * Deterministic Phase-3 probe: no agent runtime, so tier 0 is the ceiling.
36
+ *
37
+ * Imports no agent/LLM module (SC-11).
38
+ */
39
+ export class NoAgentProbe {
40
+ availableTier() {
41
+ return 0;
42
+ }
43
+ }
44
+ /**
45
+ * Resolve `requested` against the probe's ceiling, degrading loudly.
46
+ *
47
+ * `probe` defaults to {@link NoAgentProbe}. The effective tier is
48
+ * `min(requested, probe.availableTier())`; a loud {@link degradationNotice} is
49
+ * attached iff the effective tier is below the request (else `null`).
50
+ */
51
+ export function resolveTier(requested, probe = null) {
52
+ const activeProbe = probe ?? new NoAgentProbe();
53
+ const available = activeProbe.availableTier();
54
+ const effective = Math.min(requested, available);
55
+ const notice = effective < requested ? degradationNotice(requested, effective) : null;
56
+ return { requested, effective, degraded_notice: notice };
57
+ }
58
+ //# sourceMappingURL=tier.js.map
@@ -0,0 +1,303 @@
1
+ /**
2
+ * CLI subcommands for `canary history` -- faithful port of
3
+ * `agent/history/cli.py` (the `history_app` Typer sub-app), wired to the
4
+ * already-ported async history store (`store.ts` / `schema.ts` / `record.ts`).
5
+ *
6
+ * Follows the guardian CLI conventions (see `../cli-common.ts`): a
7
+ * {@link createHistoryCommand} factory wired to an injectable {@link HistoryDeps}
8
+ * (out/err sinks, env, a store factory), `CliExit` for business exits, and
9
+ * `normalizeUsageExit` on every command so usage errors exit 2.
10
+ *
11
+ * Python->TS fidelity notes:
12
+ * - `json.dumps(x, indent=2)` -> {@link jsonIndent2} (byte-exact + ensure_ascii).
13
+ * - `rich.print("[green]x[/green]")` -> `pc.green('x')`; picocolors strips color
14
+ * on a non-TTY sink, so the plain text is byte-identical to rich's markup
15
+ * stripping. Load-bearing glyphs (em-dash, >=) are `\u{...}` escapes.
16
+ * - The store query methods are ASYNC (the JS Supabase SDK is Promise-based),
17
+ * so every handler that queries is `async` and awaited.
18
+ * - INTENTIONAL DEVIATION: `flaky` and `timeline` render with `rich.Table`
19
+ * (box-drawing). Reproducing rich's exact box bytes is brittle and there is
20
+ * no Python CLI test pinning them, so this port emits a simple aligned text
21
+ * table carrying the SAME cell content. `summary`/`push`/`migrate` are NOT
22
+ * tables and are reproduced byte-for-byte via picocolors stripping.
23
+ */
24
+ import { existsSync, readFileSync } from 'node:fs';
25
+ import { Command, Option } from 'commander';
26
+ import pc from 'picocolors';
27
+ import { CliExit, jsonIndent2, normalizeUsageExit } from '../cli-common.js';
28
+ import { makeRunId } from './schema.js';
29
+ import { makeStore as realMakeStore } from './store.js';
30
+ import { pyFloat } from '../util/round.js';
31
+ const EM_DASH = '\u{2014}';
32
+ const GEQ = '\u{2265}';
33
+ const MDASH_CELL = '\u{2014}'; // rich `r.get("area") or <em-dash>`
34
+ const DEFAULT_HISTORY_FILE = 'test-results/reports/history-v2.jsonl';
35
+ /** Every field of the Python `RunRecord` dataclass (the push/migrate filter). */
36
+ const RUN_FIELDS = [
37
+ 'run_id',
38
+ 'suite',
39
+ 'repo',
40
+ 'branch',
41
+ 'commit_sha',
42
+ 'timestamp',
43
+ 'total',
44
+ 'passed',
45
+ 'failed',
46
+ 'flaky',
47
+ 'skipped',
48
+ 'commit_message',
49
+ 'env',
50
+ 'base_url',
51
+ 'duration_ms',
52
+ ];
53
+ /** Every field of the Python `TestResult` dataclass. */
54
+ const RESULT_FIELDS = [
55
+ 'run_id',
56
+ 'suite',
57
+ 'repo',
58
+ 'test_name',
59
+ 'test_file',
60
+ 'status',
61
+ 'area',
62
+ 'failure_category',
63
+ 'error_text',
64
+ 'retry_count',
65
+ 'duration_ms',
66
+ 'tags',
67
+ ];
68
+ function pick(src, fields) {
69
+ const out = {};
70
+ for (const f of fields) {
71
+ if (Object.prototype.hasOwnProperty.call(src, f)) {
72
+ out[f] = src[f];
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+ /** Process-backed defaults for production. */
78
+ export function defaultHistoryDeps() {
79
+ return {
80
+ out: (s) => process.stdout.write(`${s}\n`),
81
+ err: (s) => process.stderr.write(`${s}\n`),
82
+ env: process.env,
83
+ makeStore: (dbUrl, ndjsonPath) => realMakeStore(dbUrl, ndjsonPath),
84
+ };
85
+ }
86
+ // --- a minimal aligned text table (documented rich.Table deviation) ----------
87
+ function renderTable(title, headers, rows, rightAlign) {
88
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
89
+ const pad = (cell, i) => rightAlign[i] ? cell.padStart(widths[i]) : cell.padEnd(widths[i]);
90
+ const fmt = (cells) => cells.map((c, i) => pad(c, i)).join(' ');
91
+ const lines = [title, fmt(headers)];
92
+ lines.push(widths.map((w) => '-'.repeat(w)).join(' '));
93
+ for (const r of rows)
94
+ lines.push(fmt(r));
95
+ return lines;
96
+ }
97
+ async function pushCmd(historyFile, opts, deps) {
98
+ if (!existsSync(historyFile)) {
99
+ deps.out(`${pc.red('Not found:')} ${historyFile}`);
100
+ throw new CliExit(1);
101
+ }
102
+ const store = deps.makeStore(opts.dbUrl, historyFile);
103
+ const records = [];
104
+ for (const raw of readFileSync(historyFile, 'utf-8').split('\n')) {
105
+ const line = raw.trim();
106
+ if (line)
107
+ records.push(JSON.parse(line));
108
+ }
109
+ if (records.length === 0) {
110
+ deps.out(pc.yellow('No runs found in history file.'));
111
+ throw new CliExit(0);
112
+ }
113
+ const latest = { ...records[records.length - 1] };
114
+ const testsRaw = latest['tests'] ?? [];
115
+ delete latest['tests'];
116
+ const run = pick(latest, RUN_FIELDS);
117
+ const results = testsRaw.map((t) => pick(t, RESULT_FIELDS));
118
+ if (opts.dryRun) {
119
+ deps.out(`${pc.cyan('dry-run:')} would push run ${pc.bold(run.run_id)} (${results.length} tests)`);
120
+ throw new CliExit(0);
121
+ }
122
+ await store.pushRun(run, results);
123
+ deps.out(`${pc.green('Pushed')} run ${pc.bold(run.run_id)} (${results.length} tests)`);
124
+ }
125
+ async function flakyCmd(opts, deps) {
126
+ const store = deps.makeStore(opts.dbUrl);
127
+ const results = await store.queryFlaky(opts.window, opts.suite ?? null, opts.minRate);
128
+ if (opts.json) {
129
+ deps.out(jsonIndent2(results));
130
+ return;
131
+ }
132
+ if (results.length === 0) {
133
+ deps.out(pc.green(`No tests above ${pyFloat(opts.minRate)}% flake rate in the last ${opts.window} runs.`));
134
+ return;
135
+ }
136
+ const rows = results.map((r) => [
137
+ r.test_name,
138
+ r.suite ?? '',
139
+ r.area || MDASH_CELL,
140
+ // pyFloat so a whole-number rate renders `10.0%` like Python str(float),
141
+ // not `10%` (JS number has no int/float distinction).
142
+ `${pyFloat(r.flake_rate_pct)}%`,
143
+ `${r.flake_count}/${r.total_runs}`,
144
+ ]);
145
+ const lines = renderTable(`Flaky Tests (window: ${opts.window} runs, threshold: ${GEQ} ${pyFloat(opts.minRate)}%)`, ['Test', 'Suite', 'Area', 'Flake %', 'Flake/Total'], rows, [false, false, false, true, true]);
146
+ for (const l of lines)
147
+ deps.out(l);
148
+ }
149
+ async function timelineCmd(testName, opts, deps) {
150
+ const store = deps.makeStore(opts.dbUrl);
151
+ const rows = await store.queryTimeline(testName);
152
+ if (opts.json) {
153
+ deps.out(jsonIndent2(rows));
154
+ return;
155
+ }
156
+ if (rows.length === 0) {
157
+ deps.out(`${pc.yellow('No history found for:')} ${testName}`);
158
+ return;
159
+ }
160
+ const dataRows = rows.map((row) => [
161
+ row.run_id ?? '',
162
+ (row.commit_sha ?? '').slice(0, 8),
163
+ (row.timestamp ?? '').slice(0, 19),
164
+ row.status ?? '',
165
+ row.failure_category || MDASH_CELL,
166
+ ]);
167
+ const lines = renderTable(`Timeline: ${testName}`, ['Run ID', 'Commit', 'Timestamp', 'Status', 'Category'], dataRows, [false, false, false, false, false]);
168
+ for (const l of lines)
169
+ deps.out(l);
170
+ }
171
+ async function summaryCmd(suite, opts, deps) {
172
+ const store = deps.makeStore(opts.dbUrl);
173
+ const result = await store.querySummary(suite, opts.runs);
174
+ if (opts.json) {
175
+ deps.out(jsonIndent2(result));
176
+ return;
177
+ }
178
+ const total = result.total_runs ?? 0;
179
+ const avg = result.avg_pass_rate ?? 0.0;
180
+ const colorize = avg >= 90 ? pc.green : avg >= 70 ? pc.yellow : pc.red;
181
+ deps.out(`Suite ${pc.bold(suite)} ${EM_DASH} last ${total} runs ${EM_DASH} avg pass rate: ${colorize(`${pyFloat(avg)}%`)}`);
182
+ }
183
+ async function migrateCmd(file, opts, deps) {
184
+ if (!existsSync(file)) {
185
+ deps.out(`${pc.red('Not found:')} ${file}`);
186
+ throw new CliExit(1);
187
+ }
188
+ const store = deps.makeStore(opts.dbUrl);
189
+ let migrated = 0;
190
+ const skipped = 0;
191
+ for (const raw of readFileSync(file, 'utf-8').split('\n')) {
192
+ const line = raw.trim();
193
+ if (!line)
194
+ continue;
195
+ let entry;
196
+ try {
197
+ entry = JSON.parse(line);
198
+ }
199
+ catch {
200
+ continue; // JSONDecodeError -> skip (Python does NOT bump `skipped`)
201
+ }
202
+ const commit = entry['commit_short'] ?? 'unknown';
203
+ const tsStr = entry['timestamp'] ?? '';
204
+ let ts = 0;
205
+ const parsed = Date.parse(tsStr.replace('Z', '+00:00'));
206
+ if (!Number.isNaN(parsed))
207
+ ts = Math.floor(parsed / 1000);
208
+ const runAgg = entry['run'] ?? {};
209
+ const run = {
210
+ run_id: makeRunId(opts.suite, commit, ts || Math.floor(Date.now() / 1000)),
211
+ suite: opts.suite,
212
+ repo: opts.repo,
213
+ branch: entry['branch'] ?? 'unknown',
214
+ commit_sha: commit,
215
+ timestamp: tsStr,
216
+ total: runAgg['total'] ?? 0,
217
+ passed: runAgg['passed'] ?? 0,
218
+ failed: runAgg['failed'] ?? 0,
219
+ flaky: runAgg['flaky'] ?? 0,
220
+ skipped: runAgg['skipped'] ?? 0,
221
+ };
222
+ if (opts.dryRun) {
223
+ deps.out(`${pc.cyan('dry-run:')} ${run.run_id}`);
224
+ migrated += 1;
225
+ continue;
226
+ }
227
+ await store.pushRun(run, []);
228
+ migrated += 1;
229
+ }
230
+ deps.out(`${pc.green('Migrated')} ${migrated} runs, skipped ${skipped}`);
231
+ }
232
+ // --- assembly ----------------------------------------------------------------
233
+ /** Build a fresh `history` command wired to `depsInit`. */
234
+ export function createHistoryCommand(depsInit = {}) {
235
+ const deps = { ...defaultHistoryDeps(), ...depsInit };
236
+ const program = new Command('history');
237
+ program
238
+ .description('Query and manage test run history.')
239
+ .exitOverride(normalizeUsageExit);
240
+ program
241
+ .command('push')
242
+ .description('Push the most recent run from a local history file to the remote store.')
243
+ .argument('[history_file]', 'Path to local history-v2.jsonl to push to remote store.', DEFAULT_HISTORY_FILE)
244
+ .addOption(new Option('--db-url <url>').env('CANARY_HISTORY_DB_URL'))
245
+ .option('--dry-run', 'Show what would be pushed without pushing.')
246
+ .action(async (historyFile, opts) => {
247
+ await pushCmd(historyFile, opts, deps);
248
+ });
249
+ program
250
+ .command('flaky')
251
+ .description('Show tests ranked by flake rate over the rolling window.')
252
+ .addOption(new Option('-w, --window <n>', 'Rolling window (number of runs).')
253
+ .default(30)
254
+ .argParser((v) => Number.parseInt(v, 10)))
255
+ .option('-s, --suite <suite>', 'Filter to a specific suite.')
256
+ .addOption(new Option('--min-rate <pct>', 'Minimum flake rate % to show.')
257
+ .default(10.0)
258
+ .argParser((v) => Number.parseFloat(v)))
259
+ .addOption(new Option('--db-url <url>').env('CANARY_HISTORY_DB_URL'))
260
+ .option('--json')
261
+ .action(async (opts) => {
262
+ await flakyCmd(opts, deps);
263
+ });
264
+ program
265
+ .command('timeline')
266
+ .description('Show the full run history for a specific test.')
267
+ .argument('<test_name>', 'Exact test name to trace.')
268
+ .addOption(new Option('--db-url <url>').env('CANARY_HISTORY_DB_URL'))
269
+ .option('--json')
270
+ .action(async (testName, opts) => {
271
+ await timelineCmd(testName, opts, deps);
272
+ });
273
+ program
274
+ .command('summary')
275
+ .description('Summarize recent runs for a suite.')
276
+ .argument('<suite>', 'Suite name (e.g. api, e2e_ui).')
277
+ .addOption(new Option('-n, --runs <n>', 'Number of most recent runs to summarize.')
278
+ .default(10)
279
+ .argParser((v) => Number.parseInt(v, 10)))
280
+ .addOption(new Option('--db-url <url>').env('CANARY_HISTORY_DB_URL'))
281
+ .option('--json')
282
+ .action(async (suite, opts) => {
283
+ await summaryCmd(suite, opts, deps);
284
+ });
285
+ program
286
+ .command('migrate')
287
+ .description('Migrate a v1 history.jsonl (aggregate-only) into the v2 store.')
288
+ .argument('<file>', 'Path to history.jsonl (v1 format) to migrate.')
289
+ .requiredOption('--suite <suite>', 'Suite name for these records.')
290
+ .requiredOption('--repo <repo>', 'GitHub repo slug (e.g. acme-corp/api-service).')
291
+ .addOption(new Option('--db-url <url>').env('CANARY_HISTORY_DB_URL'))
292
+ .option('--dry-run')
293
+ .action(async (file, opts) => {
294
+ await migrateCmd(file, opts, deps);
295
+ });
296
+ for (const sub of program.commands) {
297
+ sub.exitOverride(normalizeUsageExit);
298
+ }
299
+ return program;
300
+ }
301
+ /** The production `history` command (process-backed defaults). */
302
+ export const historyCommand = createHistoryCommand();
303
+ //# sourceMappingURL=cli.js.map