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,787 @@
1
+ /**
2
+ * CLI subcommands for `canary guardian`.
3
+ *
4
+ * Faithful TypeScript port of `agent/guardian/cli.py` -- the FIRST commander CLI
5
+ * in the repo, establishing the pattern the later main-cli port follows:
6
+ *
7
+ * - A **factory** {@link createGuardianCommand} builds a fresh `commander`
8
+ * `Command` wired to an injectable {@link GuardianDeps} (stdout/stderr sinks,
9
+ * stdin, env, git/gh runners, network client factories). The production
10
+ * export {@link guardianCommand} uses process-backed defaults; tests build a
11
+ * command with capturing sinks and fake clients -- no global monkeypatching,
12
+ * no network. This is exactly how Python's `_build_client` /
13
+ * `_branch_protection_client` seams were replaced.
14
+ * - Commands are THIN: parse -> call the already-ported guardian library ->
15
+ * emit. No business logic lives in a handler.
16
+ * - Business exit codes are carried by throwing {@link CliExit} (Python's
17
+ * `typer.Exit(n)`); `parseAsync` from a test catches it to read the code.
18
+ * `.exitOverride()` turns commander's own usage errors into throws too, so a
19
+ * test never terminates the process.
20
+ *
21
+ * Command surface (kebab-case names, matching the shipping Typer CLI):
22
+ * analyze | validate-coverage | harden-gate | pr-check | author-plan |
23
+ * mark-authored | watch.
24
+ *
25
+ * Python->TS nuances honored:
26
+ * - `json.dumps(obj, indent=2)` -> `ensureAscii(JSON.stringify(obj, null, 2))`
27
+ * (indent-2 matches Python's `(', ', ': ')` separators byte-for-byte;
28
+ * `ensureAscii` restores the `ensure_ascii=True` default).
29
+ * - `rich.print("[green]x[/green]")` -> `pc.green('x')`. picocolors strips
30
+ * color when stdout is not a TTY (tests), so the plain text is byte-exact --
31
+ * the same way rich strips markup for a non-terminal sink. INTENTIONAL
32
+ * DEVIATION: rich also soft-wraps prose at 80 cols on a non-TTY sink; this
33
+ * port does NOT wrap. Content is identical; only newline placement differs
34
+ * for long human-readable lines. Notably this makes `analyze --json` emit
35
+ * VALID JSON here, whereas rich can wrap a long value mid-array and produce
36
+ * invalid JSON in Python -- we deliberately do not replicate that bug.
37
+ * - `typer.echo(x)` -> `deps.out(x)` (stdout); `typer.echo(x, err=True)` ->
38
+ * `deps.err(x)` (stderr).
39
+ * - non-ASCII output data (em-dash, arrows, check/cross glyphs) is written as
40
+ * `\u{...}` escapes to honor the ASCII-source rule, then emitted verbatim.
41
+ * - `subprocess.run(...)` -> `spawnSync(..., { maxBuffer: Infinity })` behind
42
+ * `deps.runGit`/`deps.runGh`, returning `null` on a missing binary (the
43
+ * Python `OSError`/`FileNotFoundError` fail-safe path).
44
+ */
45
+ import { spawnSync } from 'node:child_process';
46
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from 'node:fs';
47
+ import { dirname, isAbsolute, join } from 'node:path';
48
+ import { Command, Option } from 'commander';
49
+ import { load as loadYaml } from 'js-yaml';
50
+ import pc from 'picocolors';
51
+ import { AuthoringContext, InSessionAgentProbe, InSessionAgentTier, decideBlock, } from './agent-tier.js';
52
+ import { emitAnalysis } from './analysis-emit.js';
53
+ import { resolveCoverage, validateCoverageJson } from './coverage.js';
54
+ import { buildApiDelta, writeApiDelta } from './delta-emitter.js';
55
+ import { extractApiDiff } from './diff-extractor.js';
56
+ import { HardGateBlocked, RestBranchProtectionClient, applyHardGate, renderPlaybook, } from './hard-gate.js';
57
+ import { mapImpact } from './impact-mapper.js';
58
+ import { applySuppressions, buildFindings, buildWeakTestFindings, computeExitCode, effectiveGraphDepth, filterSkipped, filterTestUnits, findReexportOnly, loadGuardianConfig, render, scopeDiff, } from './pr-check.js';
59
+ import { RestGitHubClient, degradationAnnotation, upsertStickyComment, } from './pr-comment.js';
60
+ import { buildSummary } from './summary-emitter.js';
61
+ import { resolveTier } from './tier.js';
62
+ // --- Output data glyphs (load-bearing; emitted verbatim, ASCII-escaped source).
63
+ const EM_DASH = '\u{2014}';
64
+ const RIGHT_ARROW = '\u{2192}';
65
+ const CHECK = '\u{2713}';
66
+ const CROSS = '\u{2717}';
67
+ /**
68
+ * Business exit signal. Thrown from a handler to carry an exit code the way
69
+ * Python's `typer.Exit(code)` did; the runner catches it to read the code.
70
+ */
71
+ export class CliExit extends Error {
72
+ code;
73
+ constructor(code) {
74
+ super(`exit ${code}`);
75
+ this.code = code;
76
+ this.name = 'CliExit';
77
+ }
78
+ }
79
+ /**
80
+ * Normalize commander's usage-error exit code to typer/click's `2`. Commander
81
+ * defaults usage errors (unknown/missing option, bad command, no-args help) to
82
+ * exit 1; typer uses 2, so a script checking `$? -eq 2` for bad usage would
83
+ * otherwise break. Explicit `--help`/`--version` exit 0 in BOTH, so leave those.
84
+ * Used as the `.exitOverride()` callback on the program and every subcommand.
85
+ */
86
+ function normalizeUsageExit(err) {
87
+ if (err.code !== 'commander.helpDisplayed' &&
88
+ err.code !== 'commander.version') {
89
+ err.exitCode = 2;
90
+ }
91
+ throw err;
92
+ }
93
+ /** Raised by `deps.sleep` to break the `watch` poll loop (Ctrl+C analog). */
94
+ export class WatchInterrupt extends Error {
95
+ constructor() {
96
+ super('watch interrupted');
97
+ this.name = 'WatchInterrupt';
98
+ }
99
+ }
100
+ /** Process-backed defaults for production (the `guardianCommand` export). */
101
+ export function defaultDeps() {
102
+ return {
103
+ out: (s) => process.stdout.write(`${s}\n`),
104
+ err: (s) => process.stderr.write(`${s}\n`),
105
+ readStdin: () => {
106
+ try {
107
+ return readFileSync(0, 'utf-8');
108
+ }
109
+ catch {
110
+ return '';
111
+ }
112
+ },
113
+ env: process.env,
114
+ cwd: () => process.cwd(),
115
+ runGit: (args, cwd) => {
116
+ const res = spawnSync('git', args, {
117
+ encoding: 'utf-8',
118
+ maxBuffer: Infinity,
119
+ ...(cwd ? { cwd } : {}),
120
+ });
121
+ if (res.error)
122
+ return null; // missing binary -> Python OSError fail-safe
123
+ return { code: res.status ?? 1, stdout: res.stdout ?? '' };
124
+ },
125
+ runGh: (args) => {
126
+ const res = spawnSync('gh', args, {
127
+ encoding: 'utf-8',
128
+ timeout: 30_000,
129
+ maxBuffer: Infinity,
130
+ });
131
+ if (res.error) {
132
+ return { status: null, stdout: '', stderr: '', failed: true };
133
+ }
134
+ return {
135
+ status: res.status,
136
+ stdout: res.stdout ?? '',
137
+ stderr: res.stderr ?? '',
138
+ failed: false,
139
+ };
140
+ },
141
+ buildCommentClient: (repo, prNumber) => new RestGitHubClient(repo, prNumber, process.env['GITHUB_TOKEN'] ?? ''),
142
+ buildBranchProtectionClient: (repo, token) => new RestBranchProtectionClient(repo, token),
143
+ makeAgentTier: () => new InSessionAgentTier(),
144
+ sleep: (secs) => new Promise((resolve) => setTimeout(resolve, secs * 1000)),
145
+ };
146
+ }
147
+ /**
148
+ * Escape non-ASCII to `\uXXXX`, matching Python `json.dumps(ensure_ascii=True)`.
149
+ */
150
+ function ensureAscii(json) {
151
+ // Escape every UTF-16 code UNIT >= 0x80 to \uXXXX, matching Python
152
+ // json.dumps(ensure_ascii=True). Iterating by unit (not code point) means an
153
+ // astral char's surrogate pair emits \udXXX\udXXX, like Python; a code-point
154
+ // regex would stop at U+FFFF and leave astral chars raw.
155
+ let out = '';
156
+ for (let i = 0; i < json.length; i++) {
157
+ const c = json.charCodeAt(i);
158
+ out += c >= 0x80 ? '\\u' + c.toString(16).padStart(4, '0') : json[i];
159
+ }
160
+ return out;
161
+ }
162
+ /** ISO-8601 UTC timestamp with a `+00:00` offset (Python `isoformat`-shaped). */
163
+ function isoUtcNow() {
164
+ return new Date().toISOString().replace('Z', '+00:00');
165
+ }
166
+ // --- shared environment/git helpers (Python module-level `_*` functions) ------
167
+ /**
168
+ * Resolve `(repo, pr_number)` from GitHub Actions env, else `null`.
169
+ *
170
+ * `repo` comes from `GITHUB_REPOSITORY` (`owner/repo`). The PR number is parsed
171
+ * from `GITHUB_REF` (`refs/pull/<n>/merge`); when that is not a PR ref, it falls
172
+ * back to the `pull_request.number` field of the event JSON at
173
+ * `GITHUB_EVENT_PATH`. Returns `null` if either piece cannot be resolved.
174
+ */
175
+ export function prContextFromEnv(env) {
176
+ const repo = env['GITHUB_REPOSITORY'];
177
+ if (!repo || !repo.includes('/'))
178
+ return null;
179
+ const ref = env['GITHUB_REF'] ?? '';
180
+ const match = /^refs\/pull\/(\d+)\//.exec(ref);
181
+ if (match)
182
+ return [repo, Number.parseInt(match[1], 10)];
183
+ const eventPath = env['GITHUB_EVENT_PATH'];
184
+ if (eventPath) {
185
+ try {
186
+ const event = JSON.parse(readFileSync(eventPath, 'utf-8'));
187
+ const number = typeof event === 'object' && event !== null
188
+ ? event.pull_request
189
+ ?.number
190
+ : undefined;
191
+ if (typeof number === 'number' && Number.isInteger(number)) {
192
+ return [repo, number];
193
+ }
194
+ }
195
+ catch {
196
+ return null;
197
+ }
198
+ }
199
+ return null;
200
+ }
201
+ /**
202
+ * At-desk fork signal (guard b), FAIL-CLOSED on ambiguity.
203
+ *
204
+ * Only two safe sentinels mean "not a fork": `CANARY_GUARDIAN_IS_FORK` UNSET, or
205
+ * exactly `"0"` (after trim). ANY other non-empty value is treated as a fork, so
206
+ * authoring is SKIPPED rather than fail-open writing to an untrusted checkout.
207
+ */
208
+ export function isForkContext(env) {
209
+ const raw = env['CANARY_GUARDIAN_IS_FORK'];
210
+ if (raw === undefined)
211
+ return false;
212
+ return raw.trim() !== '0';
213
+ }
214
+ /** Append `notice` to the `$GITHUB_STEP_SUMMARY` file when set (no-op else). */
215
+ function appendStepSummary(env, notice) {
216
+ const summaryPath = env['GITHUB_STEP_SUMMARY'];
217
+ if (!summaryPath)
218
+ return;
219
+ try {
220
+ appendFileSync(summaryPath, `\n> ${notice}\n`, 'utf-8');
221
+ }
222
+ catch {
223
+ // best-effort
224
+ }
225
+ }
226
+ /**
227
+ * Resolve the analyses record `<ref>`: PR number (`pr-<n>`) from CI env, else the
228
+ * short HEAD sha, else `"local"`. Fails safe to `"local"` when `git` is absent.
229
+ */
230
+ export function resolveAnalysisRef(deps) {
231
+ const ctx = prContextFromEnv(deps.env);
232
+ if (ctx !== null)
233
+ return `pr-${ctx[1]}`;
234
+ const res = deps.runGit(['rev-parse', '--short', 'HEAD']);
235
+ if (res === null)
236
+ return 'local'; // missing binary -> fail-safe
237
+ return res.stdout.trim() || 'local';
238
+ }
239
+ /** Resolve the repository root via `git rev-parse --show-toplevel`. */
240
+ function gitToplevel(deps) {
241
+ const res = deps.runGit(['rev-parse', '--show-toplevel']);
242
+ if (res !== null && res.code === 0 && res.stdout.trim()) {
243
+ return res.stdout.trim();
244
+ }
245
+ return deps.cwd();
246
+ }
247
+ /** Resolve the real git dir for `root` via `git rev-parse --git-dir`. */
248
+ function gitDir(deps, root) {
249
+ const res = deps.runGit(['rev-parse', '--git-dir'], root);
250
+ if (res !== null && res.code === 0 && res.stdout.trim()) {
251
+ const resolved = res.stdout.trim();
252
+ return isAbsolute(resolved) ? resolved : join(root, resolved);
253
+ }
254
+ return join(root, '.git');
255
+ }
256
+ const AUTHORED_SENTINEL_NAME = 'canary-guardian-authored';
257
+ /** Absolute path to the guardian's authored-tests sentinel under `root`. */
258
+ function authoredSentinelPath(deps, root) {
259
+ return join(gitDir(deps, root), AUTHORED_SENTINEL_NAME);
260
+ }
261
+ /**
262
+ * Return raw unified-diff text from a source.
263
+ *
264
+ * `source === '-'` reads stdin; a path reads that file; `null` runs `git diff`
265
+ * and falls back to `git diff --staged` when the worktree is clean.
266
+ */
267
+ function readDiff(source, deps) {
268
+ if (source === '-')
269
+ return deps.readStdin();
270
+ if (source !== null)
271
+ return readFileSync(source, 'utf-8');
272
+ const unstaged = deps.runGit(['diff'])?.stdout ?? '';
273
+ if (unstaged.trim())
274
+ return unstaged;
275
+ return deps.runGit(['diff', '--staged'])?.stdout ?? '';
276
+ }
277
+ // --- analyze ------------------------------------------------------------------
278
+ function loadSpec(path, deps) {
279
+ if (!existsSync(path)) {
280
+ deps.err(`Spec file not found: ${path}`);
281
+ throw new CliExit(2);
282
+ }
283
+ const text = readFileSync(path, 'utf-8');
284
+ // Python `_load_spec`: `.json` -> json.loads; otherwise yaml.safe_load (with a
285
+ // json.loads fallback only if PyYAML is absent, which it isn't in practice).
286
+ // YAML is a JSON superset, so js-yaml `load` parses .yaml/.yml OpenAPI specs
287
+ // the oracle accepts. A parse error propagates (Python lets it raise too).
288
+ if (path.endsWith('.json')) {
289
+ return JSON.parse(text);
290
+ }
291
+ return (loadYaml(text) ?? {});
292
+ }
293
+ function loadCoverage(path) {
294
+ if (!existsSync(path))
295
+ return [];
296
+ try {
297
+ const data = JSON.parse(readFileSync(path, 'utf-8'));
298
+ if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
299
+ const endpoints = data.endpoints;
300
+ return Array.isArray(endpoints) ? endpoints : [];
301
+ }
302
+ return [];
303
+ }
304
+ catch {
305
+ return [];
306
+ }
307
+ }
308
+ function tryPostPrComment(summary, prUrl, deps) {
309
+ if (!prUrl)
310
+ return;
311
+ const result = deps.runGh(['pr', 'comment', prUrl, '--body', summary]);
312
+ if (result.failed)
313
+ return; // missing binary / timeout -> Python `pass`
314
+ if (result.status === 0) {
315
+ deps.out(pc.green('Posted impact summary as PR comment.'));
316
+ }
317
+ else {
318
+ deps.out(`${pc.yellow('Could not post PR comment:')} ${result.stderr.trim()}`);
319
+ }
320
+ }
321
+ function analyzeCmd(commit, opts, deps) {
322
+ let beforeSpec = {};
323
+ let afterSpec = {};
324
+ if (!opts.specBefore || !opts.specAfter) {
325
+ deps.out(`${pc.yellow('Tip:')} pass --spec-before and --spec-after to diff two OpenAPI specs.`);
326
+ deps.out('Without spec files, guardian reports no diff (use for testing the pipeline).');
327
+ }
328
+ else {
329
+ beforeSpec = loadSpec(opts.specBefore, deps);
330
+ afterSpec = loadSpec(opts.specAfter, deps);
331
+ }
332
+ const diff = extractApiDiff(beforeSpec, afterSpec);
333
+ const sha = commit ?? 'unknown';
334
+ if (opts.emitDiff) {
335
+ const generated = isoUtcNow();
336
+ writeApiDelta(buildApiDelta(diff, sha, opts.suite, generated), opts.emitDiff);
337
+ deps.out(`${pc.green('Wrote api-delta.json')} ${RIGHT_ARROW} ${opts.emitDiff}`);
338
+ }
339
+ const coverageRows = opts.coverage ? loadCoverage(opts.coverage) : [];
340
+ const gaps = mapImpact(diff, coverageRows);
341
+ const summary = buildSummary(gaps, sha, opts.suite);
342
+ if (opts.json) {
343
+ deps.out(ensureAscii(JSON.stringify({
344
+ commit: sha,
345
+ suite: opts.suite,
346
+ added: diff.added.length,
347
+ removed: diff.removed.length,
348
+ changed: diff.changed.length,
349
+ gaps: gaps.map((g) => ({
350
+ path: g.path,
351
+ method: g.method,
352
+ change_type: g.change_type,
353
+ severity: g.severity,
354
+ affected_tests: g.affected_tests,
355
+ })),
356
+ }, null, 2)));
357
+ }
358
+ else {
359
+ deps.out(summary);
360
+ }
361
+ if (!opts.dryRun && !opts.json) {
362
+ tryPostPrComment(summary, opts.pr, deps);
363
+ }
364
+ }
365
+ const MAX_COVERAGE_BYTES = 25 * 1024 * 1024;
366
+ function validateCoverageCmd(path, opts, deps) {
367
+ let text;
368
+ try {
369
+ const st = statSync(path);
370
+ if (st.isDirectory() || st.size > MAX_COVERAGE_BYTES) {
371
+ deps.out(`${pc.red(pc.bold(`${CROSS} cannot read ${path}:`))} not a readable file within the size limit`);
372
+ throw new CliExit(2);
373
+ }
374
+ text = readFileSync(path, 'utf-8');
375
+ }
376
+ catch (exc) {
377
+ if (exc instanceof CliExit)
378
+ throw exc;
379
+ const msg = exc instanceof Error ? exc.message : String(exc);
380
+ deps.out(`${pc.red(pc.bold(`${CROSS} cannot read ${path}:`))} ${msg}`);
381
+ throw new CliExit(2);
382
+ }
383
+ let data;
384
+ try {
385
+ data = JSON.parse(text);
386
+ }
387
+ catch (exc) {
388
+ const msg = exc instanceof Error ? exc.message : String(exc);
389
+ deps.out(`${pc.red(pc.bold(`${CROSS} ${path} is not valid JSON:`))} ${msg}`);
390
+ throw new CliExit(2);
391
+ }
392
+ const problems = validateCoverageJson(data);
393
+ const errors = problems.filter((p) => p.severity === 'error');
394
+ const warnings = problems.filter((p) => p.severity === 'warning');
395
+ const valid = errors.length === 0;
396
+ if (opts.json) {
397
+ // Plain stdout, NOT colored -- producer-controlled keys must not be
398
+ // interpreted as markup, and the payload must stay valid JSON.
399
+ deps.out(ensureAscii(JSON.stringify({
400
+ valid,
401
+ problems: problems.map((pr) => ({
402
+ severity: pr.severity,
403
+ location: pr.location,
404
+ message: pr.message,
405
+ })),
406
+ }, null, 2)));
407
+ }
408
+ else {
409
+ for (const pr of errors) {
410
+ deps.out(`${pc.red(pc.bold('error'))} ${pr.location}: ${pr.message}`);
411
+ }
412
+ for (const pr of warnings) {
413
+ deps.out(`${pc.yellow('warning')} ${pr.location}: ${pr.message}`);
414
+ }
415
+ if (valid && warnings.length === 0) {
416
+ deps.out(pc.green(pc.bold(`${CHECK} ${path} is a valid coverage-json document.`)));
417
+ }
418
+ else if (valid) {
419
+ deps.out(`${pc.green(`${CHECK} valid`)} with ${warnings.length} warning(s) ${EM_DASH} coverage is usable but degraded.`);
420
+ }
421
+ else {
422
+ deps.out(`${pc.red(pc.bold(`${CROSS} invalid`))} ${EM_DASH} ${errors.length} error(s); this coverage would be dropped.`);
423
+ }
424
+ }
425
+ if (errors.length > 0 || (opts.strict && warnings.length > 0)) {
426
+ throw new CliExit(1);
427
+ }
428
+ }
429
+ async function hardenGateCmd(opts, deps) {
430
+ const repo = opts.repo;
431
+ if (!repo) {
432
+ deps.out(`${pc.red(pc.bold(`${CROSS} no repo`))} ${EM_DASH} pass --repo owner/repo or set GITHUB_REPOSITORY.`);
433
+ throw new CliExit(2);
434
+ }
435
+ const playbook = renderPlaybook(repo, opts.branch, opts.check);
436
+ if (!opts.apply) {
437
+ deps.out(`${pc.bold('Dry run')} ${EM_DASH} would require the '${opts.check}' check on ${repo}@${opts.branch}.`);
438
+ deps.out('On --apply this merges into existing protection (or creates minimal ' +
439
+ 'protection if the branch is unprotected) and first verifies the ' +
440
+ `context is real. Re-run with ${pc.bold('--apply')} (needs an admin ` +
441
+ 'token), or do it manually:\n');
442
+ deps.out(playbook);
443
+ return;
444
+ }
445
+ if (!opts.token) {
446
+ deps.out(`${pc.red(pc.bold(`${CROSS} --apply needs an admin token`))} (pass --token or set GITHUB_TOKEN).\n`);
447
+ deps.out(playbook);
448
+ throw new CliExit(2);
449
+ }
450
+ const client = deps.buildBranchProtectionClient(repo, opts.token);
451
+ let plan;
452
+ try {
453
+ plan = await applyHardGate(client, repo, opts.branch, opts.check, opts.force ?? false);
454
+ }
455
+ catch (exc) {
456
+ if (exc instanceof HardGateBlocked) {
457
+ deps.out(`${pc.red(pc.bold(`${CROSS} ${exc.reason}`))}\n`);
458
+ deps.out(exc.playbook);
459
+ throw new CliExit(1);
460
+ }
461
+ throw exc;
462
+ }
463
+ if (plan.already_required) {
464
+ deps.out(pc.green(`${CHECK} '${opts.check}' is already required on ${repo}@${opts.branch} ${EM_DASH} nothing to do.`));
465
+ }
466
+ else {
467
+ const verb = plan.creates_protection
468
+ ? 'created protection and required'
469
+ : 'required';
470
+ deps.out(pc.green(pc.bold(`${CHECK} ${verb} '${opts.check}' on ${repo}@${opts.branch}.`)));
471
+ }
472
+ deps.out(pc.dim(`Finish the flip: set the exit gate to hard too ${EM_DASH} ` +
473
+ 'canary.guardian.pr.gate = "hard" in harness.config.json ' +
474
+ '(or run pr-check --gate hard).'));
475
+ }
476
+ // --- pr-check -----------------------------------------------------------------
477
+ /**
478
+ * Upsert the Phase-2 sticky PR comment (behavior-preserving extraction). When no
479
+ * PR context is resolvable from env, prints the body instead of crashing; a
480
+ * read-only-token degradation is surfaced LOUDLY.
481
+ */
482
+ async function postStickyComment(findings, resolution, deps) {
483
+ const body = render(findings, 'comment', resolution.effective, resolution.degraded_notice);
484
+ const ctx = prContextFromEnv(deps.env);
485
+ if (ctx === null) {
486
+ deps.out(`guardian: no PR context in env ${EM_DASH} printing instead.`);
487
+ deps.out(body);
488
+ return;
489
+ }
490
+ const client = deps.buildCommentClient(ctx[0], ctx[1]);
491
+ const res = await upsertStickyComment(client, body);
492
+ if (res.action === 'degraded' && res.notice) {
493
+ deps.out(degradationAnnotation(res.notice));
494
+ appendStepSummary(deps.env, res.notice);
495
+ }
496
+ }
497
+ async function prCheckCmd(opts, deps) {
498
+ const [config, warning] = loadGuardianConfig(opts.config);
499
+ if (warning !== null) {
500
+ // SC-8: surface the malformed-config warning loudly, never silently.
501
+ deps.err(`WARNING: ${warning}`);
502
+ }
503
+ // OT-5: while pr.enabled == false, `--post-comment` skips the PR surface
504
+ // entirely (no diff scoped, no comment posted, exit 0).
505
+ if (opts.postComment && !config.pr_enabled) {
506
+ deps.out(`guardian: pr.enabled is false ${EM_DASH} skipping PR surface.`);
507
+ throw new CliExit(0);
508
+ }
509
+ const effectiveGate = opts.gate ?? config.pr_gate;
510
+ const diffText = readDiff(opts.diff ?? null, deps);
511
+ const units = scopeDiff(diffText);
512
+ // SC-2: drop docs/config-only units matching skipGlobs.
513
+ const [keptSkip, skipped] = filterSkipped(units, config.skip_globs);
514
+ // FIX A: drop test-path units -- a test does not itself need a test.
515
+ const [keptTest, testUnits] = filterTestUnits(keptSkip);
516
+ // FIX 2: drop pure re-export/barrel files.
517
+ const reexportPaths = findReexportOnly(diffText);
518
+ const barrelUnits = keptTest.filter((u) => reexportPaths.has(u.path));
519
+ const kept = keptTest.filter((u) => !reexportPaths.has(u.path));
520
+ // Advisory weak-test findings for added tests that assert nothing.
521
+ const weakFindings = config.weak_tests
522
+ ? buildWeakTestFindings(testUnits, diffText)
523
+ : [];
524
+ if (kept.length === 0 && weakFindings.length === 0) {
525
+ deps.out(`guardian: nothing to verify ` +
526
+ `(${skipped.length + testUnits.length + barrelUnits.length} path(s) skipped).`);
527
+ throw new CliExit(0);
528
+ }
529
+ const results = resolveCoverage(kept, {
530
+ coveragePath: opts.coverage ?? null,
531
+ // #320: under a hard gate the graph tier requires a DIRECT test->source edge
532
+ // (depth 1); soft stays unbounded. An explicit config value wins.
533
+ graphMaxDepth: effectiveGraphDepth(config, effectiveGate),
534
+ });
535
+ const findings = [
536
+ ...applySuppressions(buildFindings(results)),
537
+ ...weakFindings,
538
+ ];
539
+ // SC-5 (PR half): resolve the requested tier against actual capability. No
540
+ // agent runtime exists (default NoAgentProbe), so any `pr.tier > 0` drops to
541
+ // tier 0 with a LOUD degradation notice.
542
+ const resolution = resolveTier(config.pr_tier);
543
+ if (resolution.degraded_notice) {
544
+ deps.out(degradationAnnotation(resolution.degraded_notice));
545
+ appendStepSummary(deps.env, resolution.degraded_notice);
546
+ }
547
+ // Compute the gate result once, up front: the emitted record carries it and it
548
+ // is the process exit at the end (SC-4 -- emit never changes the exit logic).
549
+ const exitCode = computeExitCode(findings, effectiveGate);
550
+ let commentPosted = false;
551
+ if (opts.emitAnalysis) {
552
+ // SC-10 producer half: write ONE record to the analyses channel. On an
553
+ // unavailable channel `emitAnalysis` returns a LOUD notice and we fall back
554
+ // to the sticky comment -- the record is never silently dropped.
555
+ const analysesDir = opts.analysesDir
556
+ ? opts.analysesDir
557
+ : join(gitToplevel(deps), '.harness', 'analyses');
558
+ const res = emitAnalysis(findings, {
559
+ analysesDir,
560
+ ref: resolveAnalysisRef(deps),
561
+ gate: effectiveGate,
562
+ effective_tier: resolution.effective,
563
+ degraded_notice: resolution.degraded_notice,
564
+ exit_code: exitCode,
565
+ });
566
+ if (res.action === 'emitted') {
567
+ deps.out(`guardian: wrote analysis record ${RIGHT_ARROW} ${res.path}`);
568
+ }
569
+ else {
570
+ // LOUD fallback: `::warning::` + step-summary + stderr, then the Phase-2
571
+ // sticky comment so findings stay visible (SC-10 fallback).
572
+ deps.out(degradationAnnotation(res.notice));
573
+ appendStepSummary(deps.env, res.notice);
574
+ deps.err(res.notice);
575
+ await postStickyComment(findings, resolution, deps);
576
+ commentPosted = true;
577
+ }
578
+ }
579
+ if (opts.postComment && !commentPosted) {
580
+ // Explicit `--post-comment`: post/upsert unless the SC-10 fallback already
581
+ // posted this run.
582
+ await postStickyComment(findings, resolution, deps);
583
+ }
584
+ else if (!opts.emitAnalysis && !opts.postComment) {
585
+ // Local, non-posting default: render to stdout in `--format`.
586
+ deps.out(render(findings, opts.format, resolution.effective, resolution.degraded_notice));
587
+ }
588
+ throw new CliExit(exitCode);
589
+ }
590
+ // --- author-plan --------------------------------------------------------------
591
+ /**
592
+ * Build Tier-0 `untested-new-code` findings from `diffText` using the SAME
593
+ * pipeline as `pr-check` (scope -> skip/test/re-export filters -> resolve
594
+ * coverage -> build/suppress findings). Agent-free (SC-11).
595
+ */
596
+ function buildGaps(diffText, config, coveragePath, graphMaxDepth) {
597
+ const units = scopeDiff(diffText);
598
+ const [keptSkip] = filterSkipped(units, config.skip_globs);
599
+ const [keptTest] = filterTestUnits(keptSkip);
600
+ const reexportPaths = findReexportOnly(diffText);
601
+ const kept = keptTest.filter((u) => !reexportPaths.has(u.path));
602
+ if (kept.length === 0)
603
+ return [];
604
+ const results = resolveCoverage(kept, { coveragePath, graphMaxDepth });
605
+ return applySuppressions(buildFindings(results));
606
+ }
607
+ /** Serialize a {@link GeneratedTest} intent for the SKILL (JSON-safe). */
608
+ function intentDict(intent) {
609
+ return {
610
+ path: intent.gap.path,
611
+ unit: intent.gap.unit,
612
+ target_path: intent.target_path,
613
+ requirement: intent.requirement,
614
+ status: intent.status,
615
+ written_path: intent.written_path,
616
+ skip_reason: intent.skip_reason,
617
+ };
618
+ }
619
+ function authorPlanCmd(opts, deps) {
620
+ const [config, warning] = loadGuardianConfig(opts.config);
621
+ if (warning !== null) {
622
+ deps.err(`WARNING: ${warning}`);
623
+ }
624
+ const diffText = readDiff(opts.diff ?? null, deps);
625
+ const gaps = buildGaps(diffText, config, opts.coverage ?? null,
626
+ // #320: author-plan is the pre-commit authoring surface -- use the same
627
+ // gate-derived graph depth the pre-commit hook computes (preCommit.gate).
628
+ effectiveGraphDepth(config, config.precommit_gate));
629
+ const requested = config.precommit_author_tests ? 2 : 0;
630
+ const effective = resolveTier(requested, new InSessionAgentProbe(deps.env)).effective;
631
+ // FIX 6: resolve the repo root from the git top-level (not cwd), so the
632
+ // collision check and sentinel lookup stay root-relative from a subdirectory.
633
+ const repoRoot = gitToplevel(deps);
634
+ const ctx = new AuthoringContext(config.precommit_author_tests, effective, {
635
+ is_fork: isForkContext(deps.env),
636
+ repo_root: repoRoot,
637
+ authored_sentinel_present: existsSync(authoredSentinelPath(deps, repoRoot)),
638
+ });
639
+ const results = deps.makeAgentTier().author_tests(gaps, ctx);
640
+ const decision = decideBlock(results);
641
+ const payload = {
642
+ intents: results.map(intentDict),
643
+ block: {
644
+ block: decision.block,
645
+ message: decision.message,
646
+ authored_count: decision.authored_count,
647
+ },
648
+ };
649
+ deps.out(ensureAscii(JSON.stringify(payload, null, 2)));
650
+ }
651
+ function markAuthoredCmd(opts, deps) {
652
+ const root = gitToplevel(deps);
653
+ const sentinel = authoredSentinelPath(deps, root);
654
+ mkdirSync(dirname(sentinel), { recursive: true });
655
+ const body = opts.path.map((p) => `${p}\n`).join('');
656
+ writeFileSync(sentinel, body, 'utf-8');
657
+ deps.out(`guardian: recorded ${opts.path.length} authored path(s) ${RIGHT_ARROW} ${sentinel}`);
658
+ }
659
+ async function watchCmd(opts, deps) {
660
+ deps.out(`${pc.cyan('Guardian watch mode')} ${EM_DASH} polling every ${opts.interval}s. Ctrl+C to stop.`);
661
+ try {
662
+ for (;;) {
663
+ deps.out(pc.dim('Polling for new merges...'));
664
+ await deps.sleep(opts.interval);
665
+ }
666
+ }
667
+ catch (exc) {
668
+ if (exc instanceof WatchInterrupt) {
669
+ deps.out(`\n${pc.yellow('Watch stopped.')}`);
670
+ return;
671
+ }
672
+ throw exc;
673
+ }
674
+ }
675
+ // --- assembly -----------------------------------------------------------------
676
+ /** Collect a repeatable option value into an array (commander pattern). */
677
+ function collect(value, previous) {
678
+ return previous.concat([value]);
679
+ }
680
+ /**
681
+ * Build a fresh `guardian` command wired to `depsInit` (process-backed defaults
682
+ * fill any gap). Every subcommand uses `.exitOverride()` so a usage error throws
683
+ * a `CommanderError` rather than terminating the process -- tests read the exit
684
+ * code from the thrown error (or from {@link CliExit} for business exits).
685
+ */
686
+ export function createGuardianCommand(depsInit = {}) {
687
+ const deps = { ...defaultDeps(), ...depsInit };
688
+ const program = new Command('guardian');
689
+ program
690
+ .description('Watch API changes and analyze test impact.')
691
+ .exitOverride(normalizeUsageExit);
692
+ program
693
+ .command('analyze')
694
+ .description('Analyze API diff for a commit and emit a test impact summary.')
695
+ .argument('[commit]', 'Commit SHA to analyze.')
696
+ .option('--pr <pr>', 'GitHub PR URL to analyze.')
697
+ .option('--spec-before <path>', 'Path to OpenAPI spec before the change.')
698
+ .option('--spec-after <path>', 'Path to OpenAPI spec after the change.')
699
+ .addOption(new Option('-s, --suite <suite>', 'Test suite name.').default('api'))
700
+ .option('--coverage <path>', 'Path to coverage-report.json.')
701
+ .option('--dry-run', 'Print summary to stdout only.')
702
+ .option('--json')
703
+ .option('--emit-diff <path>', 'Write a machine-readable api-delta.json to PATH.')
704
+ .addOption(new Option('--db-url <url>').env('CANARY_HISTORY_DB_URL'))
705
+ .action((commit, opts) => {
706
+ analyzeCmd(commit, opts, deps);
707
+ });
708
+ program
709
+ .command('validate-coverage')
710
+ .description('Validate a coverage-json file against the producer contract.')
711
+ .argument('<path>', 'Path to a coverage-json file to validate.')
712
+ .option('--strict', 'Treat warnings as failures (exit 1).')
713
+ .option('--json', 'Emit problems as JSON.')
714
+ .action((path, opts) => {
715
+ validateCoverageCmd(path, opts, deps);
716
+ });
717
+ program
718
+ .command('harden-gate')
719
+ .description('Promote the guardian gate to hard (require its status check).')
720
+ .option('--apply', 'Register the required check (default: dry-run).')
721
+ .addOption(new Option('--repo <repo>', 'owner/repo.').env('GITHUB_REPOSITORY'))
722
+ .addOption(new Option('--branch <branch>', 'Branch to protect.').default('main'))
723
+ .addOption(new Option('--check <check>', 'Status-check context to require (the guardian workflow job).').default('guardian'))
724
+ .addOption(new Option('--token <token>', 'Admin token for --apply.').env('GITHUB_TOKEN'))
725
+ .option('--force', 'Skip the check-context-exists verification (risky).')
726
+ .action(async (opts) => {
727
+ await hardenGateCmd(opts, deps);
728
+ });
729
+ program
730
+ .command('pr-check')
731
+ .description('Tier 0 deterministic PR guardian: scope, resolve, gate.')
732
+ .option('--diff <diff>', "Diff file, '-' for stdin, or omit to use `git diff`.")
733
+ .option('--coverage <path>', 'Coverage report path (lcov/json).')
734
+ .addOption(new Option('--format <fmt>', 'comment|json|text').default('comment'))
735
+ .addOption(new Option('--config <path>').default('harness.config.json'))
736
+ .option('--gate <gate>', 'Override config gate: soft|hard')
737
+ .option('--post-comment', 'Post/update the sticky PR comment via the GitHub API (CI).')
738
+ .option('--emit-analysis', 'Write the finding record to the .harness/analyses/ channel ' +
739
+ '(harness handoff, #899); falls back LOUDLY to the sticky comment ' +
740
+ 'when the channel is unavailable.')
741
+ .addOption(new Option('--analyses-dir <dir>', 'Override the analyses dir (tests).').hideHelp())
742
+ .action(async (opts) => {
743
+ await prCheckCmd(opts, deps);
744
+ });
745
+ program
746
+ .command('author-plan')
747
+ .description('Emit the at-desk authoring plan (intents + block decision).')
748
+ .option('--diff <diff>', "Diff file, '-' for stdin, or omit to use `git diff`.")
749
+ .option('--coverage <path>', 'Coverage report path (lcov/json).')
750
+ .addOption(new Option('--config <path>').default('harness.config.json'))
751
+ .option('--json')
752
+ .action((opts) => {
753
+ authorPlanCmd(opts, deps);
754
+ });
755
+ program
756
+ .command('mark-authored')
757
+ .description('Write the guardian loop-guard sentinel with authored paths.')
758
+ .option('--path <path>', 'An authored test path (repeatable). Recorded one per line.', collect, [])
759
+ .action((opts) => {
760
+ markAuthoredCmd(opts, deps);
761
+ });
762
+ program
763
+ .command('watch')
764
+ .description('Poll for new merges and analyze each (local dev / CI fallback).')
765
+ .addOption(new Option('--interval <secs>', 'Polling interval in seconds.')
766
+ .default(300)
767
+ .argParser((v) => Number.parseInt(v, 10)))
768
+ // Python's `watch` declares `--suite` with NO `-s` short form (unlike
769
+ // `analyze`); adding `-s` here would accept an invocation the oracle rejects.
770
+ .addOption(new Option('--suite <suite>').default('api'))
771
+ .addOption(new Option('--db-url <url>').env('CANARY_HISTORY_DB_URL'))
772
+ .action(async (opts) => {
773
+ await watchCmd(opts, deps);
774
+ });
775
+ // Propagate the exit-override to every subcommand so their usage errors throw
776
+ // rather than exit the process (mirrors the root override for the CLI wave).
777
+ for (const sub of program.commands) {
778
+ sub.exitOverride(normalizeUsageExit);
779
+ }
780
+ return program;
781
+ }
782
+ /**
783
+ * The production `guardian` command, wired to process-backed defaults. A future
784
+ * root CLI mounts it via `rootProgram.addCommand(guardianCommand)`.
785
+ */
786
+ export const guardianCommand = createGuardianCommand();
787
+ //# sourceMappingURL=cli.js.map