canary-test-cli 5.15.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/agent/frameworks/registry.json +655 -0
  2. package/bin/canary.js +20 -15
  3. package/dist/doctor-manifest.d.ts +94 -0
  4. package/dist/doctor.d.ts +67 -0
  5. package/dist/engine/analysis/cli.js +270 -0
  6. package/dist/engine/analysis/engine.js +146 -0
  7. package/dist/engine/analysis/reports.js +0 -0
  8. package/dist/engine/analysis/rows.js +9 -0
  9. package/dist/engine/cli-commands.js +618 -0
  10. package/dist/engine/cli-common.js +60 -0
  11. package/dist/engine/cli.core.js +208 -0
  12. package/dist/engine/cli.js +31 -0
  13. package/dist/engine/company-knowledge-cli.js +201 -0
  14. package/dist/engine/core/ci-env.js +33 -0
  15. package/dist/engine/core/classifier.js +192 -0
  16. package/dist/engine/core/company-knowledge.js +765 -0
  17. package/dist/engine/core/config-validation.js +74 -0
  18. package/dist/engine/core/detection.js +48 -0
  19. package/dist/engine/core/domain-scanner.js +212 -0
  20. package/dist/engine/core/environment-detect.js +410 -0
  21. package/dist/engine/core/executor.js +181 -0
  22. package/dist/engine/core/feedback.js +93 -0
  23. package/dist/engine/core/fixture-scanner.js +173 -0
  24. package/dist/engine/core/framework-registry.js +123 -0
  25. package/dist/engine/core/mcp-validator.js +218 -0
  26. package/dist/engine/core/metadata-scanner.js +147 -0
  27. package/dist/engine/core/migrator.js +1112 -0
  28. package/dist/engine/core/overlays.js +176 -0
  29. package/dist/engine/core/pattern-healer.js +147 -0
  30. package/dist/engine/core/pattern-matcher.js +255 -0
  31. package/dist/engine/core/quality-scorer.js +213 -0
  32. package/dist/engine/core/recommender.js +152 -0
  33. package/dist/engine/core/reporter.js +211 -0
  34. package/dist/engine/core/scaffolder.js +236 -0
  35. package/dist/engine/core/skill-registry.js +522 -0
  36. package/dist/engine/core/static-linter.js +237 -0
  37. package/dist/engine/core/ticket-updater.js +639 -0
  38. package/dist/engine/core/workflow-discovery.js +693 -0
  39. package/dist/engine/guardian/agent-tier.js +338 -0
  40. package/dist/engine/guardian/analysis-emit.js +201 -0
  41. package/dist/engine/guardian/cli.js +787 -0
  42. package/dist/engine/guardian/coverage.js +1055 -0
  43. package/dist/engine/guardian/delta-emitter.js +46 -0
  44. package/dist/engine/guardian/diff-extractor.js +257 -0
  45. package/dist/engine/guardian/hard-gate.js +373 -0
  46. package/dist/engine/guardian/impact-mapper.js +121 -0
  47. package/dist/engine/guardian/pr-check.js +975 -0
  48. package/dist/engine/guardian/pr-comment.js +200 -0
  49. package/dist/engine/guardian/summary-emitter.js +94 -0
  50. package/dist/engine/guardian/tier.js +58 -0
  51. package/dist/engine/history/cli.js +303 -0
  52. package/dist/engine/history/detector.js +68 -0
  53. package/dist/engine/history/ndjson-store.js +177 -0
  54. package/dist/engine/history/record.js +14 -0
  55. package/dist/engine/history/schema.js +59 -0
  56. package/dist/engine/history/store.js +47 -0
  57. package/dist/engine/history/supabase-store.js +113 -0
  58. package/dist/engine/main-deps.js +105 -0
  59. package/dist/engine/mcp-server.js +647 -0
  60. package/dist/engine/package.json +4 -0
  61. package/dist/engine/skills-cli.js +181 -0
  62. package/dist/engine/ui/banner.js +50 -0
  63. package/dist/engine/util/coalesce.js +12 -0
  64. package/dist/engine/util/round.js +43 -0
  65. package/dist/engine/workflow-cli.js +242 -0
  66. package/dist/engine-checks.d.ts +49 -0
  67. package/dist/overlay-commands.d.ts +81 -0
  68. package/dist/overlay-conflicts.d.ts +33 -0
  69. package/dist/overlay-lint.d.ts +19 -0
  70. package/dist/overlays-registry.d.ts +74 -0
  71. package/dist/reporters/testtracker.d.ts +89 -0
  72. package/dist/reporters/testtracker.js +195 -0
  73. package/dist/router.d.ts +12 -0
  74. package/dist/router.js +4 -4
  75. package/dist/skill-requirements.d.ts +57 -0
  76. package/dist/source-spec.d.ts +20 -0
  77. package/package.json +30 -6
  78. package/bin/canary +0 -0
  79. package/scripts/install.js +0 -104
@@ -0,0 +1,975 @@
1
+ /**
2
+ * Tier 0 deterministic PR guardian engine.
3
+ *
4
+ * Faithful TypeScript port of `agent/guardian/pr_check.py`.
5
+ *
6
+ * Scopes a git diff into changed units, resolves diff-coverage at the highest
7
+ * available fidelity (see {@link module:./coverage}), builds fidelity-labeled
8
+ * findings, honors `canary:allow-untested` suppressions, renders output, and
9
+ * computes a soft/hard gate exit code.
10
+ *
11
+ * SC-11 boundary: imports **no** `AgentTier`/LLM/agent module and never
12
+ * references the `analyze_diff`/`get_impact` MCP tools.
13
+ *
14
+ * Python→TS nuances (see the coverage-port notes for the migration):
15
+ * - **int vs. number**: Python's `json` distinguishes `2` (int) from `2.0`
16
+ * (float); `JSON.parse` collapses `2.0` to the integer `2`. The config
17
+ * coercers therefore accept a JSON `2.0` as a valid integer where Python
18
+ * would warn+default. A genuine fractional value (`1.5`) is still rejected
19
+ * on both. Documented, low-risk divergence.
20
+ * - **splitlines**: `str.splitlines()` drops a trailing newline's empty tail;
21
+ * the `splitLines` helper here keeps it. Harmless for the diff/suppression
22
+ * scanners (the phantom empty line never holds a `+`/annotation).
23
+ * - **CLI seam**: `read_diff` (stdin/subprocess/file passthrough) and the
24
+ * `pr-check` Typer command are intentionally NOT ported here — they belong
25
+ * to the later CLI wave. This module is the pure library logic.
26
+ */
27
+ import { readFileSync } from 'node:fs';
28
+ import { extname, join } from 'node:path';
29
+ import { readJsonWithWarning } from '../core/config-validation.js';
30
+ import { isAssertionFreeTest } from '../core/quality-scorer.js';
31
+ import { Fidelity, isTestPath, } from './coverage.js';
32
+ import { Severity, severitySortKey } from './impact-mapper.js';
33
+ const HUNK_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
34
+ // Suppression annotation: `// canary:allow-untested <reason>` or the `#`
35
+ // variant. A comment leader (`//` or `#`) is REQUIRED immediately before the
36
+ // token so a bare occurrence inside a string literal, docstring, or prose never
37
+ // clears the gate (FIX 1).
38
+ const SUPPRESS_RE = /(?:\/\/|#)\s*canary:allow-untested\s+(.+)/;
39
+ // Trailing inline-comment closers stripped from a captured reason.
40
+ const INLINE_COMMENT_CLOSERS = ['*/', '-->'];
41
+ /** Split like Python's `str.splitlines()` for the common line endings. */
42
+ function splitLines(text) {
43
+ return text.split(/\r\n|\r|\n/);
44
+ }
45
+ /**
46
+ * Collapse a sorted list of line numbers into inclusive `[start, end]` ranges.
47
+ */
48
+ function mergeLines(lines) {
49
+ const ranges = [];
50
+ for (const line of [...lines].sort((a, b) => a - b)) {
51
+ const last = ranges[ranges.length - 1];
52
+ if (last && line === last[1] + 1) {
53
+ last[1] = line;
54
+ }
55
+ else {
56
+ ranges.push([line, line]);
57
+ }
58
+ }
59
+ return ranges;
60
+ }
61
+ /**
62
+ * Parse a unified diff into one {@link ChangedUnit} per file with added lines.
63
+ *
64
+ * Collects ADDED line numbers (from `@@` headers + `+` body lines) on the
65
+ * new-file (`b/`) side. Deletions and context lines are ignored for range
66
+ * purposes. Deleted files (`+++ /dev/null`) and files with no additions are
67
+ * excluded from the result.
68
+ */
69
+ export function scopeDiff(diffText) {
70
+ const addedByPath = new Map();
71
+ let currentPath = null;
72
+ let newLineno = 0;
73
+ let skipCurrent = false;
74
+ let inHunk = false;
75
+ for (const line of splitLines(diffText)) {
76
+ if (line.startsWith('diff --git')) {
77
+ // New file block begins → leave any prior hunk body; path is set by the
78
+ // upcoming `+++ ` header.
79
+ inHunk = false;
80
+ currentPath = null;
81
+ skipCurrent = false;
82
+ continue;
83
+ }
84
+ // `--- `/`+++ ` are file headers ONLY before the first hunk of a file. Once
85
+ // inside a hunk body a `+++ ...` line is an ADDED content line whose real
86
+ // text is `++ ...` and must not be mistaken for a header (FIX 7).
87
+ if (!inHunk && line.startsWith('+++ ')) {
88
+ const target = line.slice(4).trim();
89
+ if (target === '/dev/null') {
90
+ skipCurrent = true;
91
+ currentPath = null;
92
+ continue;
93
+ }
94
+ skipCurrent = false;
95
+ // Strip the conventional "b/" prefix.
96
+ currentPath = target.startsWith('b/') ? target.slice(2) : target;
97
+ if (!addedByPath.has(currentPath))
98
+ addedByPath.set(currentPath, []);
99
+ continue;
100
+ }
101
+ if (!inHunk && line.startsWith('--- ')) {
102
+ // Old-file header; ignored (path comes from +++).
103
+ continue;
104
+ }
105
+ const hunk = HUNK_RE.exec(line);
106
+ if (hunk) {
107
+ newLineno = Number.parseInt(hunk[1], 10);
108
+ inHunk = true;
109
+ continue;
110
+ }
111
+ if (skipCurrent || currentPath === null)
112
+ continue;
113
+ if (line.startsWith('+')) {
114
+ addedByPath.get(currentPath).push(newLineno);
115
+ newLineno += 1;
116
+ }
117
+ else if (line.startsWith('-')) {
118
+ // Removed line: does not advance the new-file counter.
119
+ continue;
120
+ }
121
+ else if (line.startsWith('\\')) {
122
+ // "" — metadata, ignore.
123
+ continue;
124
+ }
125
+ else {
126
+ // Context line (leading space) or blank — advances new-file counter.
127
+ newLineno += 1;
128
+ }
129
+ }
130
+ const units = [];
131
+ for (const [path, lines] of addedByPath) {
132
+ if (lines.length === 0)
133
+ continue;
134
+ units.push({ path, added_ranges: mergeLines(lines) });
135
+ }
136
+ return units;
137
+ }
138
+ // FIX 2 (signal-quality): a changed file whose ADDED lines are ONLY imports /
139
+ // re-exports (no real declarations or logic) is a barrel/index file
140
+ // (`index.ts`, `__init__.py`) and should not be flagged as untested.
141
+ //
142
+ // Conservative contract: a line counts as re-export/import iff it matches one
143
+ // of these; a real declaration DISQUALIFIES the whole file (flag it). When
144
+ // unsure, treat as NOT a barrel — a false skip is worse than a false flag.
145
+ const REEXPORT_PATTERNS = [
146
+ // TS/JS.
147
+ /^\s*import\b/,
148
+ /^\s*export\s+\*\s+from\b/,
149
+ /^\s*export\s+\{[^}]*\}\s+from\b/,
150
+ /^\s*export\s+\{[^}]*\}\s*;?\s*$/, // local re-export
151
+ /^\s*export\s+\{?\s*default\b.*\}?\s+from\b/,
152
+ /^\s*export\s+default\s+\w+\s*;?\s*$/,
153
+ // Python.
154
+ /^\s*from\s+\S+\s+import\b/,
155
+ /^\s*import\s+\w/,
156
+ /^\s*__all__\s*=/,
157
+ ];
158
+ // Full-line comment leaders / block-comment scaffolding treated as NEUTRAL.
159
+ const COMMENT_LINE_RE = /^\s*(?:\/\/|#|\/\*|\*)/;
160
+ const BLOCK_COMMENT_CLOSE_RE = /\*\/\s*$/;
161
+ // A bare string entry inside an `__all__ = [ ... ]` list (neutral).
162
+ const ALL_LIST_ENTRY_RE = /^\s*["'][^"']*["']\s*,?\s*$/;
163
+ const ALL_LIST_CLOSE_RE = /^\s*\]/;
164
+ /** Return True for blank lines and full-line comments (ignored for barrels). */
165
+ function isNeutralLine(stripped) {
166
+ if (!stripped)
167
+ return true;
168
+ if (COMMENT_LINE_RE.test(stripped))
169
+ return true;
170
+ return BLOCK_COMMENT_CLOSE_RE.test(stripped) && stripped.startsWith('*');
171
+ }
172
+ /**
173
+ * Map each changed file to the CONTENT of its added (`+`) lines.
174
+ *
175
+ * Mirrors {@link scopeDiff}'s parser but captures the added-line *text* (the
176
+ * `+` stripped) rather than line numbers. Deleted files (`+++ /dev/null`) are
177
+ * excluded; a `+++ ` line inside a hunk body is added content, not a header
178
+ * (same FIX 7 guard as `scopeDiff`).
179
+ */
180
+ function addedContentByPath(diffText) {
181
+ const added = new Map();
182
+ let currentPath = null;
183
+ let skipCurrent = false;
184
+ let inHunk = false;
185
+ for (const line of splitLines(diffText)) {
186
+ if (line.startsWith('diff --git')) {
187
+ inHunk = false;
188
+ currentPath = null;
189
+ skipCurrent = false;
190
+ continue;
191
+ }
192
+ if (!inHunk && line.startsWith('+++ ')) {
193
+ const target = line.slice(4).trim();
194
+ if (target === '/dev/null') {
195
+ skipCurrent = true;
196
+ currentPath = null;
197
+ continue;
198
+ }
199
+ skipCurrent = false;
200
+ currentPath = target.startsWith('b/') ? target.slice(2) : target;
201
+ if (!added.has(currentPath))
202
+ added.set(currentPath, []);
203
+ continue;
204
+ }
205
+ if (!inHunk && line.startsWith('--- '))
206
+ continue;
207
+ if (HUNK_RE.test(line)) {
208
+ inHunk = true;
209
+ continue;
210
+ }
211
+ if (skipCurrent || currentPath === null)
212
+ continue;
213
+ if (line.startsWith('+')) {
214
+ added.get(currentPath).push(line.slice(1));
215
+ }
216
+ }
217
+ return added;
218
+ }
219
+ /**
220
+ * Return True iff every non-neutral added line is a pure import/re-export.
221
+ *
222
+ * Requires at least one actual import/re-export line (a file of only comments
223
+ * is NOT a barrel — conservatively flagged). Any non-neutral line that is not
224
+ * an import/re-export (a real declaration, JSX, or logic) disqualifies the
225
+ * file. Handles a multi-line `__all__ = [ ... ]` list: its string entries and
226
+ * closing bracket are neutral.
227
+ */
228
+ function isReexportOnly(addedLines) {
229
+ let hasReexport = false;
230
+ let inAllList = false;
231
+ for (const raw of addedLines) {
232
+ const stripped = raw.trim();
233
+ if (inAllList) {
234
+ if (ALL_LIST_CLOSE_RE.test(stripped)) {
235
+ inAllList = false;
236
+ continue;
237
+ }
238
+ if (isNeutralLine(stripped) || ALL_LIST_ENTRY_RE.test(stripped))
239
+ continue;
240
+ return false; // unexpected content inside __all__ → not a barrel
241
+ }
242
+ if (isNeutralLine(stripped))
243
+ continue;
244
+ if (REEXPORT_PATTERNS.some((pat) => pat.test(stripped))) {
245
+ hasReexport = true;
246
+ // An `__all__ = [` that does not close on the same line opens a list.
247
+ if (stripped.startsWith('__all__') &&
248
+ stripped.includes('[') &&
249
+ !stripped.includes(']')) {
250
+ inAllList = true;
251
+ }
252
+ continue;
253
+ }
254
+ return false; // a real declaration / logic line → not a barrel
255
+ }
256
+ return hasReexport;
257
+ }
258
+ /**
259
+ * Return the set of file paths whose added lines are ONLY imports/re-exports.
260
+ *
261
+ * These are barrel/index files (`index.ts`, `__init__.py`) that merely forward
262
+ * other modules' symbols and carry no logic of their own, so they should not be
263
+ * flagged as untested (FIX 2). Detection reads the diff's added (`+`) line
264
+ * CONTENT, mirroring {@link scopeDiff}.
265
+ */
266
+ export function findReexportOnly(diffText) {
267
+ const result = new Set();
268
+ for (const [path, lines] of addedContentByPath(diffText)) {
269
+ if (isReexportOnly(lines))
270
+ result.add(path);
271
+ }
272
+ return result;
273
+ }
274
+ /** Escape a single character for literal use inside a RegExp (`re.escape`). */
275
+ function escapeReChar(ch) {
276
+ return ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
277
+ }
278
+ /**
279
+ * Return True iff `path` matches a glob `pattern` supporting `**`.
280
+ *
281
+ * Translation rules (segment-aware, forward-slash paths):
282
+ *
283
+ * - `**` matches any number of characters including `/` (any depth, incl.
284
+ * zero directories). A leading double-star slash also matches zero leading
285
+ * segments.
286
+ * - `*` matches any run of characters *within a single segment* (no `/`).
287
+ * - `?` matches a single non-`/` character.
288
+ *
289
+ * The pattern is anchored to the full path (implicit `^...$`).
290
+ */
291
+ function globMatches(path, pattern) {
292
+ const parts = [];
293
+ let i = 0;
294
+ while (i < pattern.length) {
295
+ if (pattern.startsWith('**/', i)) {
296
+ // `**/` → any leading segments including none.
297
+ parts.push('(?:.*/)?');
298
+ i += 3;
299
+ }
300
+ else if (pattern.startsWith('**', i)) {
301
+ parts.push('.*');
302
+ i += 2;
303
+ }
304
+ else if (pattern[i] === '*') {
305
+ parts.push('[^/]*');
306
+ i += 1;
307
+ }
308
+ else if (pattern[i] === '?') {
309
+ parts.push('[^/]');
310
+ i += 1;
311
+ }
312
+ else {
313
+ parts.push(escapeReChar(pattern[i]));
314
+ i += 1;
315
+ }
316
+ }
317
+ return new RegExp(`^${parts.join('')}$`).test(path);
318
+ }
319
+ /**
320
+ * Partition `units` into `[kept, skipped]` by `skipGlobs` (SC-2).
321
+ *
322
+ * A unit is *skipped* iff its `.path` matches ANY glob in `skipGlobs`.
323
+ * Order-preserving in both partitions. An empty `skipGlobs` keeps every unit
324
+ * (`[units, []]`).
325
+ */
326
+ export function filterSkipped(units, skipGlobs) {
327
+ if (skipGlobs.length === 0)
328
+ return [units, []];
329
+ const kept = [];
330
+ const skipped = [];
331
+ for (const unit of units) {
332
+ if (skipGlobs.some((glob) => globMatches(unit.path, glob))) {
333
+ skipped.push(unit);
334
+ }
335
+ else {
336
+ kept.push(unit);
337
+ }
338
+ }
339
+ return [kept, skipped];
340
+ }
341
+ /**
342
+ * Partition `units` into `[kept, testUnits]` by test-path (FIX A).
343
+ *
344
+ * A test file does not itself need a test, so a changed unit whose path looks
345
+ * like a test (`tests/**`, `test_*.py`, `*.test.*`, `*.spec.*` — the single
346
+ * {@link isTestPath} predicate reused by the graph resolver) is dropped before
347
+ * findings are built. Order-preserving in both partitions.
348
+ */
349
+ export function filterTestUnits(units) {
350
+ const kept = [];
351
+ const testUnits = [];
352
+ for (const unit of units) {
353
+ if (isTestPath(unit.path)) {
354
+ testUnits.push(unit);
355
+ }
356
+ else {
357
+ kept.push(unit);
358
+ }
359
+ }
360
+ return [kept, testUnits];
361
+ }
362
+ /**
363
+ * A single guardian finding about a changed unit.
364
+ *
365
+ * Phase 1 emits only `untested-new-code` findings (`weak-test` is a Tier 1+
366
+ * concern). `severity` reuses {@link Severity}; `fidelity` carries the
367
+ * confidence tier from the underlying coverage signal.
368
+ */
369
+ export class Finding {
370
+ path;
371
+ unit;
372
+ kind;
373
+ fidelity;
374
+ severity;
375
+ evidence;
376
+ suggestion;
377
+ suppressed;
378
+ suppression_reason;
379
+ // Inclusive 1-based [start, end] ranges the diff ADDED for this unit. Carried
380
+ // from the CoverageResult/ChangedUnit so suppression can be scoped to only
381
+ // the changed lines (FIX 1). Empty → suppression falls back to a whole-file
382
+ // scan (still comment-leader gated).
383
+ added_ranges;
384
+ constructor(init) {
385
+ this.path = init.path;
386
+ this.unit = init.unit;
387
+ this.kind = init.kind ?? 'untested-new-code';
388
+ this.fidelity = init.fidelity ?? Fidelity.Heuristic;
389
+ this.severity = init.severity ?? Severity.HIGH;
390
+ this.evidence = init.evidence ?? '';
391
+ this.suggestion = init.suggestion ?? '';
392
+ this.suppressed = init.suppressed ?? false;
393
+ this.suppression_reason = init.suppression_reason ?? null;
394
+ this.added_ranges = init.added_ranges ?? [];
395
+ }
396
+ }
397
+ /**
398
+ * Turn uncovered coverage results into fidelity-labeled findings.
399
+ *
400
+ * Only **uncovered** results become findings. Severity policy (Phase 1):
401
+ *
402
+ * - `COVERAGE_VERIFIED` uncovered → `HIGH`
403
+ * - `GRAPH_VERIFIED` uncovered → `HIGH`
404
+ * - `HEURISTIC` uncovered → `MEDIUM` (lower confidence)
405
+ *
406
+ * Results are sorted by severity sort-key (critical → low).
407
+ */
408
+ export function buildFindings(results) {
409
+ const findings = [];
410
+ for (const result of results) {
411
+ if (result.covered)
412
+ continue;
413
+ const severity = result.fidelity === Fidelity.Heuristic ? Severity.MEDIUM : Severity.HIGH;
414
+ const unit = result.unit;
415
+ findings.push(new Finding({
416
+ path: unit.path,
417
+ unit: unit.symbol || unit.path,
418
+ fidelity: result.fidelity,
419
+ severity,
420
+ evidence: result.evidence,
421
+ added_ranges: [...unit.added_ranges],
422
+ }));
423
+ }
424
+ return [...findings].sort((a, b) => severitySortKey(a.severity) - severitySortKey(b.severity));
425
+ }
426
+ // Map a test file's extension to the framework whose assertion/test patterns
427
+ // the quality scorer should use. Unknown → pytest (the scorer's own fallback).
428
+ const TEST_FRAMEWORK_BY_EXT = {
429
+ '.py': 'pytest',
430
+ '.ts': 'vitest',
431
+ '.tsx': 'vitest',
432
+ '.js': 'vitest',
433
+ '.jsx': 'vitest',
434
+ '.mjs': 'vitest',
435
+ '.cjs': 'vitest',
436
+ };
437
+ function frameworkForTestPath(path) {
438
+ return TEST_FRAMEWORK_BY_EXT[extname(path).toLowerCase()] ?? 'pytest';
439
+ }
440
+ // A test-function signature / decorator / block-close / comment — lines that
441
+ // are not a test *body*. If a diff's added lines are ONLY these (e.g. a rename
442
+ // that adds just `def test_new():` while the asserting body stays as context),
443
+ // there is no added body to judge and we must not flag it.
444
+ const TEST_SIGNATURE_RE = /^\s*(?:async\s+)?def\s+test\w*\s*\(|^\s*(?:it|test|describe)\s*\(/;
445
+ const BLOCK_DELIMITERS = new Set(['})', '});', '}', ')', '{']);
446
+ /**
447
+ * True iff the added lines contain a real body line — not just a test
448
+ * signature, decorator, comment, or a bare block delimiter.
449
+ */
450
+ function hasAddedTestBody(added) {
451
+ for (const line of added) {
452
+ const stripped = line.trim();
453
+ if (!stripped)
454
+ continue;
455
+ if (stripped.startsWith('#') ||
456
+ stripped.startsWith('//') ||
457
+ stripped.startsWith('@') ||
458
+ stripped.startsWith('*') ||
459
+ stripped.startsWith('/*')) {
460
+ continue;
461
+ }
462
+ if (BLOCK_DELIMITERS.has(stripped))
463
+ continue;
464
+ if (TEST_SIGNATURE_RE.test(line))
465
+ continue;
466
+ return true;
467
+ }
468
+ return false;
469
+ }
470
+ /**
471
+ * Advisory `weak-test` findings for ADDED tests that assert nothing.
472
+ *
473
+ * Consumes the test-path units {@link filterTestUnits} sets aside (a test file
474
+ * needs no test of its own, but an added test that asserts nothing is itself a
475
+ * gap). Scores only the diff's *added* lines per test file via
476
+ * {@link isAssertionFreeTest} — a high-precision signal (a real test function
477
+ * with zero assertions), so snapshot/table-driven tests are not flagged. These
478
+ * findings are `LOW`/`weak-test` and are **never** gated (see
479
+ * {@link computeExitCode}): they surface, never block.
480
+ */
481
+ export function buildWeakTestFindings(testUnits, diffText) {
482
+ const addedByPath = addedContentByPath(diffText);
483
+ const findings = [];
484
+ for (const unit of testUnits) {
485
+ const added = addedByPath.get(unit.path);
486
+ if (!added || added.length === 0)
487
+ continue;
488
+ // A rename adds only the signature line (body is unchanged context) —
489
+ // nothing new to judge, so don't flag it (FP guard).
490
+ if (!hasAddedTestBody(added))
491
+ continue;
492
+ const code = added.join('\n');
493
+ const framework = frameworkForTestPath(unit.path);
494
+ if (isAssertionFreeTest(code, framework)) {
495
+ findings.push(new Finding({
496
+ path: unit.path,
497
+ unit: unit.path,
498
+ kind: 'weak-test',
499
+ fidelity: Fidelity.Heuristic,
500
+ severity: Severity.LOW,
501
+ evidence: 'added test asserts nothing (advisory — never blocks the gate)',
502
+ added_ranges: [...unit.added_ranges],
503
+ }));
504
+ }
505
+ }
506
+ return findings;
507
+ }
508
+ /** Flatten inclusive `[start, end]` ranges into a sorted list of line numbers. */
509
+ function linesInRanges(ranges) {
510
+ const lines = new Set();
511
+ for (const [start, end] of ranges) {
512
+ for (let ln = start; ln <= end; ln++)
513
+ lines.add(ln);
514
+ }
515
+ return [...lines].sort((a, b) => a - b);
516
+ }
517
+ /**
518
+ * Return the captured reason iff `line` carries a comment-led annotation.
519
+ *
520
+ * Requires a `//`/`#` comment leader (FIX 1), strips a trailing inline-comment
521
+ * close (e.g. `*​/`), and trims surrounding whitespace.
522
+ */
523
+ function suppressionReason(line) {
524
+ const match = SUPPRESS_RE.exec(line);
525
+ if (match === null)
526
+ return null;
527
+ let reason = match[1];
528
+ for (const closer of INLINE_COMMENT_CLOSERS) {
529
+ const idx = reason.indexOf(closer);
530
+ if (idx !== -1)
531
+ reason = reason.slice(0, idx);
532
+ }
533
+ return reason.trim();
534
+ }
535
+ /**
536
+ * Honor `canary:allow-untested <reason>` annotations (SC-12).
537
+ *
538
+ * Scans the finding's source **only within the unit's added line ranges** for a
539
+ * comment-led `canary:allow-untested <reason>` annotation (both `//` and `#`
540
+ * leaders accepted). A bare occurrence inside a string literal, docstring, or
541
+ * an untouched line therefore never clears the gate (FIX 1). When the finding
542
+ * carries no `added_ranges` the scan falls back to the whole file (still
543
+ * comment-leader gated). Suppressed findings **remain** in the returned list so
544
+ * they stay visible in rendered output — only the hard-gate exit calc ignores
545
+ * them.
546
+ */
547
+ export function applySuppressions(findings, repoRoot = '.') {
548
+ for (const finding of findings) {
549
+ let source;
550
+ try {
551
+ source = readFileSync(join(repoRoot, finding.path), 'utf-8');
552
+ }
553
+ catch {
554
+ continue;
555
+ }
556
+ const sourceLines = splitLines(source);
557
+ const candidates = finding.added_ranges.length > 0
558
+ ? linesInRanges(finding.added_ranges)
559
+ : range1(sourceLines.length);
560
+ for (const lineno of candidates) {
561
+ if (!(lineno >= 1 && lineno <= sourceLines.length))
562
+ continue;
563
+ const reason = suppressionReason(sourceLines[lineno - 1]);
564
+ if (reason !== null) {
565
+ finding.suppressed = true;
566
+ finding.suppression_reason = reason;
567
+ break;
568
+ }
569
+ }
570
+ }
571
+ return findings;
572
+ }
573
+ /** Python's `range(1, n + 1)` as an array `[1, 2, ..., n]`. */
574
+ function range1(n) {
575
+ const out = [];
576
+ for (let i = 1; i <= n; i++)
577
+ out.push(i);
578
+ return out;
579
+ }
580
+ const HARD_GATE_SEVERITIES = new Set([
581
+ Severity.CRITICAL,
582
+ Severity.HIGH,
583
+ ]);
584
+ /**
585
+ * Compute the soft/hard gate exit code (SC-4).
586
+ *
587
+ * - `gate == "soft"` → always `0`.
588
+ * - `gate == "hard"` → `1` iff any finding is an `untested-new-code` finding of
589
+ * `CRITICAL`/`HIGH` severity that is **not addressed**.
590
+ *
591
+ * A finding is *addressed* when it is suppressed (SC-12) or when a covering
592
+ * test was added in the same diff (in which case it never appears in `findings`
593
+ * at all — suppressed findings do remain, so the live check is simply `not
594
+ * suppressed`).
595
+ *
596
+ * The gate is normalized (`trim().toLowerCase()`) before the comparison so a
597
+ * mistyped `"Hard"` / `" hard "` still enforces rather than silently failing
598
+ * open (FIX 5). Config-load validation ({@link loadGuardianConfig}) is the
599
+ * primary guard against unknown gate values.
600
+ */
601
+ export function computeExitCode(findings, gate) {
602
+ const normalizedGate = typeof gate === 'string' ? gate.trim().toLowerCase() : gate;
603
+ if (normalizedGate !== 'hard')
604
+ return 0;
605
+ for (const finding of findings) {
606
+ if (finding.kind === 'untested-new-code' &&
607
+ HARD_GATE_SEVERITIES.has(finding.severity) &&
608
+ !finding.suppressed) {
609
+ return 1;
610
+ }
611
+ }
612
+ return 0;
613
+ }
614
+ const STICKY_MARKER = '<!-- canary-pr-guardian -->';
615
+ /**
616
+ * Escape every non-ASCII (>= U+0080) code unit to a `\uXXXX` sequence, matching
617
+ * Python's `json.dumps(..., ensure_ascii=True)` (the library default). `JSON`
618
+ * `.stringify` emits raw UTF-8 for these, so a finding whose evidence carries an
619
+ * em-dash (`—`, U+2014) would otherwise diverge byte-for-byte from the Python
620
+ * oracle. Only touches the >= 0x80 range, so the ASCII escapes JSON.stringify
621
+ * already produced (`\"`, `\\`, control chars) are left intact.
622
+ */
623
+ function ensureAscii(json) {
624
+ return json.replace(/[€-￿]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
625
+ }
626
+ /** Serialize a {@link Finding} to a stable JSON-friendly object. */
627
+ function findingDict(finding) {
628
+ return {
629
+ path: finding.path,
630
+ unit: finding.unit,
631
+ kind: finding.kind,
632
+ fidelity: finding.fidelity,
633
+ severity: finding.severity,
634
+ evidence: finding.evidence,
635
+ suggestion: finding.suggestion,
636
+ suppressed: finding.suppressed,
637
+ suppression_reason: finding.suppression_reason,
638
+ };
639
+ }
640
+ /**
641
+ * Render findings as a sticky PR `comment`, `json`, or plain `text`.
642
+ *
643
+ * - `comment`: leads with the sticky marker `<!-- canary-pr-guardian -->`, a
644
+ * fidelity-labeled summary line, then severity-ranked findings (each showing
645
+ * path/unit, severity, fidelity, evidence). Suppressed findings are rendered
646
+ * but visually marked `suppressed`. Footer states `tier 0` and appends
647
+ * `degradedNotice` when present.
648
+ * - `json`: `{"findings": [...], "tier": <n>}` — stable schema.
649
+ * - `text`: plain, markdown-free, for local/CLI output.
650
+ */
651
+ export function render(findings, fmt, tier = 0, degradedNotice = null) {
652
+ const ordered = [...findings].sort((a, b) => severitySortKey(a.severity) - severitySortKey(b.severity));
653
+ if (fmt === 'json') {
654
+ const payload = {
655
+ findings: ordered.map(findingDict),
656
+ tier,
657
+ };
658
+ if (degradedNotice)
659
+ payload['degraded_notice'] = degradedNotice;
660
+ return ensureAscii(JSON.stringify(payload, null, 2));
661
+ }
662
+ const active = ordered.filter((f) => !f.suppressed);
663
+ const suppressed = ordered.filter((f) => f.suppressed);
664
+ if (fmt === 'comment') {
665
+ const lines = [STICKY_MARKER, '## Canary PR Guardian'];
666
+ lines.push(`**${active.length} unaddressed** / ${suppressed.length} suppressed ` +
667
+ `finding(s) — fidelity-labeled below.`);
668
+ for (const finding of ordered) {
669
+ const mark = finding.suppressed ? ' _(suppressed)_' : '';
670
+ lines.push(`- **${finding.severity}** \`${finding.path}\` ` +
671
+ `(${finding.unit}) — _${finding.fidelity}_ — ` +
672
+ `${finding.evidence}${mark}`);
673
+ }
674
+ let footer = `_tier ${tier}_`;
675
+ if (degradedNotice)
676
+ footer += ` — ${degradedNotice}`;
677
+ lines.push(footer);
678
+ return lines.join('\n');
679
+ }
680
+ // fmt == "text" (default fallback): plain, no markdown/HTML.
681
+ const lines = [
682
+ `Canary PR Guardian — ${active.length} unaddressed, ` +
683
+ `${suppressed.length} suppressed`,
684
+ ];
685
+ for (const finding of ordered) {
686
+ const mark = finding.suppressed ? ' (suppressed)' : '';
687
+ lines.push(`[${finding.severity}] ${finding.path} (${finding.unit}) ` +
688
+ `[${finding.fidelity}] ${finding.evidence}${mark}`);
689
+ }
690
+ let footer = `tier ${tier}`;
691
+ if (degradedNotice)
692
+ footer += ` - ${degradedNotice}`;
693
+ lines.push(footer);
694
+ return lines.join('\n');
695
+ }
696
+ // SC-2 canonical skip set: files no coverage gate should ever fire on. Docs and
697
+ // markdown never need a covering test; generated/dependency artifacts
698
+ // (lockfiles, built bundles under dist/build, minified JS, test snapshots) are
699
+ // not authored code and would only produce noise. This is the DEFAULT skip set
700
+ // when a config omits `skipGlobs` entirely — an explicit `skipGlobs` (even `[]`)
701
+ // overrides it. See {@link loadGuardianConfig}.
702
+ export const DEFAULT_SKIP_GLOBS = [
703
+ 'docs/**',
704
+ '**/*.md',
705
+ // Dependency lockfiles (generated, never hand-tested).
706
+ '**/package-lock.json',
707
+ '**/yarn.lock',
708
+ '**/pnpm-lock.yaml',
709
+ '**/poetry.lock',
710
+ '**/Cargo.lock',
711
+ '**/*.lock',
712
+ // Build outputs and generated bundles/snapshots.
713
+ 'dist/**',
714
+ 'build/**',
715
+ '**/*.min.js',
716
+ '**/*.snap',
717
+ // Generated slash-command artifacts and harness state — regenerated from a
718
+ // tracked source (skill.yaml / graph scans), never hand-authored, so a
719
+ // covering test makes no sense.
720
+ 'agents/commands/**',
721
+ '.harness/**',
722
+ ];
723
+ /**
724
+ * Parsed `canary.guardian` config block.
725
+ *
726
+ * Phase 1 stores every field but only `pr_*` gate/tier drive behavior.
727
+ * `skip_globs` and the `precommit_*`/`coverage_paths` fields are read into the
728
+ * object (scaffold) for later phases (SC-2 skip, SC-5 tier).
729
+ *
730
+ * `skip_globs` defaults to docs/markdown PLUS generated/dependency artifacts
731
+ * (lockfiles, `dist`/`build` outputs, minified JS, snapshots — see
732
+ * {@link DEFAULT_SKIP_GLOBS}) so noise-only paths skip out of the box; an
733
+ * explicit `skipGlobs` in config (even `[]`) overrides it.
734
+ */
735
+ export class GuardianConfig {
736
+ pr_enabled;
737
+ pr_tier;
738
+ pr_gate;
739
+ // Emit advisory `weak-test` findings for added tests that assert nothing.
740
+ // Non-gating always; default on (it only surfaces a comment, never blocks).
741
+ weak_tests;
742
+ precommit_enabled;
743
+ precommit_author_tests;
744
+ precommit_gate;
745
+ coverage_paths;
746
+ skip_globs;
747
+ // #320: bound the graph-coverage reverse-BFS. `null` means "gate-derived"
748
+ // (see {@link effectiveGraphDepth} — hard→1 direct edge, soft→unbounded); an
749
+ // explicit int here overrides the gate default on BOTH surfaces.
750
+ graph_coverage_max_depth;
751
+ constructor(init = {}) {
752
+ this.pr_enabled = init.pr_enabled ?? true;
753
+ this.pr_tier = init.pr_tier ?? 0;
754
+ this.pr_gate = init.pr_gate ?? 'soft';
755
+ this.weak_tests = init.weak_tests ?? true;
756
+ this.precommit_enabled = init.precommit_enabled ?? false;
757
+ this.precommit_author_tests = init.precommit_author_tests ?? false;
758
+ this.precommit_gate = init.precommit_gate ?? 'soft';
759
+ this.coverage_paths = init.coverage_paths ?? [];
760
+ this.skip_globs = init.skip_globs ?? [...DEFAULT_SKIP_GLOBS];
761
+ this.graph_coverage_max_depth = init.graph_coverage_max_depth ?? null;
762
+ }
763
+ }
764
+ const VALID_GATES = new Set(['soft', 'hard']);
765
+ /** Rough analog of Python's `repr()` for a diagnostic value. */
766
+ function pyRepr(value) {
767
+ if (typeof value === 'string')
768
+ return `'${value}'`;
769
+ if (value === true)
770
+ return 'True';
771
+ if (value === false)
772
+ return 'False';
773
+ if (value === null || value === undefined)
774
+ return 'None';
775
+ if (Array.isArray(value))
776
+ return `[${value.map(pyRepr).join(', ')}]`;
777
+ if (typeof value === 'object') {
778
+ // Python dict repr: `{'k': 'v'}` (keys and values repr'd, comma-space).
779
+ const entries = Object.entries(value).map(([k, v]) => `${pyRepr(k)}: ${pyRepr(v)}`);
780
+ return `{${entries.join(', ')}}`;
781
+ }
782
+ return String(value);
783
+ }
784
+ /**
785
+ * Parse an integer the way Python's `int(str(raw).strip())` does: optional
786
+ * surrounding whitespace and sign, digits only. Returns `null` on failure
787
+ * (Python would raise `ValueError`, which the coercers catch-and-default).
788
+ */
789
+ function pyIntFromValue(raw) {
790
+ const s = typeof raw === 'string' ? raw : typeof raw === 'number' ? String(raw) : '';
791
+ const trimmed = s.trim();
792
+ if (!/^[+-]?\d+$/.test(trimmed))
793
+ return null;
794
+ return Number.parseInt(trimmed, 10);
795
+ }
796
+ /**
797
+ * Coerce a config `tier` to an int, warning + defaulting on bad input.
798
+ *
799
+ * A plain int is taken as-is; a clean integer string (`"2"`) is accepted;
800
+ * anything else (`"medium"`, `1.5`, `[]`, `true`) warns and defaults — never
801
+ * raises (FIX 4, SC-8).
802
+ */
803
+ function coerceTier(raw, def, warnings) {
804
+ if (typeof raw === 'boolean') {
805
+ warnings.push(`guardian pr.tier must be an integer, got ${pyRepr(raw)}; using ${def}`);
806
+ return def;
807
+ }
808
+ if (typeof raw === 'number' && Number.isInteger(raw))
809
+ return raw;
810
+ const parsed = pyIntFromValue(raw);
811
+ if (parsed === null) {
812
+ warnings.push(`guardian pr.tier must be an integer, got ${pyRepr(raw)}; using ${def}`);
813
+ return def;
814
+ }
815
+ return parsed;
816
+ }
817
+ /**
818
+ * Coerce a config value to `number | null`, warning + defaulting `null`.
819
+ *
820
+ * A plain int (not `boolean`) is taken as-is; a clean integer string (`"2"`) is
821
+ * accepted; anything else (a float, `"x"`, `[]`, `true`) warns and defaults to
822
+ * `null` (unbounded) — never raises (#320, SC-8).
823
+ *
824
+ * When `minValue` is given, a parsed int BELOW it is treated as a bad value:
825
+ * warn loudly and fall back to `null` (FIX 2, #320).
826
+ */
827
+ function coerceOptionalInt(raw, fieldName, warnings, minValue = null) {
828
+ let parsed;
829
+ if (typeof raw === 'boolean') {
830
+ parsed = null;
831
+ }
832
+ else if (typeof raw === 'number' && Number.isInteger(raw)) {
833
+ parsed = raw;
834
+ }
835
+ else {
836
+ parsed = pyIntFromValue(raw);
837
+ }
838
+ if (parsed === null) {
839
+ warnings.push(`guardian ${fieldName} must be an integer, got ${pyRepr(raw)}; ignoring`);
840
+ return null;
841
+ }
842
+ if (minValue !== null && parsed < minValue) {
843
+ warnings.push(`guardian ${fieldName} must be >= ${minValue}; ignoring ${parsed}, ` +
844
+ `using unbounded`);
845
+ return null;
846
+ }
847
+ return parsed;
848
+ }
849
+ /**
850
+ * Validate a config gate against `{soft, hard}`, warning + defaulting.
851
+ *
852
+ * Normalizes case/whitespace; anything outside the set warns and defaults (FIX
853
+ * 4/FIX 5 — an unknown gate must never silently disable enforcement).
854
+ *
855
+ * Only a string or number is stringified; a non-scalar (array/object) warns and
856
+ * defaults rather than being flattened. `String(["hard"])` is `"hard"`, which
857
+ * would wrongly ENFORCE, whereas Python's `str(["hard"])` is `"['hard']"` and is
858
+ * rejected — this guard keeps the two in lockstep.
859
+ */
860
+ function coerceGate(raw, def, fieldName, warnings) {
861
+ if (typeof raw !== 'string' && typeof raw !== 'number') {
862
+ warnings.push(`guardian ${fieldName} must be 'soft' or 'hard', got ${pyRepr(raw)}; ` +
863
+ `using ${def}`);
864
+ return def;
865
+ }
866
+ const normalized = String(raw).trim().toLowerCase();
867
+ if (VALID_GATES.has(normalized))
868
+ return normalized;
869
+ warnings.push(`guardian ${fieldName} must be 'soft' or 'hard', got ${pyRepr(raw)}; ` +
870
+ `using ${def}`);
871
+ return def;
872
+ }
873
+ function isRecord(value) {
874
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
875
+ }
876
+ /**
877
+ * Python-truthiness for JSON-shaped values: `null`/`undefined`, `false`, `0`,
878
+ * `""`, empty array, and empty object are all falsy (mirrors `bool(x)`).
879
+ */
880
+ function pyTruthy(value) {
881
+ if (value === null || value === undefined || value === false)
882
+ return false;
883
+ if (value === 0 || value === '')
884
+ return false;
885
+ if (Array.isArray(value))
886
+ return value.length > 0;
887
+ if (typeof value === 'object')
888
+ return Object.keys(value).length > 0;
889
+ return Boolean(value);
890
+ }
891
+ /** Python `dict.get(key, default)`: default only on a missing key. */
892
+ function pyGet(obj, key, fallback) {
893
+ return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : fallback;
894
+ }
895
+ /**
896
+ * Load the `canary.guardian` block, distinguishing absent from malformed.
897
+ *
898
+ * Uses {@link readJsonWithWarning}. Returns `[GuardianConfig, warning]`:
899
+ *
900
+ * - file absent → `[defaults, null]` (silent, normal)
901
+ * - malformed JSON → `[defaults, "<warn>"]` (LOUD, SC-8)
902
+ * - valid but no `canary.guardian` → `[defaults, null]`
903
+ * - valid `canary.guardian` → `[parsed, null]`
904
+ * - valid block, bad `tier`/`gate` → `[defaults for those fields, "<warn>"]`
905
+ * — coercion never raises and the warning rides the same loud slot the CLI
906
+ * echoes (FIX 4).
907
+ */
908
+ export function loadGuardianConfig(configPath = 'harness.config.json') {
909
+ const [data, warning] = readJsonWithWarning(configPath);
910
+ if (warning !== null || !isRecord(data)) {
911
+ return [new GuardianConfig(), warning];
912
+ }
913
+ const canary = pyGet(data, 'canary', {});
914
+ const block = isRecord(canary) ? pyGet(canary, 'guardian', {}) : {};
915
+ if (!isRecord(block) || Object.keys(block).length === 0) {
916
+ return [new GuardianConfig(), null];
917
+ }
918
+ const config = new GuardianConfig();
919
+ const warnings = [];
920
+ const pr = pyGet(block, 'pr', {});
921
+ if (isRecord(pr)) {
922
+ config.pr_enabled = pyTruthy(pyGet(pr, 'enabled', config.pr_enabled));
923
+ if ('tier' in pr) {
924
+ config.pr_tier = coerceTier(pr['tier'], config.pr_tier, warnings);
925
+ }
926
+ if ('gate' in pr) {
927
+ config.pr_gate = coerceGate(pr['gate'], config.pr_gate, 'pr.gate', warnings);
928
+ }
929
+ config.weak_tests = pyTruthy(pyGet(pr, 'weakTests', config.weak_tests));
930
+ }
931
+ const precommit = pyGet(block, 'preCommit', {});
932
+ if (isRecord(precommit)) {
933
+ config.precommit_enabled = pyTruthy(pyGet(precommit, 'enabled', config.precommit_enabled));
934
+ config.precommit_author_tests = pyTruthy(pyGet(precommit, 'authorTests', config.precommit_author_tests));
935
+ if ('gate' in precommit) {
936
+ config.precommit_gate = coerceGate(precommit['gate'], config.precommit_gate, 'preCommit.gate', warnings);
937
+ }
938
+ }
939
+ const coveragePaths = block['coveragePaths'];
940
+ if (Array.isArray(coveragePaths)) {
941
+ config.coverage_paths = coveragePaths.map((p) => String(p));
942
+ }
943
+ // #320: an explicit graphCoverageMaxDepth overrides the gate-derived default
944
+ // on both surfaces. A bad value warns loudly (SC-8) and stays null.
945
+ if ('graphCoverageMaxDepth' in block) {
946
+ config.graph_coverage_max_depth = coerceOptionalInt(block['graphCoverageMaxDepth'], 'graphCoverageMaxDepth', warnings, 1);
947
+ }
948
+ // FIX B: only override the default (docs/** + **/*.md + generated) when
949
+ // skipGlobs is PRESENT. Absent → keep the default; an explicit list
950
+ // (including empty []) is honored verbatim so a deliberate `skipGlobs: []`
951
+ // means "skip nothing".
952
+ const skipGlobs = block['skipGlobs'];
953
+ if (Array.isArray(skipGlobs)) {
954
+ config.skip_globs = skipGlobs.map((g) => String(g));
955
+ }
956
+ return [config, warnings.length > 0 ? warnings.join('; ') : null];
957
+ }
958
+ /**
959
+ * Resolve the graph-coverage BFS depth for a given `gate` (#320).
960
+ *
961
+ * An explicit `config.graph_coverage_max_depth` always wins (the operator opted
962
+ * in). Otherwise the depth is DERIVED from the gate: a `hard` gate requires a
963
+ * DIRECT test→source edge (depth `1`), while a `soft` gate stays unbounded
964
+ * (`null`) — byte-for-byte the current soft-default behavior. The gate is
965
+ * normalized (`trim().toLowerCase()`) so a mistyped `"Hard"` still bounds.
966
+ * Shared by the CLI (`pr-check`/`author-plan`) and the pre-commit hook.
967
+ */
968
+ export function effectiveGraphDepth(config, gate) {
969
+ if (config.graph_coverage_max_depth !== null) {
970
+ return config.graph_coverage_max_depth;
971
+ }
972
+ const normalized = typeof gate === 'string' ? gate.trim().toLowerCase() : gate;
973
+ return normalized === 'hard' ? 1 : null;
974
+ }
975
+ //# sourceMappingURL=pr-check.js.map