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.
@@ -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 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);
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
- `Canary PR Guardian — ${active.length} unaddressed, ` +
683
- `${suppressed.length} suppressed`,
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.unit}) ` +
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)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canary-test-cli",
3
- "version": "6.1.0",
3
+ "version": "6.3.0",
4
4
  "description": "Canary — AI-powered test automation agent",
5
5
  "license": "MIT",
6
6
  "repository": {