canary-test-cli 7.0.0 → 7.1.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/dist/engine/analysis/cli.js +116 -54
- package/dist/engine/analysis/engine.js +34 -16
- package/dist/engine/analysis/reports.js +5 -4
- package/dist/engine/cli-commands.js +249 -41
- package/dist/engine/cli-common.js +15 -24
- package/dist/engine/cli.core.js +37 -11
- package/dist/engine/cli.js +2 -2
- package/dist/engine/company-knowledge-cli.js +2 -2
- package/dist/engine/core/adoption.js +408 -0
- package/dist/engine/core/framework-probes.js +7 -7
- package/dist/engine/core/fs-glob.js +2 -2
- package/dist/engine/core/gate-result.js +17 -0
- package/dist/engine/core/migrator.js +9 -17
- package/dist/engine/core/pattern-matcher.js +23 -5
- package/dist/engine/core/persona.js +421 -0
- package/dist/engine/core/promotion-verdict.js +261 -0
- package/dist/engine/core/reporter.js +1 -9
- package/dist/engine/core/skill-examples.js +292 -0
- package/dist/engine/core/skill-surfaces.js +307 -0
- package/dist/engine/core/static-linter.js +310 -38
- package/dist/engine/core/ticket-updater.js +1 -7
- package/dist/engine/core/vacuity-scanner.js +556 -0
- package/dist/engine/core/workflow-discovery.js +2 -8
- package/dist/engine/core/workspace-detect.js +7 -6
- package/dist/engine/data/personas/registry.json +36 -0
- package/dist/engine/guardian/adjudication.js +5 -5
- package/dist/engine/guardian/analysis-emit.js +13 -27
- package/dist/engine/guardian/cli.js +30 -43
- package/dist/engine/guardian/coverage.js +1 -1
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
- package/dist/engine/guardian/pr-check.js +5 -15
- package/dist/engine/guardian/pr-comment.js +4 -3
- package/dist/engine/history/cli.js +210 -6
- package/dist/engine/history/ndjson-store.js +9 -5
- package/dist/engine/history/record.js +34 -5
- package/dist/engine/history/run-recorder.js +165 -0
- package/dist/engine/history/schema.js +25 -7
- package/dist/engine/history/store.js +9 -0
- package/dist/engine/mcp-server.js +35 -13
- package/dist/engine/skills-cli.js +133 -11
- package/dist/engine/util/ensure-ascii.js +37 -0
- package/dist/engine/workflow-cli.js +6 -6
- package/dist/gate-result.d.ts +11 -0
- package/dist/gate-result.js +18 -0
- package/dist/uninstall.js +12 -5
- package/package.json +1 -1
|
@@ -162,9 +162,9 @@ export function tallyAdjudications(reactions) {
|
|
|
162
162
|
const FINDING_ROW_RE = /^\|[^|]*\|\s*\[?`([^`]+)`/;
|
|
163
163
|
/**
|
|
164
164
|
* Extract the file paths of the ACTIVE findings shown in a sticky-comment body
|
|
165
|
-
* (PURE). Reads the rendered table `
|
|
166
|
-
* deliberately parsing the exact body reviewers reacted to, not the
|
|
167
|
-
* finding set, so a reaction is attributed to what the reviewer
|
|
165
|
+
* (PURE). Reads the rendered table `renderFindings(fmt='comment')` emitted —
|
|
166
|
+
* this is deliberately parsing the exact body reviewers reacted to, not the
|
|
167
|
+
* current finding set, so a reaction is attributed to what the reviewer saw.
|
|
168
168
|
* Returns `[]` for a no-gaps body (no table).
|
|
169
169
|
*/
|
|
170
170
|
export function activeFindingPaths(commentBody) {
|
|
@@ -204,7 +204,7 @@ export function adjudicationFilename(prNumber) {
|
|
|
204
204
|
return `${ADJUDICATION_SOURCE}-pr-${prNumber}.json`;
|
|
205
205
|
}
|
|
206
206
|
/** True iff the harness home (`dirname(analysesDir)`) exists. */
|
|
207
|
-
function
|
|
207
|
+
function isChannelAvailable(analysesDir) {
|
|
208
208
|
try {
|
|
209
209
|
return statSync(dirname(analysesDir)).isDirectory();
|
|
210
210
|
}
|
|
@@ -241,7 +241,7 @@ export async function collectAdjudications(client, args) {
|
|
|
241
241
|
tally,
|
|
242
242
|
collectedAt: args.collectedAt,
|
|
243
243
|
});
|
|
244
|
-
if (!
|
|
244
|
+
if (!isChannelAvailable(args.analysesDir)) {
|
|
245
245
|
return {
|
|
246
246
|
action: 'unavailable',
|
|
247
247
|
path: null,
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* canary's records -- they never clobber a harness record and always pass
|
|
15
15
|
* `AnalysisArchive.safePath` (no traversal).
|
|
16
16
|
* - {@link buildAnalysisRecord} -- the v1.0 envelope wrapping the verbatim
|
|
17
|
-
* `
|
|
17
|
+
* `renderFindings(fmt="json")` findings array.
|
|
18
18
|
*
|
|
19
19
|
* SC-11 boundary: this module is deterministic filesystem/JSON only. It imports
|
|
20
20
|
* no `AgentTier`/LLM-SDK module (only intra-guardian, agent-free helpers).
|
|
@@ -34,36 +34,21 @@
|
|
|
34
34
|
import { createHash, randomBytes } from 'node:crypto';
|
|
35
35
|
import { mkdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
36
36
|
import { dirname, join } from 'node:path';
|
|
37
|
+
import { ensureAscii } from '../util/ensure-ascii.js';
|
|
37
38
|
import { coverageDegradedNotice, coverageStatus, } from './coverage.js';
|
|
38
|
-
import { combineNotices,
|
|
39
|
+
import { combineNotices, renderFindings } from './pr-check.js';
|
|
39
40
|
// 1.1 adds the additive `coverage` block (#554); readers of 1.0 are unaffected.
|
|
40
41
|
// 1.2 adds the additive `skipped` list (#582). Additive again, and bumped again
|
|
41
42
|
// for the reason recorded in #572: a reader that pins a version must be able to
|
|
42
43
|
// tell which fields it can rely on being present, and silence about a new field
|
|
43
44
|
// is indistinguishable from the field being absent for a real reason.
|
|
44
45
|
export const SCHEMA_VERSION = '1.2';
|
|
45
|
-
|
|
46
|
+
const ANALYSIS_SOURCE = 'canary-pr-guardian';
|
|
46
47
|
const REF_SAFE = /[^A-Za-z0-9._-]/g;
|
|
47
48
|
const REF_MAX = 100; // cap the sanitized ref so a long branch never hits ENAMETOOLONG
|
|
48
49
|
// The loud-fallback notices carry an em-dash (U+2014) as output data; written as
|
|
49
50
|
// an escape to honor the ASCII-source rule.
|
|
50
51
|
const EM_DASH = '\u{2014}';
|
|
51
|
-
/**
|
|
52
|
-
* Escape every non-ASCII (>= U+0080) code unit to a `\uXXXX` sequence, matching
|
|
53
|
-
* Python's `json.dumps(..., ensure_ascii=True)` (the library default).
|
|
54
|
-
*/
|
|
55
|
-
function ensureAscii(json) {
|
|
56
|
-
// Escape every UTF-16 code UNIT >= 0x80 to \uXXXX, matching Python
|
|
57
|
-
// json.dumps(ensure_ascii=True). Iterating by unit (not code point) means an
|
|
58
|
-
// astral char's surrogate pair emits \udXXX\udXXX, like Python; a code-point
|
|
59
|
-
// regex would stop at U+FFFF and leave astral chars raw.
|
|
60
|
-
let out = '';
|
|
61
|
-
for (let i = 0; i < json.length; i++) {
|
|
62
|
-
const c = json.charCodeAt(i);
|
|
63
|
-
out += c >= 0x80 ? '\\u' + c.toString(16).padStart(4, '0') : json[i];
|
|
64
|
-
}
|
|
65
|
-
return out;
|
|
66
|
-
}
|
|
67
52
|
/** Trim leading/trailing `-` characters (Python `str.strip("-")`). */
|
|
68
53
|
function stripDashes(value) {
|
|
69
54
|
return value.replace(/^-+/, '').replace(/-+$/, '');
|
|
@@ -76,7 +61,7 @@ function stripDashes(value) {
|
|
|
76
61
|
* `REF_MAX` chars with a short hash suffix appended to preserve uniqueness --
|
|
77
62
|
* only when truncation actually happens. Short refs are unchanged.
|
|
78
63
|
*/
|
|
79
|
-
export function analysisFilename(ref, source =
|
|
64
|
+
export function analysisFilename(ref, source = ANALYSIS_SOURCE) {
|
|
80
65
|
let safe = stripDashes(ref.replace(REF_SAFE, '-')) || 'local';
|
|
81
66
|
if (safe.length > REF_MAX) {
|
|
82
67
|
// sha1 is a filename disambiguator (not a security digest); it matches the
|
|
@@ -96,7 +81,8 @@ function isoUtcNow() {
|
|
|
96
81
|
return new Date().toISOString().replace('Z', '+00:00');
|
|
97
82
|
}
|
|
98
83
|
/**
|
|
99
|
-
* Build the v1.0 envelope. `findings` is exactly
|
|
84
|
+
* Build the v1.0 envelope. `findings` is exactly the array from
|
|
85
|
+
* `renderFindings(fmt='json')`.
|
|
100
86
|
*/
|
|
101
87
|
export function buildAnalysisRecord(findings, args) {
|
|
102
88
|
const { ref, gate, effective_tier, degraded_notice, exit_code } = args;
|
|
@@ -104,7 +90,7 @@ export function buildAnalysisRecord(findings, args) {
|
|
|
104
90
|
// input's — so "no findings" can never be read as "coverage said so".
|
|
105
91
|
const coverage = args.coverage ?? null;
|
|
106
92
|
const notice = combineNotices(degraded_notice, coverage ? coverageDegradedNotice(coverage) : null);
|
|
107
|
-
const inner = JSON.parse(
|
|
93
|
+
const inner = JSON.parse(renderFindings(findings, 'json', effective_tier, notice));
|
|
108
94
|
const active = findings.filter((f) => !f.suppressed);
|
|
109
95
|
const suppressed = findings.filter((f) => f.suppressed);
|
|
110
96
|
const byFidelity = {};
|
|
@@ -113,7 +99,7 @@ export function buildAnalysisRecord(findings, args) {
|
|
|
113
99
|
}
|
|
114
100
|
return {
|
|
115
101
|
schemaVersion: SCHEMA_VERSION,
|
|
116
|
-
source:
|
|
102
|
+
source: ANALYSIS_SOURCE,
|
|
117
103
|
ref,
|
|
118
104
|
gate,
|
|
119
105
|
exitCode: exit_code,
|
|
@@ -151,7 +137,7 @@ export const emitSeams = {
|
|
|
151
137
|
* The analyses dir itself is created on demand by {@link emitAnalysis} (mirroring
|
|
152
138
|
* harness `AnalysisArchive.save`'s recursive `mkdir`).
|
|
153
139
|
*/
|
|
154
|
-
export function
|
|
140
|
+
export function isChannelAvailable(analysesDir) {
|
|
155
141
|
try {
|
|
156
142
|
return statSync(dirname(analysesDir)).isDirectory();
|
|
157
143
|
}
|
|
@@ -168,7 +154,7 @@ export function channelAvailable(analysesDir) {
|
|
|
168
154
|
*/
|
|
169
155
|
export function emitAnalysis(findings, args) {
|
|
170
156
|
const { analysesDir } = args;
|
|
171
|
-
if (!
|
|
157
|
+
if (!isChannelAvailable(analysesDir)) {
|
|
172
158
|
return {
|
|
173
159
|
action: 'unavailable',
|
|
174
160
|
path: null,
|
|
@@ -178,8 +164,8 @@ export function emitAnalysis(findings, args) {
|
|
|
178
164
|
}
|
|
179
165
|
const target = join(analysesDir, analysisFilename(args.ref));
|
|
180
166
|
try {
|
|
181
|
-
// Build INSIDE the try: a non-I/O error (from JSON/
|
|
182
|
-
// crash pr-check instead of degrading.
|
|
167
|
+
// Build INSIDE the try: a non-I/O error (from JSON/renderFindings) would
|
|
168
|
+
// otherwise crash pr-check instead of degrading.
|
|
183
169
|
const record = emitSeams.buildAnalysisRecord(findings, args);
|
|
184
170
|
mkdirSync(analysesDir, { recursive: true });
|
|
185
171
|
// Atomic write: stage into a same-dir temp then rename (atomic on one
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* `_branch_protection_client` seams were replaced.
|
|
14
14
|
* - Commands are THIN: parse -> call the already-ported guardian library ->
|
|
15
15
|
* emit. No business logic lives in a handler.
|
|
16
|
-
* - Business exit codes are carried by throwing {@link
|
|
17
|
-
* `typer.Exit(n)`); `parseAsync`
|
|
16
|
+
* - Business exit codes are carried by throwing {@link CliExitError}
|
|
17
|
+
* (Python's `typer.Exit(n)`); a test's `parseAsync` catches it to read it.
|
|
18
18
|
* `.exitOverride()` turns commander's own usage errors into throws too, so a
|
|
19
19
|
* test never terminates the process.
|
|
20
20
|
*
|
|
@@ -58,7 +58,8 @@ import { buildApiDelta, writeApiDelta } from './delta-emitter.js';
|
|
|
58
58
|
import { extractApiDiff } from './diff-extractor.js';
|
|
59
59
|
import { HardGateAbstained, HardGateBlocked, RestBranchProtectionClient, applyHardGate, renderPlaybook, } from './hard-gate.js';
|
|
60
60
|
import { mapImpact } from './impact-mapper.js';
|
|
61
|
-
import {
|
|
61
|
+
import { ensureAscii } from '../util/ensure-ascii.js';
|
|
62
|
+
import { applySuppressions, buildFindings, buildWeakTestFindings, computeExitCode, effectiveGraphDepth, filterHeuristicNoise, filterSkipped, filterTestSupportUnits, filterTestUnits, filterTypeOnlyUnits, findReexportOnly, loadGuardianConfig, renderFindings, scopeDiff, } from './pr-check.js';
|
|
62
63
|
import { RestGitHubClient, degradationAnnotation, upsertStickyComment, } from './pr-comment.js';
|
|
63
64
|
import { buildSummary } from './summary-emitter.js';
|
|
64
65
|
import { resolveTier } from './tier.js';
|
|
@@ -71,12 +72,12 @@ const CROSS = '\u{2717}';
|
|
|
71
72
|
* Business exit signal. Thrown from a handler to carry an exit code the way
|
|
72
73
|
* Python's `typer.Exit(code)` did; the runner catches it to read the code.
|
|
73
74
|
*/
|
|
74
|
-
export class
|
|
75
|
+
export class CliExitError extends Error {
|
|
75
76
|
code;
|
|
76
77
|
constructor(code) {
|
|
77
78
|
super(`exit ${code}`);
|
|
78
79
|
this.code = code;
|
|
79
|
-
this.name = '
|
|
80
|
+
this.name = 'CliExitError';
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
83
|
/**
|
|
@@ -94,10 +95,10 @@ function normalizeUsageExit(err) {
|
|
|
94
95
|
throw err;
|
|
95
96
|
}
|
|
96
97
|
/** Raised by `deps.sleep` to break the `watch` poll loop (Ctrl+C analog). */
|
|
97
|
-
export class
|
|
98
|
+
export class WatchInterruptError extends Error {
|
|
98
99
|
constructor() {
|
|
99
100
|
super('watch interrupted');
|
|
100
|
-
this.name = '
|
|
101
|
+
this.name = 'WatchInterruptError';
|
|
101
102
|
}
|
|
102
103
|
}
|
|
103
104
|
/** Process-backed defaults for production (the `guardianCommand` export). */
|
|
@@ -148,21 +149,6 @@ export function defaultDeps() {
|
|
|
148
149
|
sleep: (secs) => new Promise((resolve) => setTimeout(resolve, secs * 1000)),
|
|
149
150
|
};
|
|
150
151
|
}
|
|
151
|
-
/**
|
|
152
|
-
* Escape non-ASCII to `\uXXXX`, matching Python `json.dumps(ensure_ascii=True)`.
|
|
153
|
-
*/
|
|
154
|
-
function ensureAscii(json) {
|
|
155
|
-
// Escape every UTF-16 code UNIT >= 0x80 to \uXXXX, matching Python
|
|
156
|
-
// json.dumps(ensure_ascii=True). Iterating by unit (not code point) means an
|
|
157
|
-
// astral char's surrogate pair emits \udXXX\udXXX, like Python; a code-point
|
|
158
|
-
// regex would stop at U+FFFF and leave astral chars raw.
|
|
159
|
-
let out = '';
|
|
160
|
-
for (let i = 0; i < json.length; i++) {
|
|
161
|
-
const c = json.charCodeAt(i);
|
|
162
|
-
out += c >= 0x80 ? '\\u' + c.toString(16).padStart(4, '0') : json[i];
|
|
163
|
-
}
|
|
164
|
-
return out;
|
|
165
|
-
}
|
|
166
152
|
/** ISO-8601 UTC timestamp with a `+00:00` offset (Python `isoformat`-shaped). */
|
|
167
153
|
function isoUtcNow() {
|
|
168
154
|
return new Date().toISOString().replace('Z', '+00:00');
|
|
@@ -212,8 +198,9 @@ export function prContextFromEnv(env) {
|
|
|
212
198
|
* the contributor's branch and stays resolvable.
|
|
213
199
|
*
|
|
214
200
|
* Returns `null` rather than a partial URL whenever repo or SHA is missing, so
|
|
215
|
-
* {@link
|
|
216
|
-
* an unresolvable link still *looks* clickable, which is worse than
|
|
201
|
+
* {@link renderFindings} falls back to plain code text. That degradation is
|
|
202
|
+
* deliberate: an unresolvable link still *looks* clickable, which is worse than
|
|
203
|
+
* no link.
|
|
217
204
|
*/
|
|
218
205
|
export function blobBaseFromEnv(env) {
|
|
219
206
|
const repo = env['GITHUB_REPOSITORY'];
|
|
@@ -513,7 +500,7 @@ function warnIfEmptyCiDiff(resolved, unitCount, deps) {
|
|
|
513
500
|
function loadSpec(path, deps) {
|
|
514
501
|
if (!existsSync(path)) {
|
|
515
502
|
deps.err(`Spec file not found: ${path}`);
|
|
516
|
-
throw new
|
|
503
|
+
throw new CliExitError(2);
|
|
517
504
|
}
|
|
518
505
|
const text = readFileSync(path, 'utf-8');
|
|
519
506
|
// Python `_load_spec`: `.json` -> json.loads; otherwise yaml.safe_load (with a
|
|
@@ -627,16 +614,16 @@ function validateCoverageCmd(path, opts, deps) {
|
|
|
627
614
|
const st = statSync(path);
|
|
628
615
|
if (st.isDirectory() || st.size > MAX_COVERAGE_BYTES) {
|
|
629
616
|
deps.out(`${pc.red(pc.bold(`${CROSS} cannot read ${path}:`))} not a readable file within the size limit`);
|
|
630
|
-
throw new
|
|
617
|
+
throw new CliExitError(2);
|
|
631
618
|
}
|
|
632
619
|
text = readFileSync(path, 'utf-8');
|
|
633
620
|
}
|
|
634
621
|
catch (exc) {
|
|
635
|
-
if (exc instanceof
|
|
622
|
+
if (exc instanceof CliExitError)
|
|
636
623
|
throw exc;
|
|
637
624
|
const msg = exc instanceof Error ? exc.message : String(exc);
|
|
638
625
|
deps.out(`${pc.red(pc.bold(`${CROSS} cannot read ${path}:`))} ${msg}`);
|
|
639
|
-
throw new
|
|
626
|
+
throw new CliExitError(2);
|
|
640
627
|
}
|
|
641
628
|
let data;
|
|
642
629
|
try {
|
|
@@ -645,7 +632,7 @@ function validateCoverageCmd(path, opts, deps) {
|
|
|
645
632
|
catch (exc) {
|
|
646
633
|
const msg = exc instanceof Error ? exc.message : String(exc);
|
|
647
634
|
deps.out(`${pc.red(pc.bold(`${CROSS} ${path} is not valid JSON:`))} ${msg}`);
|
|
648
|
-
throw new
|
|
635
|
+
throw new CliExitError(2);
|
|
649
636
|
}
|
|
650
637
|
const problems = validateCoverageJson(data);
|
|
651
638
|
const errors = problems.filter((p) => p.severity === 'error');
|
|
@@ -692,7 +679,7 @@ function validateCoverageCmd(path, opts, deps) {
|
|
|
692
679
|
}
|
|
693
680
|
}
|
|
694
681
|
if (errors.length > 0 || (opts.strict && warnings.length > 0)) {
|
|
695
|
-
throw new
|
|
682
|
+
throw new CliExitError(1);
|
|
696
683
|
}
|
|
697
684
|
}
|
|
698
685
|
/**
|
|
@@ -713,7 +700,7 @@ async function hardenGateCmd(opts, deps) {
|
|
|
713
700
|
const repo = opts.repo;
|
|
714
701
|
if (!repo) {
|
|
715
702
|
deps.out(`${pc.red(pc.bold(`${CROSS} no repo`))} ${EM_DASH} pass --repo owner/repo or set GITHUB_REPOSITORY.`);
|
|
716
|
-
throw new
|
|
703
|
+
throw new CliExitError(2);
|
|
717
704
|
}
|
|
718
705
|
const playbook = renderPlaybook(repo, opts.branch, opts.check);
|
|
719
706
|
// #490: the readiness evidence the promotion is supposed to rest on.
|
|
@@ -730,7 +717,7 @@ async function hardenGateCmd(opts, deps) {
|
|
|
730
717
|
if (!opts.token) {
|
|
731
718
|
deps.out(`${pc.red(pc.bold(`${CROSS} --apply needs an admin token`))} (pass --token or set GITHUB_TOKEN).\n`);
|
|
732
719
|
deps.out(playbook);
|
|
733
|
-
throw new
|
|
720
|
+
throw new CliExitError(2);
|
|
734
721
|
}
|
|
735
722
|
const client = deps.buildBranchProtectionClient(repo, opts.token);
|
|
736
723
|
let plan;
|
|
@@ -745,12 +732,12 @@ async function hardenGateCmd(opts, deps) {
|
|
|
745
732
|
deps.out(outcome.summaryLine);
|
|
746
733
|
deps.out(`${pc.red(pc.bold(`${CROSS} ${exc.reason}`))}\n`);
|
|
747
734
|
deps.out(exc.playbook);
|
|
748
|
-
throw new
|
|
735
|
+
throw new CliExitError(outcome.exitCode); // 3, never 1
|
|
749
736
|
}
|
|
750
737
|
if (exc instanceof HardGateBlocked) {
|
|
751
738
|
deps.out(`${pc.red(pc.bold(`${CROSS} ${exc.reason}`))}\n`);
|
|
752
739
|
deps.out(exc.playbook);
|
|
753
|
-
throw new
|
|
740
|
+
throw new CliExitError(1);
|
|
754
741
|
}
|
|
755
742
|
throw exc;
|
|
756
743
|
}
|
|
@@ -785,7 +772,7 @@ async function collectAdjudicationsCmd(opts, deps) {
|
|
|
785
772
|
}
|
|
786
773
|
if (!repo || prNumber === undefined) {
|
|
787
774
|
deps.out(`${pc.red(pc.bold(`${CROSS} no PR context`))} ${EM_DASH} pass --repo and --pr, or run in Actions.`);
|
|
788
|
-
throw new
|
|
775
|
+
throw new CliExitError(2);
|
|
789
776
|
}
|
|
790
777
|
const client = deps.buildReactionsClient(repo, prNumber);
|
|
791
778
|
const res = await collectAdjudications(client, {
|
|
@@ -811,7 +798,7 @@ async function collectAdjudicationsCmd(opts, deps) {
|
|
|
811
798
|
}
|
|
812
799
|
if (res.action === 'unavailable') {
|
|
813
800
|
deps.out(pc.red(pc.bold(`${CROSS} ${res.notice ?? 'not persisted'}`)));
|
|
814
|
-
throw new
|
|
801
|
+
throw new CliExitError(1);
|
|
815
802
|
}
|
|
816
803
|
}
|
|
817
804
|
/** Aggregate the persisted adjudications into the promotion evidence (#490). */
|
|
@@ -835,7 +822,7 @@ function precisionCmd(opts, deps) {
|
|
|
835
822
|
* read-only-token degradation is surfaced LOUDLY.
|
|
836
823
|
*/
|
|
837
824
|
async function postStickyComment(findings, resolution, deps, gateMeta = null) {
|
|
838
|
-
const body =
|
|
825
|
+
const body = renderFindings(findings, 'comment', resolution.effective, resolution.degraded_notice, gateMeta, blobBaseFromEnv(deps.env));
|
|
839
826
|
const ctx = prContextFromEnv(deps.env);
|
|
840
827
|
if (ctx === null) {
|
|
841
828
|
deps.out(`guardian: no PR context in env ${EM_DASH} printing instead.`);
|
|
@@ -905,7 +892,7 @@ function abstainPrCheck(skipped, format, deps) {
|
|
|
905
892
|
// only surface a machine can read.
|
|
906
893
|
{ findings: [], tier: 0, checked: 0, abstained: true, skipped }, null, 2)));
|
|
907
894
|
}
|
|
908
|
-
throw new
|
|
895
|
+
throw new CliExitError(outcome.exitCode); // EXIT_ABSTAINED
|
|
909
896
|
}
|
|
910
897
|
/** Resolve the analyses-channel dir (test override, else repo-root default). */
|
|
911
898
|
function resolveAnalysesDir(override, deps) {
|
|
@@ -968,7 +955,7 @@ async function prCheckCmd(opts, deps) {
|
|
|
968
955
|
// entirely (no diff scoped, no comment posted, exit 0).
|
|
969
956
|
if (opts.postComment && !config.pr_enabled) {
|
|
970
957
|
deps.out(`guardian: pr.enabled is false ${EM_DASH} skipping PR surface.`);
|
|
971
|
-
throw new
|
|
958
|
+
throw new CliExitError(0);
|
|
972
959
|
}
|
|
973
960
|
const effectiveGate = opts.gate ?? config.pr_gate;
|
|
974
961
|
// #490: read reviewer 👍/👎 off the PREVIOUS run's sticky comment before this
|
|
@@ -1109,9 +1096,9 @@ async function prCheckCmd(opts, deps) {
|
|
|
1109
1096
|
}
|
|
1110
1097
|
else if (!opts.emitAnalysis && !opts.postComment) {
|
|
1111
1098
|
// Local, non-posting default: render to stdout in `--format`.
|
|
1112
|
-
deps.out(
|
|
1099
|
+
deps.out(renderFindings(findings, opts.format, resolution.effective, resolution.degraded_notice, gateMeta, blobBaseFromEnv(deps.env)));
|
|
1113
1100
|
}
|
|
1114
|
-
throw new
|
|
1101
|
+
throw new CliExitError(exitCode);
|
|
1115
1102
|
}
|
|
1116
1103
|
// --- author-plan --------------------------------------------------------------
|
|
1117
1104
|
/**
|
|
@@ -1246,7 +1233,7 @@ async function watchCmd(opts, deps) {
|
|
|
1246
1233
|
}
|
|
1247
1234
|
}
|
|
1248
1235
|
catch (exc) {
|
|
1249
|
-
if (exc instanceof
|
|
1236
|
+
if (exc instanceof WatchInterruptError) {
|
|
1250
1237
|
deps.out(`\n${pc.yellow('Watch stopped.')}`);
|
|
1251
1238
|
return;
|
|
1252
1239
|
}
|
|
@@ -1262,7 +1249,7 @@ function collect(value, previous) {
|
|
|
1262
1249
|
* Build a fresh `guardian` command wired to `depsInit` (process-backed defaults
|
|
1263
1250
|
* fill any gap). Every subcommand uses `.exitOverride()` so a usage error throws
|
|
1264
1251
|
* a `CommanderError` rather than terminating the process -- tests read the exit
|
|
1265
|
-
* code from the thrown error (or from {@link
|
|
1252
|
+
* code from the thrown error (or from {@link CliExitError} for business exits).
|
|
1266
1253
|
*/
|
|
1267
1254
|
export function createGuardianCommand(depsInit = {}) {
|
|
1268
1255
|
const deps = { ...defaultDeps(), ...depsInit };
|
|
@@ -36,7 +36,7 @@ export { coverageLimits } from './diff-coverage/formats/cobertura.js';
|
|
|
36
36
|
export { parseCoverageJson } from './diff-coverage/formats/coverage-json.js';
|
|
37
37
|
export { validateCoverageJson, } from './diff-coverage/formats/coverage-json-lint.js';
|
|
38
38
|
export { resolveFromGraph } from './diff-coverage/graph-tier.js';
|
|
39
|
-
export {
|
|
39
|
+
export { resolveFromHeuristic } from './diff-coverage/heuristic-tier.js';
|
|
40
40
|
export { coverageDegradedNotice, coverageStatus, resolveCoverage, resolveCoverageWithInput, } from './diff-coverage/orchestrator.js';
|
|
41
41
|
export { isSourcePath, isTestPath, isTestSupportPath, } from './diff-coverage/paths.js';
|
|
42
42
|
export { resolveFromReport } from './diff-coverage/report-tier.js';
|
|
@@ -109,7 +109,7 @@ function iterTestFiles(repoRoot) {
|
|
|
109
109
|
* A unit is heuristic-covered iff some test file under `repoRoot` references
|
|
110
110
|
* the unit's file stem or a top-level symbol name (word-boundary scan).
|
|
111
111
|
*/
|
|
112
|
-
export function
|
|
112
|
+
export function resolveFromHeuristic(units, repoRoot = '.') {
|
|
113
113
|
const testFiles = iterTestFiles(repoRoot);
|
|
114
114
|
const results = [];
|
|
115
115
|
for (const unit of units) {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* clean" from "never checked".
|
|
5
5
|
*/
|
|
6
6
|
import { resolveFromGraph } from './graph-tier.js';
|
|
7
|
-
import {
|
|
7
|
+
import { resolveFromHeuristic } from './heuristic-tier.js';
|
|
8
8
|
import { matchUnitsToIndex, readReportIndex } from './report-tier.js';
|
|
9
9
|
/**
|
|
10
10
|
* SC-3 orchestrator: resolve each unit at the highest available fidelity.
|
|
@@ -116,7 +116,7 @@ export function resolveCoverageWithInput(units, options = {}) {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
if (remaining.length > 0) {
|
|
119
|
-
for (const r of
|
|
119
|
+
for (const r of resolveFromHeuristic(remaining, repoRoot)) {
|
|
120
120
|
resolved.set(r.unit, r);
|
|
121
121
|
}
|
|
122
122
|
}
|
|
@@ -30,6 +30,7 @@ import { readJsonWithWarning } from '../core/config-validation.js';
|
|
|
30
30
|
import { isAssertionFreeTest } from '../core/quality-scorer.js';
|
|
31
31
|
import { Fidelity, coverageDegradedNotice, coverageStatus, isSourcePath, isTestPath, isTestSupportPath, isTypeOnlyModule, } from './coverage.js';
|
|
32
32
|
import { Severity, severitySortKey } from './impact-mapper.js';
|
|
33
|
+
import { ensureAscii } from '../util/ensure-ascii.js';
|
|
33
34
|
const HUNK_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
|
|
34
35
|
// Suppression annotation: `// canary:allow-untested <reason>` or the `#`
|
|
35
36
|
// variant. A comment leader (`//` or `#`) is REQUIRED immediately before the
|
|
@@ -859,9 +860,9 @@ const STICKY_MARKER = '<!-- canary-pr-guardian -->';
|
|
|
859
860
|
* silently produces nothing on exactly the large PRs that need it most -- the
|
|
860
861
|
* same silent-green failure #369 was filed for.
|
|
861
862
|
*
|
|
862
|
-
* 60,000 leaves ~5.5k of headroom for anything appended outside
|
|
863
|
-
* (degradation annotations, upsert wrappers) without inviting
|
|
864
|
-
* *just* fits and then breaks when a filename grows.
|
|
863
|
+
* 60,000 leaves ~5.5k of headroom for anything appended outside
|
|
864
|
+
* `renderFindings` (degradation annotations, upsert wrappers) without inviting
|
|
865
|
+
* a body that only *just* fits and then breaks when a filename grows.
|
|
865
866
|
*
|
|
866
867
|
* The cap applies ONLY to the comment. The `--emit-analysis` JSON record is the
|
|
867
868
|
* authoritative complete set and is never truncated.
|
|
@@ -893,17 +894,6 @@ const SEVERITY_ICON = {
|
|
|
893
894
|
[Severity.MEDIUM]: YELLOW_CIRCLE,
|
|
894
895
|
[Severity.LOW]: WHITE_CIRCLE,
|
|
895
896
|
};
|
|
896
|
-
/**
|
|
897
|
-
* Escape every non-ASCII (>= U+0080) code unit to a `\uXXXX` sequence, matching
|
|
898
|
-
* Python's `json.dumps(..., ensure_ascii=True)` (the library default). `JSON`
|
|
899
|
-
* `.stringify` emits raw UTF-8 for these, so a finding whose evidence carries an
|
|
900
|
-
* em-dash (`—`, U+2014) would otherwise diverge byte-for-byte from the Python
|
|
901
|
-
* oracle. Only touches the >= 0x80 range, so the ASCII escapes JSON.stringify
|
|
902
|
-
* already produced (`\"`, `\\`, control chars) are left intact.
|
|
903
|
-
*/
|
|
904
|
-
function ensureAscii(json) {
|
|
905
|
-
return json.replace(/[-]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
|
|
906
|
-
}
|
|
907
897
|
/** Serialize a {@link GuardianFinding} to a stable JSON-friendly object. */
|
|
908
898
|
function findingDict(finding) {
|
|
909
899
|
return {
|
|
@@ -955,7 +945,7 @@ function noGapsLines(coverageState, suppressedCount) {
|
|
|
955
945
|
}
|
|
956
946
|
return lines;
|
|
957
947
|
}
|
|
958
|
-
export function
|
|
948
|
+
export function renderFindings(findings, fmt, tier = 0, degradedNotice = null, gateMeta = null, blobBase = null) {
|
|
959
949
|
const ordered = [...findings].sort((a, b) => severitySortKey(a.severity) - severitySortKey(b.severity));
|
|
960
950
|
// #554: the coverage ladder's own degradation, stated alongside the tier's.
|
|
961
951
|
const coverageState = gateMeta?.coverage ?? null;
|
|
@@ -32,9 +32,10 @@
|
|
|
32
32
|
* permission error here; any other non-2xx propagates as a generic error.
|
|
33
33
|
*/
|
|
34
34
|
import { readAllPages, restPageReader } from './github-paging.js';
|
|
35
|
-
// Single source of truth for the sticky-comment marker.
|
|
36
|
-
// emits the identical literal at the head of a
|
|
37
|
-
// `findSticky` can locate the guardian comment for
|
|
35
|
+
// Single source of truth for the sticky-comment marker.
|
|
36
|
+
// `pr_check.renderFindings` emits the identical literal at the head of a
|
|
37
|
+
// `comment`-format body so `findSticky` can locate the guardian comment for
|
|
38
|
+
// in-place upsert.
|
|
38
39
|
export const STICKY_MARKER = '<!-- canary-pr-guardian -->';
|
|
39
40
|
/**
|
|
40
41
|
* A client cannot write (fork read-only token → HTTP 403).
|