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.
Files changed (47) hide show
  1. package/dist/engine/analysis/cli.js +116 -54
  2. package/dist/engine/analysis/engine.js +34 -16
  3. package/dist/engine/analysis/reports.js +5 -4
  4. package/dist/engine/cli-commands.js +249 -41
  5. package/dist/engine/cli-common.js +15 -24
  6. package/dist/engine/cli.core.js +37 -11
  7. package/dist/engine/cli.js +2 -2
  8. package/dist/engine/company-knowledge-cli.js +2 -2
  9. package/dist/engine/core/adoption.js +408 -0
  10. package/dist/engine/core/framework-probes.js +7 -7
  11. package/dist/engine/core/fs-glob.js +2 -2
  12. package/dist/engine/core/gate-result.js +17 -0
  13. package/dist/engine/core/migrator.js +9 -17
  14. package/dist/engine/core/pattern-matcher.js +23 -5
  15. package/dist/engine/core/persona.js +421 -0
  16. package/dist/engine/core/promotion-verdict.js +261 -0
  17. package/dist/engine/core/reporter.js +1 -9
  18. package/dist/engine/core/skill-examples.js +292 -0
  19. package/dist/engine/core/skill-surfaces.js +307 -0
  20. package/dist/engine/core/static-linter.js +310 -38
  21. package/dist/engine/core/ticket-updater.js +1 -7
  22. package/dist/engine/core/vacuity-scanner.js +556 -0
  23. package/dist/engine/core/workflow-discovery.js +2 -8
  24. package/dist/engine/core/workspace-detect.js +7 -6
  25. package/dist/engine/data/personas/registry.json +36 -0
  26. package/dist/engine/guardian/adjudication.js +5 -5
  27. package/dist/engine/guardian/analysis-emit.js +13 -27
  28. package/dist/engine/guardian/cli.js +30 -43
  29. package/dist/engine/guardian/coverage.js +1 -1
  30. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
  31. package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
  32. package/dist/engine/guardian/pr-check.js +5 -15
  33. package/dist/engine/guardian/pr-comment.js +4 -3
  34. package/dist/engine/history/cli.js +210 -6
  35. package/dist/engine/history/ndjson-store.js +9 -5
  36. package/dist/engine/history/record.js +34 -5
  37. package/dist/engine/history/run-recorder.js +165 -0
  38. package/dist/engine/history/schema.js +25 -7
  39. package/dist/engine/history/store.js +9 -0
  40. package/dist/engine/mcp-server.js +35 -13
  41. package/dist/engine/skills-cli.js +133 -11
  42. package/dist/engine/util/ensure-ascii.js +37 -0
  43. package/dist/engine/workflow-cli.js +6 -6
  44. package/dist/gate-result.d.ts +11 -0
  45. package/dist/gate-result.js +18 -0
  46. package/dist/uninstall.js +12 -5
  47. 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 `render(fmt='comment')` emitted — this is
166
- * deliberately parsing the exact body reviewers reacted to, not the current
167
- * finding set, so a reaction is attributed to what the reviewer actually saw.
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 channelAvailable(analysesDir) {
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 (!channelAvailable(args.analysesDir)) {
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
- * `render(fmt="json")` findings array.
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, render } from './pr-check.js';
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
- export const SOURCE = 'canary-pr-guardian';
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 = 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 `render(fmt='json')`'s array.
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(render(findings, 'json', effective_tier, notice));
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: 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 channelAvailable(analysesDir) {
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 (!channelAvailable(analysesDir)) {
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/render) would otherwise
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 CliExit} (Python's
17
- * `typer.Exit(n)`); `parseAsync` from a test catches it to read the code.
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 { applySuppressions, buildFindings, buildWeakTestFindings, computeExitCode, effectiveGraphDepth, filterHeuristicNoise, filterSkipped, filterTestSupportUnits, filterTestUnits, filterTypeOnlyUnits, findReexportOnly, loadGuardianConfig, render, scopeDiff, } from './pr-check.js';
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 CliExit extends Error {
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 = 'CliExit';
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 WatchInterrupt extends Error {
98
+ export class WatchInterruptError extends Error {
98
99
  constructor() {
99
100
  super('watch interrupted');
100
- this.name = 'WatchInterrupt';
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 render} falls back to plain code text. That degradation is deliberate:
216
- * an unresolvable link still *looks* clickable, which is worse than no link.
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 CliExit(2);
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 CliExit(2);
617
+ throw new CliExitError(2);
631
618
  }
632
619
  text = readFileSync(path, 'utf-8');
633
620
  }
634
621
  catch (exc) {
635
- if (exc instanceof CliExit)
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 CliExit(2);
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 CliExit(2);
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 CliExit(1);
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 CliExit(2);
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 CliExit(2);
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 CliExit(outcome.exitCode); // 3, never 1
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 CliExit(1);
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 CliExit(2);
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 CliExit(1);
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 = render(findings, 'comment', resolution.effective, resolution.degraded_notice, gateMeta, blobBaseFromEnv(deps.env));
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 CliExit(outcome.exitCode); // EXIT_ABSTAINED
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 CliExit(0);
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(render(findings, opts.format, resolution.effective, resolution.degraded_notice, gateMeta, blobBaseFromEnv(deps.env)));
1099
+ deps.out(renderFindings(findings, opts.format, resolution.effective, resolution.degraded_notice, gateMeta, blobBaseFromEnv(deps.env)));
1113
1100
  }
1114
- throw new CliExit(exitCode);
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 WatchInterrupt) {
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 CliExit} for business exits).
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 { resolveHeuristic } from './diff-coverage/heuristic-tier.js';
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 resolveHeuristic(units, repoRoot = '.') {
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 { resolveHeuristic } from './heuristic-tier.js';
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 resolveHeuristic(remaining, repoRoot)) {
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 `render`
863
- * (degradation annotations, upsert wrappers) without inviting a body that only
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 render(findings, fmt, tier = 0, degradedNotice = null, gateMeta = null, blobBase = null) {
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. `pr_check.render`
36
- // emits the identical literal at the head of a `comment`-format body so
37
- // `findSticky` can locate the guardian comment for in-place upsert.
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).