canary-test-cli 5.15.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/frameworks/registry.json +655 -0
- package/bin/canary.js +20 -15
- package/dist/doctor-manifest.d.ts +94 -0
- package/dist/doctor.d.ts +67 -0
- package/dist/engine/analysis/cli.js +270 -0
- package/dist/engine/analysis/engine.js +146 -0
- package/dist/engine/analysis/reports.js +0 -0
- package/dist/engine/analysis/rows.js +9 -0
- package/dist/engine/cli-commands.js +618 -0
- package/dist/engine/cli-common.js +60 -0
- package/dist/engine/cli.core.js +208 -0
- package/dist/engine/cli.js +31 -0
- package/dist/engine/company-knowledge-cli.js +201 -0
- package/dist/engine/core/ci-env.js +33 -0
- package/dist/engine/core/classifier.js +192 -0
- package/dist/engine/core/company-knowledge.js +765 -0
- package/dist/engine/core/config-validation.js +74 -0
- package/dist/engine/core/detection.js +48 -0
- package/dist/engine/core/domain-scanner.js +212 -0
- package/dist/engine/core/environment-detect.js +410 -0
- package/dist/engine/core/executor.js +181 -0
- package/dist/engine/core/feedback.js +93 -0
- package/dist/engine/core/fixture-scanner.js +173 -0
- package/dist/engine/core/framework-registry.js +123 -0
- package/dist/engine/core/mcp-validator.js +218 -0
- package/dist/engine/core/metadata-scanner.js +147 -0
- package/dist/engine/core/migrator.js +1112 -0
- package/dist/engine/core/overlays.js +176 -0
- package/dist/engine/core/pattern-healer.js +147 -0
- package/dist/engine/core/pattern-matcher.js +255 -0
- package/dist/engine/core/quality-scorer.js +213 -0
- package/dist/engine/core/recommender.js +152 -0
- package/dist/engine/core/reporter.js +211 -0
- package/dist/engine/core/scaffolder.js +236 -0
- package/dist/engine/core/skill-registry.js +522 -0
- package/dist/engine/core/static-linter.js +237 -0
- package/dist/engine/core/ticket-updater.js +639 -0
- package/dist/engine/core/workflow-discovery.js +693 -0
- package/dist/engine/guardian/agent-tier.js +338 -0
- package/dist/engine/guardian/analysis-emit.js +201 -0
- package/dist/engine/guardian/cli.js +787 -0
- package/dist/engine/guardian/coverage.js +1055 -0
- package/dist/engine/guardian/delta-emitter.js +46 -0
- package/dist/engine/guardian/diff-extractor.js +257 -0
- package/dist/engine/guardian/hard-gate.js +373 -0
- package/dist/engine/guardian/impact-mapper.js +121 -0
- package/dist/engine/guardian/pr-check.js +975 -0
- package/dist/engine/guardian/pr-comment.js +200 -0
- package/dist/engine/guardian/summary-emitter.js +94 -0
- package/dist/engine/guardian/tier.js +58 -0
- package/dist/engine/history/cli.js +303 -0
- package/dist/engine/history/detector.js +68 -0
- package/dist/engine/history/ndjson-store.js +177 -0
- package/dist/engine/history/record.js +14 -0
- package/dist/engine/history/schema.js +59 -0
- package/dist/engine/history/store.js +47 -0
- package/dist/engine/history/supabase-store.js +113 -0
- package/dist/engine/main-deps.js +105 -0
- package/dist/engine/mcp-server.js +647 -0
- package/dist/engine/package.json +4 -0
- package/dist/engine/skills-cli.js +181 -0
- package/dist/engine/ui/banner.js +50 -0
- package/dist/engine/util/coalesce.js +12 -0
- package/dist/engine/util/round.js +43 -0
- package/dist/engine/workflow-cli.js +242 -0
- package/dist/engine-checks.d.ts +49 -0
- package/dist/overlay-commands.d.ts +81 -0
- package/dist/overlay-conflicts.d.ts +33 -0
- package/dist/overlay-lint.d.ts +19 -0
- package/dist/overlays-registry.d.ts +74 -0
- package/dist/reporters/testtracker.d.ts +89 -0
- package/dist/reporters/testtracker.js +195 -0
- package/dist/router.d.ts +12 -0
- package/dist/router.js +4 -4
- package/dist/skill-requirements.d.ts +57 -0
- package/dist/source-spec.d.ts +20 -0
- package/package.json +30 -6
- package/bin/canary +0 -0
- package/scripts/install.js +0 -104
|
@@ -0,0 +1,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
|