canary-test-cli 6.1.0 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/engine/analysis/reachability.js +305 -0
- package/dist/engine/cli-commands.js +2 -0
- package/dist/engine/cli.core.js +1 -0
- package/dist/engine/core/company-knowledge.js +131 -40
- package/dist/engine/core/migrator.js +317 -20
- package/dist/engine/guardian/agent-tier.js +7 -2
- package/dist/engine/guardian/cli.js +232 -9
- package/dist/engine/guardian/coverage.js +90 -0
- package/dist/engine/guardian/pr-check.js +194 -20
- package/package.json +1 -1
|
@@ -28,7 +28,7 @@ import { readFileSync } from 'node:fs';
|
|
|
28
28
|
import { extname, join } from 'node:path';
|
|
29
29
|
import { readJsonWithWarning } from '../core/config-validation.js';
|
|
30
30
|
import { isAssertionFreeTest } from '../core/quality-scorer.js';
|
|
31
|
-
import { Fidelity, isTestPath, } from './coverage.js';
|
|
31
|
+
import { Fidelity, isSourcePath, isTestPath, } from './coverage.js';
|
|
32
32
|
import { Severity, severitySortKey } from './impact-mapper.js';
|
|
33
33
|
const HUNK_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
|
|
34
34
|
// Suppression annotation: `// canary:allow-untested <reason>` or the `#`
|
|
@@ -359,6 +359,58 @@ export function filterTestUnits(units) {
|
|
|
359
359
|
}
|
|
360
360
|
return [kept, testUnits];
|
|
361
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* Default glob layer over the {@link isSourcePath} extension floor (#413).
|
|
364
|
+
*
|
|
365
|
+
* These paths carry a *source* extension but still have nothing a naming
|
|
366
|
+
* heuristic could judge: ambient type declarations have no runtime behavior,
|
|
367
|
+
* and generated clients/stubs are regenerated from a schema rather than
|
|
368
|
+
* hand-authored. An explicit `heuristicExclude` in config (even `[]`) replaces
|
|
369
|
+
* this list; the extension floor is NOT config-defeatable.
|
|
370
|
+
*/
|
|
371
|
+
export const DEFAULT_HEURISTIC_EXCLUDE_GLOBS = [
|
|
372
|
+
'**/*.d.ts',
|
|
373
|
+
'**/__generated__/**',
|
|
374
|
+
'**/generated/**',
|
|
375
|
+
'**/*.generated.*',
|
|
376
|
+
'**/*_pb2.py',
|
|
377
|
+
'**/*.pb.go',
|
|
378
|
+
];
|
|
379
|
+
/**
|
|
380
|
+
* Partition coverage results, dropping heuristic false positives (#413).
|
|
381
|
+
*
|
|
382
|
+
* A result is dropped iff ALL of:
|
|
383
|
+
*
|
|
384
|
+
* - its fidelity is `HEURISTIC` (the last-resort naming tier), AND
|
|
385
|
+
* - it is **uncovered** (a covered result raises no finding anyway), AND
|
|
386
|
+
* - its path is not program source ({@link isSourcePath}) OR it matches an
|
|
387
|
+
* `excludeGlobs` entry.
|
|
388
|
+
*
|
|
389
|
+
* The narrowness is the point. A `COVERAGE_VERIFIED` or `GRAPH_VERIFIED`
|
|
390
|
+
* verdict on the very same path rests on real evidence (an lcov row, a graph
|
|
391
|
+
* edge) and still fires — the suppression is scoped to the tier, never to the
|
|
392
|
+
* path. Returns `[kept, dropped]`, order-preserving in both.
|
|
393
|
+
*
|
|
394
|
+
* Why this matters beyond noise: the soft→hard gate promotion is earned by
|
|
395
|
+
* reviewer adjudication feeding `precision = TP / (TP + FP)`. A repo that
|
|
396
|
+
* routinely touches config files accumulated 👎 on findings that could never
|
|
397
|
+
* have been true, holding it below its promotion bar indefinitely.
|
|
398
|
+
*/
|
|
399
|
+
export function filterHeuristicNoise(results, excludeGlobs) {
|
|
400
|
+
const kept = [];
|
|
401
|
+
const dropped = [];
|
|
402
|
+
for (const result of results) {
|
|
403
|
+
const ineligible = result.fidelity === Fidelity.Heuristic &&
|
|
404
|
+
!result.covered &&
|
|
405
|
+
(!isSourcePath(result.unit.path) ||
|
|
406
|
+
excludeGlobs.some((glob) => globMatches(result.unit.path, glob)));
|
|
407
|
+
if (ineligible)
|
|
408
|
+
dropped.push(result);
|
|
409
|
+
else
|
|
410
|
+
kept.push(result);
|
|
411
|
+
}
|
|
412
|
+
return [kept, dropped];
|
|
413
|
+
}
|
|
362
414
|
/**
|
|
363
415
|
* A single guardian finding about a changed unit.
|
|
364
416
|
*
|
|
@@ -612,6 +664,46 @@ export function computeExitCode(findings, gate) {
|
|
|
612
664
|
return 0;
|
|
613
665
|
}
|
|
614
666
|
const STICKY_MARKER = '<!-- canary-pr-guardian -->';
|
|
667
|
+
// Severity → status icon for the sticky comment (encodes severity in form, not
|
|
668
|
+
// just text, so the most urgent findings read at a glance).
|
|
669
|
+
//
|
|
670
|
+
// Written as `\u{...}` escapes, not literal glyphs: this file is `.ts`, and the
|
|
671
|
+
// house rule keeps emitted non-ASCII out of non-Markdown source (see the
|
|
672
|
+
// "Output data glyphs" block in `cli.ts`). They are emitted verbatim.
|
|
673
|
+
/**
|
|
674
|
+
* Character budget for a rendered sticky comment (#457).
|
|
675
|
+
*
|
|
676
|
+
* GitHub rejects an issue/PR comment body over **65,536** characters. The post
|
|
677
|
+
* path reports that as "could not post", so an over-long body means the gate
|
|
678
|
+
* silently produces nothing on exactly the large PRs that need it most -- the
|
|
679
|
+
* same silent-green failure #369 was filed for.
|
|
680
|
+
*
|
|
681
|
+
* 60,000 leaves ~5.5k of headroom for anything appended outside `render`
|
|
682
|
+
* (degradation annotations, upsert wrappers) without inviting a body that only
|
|
683
|
+
* *just* fits and then breaks when a filename grows.
|
|
684
|
+
*
|
|
685
|
+
* The cap applies ONLY to the comment. The `--emit-analysis` JSON record is the
|
|
686
|
+
* authoritative complete set and is never truncated.
|
|
687
|
+
*/
|
|
688
|
+
export const COMMENT_CHAR_BUDGET = 60_000;
|
|
689
|
+
/** The line that accounts for findings the budget could not fit (#457). */
|
|
690
|
+
function overflowNote(omitted) {
|
|
691
|
+
return (`<sub>${EM_DASH} and ${omitted} more finding(s) omitted to keep this ` +
|
|
692
|
+
`comment under GitHub's size limit. The full set is in the analysis ` +
|
|
693
|
+
`record (\`--emit-analysis\`) and the CI logs.</sub>`);
|
|
694
|
+
}
|
|
695
|
+
const EM_DASH = '\u{2014}';
|
|
696
|
+
const RED_CIRCLE = '\u{1F534}';
|
|
697
|
+
const YELLOW_CIRCLE = '\u{1F7E1}';
|
|
698
|
+
const WHITE_CIRCLE = '\u{26AA}';
|
|
699
|
+
const BABY_CHICK = '\u{1F424}';
|
|
700
|
+
const WHITE_CHECK = '\u{2705}';
|
|
701
|
+
const SEVERITY_ICON = {
|
|
702
|
+
[Severity.CRITICAL]: RED_CIRCLE,
|
|
703
|
+
[Severity.HIGH]: RED_CIRCLE,
|
|
704
|
+
[Severity.MEDIUM]: YELLOW_CIRCLE,
|
|
705
|
+
[Severity.LOW]: WHITE_CIRCLE,
|
|
706
|
+
};
|
|
615
707
|
/**
|
|
616
708
|
* Escape every non-ASCII (>= U+0080) code unit to a `\uXXXX` sequence, matching
|
|
617
709
|
* Python's `json.dumps(..., ensure_ascii=True)` (the library default). `JSON`
|
|
@@ -661,33 +753,79 @@ export function render(findings, fmt, tier = 0, degradedNotice = null) {
|
|
|
661
753
|
}
|
|
662
754
|
const active = ordered.filter((f) => !f.suppressed);
|
|
663
755
|
const suppressed = ordered.filter((f) => f.suppressed);
|
|
756
|
+
// A finding's file label shows the path once, appending the unit only when
|
|
757
|
+
// it is a distinct symbol within the file (never `path (path)`).
|
|
758
|
+
const fileLabel = (f) => f.unit && f.unit !== f.path
|
|
759
|
+
? `\`${f.path}\` → \`${f.unit}\``
|
|
760
|
+
: `\`${f.path}\``;
|
|
761
|
+
const cell = (s) => s.replace(/\|/g, '\\|');
|
|
762
|
+
const CONFIDENCE_NOTE = 'Confidence — **coverage-verified**: measured from a real coverage run · ' +
|
|
763
|
+
'**graph-verified**: inferred from the call graph · **heuristic**: filename ' +
|
|
764
|
+
`guess (lowest). tier ${tier}: deterministic check, no LLM.`;
|
|
765
|
+
const footerLine = `<sub>${CONFIDENCE_NOTE}${degradedNotice ? ` ${EM_DASH} ${degradedNotice}` : ''}</sub>`;
|
|
664
766
|
if (fmt === 'comment') {
|
|
665
|
-
const
|
|
666
|
-
lines
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
`
|
|
672
|
-
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
767
|
+
const fileCount = new Set(active.map((f) => f.path)).size;
|
|
768
|
+
const lines = [STICKY_MARKER];
|
|
769
|
+
if (active.length === 0) {
|
|
770
|
+
lines.push(`## ${BABY_CHICK} Canary PR Guardian ${EM_DASH} ` +
|
|
771
|
+
`${WHITE_CHECK} no test-coverage gaps`);
|
|
772
|
+
if (suppressed.length) {
|
|
773
|
+
lines.push(`_${suppressed.length} finding(s) suppressed as intentional._`);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
else {
|
|
777
|
+
const noun = fileCount === 1 ? 'file needs' : 'files need';
|
|
778
|
+
lines.push(`## ${BABY_CHICK} Canary PR Guardian ${EM_DASH} ` +
|
|
779
|
+
`${fileCount} ${noun} test coverage`);
|
|
780
|
+
lines.push('These lines were changed by this PR but no test exercises them. Add or ' +
|
|
781
|
+
'extend a test that covers them, or reply ' +
|
|
782
|
+
'`/guardian suppress <file> <reason>` if they are intentionally untested.');
|
|
783
|
+
lines.push('', '| Sev | File | What is uncovered | Confidence |', '| --- | --- | --- | --- |');
|
|
784
|
+
// #457: fill rows against a character budget instead of emitting all of
|
|
785
|
+
// them. `active` is already severity-ordered, so the rows that survive
|
|
786
|
+
// are the most severe -- a critical finding is never dropped to make room
|
|
787
|
+
// for a low one.
|
|
788
|
+
const suppressedNote = suppressed.length
|
|
789
|
+
? `\n\n<sub>${suppressed.length} finding(s) suppressed as intentional and not counted above.</sub>`
|
|
790
|
+
: '';
|
|
791
|
+
// Reserved so the tail always fits: footer, suppressed note, and a
|
|
792
|
+
// worst-case overflow line (the real one is shorter).
|
|
793
|
+
const reserve = footerLine.length +
|
|
794
|
+
suppressedNote.length +
|
|
795
|
+
overflowNote(active.length).length +
|
|
796
|
+
4;
|
|
797
|
+
let used = lines.join('\n').length;
|
|
798
|
+
let shown = 0;
|
|
799
|
+
for (const f of active) {
|
|
800
|
+
const row = `| ${SEVERITY_ICON[f.severity] ?? ''} ${f.severity} | ${cell(fileLabel(f))} | ${cell(f.evidence)} | ${f.fidelity} |`;
|
|
801
|
+
if (used + row.length + 1 + reserve > COMMENT_CHAR_BUDGET)
|
|
802
|
+
break;
|
|
803
|
+
lines.push(row);
|
|
804
|
+
used += row.length + 1;
|
|
805
|
+
shown += 1;
|
|
806
|
+
}
|
|
807
|
+
const omitted = active.length - shown;
|
|
808
|
+
if (omitted > 0)
|
|
809
|
+
lines.push('', overflowNote(omitted));
|
|
810
|
+
if (suppressed.length) {
|
|
811
|
+
lines.push('', `<sub>${suppressed.length} finding(s) suppressed as intentional and not counted above.</sub>`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
lines.push('', footerLine);
|
|
678
815
|
return lines.join('\n');
|
|
679
816
|
}
|
|
680
817
|
// fmt == "text" (default fallback): plain, no markdown/HTML.
|
|
681
818
|
const lines = [
|
|
682
|
-
|
|
683
|
-
|
|
819
|
+
active.length === 0
|
|
820
|
+
? 'Canary PR Guardian — no test-coverage gaps'
|
|
821
|
+
: `Canary PR Guardian — ${new Set(active.map((f) => f.path)).size} file(s) need test coverage`,
|
|
684
822
|
];
|
|
685
823
|
for (const finding of ordered) {
|
|
824
|
+
const unit = finding.unit && finding.unit !== finding.path ? ` → ${finding.unit}` : '';
|
|
686
825
|
const mark = finding.suppressed ? ' (suppressed)' : '';
|
|
687
|
-
lines.push(`[${finding.severity}] ${finding.path} (${finding.
|
|
688
|
-
`[${finding.fidelity}] ${finding.evidence}${mark}`);
|
|
826
|
+
lines.push(`[${finding.severity}] ${finding.path}${unit} — ${finding.evidence} (${finding.fidelity})${mark}`);
|
|
689
827
|
}
|
|
690
|
-
let footer = `tier ${tier}`;
|
|
828
|
+
let footer = `tier ${tier}: deterministic check, no LLM`;
|
|
691
829
|
if (degradedNotice)
|
|
692
830
|
footer += ` - ${degradedNotice}`;
|
|
693
831
|
lines.push(footer);
|
|
@@ -716,9 +854,30 @@ export const DEFAULT_SKIP_GLOBS = [
|
|
|
716
854
|
'**/*.snap',
|
|
717
855
|
// Generated slash-command artifacts and harness state — regenerated from a
|
|
718
856
|
// tracked source (skill.yaml / graph scans), never hand-authored, so a
|
|
719
|
-
// covering test makes no sense.
|
|
857
|
+
// covering test makes no sense. `**/.harness/**` also catches harness state
|
|
858
|
+
// nested under a subproject (e.g. `services/neo/.harness/…`), which the
|
|
859
|
+
// top-level `.harness/**` misses (#413).
|
|
720
860
|
'agents/commands/**',
|
|
721
861
|
'.harness/**',
|
|
862
|
+
'**/.harness/**',
|
|
863
|
+
// Dotfile config/metadata at any depth (.gitignore, .env, .eslintrc,
|
|
864
|
+
// .neorc*, .dockerignore, .npmrc, …). None carry testable code, so the
|
|
865
|
+
// heuristic tier's "no test file references this" is a false positive on them
|
|
866
|
+
// (#413 — observed on `.gitignore` / `.neorc.dev`). Matches only files whose
|
|
867
|
+
// basename starts with a dot, not source inside a dot-directory.
|
|
868
|
+
'**/.*',
|
|
869
|
+
// Build/tooling config files (not authored product logic).
|
|
870
|
+
'**/*.config.js',
|
|
871
|
+
'**/*.config.ts',
|
|
872
|
+
'**/*.config.mjs',
|
|
873
|
+
'**/*.config.cjs',
|
|
874
|
+
// Test fixtures / mocks and generated code — noise, not logic under test.
|
|
875
|
+
'**/fixtures/**',
|
|
876
|
+
'**/__fixtures__/**',
|
|
877
|
+
'**/__mocks__/**',
|
|
878
|
+
'**/testdata/**',
|
|
879
|
+
'**/generated/**',
|
|
880
|
+
'**/__generated__/**',
|
|
722
881
|
];
|
|
723
882
|
/**
|
|
724
883
|
* Parsed `canary.guardian` config block.
|
|
@@ -744,6 +903,10 @@ export class GuardianConfig {
|
|
|
744
903
|
precommit_gate;
|
|
745
904
|
coverage_paths;
|
|
746
905
|
skip_globs;
|
|
906
|
+
// #413: glob layer suppressing the HEURISTIC tier only (a source path that
|
|
907
|
+
// still has nothing a naming heuristic can judge). Distinct from
|
|
908
|
+
// `skip_globs`, which drops a path from the gate entirely at every tier.
|
|
909
|
+
heuristic_exclude;
|
|
747
910
|
// #320: bound the graph-coverage reverse-BFS. `null` means "gate-derived"
|
|
748
911
|
// (see {@link effectiveGraphDepth} — hard→1 direct edge, soft→unbounded); an
|
|
749
912
|
// explicit int here overrides the gate default on BOTH surfaces.
|
|
@@ -758,6 +921,9 @@ export class GuardianConfig {
|
|
|
758
921
|
this.precommit_gate = init.precommit_gate ?? 'soft';
|
|
759
922
|
this.coverage_paths = init.coverage_paths ?? [];
|
|
760
923
|
this.skip_globs = init.skip_globs ?? [...DEFAULT_SKIP_GLOBS];
|
|
924
|
+
this.heuristic_exclude = init.heuristic_exclude ?? [
|
|
925
|
+
...DEFAULT_HEURISTIC_EXCLUDE_GLOBS,
|
|
926
|
+
];
|
|
761
927
|
this.graph_coverage_max_depth = init.graph_coverage_max_depth ?? null;
|
|
762
928
|
}
|
|
763
929
|
}
|
|
@@ -927,6 +1093,14 @@ export function loadGuardianConfig(configPath = 'harness.config.json') {
|
|
|
927
1093
|
config.pr_gate = coerceGate(pr['gate'], config.pr_gate, 'pr.gate', warnings);
|
|
928
1094
|
}
|
|
929
1095
|
config.weak_tests = pyTruthy(pyGet(pr, 'weakTests', config.weak_tests));
|
|
1096
|
+
// #413: same present-vs-absent contract as `skipGlobs` (FIX B) — absent
|
|
1097
|
+
// keeps the built-in default, an explicit list (including `[]`) is honored
|
|
1098
|
+
// verbatim so `heuristicExclude: []` means "no glob layer". The
|
|
1099
|
+
// {@link isSourcePath} extension floor is unaffected either way.
|
|
1100
|
+
const heuristicExclude = pr['heuristicExclude'];
|
|
1101
|
+
if (Array.isArray(heuristicExclude)) {
|
|
1102
|
+
config.heuristic_exclude = heuristicExclude.map((g) => String(g));
|
|
1103
|
+
}
|
|
930
1104
|
}
|
|
931
1105
|
const precommit = pyGet(block, 'preCommit', {});
|
|
932
1106
|
if (isRecord(precommit)) {
|