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,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
|