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.
- package/agent/frameworks/registry.json +655 -0
- package/bin/canary.js +20 -15
- package/dist/doctor-manifest.d.ts +94 -0
- package/dist/doctor.d.ts +67 -0
- package/dist/engine/analysis/cli.js +270 -0
- package/dist/engine/analysis/engine.js +146 -0
- package/dist/engine/analysis/reports.js +0 -0
- package/dist/engine/analysis/rows.js +9 -0
- package/dist/engine/cli-commands.js +618 -0
- package/dist/engine/cli-common.js +60 -0
- package/dist/engine/cli.core.js +208 -0
- package/dist/engine/cli.js +31 -0
- package/dist/engine/company-knowledge-cli.js +201 -0
- package/dist/engine/core/ci-env.js +33 -0
- package/dist/engine/core/classifier.js +192 -0
- package/dist/engine/core/company-knowledge.js +765 -0
- package/dist/engine/core/config-validation.js +74 -0
- package/dist/engine/core/detection.js +48 -0
- package/dist/engine/core/domain-scanner.js +212 -0
- package/dist/engine/core/environment-detect.js +410 -0
- package/dist/engine/core/executor.js +181 -0
- package/dist/engine/core/feedback.js +93 -0
- package/dist/engine/core/fixture-scanner.js +173 -0
- package/dist/engine/core/framework-registry.js +123 -0
- package/dist/engine/core/mcp-validator.js +218 -0
- package/dist/engine/core/metadata-scanner.js +147 -0
- package/dist/engine/core/migrator.js +1112 -0
- package/dist/engine/core/overlays.js +176 -0
- package/dist/engine/core/pattern-healer.js +147 -0
- package/dist/engine/core/pattern-matcher.js +255 -0
- package/dist/engine/core/quality-scorer.js +213 -0
- package/dist/engine/core/recommender.js +152 -0
- package/dist/engine/core/reporter.js +211 -0
- package/dist/engine/core/scaffolder.js +236 -0
- package/dist/engine/core/skill-registry.js +522 -0
- package/dist/engine/core/static-linter.js +237 -0
- package/dist/engine/core/ticket-updater.js +639 -0
- package/dist/engine/core/workflow-discovery.js +693 -0
- package/dist/engine/guardian/agent-tier.js +338 -0
- package/dist/engine/guardian/analysis-emit.js +201 -0
- package/dist/engine/guardian/cli.js +787 -0
- package/dist/engine/guardian/coverage.js +1055 -0
- package/dist/engine/guardian/delta-emitter.js +46 -0
- package/dist/engine/guardian/diff-extractor.js +257 -0
- package/dist/engine/guardian/hard-gate.js +373 -0
- package/dist/engine/guardian/impact-mapper.js +121 -0
- package/dist/engine/guardian/pr-check.js +975 -0
- package/dist/engine/guardian/pr-comment.js +200 -0
- package/dist/engine/guardian/summary-emitter.js +94 -0
- package/dist/engine/guardian/tier.js +58 -0
- package/dist/engine/history/cli.js +303 -0
- package/dist/engine/history/detector.js +68 -0
- package/dist/engine/history/ndjson-store.js +177 -0
- package/dist/engine/history/record.js +14 -0
- package/dist/engine/history/schema.js +59 -0
- package/dist/engine/history/store.js +47 -0
- package/dist/engine/history/supabase-store.js +113 -0
- package/dist/engine/main-deps.js +105 -0
- package/dist/engine/mcp-server.js +647 -0
- package/dist/engine/package.json +4 -0
- package/dist/engine/skills-cli.js +181 -0
- package/dist/engine/ui/banner.js +50 -0
- package/dist/engine/util/coalesce.js +12 -0
- package/dist/engine/util/round.js +43 -0
- package/dist/engine/workflow-cli.js +242 -0
- package/dist/engine-checks.d.ts +49 -0
- package/dist/overlay-commands.d.ts +81 -0
- package/dist/overlay-conflicts.d.ts +33 -0
- package/dist/overlay-lint.d.ts +19 -0
- package/dist/overlays-registry.d.ts +74 -0
- package/dist/reporters/testtracker.d.ts +89 -0
- package/dist/reporters/testtracker.js +195 -0
- package/dist/router.d.ts +12 -0
- package/dist/router.js +4 -4
- package/dist/skill-requirements.d.ts +57 -0
- package/dist/source-spec.d.ts +20 -0
- package/package.json +30 -6
- package/bin/canary +0 -0
- package/scripts/install.js +0 -104
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Top-level `canary` command handlers -- faithful ports of the module-level
|
|
3
|
+
* commands in `agent/cli.py` (recommend, frameworks, feedback, run, init, setup,
|
|
4
|
+
* migrate, review-test, flake-check, heal-test, version, upgrade, overlay,
|
|
5
|
+
* doctor, ticket-update). The commander wiring (options/args/defaults) lives in
|
|
6
|
+
* `cli.ts`; these functions are the thin handlers it dispatches to.
|
|
7
|
+
*
|
|
8
|
+
* Conventions mirror `guardian/cli.ts`: `CliExit` for business exits,
|
|
9
|
+
* `jsonIndent2` for `json.dumps(indent=2)`, picocolors for rich markup (color
|
|
10
|
+
* strips on a non-TTY sink so plain text is byte-exact), and output glyphs as
|
|
11
|
+
* `\u{...}` escapes emitted verbatim.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, } from 'node:fs';
|
|
14
|
+
import { basename, join, resolve } from 'node:path';
|
|
15
|
+
import pc from 'picocolors';
|
|
16
|
+
import { CliExit, jsonIndent2 } from './cli-common.js';
|
|
17
|
+
import { ckInitCmd } from './company-knowledge-cli.js';
|
|
18
|
+
import { extractFrameworkHint } from './core/classifier.js';
|
|
19
|
+
import { VALID_CATEGORIES, buildFeedback } from './core/feedback.js';
|
|
20
|
+
import { OverlayNotFound, listOverlays, resolveOverlay, } from './core/overlays.js';
|
|
21
|
+
import { RunSummary } from './core/ticket-updater.js';
|
|
22
|
+
import { renderBanner } from './ui/banner.js';
|
|
23
|
+
import { ARROW, CHECK, CHECK_MARK, CROSS, EM_DASH, HAMMER, NEXT, REDX, ROCKET, WARN, WRENCH, } from './main-deps.js';
|
|
24
|
+
function resolveVersion(deps) {
|
|
25
|
+
try {
|
|
26
|
+
return deps.pkgVersion();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return 'unknown';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function isFile(p) {
|
|
33
|
+
try {
|
|
34
|
+
return statSync(p).isFile();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function isDir(p) {
|
|
41
|
+
try {
|
|
42
|
+
return statSync(p).isDirectory();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function walkFiles(dir) {
|
|
49
|
+
const out = [];
|
|
50
|
+
let entries;
|
|
51
|
+
try {
|
|
52
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
for (const e of entries) {
|
|
58
|
+
const full = join(dir, e.name);
|
|
59
|
+
if (e.isDirectory())
|
|
60
|
+
out.push(...walkFiles(full));
|
|
61
|
+
else if (e.isFile())
|
|
62
|
+
out.push(full);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/** Recursive test-file glob matching Python's `rglob` union, sorted by path. */
|
|
67
|
+
function collectTestFiles(dir) {
|
|
68
|
+
return walkFiles(dir)
|
|
69
|
+
.filter((p) => {
|
|
70
|
+
const b = basename(p);
|
|
71
|
+
return ((b.startsWith('test_') && b.endsWith('.py')) ||
|
|
72
|
+
b.endsWith('.spec.ts') ||
|
|
73
|
+
b.endsWith('.spec.js') ||
|
|
74
|
+
b.endsWith('.test.ts') ||
|
|
75
|
+
b.endsWith('.test.js'));
|
|
76
|
+
})
|
|
77
|
+
.sort();
|
|
78
|
+
}
|
|
79
|
+
export function recommendCmd(promptText, opts, deps) {
|
|
80
|
+
const classifier = deps.makeClassifier();
|
|
81
|
+
const recommender = deps.makeRecommender();
|
|
82
|
+
const classification = classifier.classify(promptText);
|
|
83
|
+
const results = recommender.recommend(classification, null, extractFrameworkHint(promptText));
|
|
84
|
+
const result = results[0] ??
|
|
85
|
+
{
|
|
86
|
+
framework: null,
|
|
87
|
+
file_extension: 'ts',
|
|
88
|
+
reason: ['No matching framework found'],
|
|
89
|
+
};
|
|
90
|
+
const alternatives = results.slice(1).map((r) => r.framework);
|
|
91
|
+
if (opts.json) {
|
|
92
|
+
const execInfo = deps.makeRegistry().executionInfo(result.framework) ?? {};
|
|
93
|
+
const payload = {
|
|
94
|
+
status: 'success',
|
|
95
|
+
test_type: classification.test_type,
|
|
96
|
+
framework: result.framework,
|
|
97
|
+
file_extension: result.file_extension,
|
|
98
|
+
execution_command: execInfo.execution_command ?? null,
|
|
99
|
+
ci_flags: execInfo.ci_flags ?? [],
|
|
100
|
+
reasoning: result.reason,
|
|
101
|
+
alternatives,
|
|
102
|
+
};
|
|
103
|
+
if (result.warning) {
|
|
104
|
+
payload['license'] = result.license;
|
|
105
|
+
payload['warning'] = result.warning;
|
|
106
|
+
}
|
|
107
|
+
deps.out(jsonIndent2(payload));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
deps.out(`${pc.bold(pc.green(`${CHECK_MARK} Canary Recommendation`))}\n`);
|
|
111
|
+
deps.out(`${pc.bold('Test Type:')} ${classification.test_type}`);
|
|
112
|
+
deps.out(`${pc.bold('Framework:')} ${result.framework === null ? 'None' : result.framework}`);
|
|
113
|
+
deps.out(`\n${pc.bold('Reasoning:')}`);
|
|
114
|
+
for (const r of result.reason)
|
|
115
|
+
deps.out(` - ${r}`);
|
|
116
|
+
if (result.warning) {
|
|
117
|
+
deps.out(`\n${pc.yellow(`${WARN} License: ${result.warning}`)}`);
|
|
118
|
+
}
|
|
119
|
+
if (alternatives.length) {
|
|
120
|
+
deps.out(`\n${pc.bold('Alternatives:')} ${alternatives.join(', ')}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const TIER_STYLE = {
|
|
124
|
+
full: pc.green,
|
|
125
|
+
executable: pc.cyan,
|
|
126
|
+
catalog: pc.yellow,
|
|
127
|
+
};
|
|
128
|
+
export function frameworksCmd(opts, deps) {
|
|
129
|
+
const summaries = deps.makeRegistry().summaries();
|
|
130
|
+
if (opts.json) {
|
|
131
|
+
deps.out(jsonIndent2({ frameworks: summaries }));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
deps.out(`${pc.bold(pc.green('Canary Frameworks'))}\n`);
|
|
135
|
+
for (const f of summaries) {
|
|
136
|
+
const status = f.status ? ` ${pc.dim(`(${f.status})`)}` : '';
|
|
137
|
+
const tier = f.tier || 'catalog';
|
|
138
|
+
const tierLabel = (TIER_STYLE[tier] ?? pc.white)(tier);
|
|
139
|
+
deps.out(`${pc.bold(f.name)} ${tierLabel}${status} ${EM_DASH} ${f.category || 'n/a'}`);
|
|
140
|
+
const cmd = f.execution_command || '(no run command)';
|
|
141
|
+
deps.out(` run: ${cmd}`);
|
|
142
|
+
if (f.ci_flags.length) {
|
|
143
|
+
deps.out(` ci: ${f.ci_flags.join(' ')}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
deps.out(`\n${pc.dim(`tier: full=scaffold+run \u{00b7} executable=run only \u{00b7} catalog=listed only.`)}`);
|
|
147
|
+
deps.out(pc.dim('`{file}` in a run command is the test-file path placeholder.'));
|
|
148
|
+
}
|
|
149
|
+
export function feedbackCmd(message, opts, deps) {
|
|
150
|
+
if (!VALID_CATEGORIES.includes(opts.category)) {
|
|
151
|
+
deps.out(`${pc.bold(pc.red(CROSS))} Unknown --category '${opts.category}'. Choose one of: ${VALID_CATEGORIES.join(', ')}.`);
|
|
152
|
+
throw new CliExit(1);
|
|
153
|
+
}
|
|
154
|
+
if (!message || !message.trim()) {
|
|
155
|
+
deps.out(`${pc.bold(pc.red(CROSS))} A feedback message is required.\nUsage: ${pc.bold('canary feedback "<message>" [--category bug|ux|docs|idea]')}`);
|
|
156
|
+
throw new CliExit(1);
|
|
157
|
+
}
|
|
158
|
+
const fb = buildFeedback(message.trim(), opts.category);
|
|
159
|
+
if (opts.json) {
|
|
160
|
+
deps.out(jsonIndent2(fb));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
deps.out(`${pc.bold(pc.green('Canary Feedback'))}\n`);
|
|
164
|
+
deps.out(`${pc.bold('Category:')} ${fb.category}`);
|
|
165
|
+
deps.out(`${pc.bold('Message:')} ${fb.message}`);
|
|
166
|
+
deps.out(`\n${pc.bold('Attached context')} (no env vars, no file contents):`);
|
|
167
|
+
for (const [k, v] of Object.entries(fb.context)) {
|
|
168
|
+
deps.out(` - ${k}: ${v}`);
|
|
169
|
+
}
|
|
170
|
+
deps.out(`\n${pc.bold('Open this pre-filled issue to submit:')}`);
|
|
171
|
+
deps.out(fb.issue_url);
|
|
172
|
+
if (opts.open) {
|
|
173
|
+
deps.openBrowser(fb.issue_url);
|
|
174
|
+
deps.out(`\n${pc.dim(`Opened in your browser ${EM_DASH} review and submit there.`)}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// --- run ---------------------------------------------------------------------
|
|
178
|
+
export function runCmd(filePath, framework, deps) {
|
|
179
|
+
deps.out(`\n${pc.bold(pc.cyan(`${ROCKET} Canary Executing ${framework} Test...`))}\n`);
|
|
180
|
+
const [exitCode, stdout, stderr] = deps
|
|
181
|
+
.makeExecutor()
|
|
182
|
+
.execute(filePath, framework);
|
|
183
|
+
const colorize = exitCode === 0 ? pc.green : pc.red;
|
|
184
|
+
deps.out(colorize(`Result: ${exitCode === 0 ? 'Success' : 'Failure'} (Exit ${exitCode})`));
|
|
185
|
+
if (stderr)
|
|
186
|
+
deps.out(`\n${pc.red('Error:')}\n${stderr}`);
|
|
187
|
+
if (stdout)
|
|
188
|
+
deps.out(`\n${pc.dim('Output:')}\n${stdout}`);
|
|
189
|
+
}
|
|
190
|
+
// --- init / setup ------------------------------------------------------------
|
|
191
|
+
function companyJsonPresent(deps) {
|
|
192
|
+
return existsSync(join(deps.cwd(), '.canary', 'company.json'));
|
|
193
|
+
}
|
|
194
|
+
function printInitSignpost(deps) {
|
|
195
|
+
deps.out(`\n${pc.bold(pc.cyan('canary init'))} ${EM_DASH} what would you like to do?\n`);
|
|
196
|
+
if (!companyJsonPresent(deps)) {
|
|
197
|
+
deps.out(`${pc.yellow(`This repo has no ${pc.bold('.canary/company.json')} yet ${EM_DASH} agents that rely on it run degraded.`)}\n`);
|
|
198
|
+
}
|
|
199
|
+
deps.out(`${pc.bold('Set up canary in this repo')} (recommended first step):`);
|
|
200
|
+
deps.out(` ${pc.bold(pc.green('canary setup'))} ${pc.dim('alias for `canary company-knowledge init`')}\n`);
|
|
201
|
+
deps.out(pc.bold('Scaffold a test suite:'));
|
|
202
|
+
deps.out(` ${pc.bold(pc.green('canary init <framework>'))} ${pc.dim('playwright | vitest | pytest | k6')}\n`);
|
|
203
|
+
}
|
|
204
|
+
export function initCmd(framework, deps) {
|
|
205
|
+
if (framework === undefined) {
|
|
206
|
+
printInitSignpost(deps);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
deps.out(`\n${pc.bold(pc.cyan(`${HAMMER} Canary Initializing ${framework} Scaffold...`))}\n`);
|
|
210
|
+
try {
|
|
211
|
+
const result = deps.makeScaffolder().scaffold(framework);
|
|
212
|
+
if (result.status === 'unsupported') {
|
|
213
|
+
deps.out(pc.bold(pc.yellow(`${WARN} ${result.guidance}`)));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
deps.out(`${pc.bold(pc.green(`${CHECK_MARK} Scaffolding Complete`))}\n`);
|
|
217
|
+
if (result.created_dirs && result.created_dirs.length) {
|
|
218
|
+
deps.out(pc.bold('Directories Created:'));
|
|
219
|
+
for (const d of result.created_dirs)
|
|
220
|
+
deps.out(` + ${d}`);
|
|
221
|
+
}
|
|
222
|
+
if (result.created_files && result.created_files.length) {
|
|
223
|
+
deps.out(`\n${pc.bold('Files Created:')}`);
|
|
224
|
+
for (const f of result.created_files)
|
|
225
|
+
deps.out(` + ${f}`);
|
|
226
|
+
}
|
|
227
|
+
if (result.skipped_files && result.skipped_files.length) {
|
|
228
|
+
deps.out(`\n${pc.bold(pc.yellow('Files Skipped (Already Exist):'))}`);
|
|
229
|
+
for (const f of result.skipped_files)
|
|
230
|
+
deps.out(` - ${f}`);
|
|
231
|
+
}
|
|
232
|
+
deps.out(`\n${pc.bold(pc.cyan(`${NEXT} Next Steps:`))}`);
|
|
233
|
+
const fw = framework.toLowerCase();
|
|
234
|
+
if (fw === 'playwright') {
|
|
235
|
+
deps.out(` 1. Run: ${pc.bold(pc.green('npm install -D @playwright/test'))}`);
|
|
236
|
+
deps.out(` 2. Run: ${pc.bold(pc.green('npx playwright install'))}`);
|
|
237
|
+
}
|
|
238
|
+
else if (fw === 'vitest') {
|
|
239
|
+
deps.out(` 1. Run: ${pc.bold(pc.green('npm install -D vitest'))}`);
|
|
240
|
+
}
|
|
241
|
+
else if (fw === 'pytest') {
|
|
242
|
+
deps.out(` 1. Run: ${pc.bold(pc.green('pip install pytest'))}`);
|
|
243
|
+
}
|
|
244
|
+
else if (fw === 'k6') {
|
|
245
|
+
deps.out(` 1. Install k6: ${pc.bold(pc.green('https://k6.io/docs/getting-started/installation/'))}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch (e) {
|
|
249
|
+
// Python catches ValueError (unknown framework); the TS scaffolder throws a
|
|
250
|
+
// plain Error for the same case.
|
|
251
|
+
deps.out(`\n${pc.bold(pc.red(`${REDX} Error: ${e instanceof Error ? e.message : String(e)}`))}`);
|
|
252
|
+
deps.out(pc.yellow('Supported frameworks: playwright, vitest, pytest, k6'));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
export function setupCmd(opts, deps) {
|
|
256
|
+
ckInitCmd({ force: opts.force ?? false }, deps);
|
|
257
|
+
}
|
|
258
|
+
function resolveMigrateOverlay(fromOverlay, overlay, deps) {
|
|
259
|
+
if (fromOverlay) {
|
|
260
|
+
if (overlay) {
|
|
261
|
+
deps.out(pc.yellow('Both --from and --overlay given; using --from and ignoring --overlay.'));
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
return resolveOverlay(fromOverlay, deps.home());
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
if (e instanceof OverlayNotFound) {
|
|
268
|
+
deps.out(`\n${pc.bold(pc.red(CROSS))} ${e.message}`);
|
|
269
|
+
throw new CliExit(1);
|
|
270
|
+
}
|
|
271
|
+
throw e;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (overlay) {
|
|
275
|
+
deps.out(pc.yellow(`--overlay is deprecated; use ${pc.bold('--from <overlay-name|path>')} instead.`));
|
|
276
|
+
return resolve(overlay);
|
|
277
|
+
}
|
|
278
|
+
const tracked = listOverlays(deps.home());
|
|
279
|
+
if (tracked.length === 1) {
|
|
280
|
+
deps.out(pc.dim(`Using tracked overlay '${tracked[0]}' (the only one registered).`));
|
|
281
|
+
return resolveOverlay(tracked[0], deps.home());
|
|
282
|
+
}
|
|
283
|
+
if (tracked.length > 1) {
|
|
284
|
+
const names = tracked.join(', ');
|
|
285
|
+
deps.out(`\n${pc.bold(pc.red(CROSS))} ${tracked.length} tracked overlays registered (${names}).\nChoose one with ${pc.bold('--from <name>')}.`);
|
|
286
|
+
throw new CliExit(1);
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
function migrateCheck(migrator, root, overlayPath, json, deps) {
|
|
291
|
+
if (overlayPath === null) {
|
|
292
|
+
deps.out(`\n${pc.yellow('No overlay to check against.')} Track one with ${pc.bold('canary overlay add')} or pass ${pc.bold('--from <overlay>')}.`);
|
|
293
|
+
throw new CliExit(0);
|
|
294
|
+
}
|
|
295
|
+
let report;
|
|
296
|
+
try {
|
|
297
|
+
report = migrator.checkFreshness(root, { overlayPath });
|
|
298
|
+
}
|
|
299
|
+
catch (e) {
|
|
300
|
+
deps.out(`\n${pc.bold(pc.red(CROSS))} ${e instanceof Error ? e.message : String(e)}`);
|
|
301
|
+
throw new CliExit(1);
|
|
302
|
+
}
|
|
303
|
+
if (json) {
|
|
304
|
+
deps.out(jsonIndent2(report.to_dict()));
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
deps.out(report.to_markdown());
|
|
308
|
+
}
|
|
309
|
+
throw new CliExit(report.exit_code());
|
|
310
|
+
}
|
|
311
|
+
export function migrateCmd(opts, deps) {
|
|
312
|
+
const root = resolve(opts.path);
|
|
313
|
+
const overlayPath = resolveMigrateOverlay(opts.from, opts.overlay, deps);
|
|
314
|
+
const migrator = deps.makeMigrator();
|
|
315
|
+
if (opts.check) {
|
|
316
|
+
migrateCheck(migrator, root, overlayPath, opts.json ?? false, deps);
|
|
317
|
+
return; // unreachable -- migrateCheck always throws CliExit
|
|
318
|
+
}
|
|
319
|
+
let ctx;
|
|
320
|
+
try {
|
|
321
|
+
ctx = migrator.detect(root);
|
|
322
|
+
}
|
|
323
|
+
catch (e) {
|
|
324
|
+
deps.out(`\n${pc.bold(pc.red('Detection error:'))} ${e instanceof Error ? e.message : String(e)}`);
|
|
325
|
+
throw new CliExit(1);
|
|
326
|
+
}
|
|
327
|
+
if (!ctx.is_harness_project) {
|
|
328
|
+
if (ctx.not_test_project_reason) {
|
|
329
|
+
deps.out(`\n${pc.bold(pc.red(CROSS))} ${ctx.not_test_project_reason}`);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
deps.out(`\n${pc.bold(pc.red(CROSS))} No harness project detected at ${pc.bold(root)}.\nExpected ${pc.dim('harness.config.json')} and ${pc.dim('.harness/')} directory.`);
|
|
333
|
+
}
|
|
334
|
+
throw new CliExit(1);
|
|
335
|
+
}
|
|
336
|
+
const dryRun = !opts.apply;
|
|
337
|
+
const modeLabel = dryRun ? pc.dim('(dry run)') : pc.green('(apply)');
|
|
338
|
+
deps.out(`\n${pc.bold(pc.cyan('Canary Migrate'))} ${modeLabel}\n`);
|
|
339
|
+
if (!dryRun)
|
|
340
|
+
deps.out(`${pc.yellow('Writing files to disk...')}\n`);
|
|
341
|
+
let report;
|
|
342
|
+
try {
|
|
343
|
+
report = migrator.migrate(root, {
|
|
344
|
+
dryRun,
|
|
345
|
+
framework: opts.framework || null,
|
|
346
|
+
overlayPath,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
catch (e) {
|
|
350
|
+
deps.out(`\n${pc.bold(pc.red('Error:'))} ${e instanceof Error ? e.message : String(e)}`);
|
|
351
|
+
throw new CliExit(1);
|
|
352
|
+
}
|
|
353
|
+
if (opts.json) {
|
|
354
|
+
deps.out(jsonIndent2({
|
|
355
|
+
framework: report.framework,
|
|
356
|
+
shape: report.shape,
|
|
357
|
+
dry_run: report.dry_run,
|
|
358
|
+
created_files: report.created_files,
|
|
359
|
+
created_dirs: report.created_dirs,
|
|
360
|
+
skipped_configs: report.skipped_configs,
|
|
361
|
+
preserved_files: report.preserved_files,
|
|
362
|
+
would_create: report.would_create,
|
|
363
|
+
manual_followups: report.manual_followups,
|
|
364
|
+
config_warnings: report.config_warnings,
|
|
365
|
+
deployed_skills: report.deployed_skills.map((r) => ({
|
|
366
|
+
skill_name: r.skill_name,
|
|
367
|
+
status: r.status,
|
|
368
|
+
note: r.note,
|
|
369
|
+
})),
|
|
370
|
+
}));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
deps.out(report.to_markdown());
|
|
374
|
+
if (dryRun && report.would_create.length) {
|
|
375
|
+
deps.out(`\n${pc.dim(`Re-run with ${pc.bold('--apply')} to write these files.`)}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const SEV_COLOR = {
|
|
379
|
+
critical: pc.red,
|
|
380
|
+
warning: pc.yellow,
|
|
381
|
+
info: pc.dim,
|
|
382
|
+
};
|
|
383
|
+
const SEV_ORDER = { critical: 0, warning: 1, info: 2 };
|
|
384
|
+
function findingPayload(f) {
|
|
385
|
+
return {
|
|
386
|
+
file: f.file,
|
|
387
|
+
line: f.line,
|
|
388
|
+
rule: f.rule,
|
|
389
|
+
severity: f.severity,
|
|
390
|
+
message: f.message,
|
|
391
|
+
suggestion: f.suggestion,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
export function reviewTestCmd(path, opts, deps) {
|
|
395
|
+
const files = isDir(path) ? collectTestFiles(path) : [path];
|
|
396
|
+
const linter = deps.makeLinter();
|
|
397
|
+
const allFindings = [];
|
|
398
|
+
for (const f of files)
|
|
399
|
+
allFindings.push(...linter.lint(f, opts.framework));
|
|
400
|
+
if (opts.json) {
|
|
401
|
+
deps.out(jsonIndent2(allFindings.map(findingPayload)));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (allFindings.length === 0) {
|
|
405
|
+
deps.out(pc.bold(pc.green(`${CHECK_MARK} No issues found.`)));
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
allFindings.sort((a, b) => cmpStr(a.file, b.file) ||
|
|
409
|
+
a.line - b.line ||
|
|
410
|
+
(SEV_ORDER[a.severity] ?? 9) - (SEV_ORDER[b.severity] ?? 9));
|
|
411
|
+
const counts = { critical: 0, warning: 0, info: 0 };
|
|
412
|
+
for (const f of allFindings) {
|
|
413
|
+
counts[f.severity] = (counts[f.severity] ?? 0) + 1;
|
|
414
|
+
const color = SEV_COLOR[f.severity] ?? pc.white;
|
|
415
|
+
deps.out(`${color(`[${f.severity.toUpperCase()}]`)} ${f.file}:${f.line} ${pc.dim(`(${f.rule})`)}`);
|
|
416
|
+
deps.out(` ${f.message}`);
|
|
417
|
+
deps.out(` ${pc.dim(`${ARROW} ${f.suggestion}`)}\n`);
|
|
418
|
+
}
|
|
419
|
+
const parts = [];
|
|
420
|
+
if (counts['critical'])
|
|
421
|
+
parts.push(pc.red(`${counts['critical']} critical`));
|
|
422
|
+
if (counts['warning'])
|
|
423
|
+
parts.push(pc.yellow(`${counts['warning']} warning`));
|
|
424
|
+
if (counts['info'])
|
|
425
|
+
parts.push(pc.dim(`${counts['info']} info`));
|
|
426
|
+
deps.out(`${pc.bold(`${allFindings.length} finding(s):`)} ${parts.join(', ')}`);
|
|
427
|
+
if (counts['critical'])
|
|
428
|
+
throw new CliExit(1);
|
|
429
|
+
}
|
|
430
|
+
export function flakeCheckCmd(path, opts, deps) {
|
|
431
|
+
const files = isDir(path) ? collectTestFiles(path) : [path];
|
|
432
|
+
const linter = deps.makeLinter();
|
|
433
|
+
const allFindings = [];
|
|
434
|
+
for (const f of files)
|
|
435
|
+
allFindings.push(...linter.flakeCheck(f));
|
|
436
|
+
if (opts.json) {
|
|
437
|
+
deps.out(jsonIndent2(allFindings.map(findingPayload)));
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (allFindings.length === 0) {
|
|
441
|
+
deps.out(pc.bold(pc.green(`${CHECK_MARK} No flakiness patterns detected.`)));
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
for (const f of allFindings) {
|
|
445
|
+
const color = f.severity === 'critical' ? pc.red : pc.yellow;
|
|
446
|
+
deps.out(`${color(`[${f.severity.toUpperCase()}]`)} ${f.file}:${f.line} ${pc.dim(`(${f.rule})`)}`);
|
|
447
|
+
deps.out(` ${f.message}`);
|
|
448
|
+
deps.out(` ${pc.dim(`${ARROW} ${f.suggestion}`)}\n`);
|
|
449
|
+
}
|
|
450
|
+
deps.out(pc.bold(`${allFindings.length} flakiness pattern(s) found.`));
|
|
451
|
+
throw new CliExit(1);
|
|
452
|
+
}
|
|
453
|
+
export function healTestCmd(path, opts, deps) {
|
|
454
|
+
if (!isFile(path)) {
|
|
455
|
+
deps.out(pc.red(`Error: ${path} is not a file.`));
|
|
456
|
+
throw new CliExit(1);
|
|
457
|
+
}
|
|
458
|
+
const result = deps.makeHealer().heal(path);
|
|
459
|
+
if (opts.json) {
|
|
460
|
+
const payload = {
|
|
461
|
+
file: result.file,
|
|
462
|
+
changed: result.changed,
|
|
463
|
+
changes: result.changes.map((c) => ({
|
|
464
|
+
line: c.line,
|
|
465
|
+
rule: c.rule,
|
|
466
|
+
description: c.description,
|
|
467
|
+
before: c.before.trim(),
|
|
468
|
+
after: c.after.trim(),
|
|
469
|
+
})),
|
|
470
|
+
skipped: result.skipped,
|
|
471
|
+
};
|
|
472
|
+
deps.out(jsonIndent2(payload));
|
|
473
|
+
if (result.changed && !opts.dryRun) {
|
|
474
|
+
writeFileSync(path, result.patched_content, 'utf-8');
|
|
475
|
+
}
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (!result.changed) {
|
|
479
|
+
deps.out(pc.bold(pc.green(`${CHECK_MARK} No auto-fixable patterns found.`)));
|
|
480
|
+
for (const s of result.skipped) {
|
|
481
|
+
deps.out(`${pc.yellow(`${WARN} Skipped:`)} ${s}`);
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
deps.out(`${pc.bold(pc.cyan(`${WRENCH} Pattern fixes for ${path}`))}\n`);
|
|
486
|
+
for (const c of result.changes) {
|
|
487
|
+
deps.out(`${pc.green(`line ${c.line}`)} ${pc.dim(`(${c.rule})`)} ${c.description}`);
|
|
488
|
+
deps.out(` ${pc.red(`- ${c.before.trim()}`)}`);
|
|
489
|
+
deps.out(` ${pc.green(`+ ${c.after.trim()}`)}\n`);
|
|
490
|
+
}
|
|
491
|
+
for (const s of result.skipped) {
|
|
492
|
+
deps.out(`${pc.yellow(`${WARN} Skipped:`)} ${s}\n`);
|
|
493
|
+
}
|
|
494
|
+
if (opts.dryRun) {
|
|
495
|
+
deps.out(pc.dim(`${result.changes.length} fix(es) ready. Re-run without --dry-run to apply.`));
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
writeFileSync(path, result.patched_content, 'utf-8');
|
|
499
|
+
deps.out(pc.bold(pc.green(`${CHECK_MARK} ${result.changes.length} fix(es) applied to ${path}`)));
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// --- version / upgrade -------------------------------------------------------
|
|
503
|
+
export function versionCmd(deps) {
|
|
504
|
+
deps.out(renderBanner(resolveVersion(deps)));
|
|
505
|
+
}
|
|
506
|
+
export function upgradeCmd(opts, deps) {
|
|
507
|
+
const current = resolveVersion(deps);
|
|
508
|
+
deps.out(pc.dim(`Current version: ${current}`));
|
|
509
|
+
if (opts.dryRun) {
|
|
510
|
+
deps.out(pc.dim(`Dry run ${EM_DASH} run without --dry-run to apply the upgrade.`));
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const report = () => {
|
|
514
|
+
const updated = resolveVersion(deps);
|
|
515
|
+
if (updated !== current) {
|
|
516
|
+
deps.out(pc.green(`${CHECK} ${current} ${ARROW} ${updated}`));
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
deps.out(pc.dim(`Already up to date (${current})`));
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
const pipx = deps.runSubprocess('pipx', ['upgrade', 'canary-test-ai']);
|
|
523
|
+
if (pipx.status === 0) {
|
|
524
|
+
report();
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
deps.out(pc.yellow(`pipx not found ${EM_DASH} trying pip...`));
|
|
528
|
+
const pip = deps.runSubprocess(deps.pythonExe(), [
|
|
529
|
+
'-m',
|
|
530
|
+
'pip',
|
|
531
|
+
'install',
|
|
532
|
+
'--upgrade',
|
|
533
|
+
'canary-test-ai',
|
|
534
|
+
]);
|
|
535
|
+
if (pip.status === 0) {
|
|
536
|
+
report();
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
deps.out(`${pc.red('Upgrade failed.')}\n${pip.stderr.trim()}`);
|
|
540
|
+
throw new CliExit(1);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// --- overlay / doctor (npm-shim pointers) ------------------------------------
|
|
544
|
+
export function overlayStub(deps) {
|
|
545
|
+
deps.out(`${pc.yellow('`canary overlay` is provided by the npm install of Canary.')}\nInstall it with: ${pc.bold('npm install -g canary-test-cli')}\nThe pipx/Python entry point does not include the overlay commands.`);
|
|
546
|
+
throw new CliExit(1);
|
|
547
|
+
}
|
|
548
|
+
export function doctorStub(deps) {
|
|
549
|
+
deps.out(`${pc.yellow('`canary doctor` is provided by the npm install of Canary.')}\nInstall it with: ${pc.bold('npm install -g canary-test-cli')}\nThe pipx/Python entry point does not include the doctor command.`);
|
|
550
|
+
throw new CliExit(1);
|
|
551
|
+
}
|
|
552
|
+
export async function ticketUpdateCmd(opts, deps) {
|
|
553
|
+
let reportData = {};
|
|
554
|
+
if (opts.result) {
|
|
555
|
+
try {
|
|
556
|
+
reportData = JSON.parse(readFileSync(opts.result, 'utf-8'));
|
|
557
|
+
}
|
|
558
|
+
catch (exc) {
|
|
559
|
+
deps.out(pc.red(`Could not read result file '${opts.result}': ${exc instanceof Error ? exc.message : String(exc)}`));
|
|
560
|
+
throw new CliExit(1);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const testFile = opts.testFile;
|
|
564
|
+
const suiteName = reportData['suite_name'] ?? (testFile || 'unknown');
|
|
565
|
+
const envName = reportData['env'] ?? deps.env['CANARY_ENV'] ?? 'unknown';
|
|
566
|
+
let rawResult = String(reportData['result'] ?? 'FAIL').toUpperCase();
|
|
567
|
+
if (!['PASS', 'FAIL', 'PARTIAL'].includes(rawResult))
|
|
568
|
+
rawResult = 'FAIL';
|
|
569
|
+
const passedNames = reportData['passed_names'] ?? [];
|
|
570
|
+
const failedPairs = reportData['failed_names'] ?? [];
|
|
571
|
+
const failedNames = failedPairs.map((item) => Array.isArray(item) && item.length >= 2
|
|
572
|
+
? [String(item[0]), String(item[1])]
|
|
573
|
+
: [String(item), 'unknown']);
|
|
574
|
+
const summary = new RunSummary({
|
|
575
|
+
suite_name: suiteName,
|
|
576
|
+
env: envName,
|
|
577
|
+
result: rawResult,
|
|
578
|
+
passed: reportData['passed'] ?? passedNames.length,
|
|
579
|
+
total: reportData['total'] ??
|
|
580
|
+
passedNames.length + failedNames.length,
|
|
581
|
+
flaky_count: reportData['flaky_count'] ?? 0,
|
|
582
|
+
duration_s: Number(reportData['duration_s'] ?? 0.0),
|
|
583
|
+
test_file: testFile ?? reportData['test_file'] ?? '',
|
|
584
|
+
report_url: reportData['report_url'] ?? null,
|
|
585
|
+
passed_names: passedNames,
|
|
586
|
+
failed_names: failedNames,
|
|
587
|
+
ticket_key: opts.ticket ?? null,
|
|
588
|
+
project_key: opts.project ?? null,
|
|
589
|
+
});
|
|
590
|
+
const updater = deps.makeTicketUpdater();
|
|
591
|
+
const updateResult = await updater.update(summary, {
|
|
592
|
+
dryRun: opts.dryRun ?? false,
|
|
593
|
+
commentOnly: opts.commentOnly ?? false,
|
|
594
|
+
transitionOnly: opts.transitionOnly ?? false,
|
|
595
|
+
});
|
|
596
|
+
for (const msg of updateResult.messages)
|
|
597
|
+
deps.out(msg);
|
|
598
|
+
if (!opts.dryRun) {
|
|
599
|
+
if (updateResult.comment_posted) {
|
|
600
|
+
deps.out(`${pc.green(CHECK)} Comment posted to ${updateResult.ticket_key}`);
|
|
601
|
+
}
|
|
602
|
+
const tr = updateResult.transition;
|
|
603
|
+
if (tr.attempted && tr.succeeded) {
|
|
604
|
+
deps.out(`${pc.green(CHECK)} Transitioned ${updateResult.ticket_key}: "${tr.from_status}" ${ARROW} "${tr.to_status}"`);
|
|
605
|
+
}
|
|
606
|
+
else if (tr.attempted && !tr.succeeded) {
|
|
607
|
+
deps.out(`${pc.yellow(WARN)} Transition failed: ${tr.reason}`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
if (updateResult.transition.reason.startsWith(WARN)) {
|
|
611
|
+
throw new CliExit(1);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
/** Python string `<`/`>` comparison (stable code-unit order). */
|
|
615
|
+
function cmpStr(a, b) {
|
|
616
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
617
|
+
}
|
|
618
|
+
//# sourceMappingURL=cli-commands.js.map
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plumbing for the Canary commander CLIs (main program + `history` and
|
|
3
|
+
* `analyze` sub-apps), factored out of the guardian CLI pattern established in
|
|
4
|
+
* `guardian/cli.ts`.
|
|
5
|
+
*
|
|
6
|
+
* The three concerns every command tree needs:
|
|
7
|
+
*
|
|
8
|
+
* - {@link CliExit} -- the business-exit signal thrown from a handler (Python
|
|
9
|
+
* `typer.Exit(code)`). Re-exported from `guardian/cli.ts` so the ENTIRE
|
|
10
|
+
* command tree (main program, its sub-apps, AND the already-ported guardian
|
|
11
|
+
* sub-app it mounts) throws ONE class. `bin/canary.js` then catches a single
|
|
12
|
+
* `CliExit` regardless of which sub-app raised it.
|
|
13
|
+
* - {@link normalizeUsageExit} -- the `.exitOverride()` callback that maps
|
|
14
|
+
* commander's usage-error exit code (1) to typer/click's 2, while leaving an
|
|
15
|
+
* explicit `--help`/`--version` at 0. Applied to the program AND every
|
|
16
|
+
* subcommand.
|
|
17
|
+
* - {@link ensureAscii} / {@link jsonIndent2} -- Python `json.dumps` fidelity:
|
|
18
|
+
* `ensure_ascii=True` (per-UTF-16-unit escaping, so astral chars emit a
|
|
19
|
+
* surrogate pair exactly like CPython) and the `indent=2` pretty form (whose
|
|
20
|
+
* separators match `JSON.stringify(x, null, 2)` byte-for-byte).
|
|
21
|
+
*/
|
|
22
|
+
export { CliExit } from './guardian/cli.js';
|
|
23
|
+
/**
|
|
24
|
+
* Map commander's usage-error exit code to typer/click's `2`. Commander defaults
|
|
25
|
+
* unknown/missing-option, bad-command and no-args-help errors to exit 1; typer
|
|
26
|
+
* uses 2. Explicit `--help`/`--version` exit 0 in BOTH, so leave those. Used as
|
|
27
|
+
* the `.exitOverride()` callback on the program and every subcommand.
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeUsageExit(err) {
|
|
30
|
+
if (err.code !== 'commander.helpDisplayed' &&
|
|
31
|
+
err.code !== 'commander.version') {
|
|
32
|
+
err.exitCode = 2;
|
|
33
|
+
}
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Escape every non-ASCII UTF-16 code UNIT to `\uXXXX`, matching Python
|
|
38
|
+
* `json.dumps(ensure_ascii=True)`. Iterating by unit (not code point) means an
|
|
39
|
+
* astral char's surrogate pair emits `\udXXX\udXXX`, exactly like CPython; a
|
|
40
|
+
* code-point regex would stop at U+FFFF and leave astral chars raw.
|
|
41
|
+
*/
|
|
42
|
+
export function ensureAscii(json) {
|
|
43
|
+
let out = '';
|
|
44
|
+
for (let i = 0; i < json.length; i++) {
|
|
45
|
+
const c = json.charCodeAt(i);
|
|
46
|
+
out += c >= 0x80 ? '\\u' + c.toString(16).padStart(4, '0') : json[i];
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* `json.dumps(x, indent=2)` fidelity: `JSON.stringify(x, null, 2)` reproduces
|
|
52
|
+
* Python's 2-space indent and `(',', ': ')` separators byte-for-byte (including
|
|
53
|
+
* `[]`/`{}` for empty containers), and {@link ensureAscii} restores the
|
|
54
|
+
* `ensure_ascii=True` default. A trailing newline is NOT added here -- callers
|
|
55
|
+
* that mirror `sys.stdout.write(... + "\n")` add it via their line sink.
|
|
56
|
+
*/
|
|
57
|
+
export function jsonIndent2(value) {
|
|
58
|
+
return ensureAscii(JSON.stringify(value, null, 2));
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=cli-common.js.map
|