canary-test-cli 6.8.1 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/engine/analysis/cli.js +261 -89
  2. package/dist/engine/analysis/engine.js +39 -21
  3. package/dist/engine/analysis/reports.js +0 -0
  4. package/dist/engine/cli-commands.js +251 -43
  5. package/dist/engine/cli-common.js +15 -24
  6. package/dist/engine/cli.core.js +37 -11
  7. package/dist/engine/cli.js +2 -2
  8. package/dist/engine/company-knowledge-cli.js +2 -2
  9. package/dist/engine/core/adoption.js +408 -0
  10. package/dist/engine/core/framework-probes.js +7 -7
  11. package/dist/engine/core/fs-glob.js +2 -2
  12. package/dist/engine/core/gate-result.js +17 -0
  13. package/dist/engine/core/migrator.js +151 -48
  14. package/dist/engine/core/pattern-matcher.js +23 -5
  15. package/dist/engine/core/persona.js +421 -0
  16. package/dist/engine/core/promotion-verdict.js +261 -0
  17. package/dist/engine/core/reporter.js +1 -9
  18. package/dist/engine/core/skill-examples.js +292 -0
  19. package/dist/engine/core/skill-surfaces.js +307 -0
  20. package/dist/engine/core/static-linter.js +312 -40
  21. package/dist/engine/core/ticket-updater.js +1 -7
  22. package/dist/engine/core/vacuity-scanner.js +556 -0
  23. package/dist/engine/core/workflow-discovery.js +2 -8
  24. package/dist/engine/core/workspace-detect.js +0 -0
  25. package/dist/engine/data/personas/registry.json +36 -0
  26. package/dist/engine/guardian/adjudication.js +6 -6
  27. package/dist/engine/guardian/agent-tier.js +3 -3
  28. package/dist/engine/guardian/analysis-emit.js +13 -27
  29. package/dist/engine/guardian/cli.js +30 -43
  30. package/dist/engine/guardian/coverage.js +15 -1420
  31. package/dist/engine/guardian/diff-coverage/formats/cobertura.js +130 -0
  32. package/dist/engine/guardian/diff-coverage/formats/coverage-json-lint.js +197 -0
  33. package/dist/engine/guardian/diff-coverage/formats/coverage-json.js +107 -0
  34. package/dist/engine/guardian/diff-coverage/formats/xml.js +151 -0
  35. package/dist/engine/guardian/diff-coverage/graph-tier.js +223 -0
  36. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +150 -0
  37. package/dist/engine/guardian/diff-coverage/orchestrator.js +125 -0
  38. package/dist/engine/guardian/diff-coverage/paths.js +164 -0
  39. package/dist/engine/guardian/diff-coverage/report-tier.js +153 -0
  40. package/dist/engine/guardian/diff-coverage/type-only.js +150 -0
  41. package/dist/engine/guardian/diff-coverage/types.js +115 -0
  42. package/dist/engine/guardian/pr-check.js +10 -20
  43. package/dist/engine/guardian/pr-comment.js +4 -3
  44. package/dist/engine/history/cli.js +210 -6
  45. package/dist/engine/history/ndjson-store.js +9 -5
  46. package/dist/engine/history/record.js +34 -5
  47. package/dist/engine/history/run-recorder.js +165 -0
  48. package/dist/engine/history/schema.js +25 -7
  49. package/dist/engine/history/store.js +9 -0
  50. package/dist/engine/mcp-server.js +35 -13
  51. package/dist/engine/skills-cli.js +133 -11
  52. package/dist/engine/util/ensure-ascii.js +37 -0
  53. package/dist/engine/workflow-cli.js +6 -6
  54. package/dist/engine-checks.d.ts +15 -0
  55. package/dist/engine-checks.js +92 -1
  56. package/dist/gate-result.d.ts +11 -0
  57. package/dist/gate-result.js +18 -0
  58. package/dist/overlay-commands.d.ts +12 -1
  59. package/dist/overlay-commands.js +28 -2
  60. package/dist/router.js +17 -5
  61. package/dist/uninstall-render.d.ts +11 -0
  62. package/dist/uninstall-render.js +60 -0
  63. package/dist/uninstall-scan.d.ts +14 -0
  64. package/dist/uninstall-scan.js +273 -0
  65. package/dist/uninstall-types.d.ts +46 -0
  66. package/dist/uninstall-types.js +91 -0
  67. package/dist/uninstall.d.ts +13 -0
  68. package/dist/uninstall.js +181 -0
  69. package/package.json +1 -1
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Tier 1 — coverage resolved from an explicit report (`COVERAGE_VERIFIED`).
3
+ *
4
+ * Owns the lcov reader, the format dispatch (which report shape is this file?),
5
+ * and the per-unit matching that turns a report index into verdicts.
6
+ */
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { basename } from 'node:path';
9
+ import { parseCobertura } from './formats/cobertura.js';
10
+ import { parseCoverageJson } from './formats/coverage-json.js';
11
+ import { expandRanges, makeResult, matchFile, rangesStr, selfDescribing, splitLines, pyInt, Fidelity, } from './types.js';
12
+ /**
13
+ * Parse `lcov.info` into `{path: {line: hits}}`.
14
+ *
15
+ * Every `DA:` record is a line the instrumenter measured, so the recorded lines
16
+ * are exactly the coverable set — see `FileCoverage`.
17
+ */
18
+ function parseLcov(text) {
19
+ const byPath = {};
20
+ let current = null;
21
+ for (const line of splitLines(text)) {
22
+ if (line.startsWith('SF:')) {
23
+ current = line.slice(3).trim();
24
+ if (!(current in byPath))
25
+ byPath[current] = {};
26
+ }
27
+ else if (line.startsWith('DA:') && current !== null) {
28
+ recordDa(byPath[current], line.slice(3).trim());
29
+ }
30
+ else if (line.trim() === 'end_of_record') {
31
+ current = null;
32
+ }
33
+ }
34
+ const index = {};
35
+ for (const [path, hits] of Object.entries(byPath)) {
36
+ index[path] = selfDescribing(hits);
37
+ }
38
+ return index;
39
+ }
40
+ /** Fold one `DA:<line>,<hits>` body into a file's hit map, skipping junk. */
41
+ function recordDa(hits, body) {
42
+ const parts = body.split(',');
43
+ if (parts.length < 2)
44
+ return;
45
+ const lineno = pyInt(parts[0]);
46
+ const count = pyInt(parts[1]);
47
+ if (lineno === null || count === null)
48
+ return;
49
+ hits[lineno] = count;
50
+ }
51
+ /** Read a report file as UTF-8, returning `null` on any read/decode failure. */
52
+ function readReportText(reportPath) {
53
+ try {
54
+ const buf = readFileSync(reportPath);
55
+ // Fatal decode: a non-UTF-8 report must fall through, never raise out of
56
+ // the guardian gate (mirrors Python's UnicodeDecodeError → None).
57
+ return new TextDecoder('utf-8', { fatal: true }).decode(buf);
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /**
64
+ * Tier 1: resolve coverage from an explicit report (`COVERAGE_VERIFIED`).
65
+ *
66
+ * Supports `lcov.info` (`DA:<line>,<hits>`), the canary coverage-json shape,
67
+ * and Cobertura `coverage.xml` (line-level). Unrecognized/empty/unreadable →
68
+ * `null` (caller falls through to a lower fidelity tier — absence never
69
+ * blocks).
70
+ */
71
+ export function resolveFromReport(units, reportPath) {
72
+ const { index } = readReportIndex(reportPath);
73
+ if (index === null)
74
+ return null;
75
+ return matchUnitsToIndex(units, index);
76
+ }
77
+ /** Read + parse a coverage report, reporting each step's outcome separately. */
78
+ export function readReportIndex(reportPath) {
79
+ const unusable = (found) => ({ found, index: null });
80
+ if (!existsSync(reportPath))
81
+ return unusable(false);
82
+ const text = readReportText(reportPath);
83
+ // Present but unreadable/non-UTF-8 counts as found-and-unusable, not absent.
84
+ if (text === null)
85
+ return unusable(true);
86
+ const index = parseByFormat(basename(reportPath).toLowerCase(), text);
87
+ if (index === null || Object.keys(index).length === 0)
88
+ return unusable(true);
89
+ return { found: true, index };
90
+ }
91
+ /** Pick the reader by report filename; `null` for a format we don't know. */
92
+ function parseByFormat(name, text) {
93
+ if (name.endsWith('.json')) {
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(text);
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ return parseCoverageJson(parsed);
102
+ }
103
+ if (name.endsWith('.info') || name.includes('lcov'))
104
+ return parseLcov(text);
105
+ if (name.endsWith('.xml'))
106
+ return parseCobertura(text);
107
+ // Unrecognized format → fall through to a lower fidelity tier.
108
+ return null;
109
+ }
110
+ /** Resolve every unit the report index can speak to (COVERAGE_VERIFIED). */
111
+ export function matchUnitsToIndex(units, index) {
112
+ const results = [];
113
+ for (const unit of units) {
114
+ const file = matchFile(unit.path, index);
115
+ if (file === null) {
116
+ // Unit path is nowhere in the report index → "not instrumented", which is
117
+ // NOT the same as "instrumented and unhit". Emit no COVERAGE_VERIFIED
118
+ // result so the orchestrator falls through to a lower-fidelity tier for
119
+ // this unit (FIX 2).
120
+ continue;
121
+ }
122
+ const { hits, coverable: measured } = file;
123
+ const added = expandRanges(unit.added_ranges);
124
+ // The per-line form of the check above (#655/#657): where the report says
125
+ // which lines it instrumented, a changed line outside that set could not
126
+ // have been executed and is scored by neither side. Where it does not say,
127
+ // every changed line counts and absence means uncovered.
128
+ const coverable = measured === null ? added : added.filter((ln) => measured.has(ln));
129
+ if (coverable.length === 0) {
130
+ // Every changed line is non-coverable, so this report has nothing to say
131
+ // about the unit. An abstention — never a clean pass, never a finding.
132
+ // Falls through to the graph/heuristic tier exactly as an absent path does.
133
+ continue;
134
+ }
135
+ const uncovered = coverable.filter((ln) => (hits[ln] ?? 0) <= 0);
136
+ const covered = uncovered.length === 0;
137
+ // State the denominator: "all covered" over 20 changed lines and over the 3
138
+ // of them that were coverable are very different claims (#508).
139
+ const evidence = covered
140
+ ? `lines ${rangesStr(unit.added_ranges)}: all ${coverable.length} coverable line(s) covered`
141
+ : `lines ${rangesStr(unit.added_ranges)}: ${uncovered.length} of ${coverable.length} coverable line(s) uncovered`;
142
+ results.push(makeResult({
143
+ unit,
144
+ covered,
145
+ fidelity: Fidelity.CoverageVerified,
146
+ evidence,
147
+ uncovered_lines: uncovered,
148
+ coverable_lines: coverable.length,
149
+ }));
150
+ }
151
+ return results;
152
+ }
153
+ //# sourceMappingURL=report-tier.js.map
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Proof that a module has no runtime content at all (#562) — the one
3
+ * suppression that is about the FILE rather than the fidelity tier.
4
+ */
5
+ import { readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { splitLines } from './types.js';
8
+ /**
9
+ * Filenames/paths that plausibly hold nothing but type declarations (#562).
10
+ *
11
+ * A NAME GATE ONLY -- it decides which files are worth reading, never which
12
+ * are suppressed. {@link isTypeOnlyModule} always confirms against content,
13
+ * because a `types.ts` that also exports an enum or a const map is ordinary
14
+ * TypeScript and its findings are real.
15
+ */
16
+ function isTypeModuleCandidate(path) {
17
+ const base = path.slice(path.lastIndexOf('/') + 1);
18
+ if (base.endsWith('.d.ts'))
19
+ return true;
20
+ if (!/\.(ts|tsx|mts|cts)$/.test(base))
21
+ return false;
22
+ if (base === 'types.ts' || base.endsWith('.types.ts'))
23
+ return true;
24
+ return path.split('/').slice(0, -1).includes('types');
25
+ }
26
+ /**
27
+ * True if `path` is a module with no runtime content at all (#562).
28
+ *
29
+ * The false-positive class this closes is the one the heuristic-tier fix
30
+ * (#413) structurally cannot reach. `filterHeuristicNoise` is gated on
31
+ * `fidelity === HEURISTIC` on purpose -- a coverage-verified verdict rests on
32
+ * a real lcov row, so suppressing by path at that tier would discard
33
+ * evidence. A type-only module is the case that breaks the symmetry: the lcov
34
+ * row is accurate (39 lines, genuinely never executed) and the finding is
35
+ * still unsatisfiable, because an interface has no runtime existence for a
36
+ * test to reach. The evidence needed is therefore about the FILE, not the
37
+ * tier: prove there is nothing executable in it.
38
+ *
39
+ * Conservative in one direction on purpose. Every uncertainty -- an
40
+ * unreadable file, an unrecognised construct -- resolves to `false`, keeping
41
+ * the finding. A missed suppression costs one noisy finding; a wrong
42
+ * suppression hides untested code, which is the thing the guardian exists to
43
+ * catch.
44
+ */
45
+ export function isTypeOnlyModule(path, repoRoot) {
46
+ if (!isTypeModuleCandidate(path))
47
+ return false;
48
+ let source;
49
+ try {
50
+ source = readFileSync(join(repoRoot, path), 'utf-8');
51
+ }
52
+ catch {
53
+ return false; // unreadable -> unproven -> keep the finding
54
+ }
55
+ return isTypeOnlySource(source);
56
+ }
57
+ /** Drop `//` and `/* *\/` comments so keywords inside prose never count. */
58
+ function stripComments(source) {
59
+ const out = [];
60
+ let inBlock = false;
61
+ for (const line of splitLines(source)) {
62
+ let text = line;
63
+ if (inBlock) {
64
+ const end = text.indexOf('*/');
65
+ if (end === -1) {
66
+ out.push('');
67
+ continue;
68
+ }
69
+ text = text.slice(end + 2);
70
+ inBlock = false;
71
+ }
72
+ for (;;) {
73
+ const start = text.indexOf('/*');
74
+ if (start === -1)
75
+ break;
76
+ const end = text.indexOf('*/', start + 2);
77
+ if (end === -1) {
78
+ text = text.slice(0, start);
79
+ inBlock = true;
80
+ break;
81
+ }
82
+ text = text.slice(0, start) + text.slice(end + 2);
83
+ }
84
+ const line2 = text.indexOf('//');
85
+ out.push(line2 === -1 ? text : text.slice(0, line2));
86
+ }
87
+ return out.join('\n');
88
+ }
89
+ /**
90
+ * Top-level constructs TypeScript erases entirely at compile time (#562).
91
+ *
92
+ * An ALLOWLIST, not a denylist, and that is the load-bearing choice. A
93
+ * denylist of runtime keywords misses everything it did not enumerate -- a
94
+ * bare `register('widget')` declares nothing and still runs -- and every gap
95
+ * in it suppresses a real finding. An allowlist fails the other way: an
96
+ * unrecognised construct reads as runtime and the finding survives.
97
+ *
98
+ * Plain `import { X } from '...'` is allowed because TypeScript elides an
99
+ * import whose bindings are only used in type positions; if a binding were
100
+ * used as a value, the using statement itself would appear at top level and
101
+ * be rejected. A side-effect `import './x'` carries no binding, is never
102
+ * elided, and is therefore not matched.
103
+ */
104
+ function isErasableTopLevelLine(line) {
105
+ if (line === '')
106
+ return true;
107
+ if (/^[})\];,]+$/.test(line))
108
+ return true;
109
+ if (/^import\s+type\b/.test(line))
110
+ return true;
111
+ if (/^import\b.*\sfrom\s/.test(line))
112
+ return true;
113
+ if (/^export\s+type\b/.test(line))
114
+ return true;
115
+ const bare = line.replace(/^(?:export\s+default\s+|export\s+|declare\s+)+/, '');
116
+ return /^(?:interface|type)\s/.test(bare);
117
+ }
118
+ /**
119
+ * True if every TOP-LEVEL statement in `source` is compile-time-only.
120
+ *
121
+ * Brace depth is tracked so an interface body is never mistaken for
122
+ * statements: only depth-0 lines are judged. An unbalanced file (depth does
123
+ * not return to zero) is treated as unproven rather than type-only -- brace
124
+ * counting is lexical, so a `{` inside a string literal could otherwise hide
125
+ * the rest of the file from inspection.
126
+ */
127
+ function isTypeOnlySource(source) {
128
+ let depth = 0;
129
+ for (const raw of splitLines(stripComments(source))) {
130
+ const line = raw.trim();
131
+ if (depth === 0 && !isErasableTopLevelLine(line))
132
+ return false;
133
+ depth += bracketDelta(line);
134
+ if (depth < 0)
135
+ return false;
136
+ }
137
+ return depth === 0;
138
+ }
139
+ /** Net nesting change across one line: openers minus closers. */
140
+ function bracketDelta(line) {
141
+ let delta = 0;
142
+ for (const ch of line) {
143
+ if (ch === '{' || ch === '(' || ch === '[')
144
+ delta += 1;
145
+ else if (ch === '}' || ch === ')' || ch === ']')
146
+ delta -= 1;
147
+ }
148
+ return delta;
149
+ }
150
+ //# sourceMappingURL=type-only.js.map
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The coverage resolver's shared shapes, plus the small primitives every tier
3
+ * needs to speak about them (line arithmetic, Python-parity parsing, path
4
+ * matching). Leaf module: it imports nothing from the rest of the resolver.
5
+ */
6
+ import { basename, extname } from 'node:path';
7
+ /** Confidence tier of a coverage signal (lower rank == higher fidelity). */
8
+ export var Fidelity;
9
+ (function (Fidelity) {
10
+ Fidelity["CoverageVerified"] = "coverage-verified";
11
+ Fidelity["GraphVerified"] = "graph-verified";
12
+ Fidelity["Heuristic"] = "heuristic";
13
+ })(Fidelity || (Fidelity = {}));
14
+ const FIDELITY_RANK = {
15
+ [Fidelity.CoverageVerified]: 0,
16
+ [Fidelity.GraphVerified]: 1,
17
+ [Fidelity.Heuristic]: 2,
18
+ };
19
+ /** 0=coverage, 1=graph, 2=heuristic. Lower means higher fidelity. */
20
+ export function fidelityRank(fidelity) {
21
+ return FIDELITY_RANK[fidelity];
22
+ }
23
+ /**
24
+ * Build a {@link CoverageResult}, defaulting `uncovered_lines` to `[]`. Stands
25
+ * in for the Python dataclass's `field(default_factory=list)` — the graph and
26
+ * heuristic tiers never populate uncovered lines and rely on that default.
27
+ */
28
+ export function makeResult(fields) {
29
+ return { ...fields, uncovered_lines: fields.uncovered_lines ?? [] };
30
+ }
31
+ /** Every line the report recorded is a line it could measure (lcov/Cobertura). */
32
+ export function selfDescribing(hits) {
33
+ return { hits, coverable: recordedLines(hits) };
34
+ }
35
+ /** The line numbers a hit map has records for. */
36
+ export function recordedLines(hits) {
37
+ return new Set(Object.keys(hits).map(Number));
38
+ }
39
+ /** Split like Python's `str.splitlines()` for the common line endings. */
40
+ export function splitLines(text) {
41
+ return text.split(/\r\n|\r|\n/);
42
+ }
43
+ /**
44
+ * Parse an integer the way Python's `int(str)` does for our inputs: optional
45
+ * surrounding whitespace and sign, digits only. Returns `null` on failure
46
+ * (Python would raise `ValueError`, which the callers catch-and-skip).
47
+ */
48
+ export function pyInt(value) {
49
+ const trimmed = value.trim();
50
+ if (!/^[+-]?\d+$/.test(trimmed))
51
+ return null;
52
+ return Number.parseInt(trimmed, 10);
53
+ }
54
+ /** bool is excluded (a JSON true/false is not a valid line/hit count). */
55
+ export function isInt(value) {
56
+ // typeof boolean !== 'number', so booleans are already excluded here — the
57
+ // JS analog of Python's explicit `not isinstance(value, bool)` guard.
58
+ return typeof value === 'number' && Number.isInteger(value);
59
+ }
60
+ export function isRecord(value) {
61
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
62
+ }
63
+ /** Flatten inclusive `[start, end]` ranges into a sorted, de-duped line list. */
64
+ export function expandRanges(ranges) {
65
+ const lines = new Set();
66
+ for (const [start, end] of ranges) {
67
+ for (let ln = start; ln <= end; ln++)
68
+ lines.add(ln);
69
+ }
70
+ return [...lines].sort((a, b) => a - b);
71
+ }
72
+ /** Python's `Path(p).stem`: basename minus its final extension. */
73
+ export function stem(path) {
74
+ const base = basename(path);
75
+ const ext = extname(base);
76
+ return ext ? base.slice(0, -ext.length) : base;
77
+ }
78
+ /**
79
+ * True iff `candidate` and `target` name the same file path suffix.
80
+ *
81
+ * Exact match, or one is a suffix of the other on a **path-separator boundary**
82
+ * (`a/b/foo.py` vs `foo.py`). Rejects loose substring collisions such as
83
+ * `foobar.py` vs `bar.py` and `usermodels.py` vs `models.py` (FIX 6).
84
+ */
85
+ export function pathBoundaryMatch(candidate, target) {
86
+ return (candidate === target ||
87
+ candidate.endsWith('/' + target) ||
88
+ target.endsWith('/' + candidate));
89
+ }
90
+ /**
91
+ * Look up one file's coverage for `path` in a report index.
92
+ *
93
+ * Prefers an EXACT path match. Otherwise falls back to a **boundary** suffix
94
+ * match (report paths may be absolute, `./`-prefixed, or repo-relative). On
95
+ * multiple boundary matches (duplicate basenames) the lookup is ambiguous and
96
+ * returns `null` — the unit is then skipped and falls through rather than
97
+ * binding to an arbitrary first match (FIX 6).
98
+ */
99
+ export function matchFile(path, index) {
100
+ if (path in index)
101
+ return index[path];
102
+ const matches = [];
103
+ for (const [reportPath, file] of Object.entries(index)) {
104
+ if (pathBoundaryMatch(reportPath, path))
105
+ matches.push(file);
106
+ }
107
+ return matches.length === 1 ? matches[0] : null;
108
+ }
109
+ /** Render ranges compactly, e.g. `[[12, 28], [30, 30]]` → `"12-28, 30"`. */
110
+ export function rangesStr(ranges) {
111
+ return ranges
112
+ .map(([start, end]) => (start === end ? `${start}` : `${start}-${end}`))
113
+ .join(', ');
114
+ }
115
+ //# sourceMappingURL=types.js.map
@@ -30,6 +30,7 @@ import { readJsonWithWarning } from '../core/config-validation.js';
30
30
  import { isAssertionFreeTest } from '../core/quality-scorer.js';
31
31
  import { Fidelity, coverageDegradedNotice, coverageStatus, isSourcePath, isTestPath, isTestSupportPath, isTypeOnlyModule, } from './coverage.js';
32
32
  import { Severity, severitySortKey } from './impact-mapper.js';
33
+ import { ensureAscii } from '../util/ensure-ascii.js';
33
34
  const HUNK_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
34
35
  // Suppression annotation: `// canary:allow-untested <reason>` or the `#`
35
36
  // variant. A comment leader (`//` or `#`) is REQUIRED immediately before the
@@ -470,7 +471,7 @@ export function filterHeuristicNoise(results, excludeGlobs) {
470
471
  * concern). `severity` reuses {@link Severity}; `fidelity` carries the
471
472
  * confidence tier from the underlying coverage signal.
472
473
  */
473
- export class Finding {
474
+ export class GuardianFinding {
474
475
  path;
475
476
  unit;
476
477
  kind;
@@ -585,7 +586,7 @@ function uncoveredShare(result, uncovered) {
585
586
  * Severity for an uncovered **coverage-verified** result (#553).
586
587
  *
587
588
  * Only this tier can be graded, because only this tier knows *which* lines ran
588
- * (see the `uncovered_lines` comment on {@link Finding}). The grade combines
589
+ * (see the `uncovered_lines` comment on {@link GuardianFinding}). The grade combines
589
590
  * how much is unhit with how much of the change that represents:
590
591
  *
591
592
  * - `CRITICAL` — a large block (>= 20 lines) that is essentially untouched
@@ -642,7 +643,7 @@ export function buildFindings(results) {
642
643
  severity = Severity.HIGH;
643
644
  const unit = result.unit;
644
645
  const uncovered = [...(result.uncovered_lines ?? [])];
645
- findings.push(new Finding({
646
+ findings.push(new GuardianFinding({
646
647
  path: unit.path,
647
648
  unit: unit.symbol || unit.path,
648
649
  fidelity: result.fidelity,
@@ -724,7 +725,7 @@ export function buildWeakTestFindings(testUnits, diffText) {
724
725
  const code = added.join('\n');
725
726
  const framework = frameworkForTestPath(unit.path);
726
727
  if (isAssertionFreeTest(code, framework)) {
727
- findings.push(new Finding({
728
+ findings.push(new GuardianFinding({
728
729
  path: unit.path,
729
730
  unit: unit.path,
730
731
  kind: 'weak-test',
@@ -859,9 +860,9 @@ const STICKY_MARKER = '<!-- canary-pr-guardian -->';
859
860
  * silently produces nothing on exactly the large PRs that need it most -- the
860
861
  * same silent-green failure #369 was filed for.
861
862
  *
862
- * 60,000 leaves ~5.5k of headroom for anything appended outside `render`
863
- * (degradation annotations, upsert wrappers) without inviting a body that only
864
- * *just* fits and then breaks when a filename grows.
863
+ * 60,000 leaves ~5.5k of headroom for anything appended outside
864
+ * `renderFindings` (degradation annotations, upsert wrappers) without inviting
865
+ * a body that only *just* fits and then breaks when a filename grows.
865
866
  *
866
867
  * The cap applies ONLY to the comment. The `--emit-analysis` JSON record is the
867
868
  * authoritative complete set and is never truncated.
@@ -893,18 +894,7 @@ const SEVERITY_ICON = {
893
894
  [Severity.MEDIUM]: YELLOW_CIRCLE,
894
895
  [Severity.LOW]: WHITE_CIRCLE,
895
896
  };
896
- /**
897
- * Escape every non-ASCII (>= U+0080) code unit to a `\uXXXX` sequence, matching
898
- * Python's `json.dumps(..., ensure_ascii=True)` (the library default). `JSON`
899
- * `.stringify` emits raw UTF-8 for these, so a finding whose evidence carries an
900
- * em-dash (`—`, U+2014) would otherwise diverge byte-for-byte from the Python
901
- * oracle. Only touches the >= 0x80 range, so the ASCII escapes JSON.stringify
902
- * already produced (`\"`, `\\`, control chars) are left intact.
903
- */
904
- function ensureAscii(json) {
905
- return json.replace(/[€-￿]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
906
- }
907
- /** Serialize a {@link Finding} to a stable JSON-friendly object. */
897
+ /** Serialize a {@link GuardianFinding} to a stable JSON-friendly object. */
908
898
  function findingDict(finding) {
909
899
  return {
910
900
  path: finding.path,
@@ -955,7 +945,7 @@ function noGapsLines(coverageState, suppressedCount) {
955
945
  }
956
946
  return lines;
957
947
  }
958
- export function render(findings, fmt, tier = 0, degradedNotice = null, gateMeta = null, blobBase = null) {
948
+ export function renderFindings(findings, fmt, tier = 0, degradedNotice = null, gateMeta = null, blobBase = null) {
959
949
  const ordered = [...findings].sort((a, b) => severitySortKey(a.severity) - severitySortKey(b.severity));
960
950
  // #554: the coverage ladder's own degradation, stated alongside the tier's.
961
951
  const coverageState = gateMeta?.coverage ?? null;
@@ -32,9 +32,10 @@
32
32
  * permission error here; any other non-2xx propagates as a generic error.
33
33
  */
34
34
  import { readAllPages, restPageReader } from './github-paging.js';
35
- // Single source of truth for the sticky-comment marker. `pr_check.render`
36
- // emits the identical literal at the head of a `comment`-format body so
37
- // `findSticky` can locate the guardian comment for in-place upsert.
35
+ // Single source of truth for the sticky-comment marker.
36
+ // `pr_check.renderFindings` emits the identical literal at the head of a
37
+ // `comment`-format body so `findSticky` can locate the guardian comment for
38
+ // in-place upsert.
38
39
  export const STICKY_MARKER = '<!-- canary-pr-guardian -->';
39
40
  /**
40
41
  * A client cannot write (fork read-only token → HTTP 403).