canary-test-cli 6.6.0 → 6.7.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.
@@ -594,8 +594,6 @@ function attrValue(attrs, name) {
594
594
  /** Read a report file as UTF-8, returning `null` on any read/decode failure. */
595
595
  function readReportText(reportPath) {
596
596
  try {
597
- if (!existsSync(reportPath))
598
- return null;
599
597
  const buf = readFileSync(reportPath);
600
598
  // Fatal decode: a non-UTF-8 report must fall through, never raise out of
601
599
  // the guardian gate (mirrors Python's UnicodeDecodeError → None).
@@ -614,9 +612,19 @@ function readReportText(reportPath) {
614
612
  * blocks).
615
613
  */
616
614
  export function resolveFromReport(units, reportPath) {
615
+ const { index } = readReportIndex(reportPath);
616
+ if (index === null)
617
+ return null;
618
+ return matchUnitsToIndex(units, index);
619
+ }
620
+ /** Read + parse a coverage report, reporting each step's outcome separately. */
621
+ function readReportIndex(reportPath) {
622
+ if (!existsSync(reportPath))
623
+ return { found: false, index: null };
617
624
  const text = readReportText(reportPath);
625
+ // Present but unreadable/non-UTF-8 counts as found-and-unusable, not absent.
618
626
  if (text === null)
619
- return null;
627
+ return { found: true, index: null };
620
628
  const name = basename(reportPath).toLowerCase();
621
629
  let index;
622
630
  if (name.endsWith('.json')) {
@@ -625,7 +633,7 @@ export function resolveFromReport(units, reportPath) {
625
633
  parsed = JSON.parse(text);
626
634
  }
627
635
  catch {
628
- return null;
636
+ return { found: true, index: null };
629
637
  }
630
638
  index = parseCoverageJson(parsed);
631
639
  }
@@ -637,10 +645,15 @@ export function resolveFromReport(units, reportPath) {
637
645
  }
638
646
  else {
639
647
  // Unrecognized format → fall through to a lower fidelity tier.
640
- return null;
648
+ return { found: true, index: null };
641
649
  }
642
- if (index === null || Object.keys(index).length === 0)
643
- return null;
650
+ if (index === null || Object.keys(index).length === 0) {
651
+ return { found: true, index: null };
652
+ }
653
+ return { found: true, index };
654
+ }
655
+ /** Resolve every unit the report index can speak to (COVERAGE_VERIFIED). */
656
+ function matchUnitsToIndex(units, index) {
644
657
  const results = [];
645
658
  for (const unit of units) {
646
659
  const hits = matchHits(unit.path, index);
@@ -682,6 +695,207 @@ const TEST_PATH_RE = /(^|\/)tests?\/|(^|\/)test_[^/]*\.py$|\.test\.[^/]+$|\.spec
682
695
  export function isTestPath(path) {
683
696
  return TEST_PATH_RE.test(path);
684
697
  }
698
+ /**
699
+ * Split a basename's stem into `-`/`_`/`.`-separated components (#565).
700
+ *
701
+ * `playwright-fixture.ts` → `['playwright', 'fixture']`;
702
+ * `user.fixtures.ts` → `['user', 'fixtures']`. The final extension is dropped
703
+ * first so it never appears as a component.
704
+ */
705
+ function basenameComponents(path) {
706
+ const base = path.slice(path.lastIndexOf('/') + 1);
707
+ const dot = base.lastIndexOf('.');
708
+ const stem = dot > 0 ? base.slice(0, dot) : base;
709
+ return stem.split(/[-_.]/).filter((part) => part.length > 0);
710
+ }
711
+ /** Basename components that mark a file as test *support* rather than source. */
712
+ const FIXTURE_COMPONENTS = new Set([
713
+ 'fixture',
714
+ 'fixtures',
715
+ ]);
716
+ /**
717
+ * True if `path` is test *infrastructure* identified by filename idiom (#565).
718
+ *
719
+ * {@link isTestPath} recognises tests by directory (`tests/**`) or by the
720
+ * `*.test.*` / `*.spec.*` / `test_*.py` naming rules, and the skip layer
721
+ * recognises fixtures by the `fixtures/` *directory* convention. Neither
722
+ * catches a file that is test support by **name** while sitting in an ordinary
723
+ * source directory — the measured cases being a pytest `conftest_otel.py` and a
724
+ * Playwright `playwright-fixture.ts`, both under `scripts/otel_bootstrap/`.
725
+ *
726
+ * Such a file cannot host a test in the sense a coverage finding means: it *is*
727
+ * the harness the tests run inside. Asking it for a covering test inverts the
728
+ * relationship, so a reviewer's only correct response is 👎 — the precision
729
+ * cost #413 and #562 both describe.
730
+ *
731
+ * Two deliberate narrowings:
732
+ *
733
+ * - **Components, not substrings.** `conftestimonial.py` and
734
+ * `prefixtures.ts` are ordinary source and must survive. Matching on
735
+ * `-`/`_`/`.`-separated components is what pytest's own name-based
736
+ * resolution and the `*.fixtures.ts` idiom actually mean.
737
+ * - **`conftest` is Python-only.** It is pytest's resolution rule
738
+ * specifically; a `conftest.js` carries no framework meaning and is left as
739
+ * source.
740
+ *
741
+ * Known over-match, accepted: a production module genuinely named
742
+ * `fixture-generator.ts` is suppressed. That trade is deliberate — a missed
743
+ * finding on a file named after fixtures costs far less than a finding no
744
+ * reviewer can ever act on, which is what drags `precision = TP / (TP + FP)`
745
+ * below the promotion bar.
746
+ *
747
+ * Kept separate from {@link isTestPath} on purpose: that predicate also decides
748
+ * what *confers* graph coverage, so widening it would let a conftest mark every
749
+ * module it imports as tested — a false negative in place of a false positive.
750
+ */
751
+ export function isTestSupportPath(path) {
752
+ const components = basenameComponents(path);
753
+ if (path.endsWith('.py') && components.includes('conftest'))
754
+ return true;
755
+ return components.some((part) => FIXTURE_COMPONENTS.has(part));
756
+ }
757
+ /**
758
+ * Filenames/paths that plausibly hold nothing but type declarations (#562).
759
+ *
760
+ * A NAME GATE ONLY -- it decides which files are worth reading, never which
761
+ * are suppressed. {@link isTypeOnlyModule} always confirms against content,
762
+ * because a `types.ts` that also exports an enum or a const map is ordinary
763
+ * TypeScript and its findings are real.
764
+ */
765
+ function isTypeModuleCandidate(path) {
766
+ const base = path.slice(path.lastIndexOf('/') + 1);
767
+ if (base.endsWith('.d.ts'))
768
+ return true;
769
+ if (!/\.(ts|tsx|mts|cts)$/.test(base))
770
+ return false;
771
+ if (base === 'types.ts' || base.endsWith('.types.ts'))
772
+ return true;
773
+ return path.split('/').slice(0, -1).includes('types');
774
+ }
775
+ /**
776
+ * True if `path` is a module with no runtime content at all (#562).
777
+ *
778
+ * The false-positive class this closes is the one the heuristic-tier fix
779
+ * (#413) structurally cannot reach. {@link filterHeuristicNoise} is gated on
780
+ * `fidelity === HEURISTIC` on purpose -- a coverage-verified verdict rests on
781
+ * a real lcov row, so suppressing by path at that tier would discard
782
+ * evidence. A type-only module is the case that breaks the symmetry: the lcov
783
+ * row is accurate (39 lines, genuinely never executed) and the finding is
784
+ * still unsatisfiable, because an interface has no runtime existence for a
785
+ * test to reach. The evidence needed is therefore about the FILE, not the
786
+ * tier: prove there is nothing executable in it.
787
+ *
788
+ * Conservative in one direction on purpose. Every uncertainty -- an
789
+ * unreadable file, an unrecognised construct -- resolves to `false`, keeping
790
+ * the finding. A missed suppression costs one noisy finding; a wrong
791
+ * suppression hides untested code, which is the thing the guardian exists to
792
+ * catch.
793
+ */
794
+ export function isTypeOnlyModule(path, repoRoot) {
795
+ if (!isTypeModuleCandidate(path))
796
+ return false;
797
+ let source;
798
+ try {
799
+ source = readFileSync(join(repoRoot, path), 'utf-8');
800
+ }
801
+ catch {
802
+ return false; // unreadable -> unproven -> keep the finding
803
+ }
804
+ return isTypeOnlySource(source);
805
+ }
806
+ /** Drop `//` and `/* *\/` comments so keywords inside prose never count. */
807
+ function stripComments(source) {
808
+ const out = [];
809
+ let inBlock = false;
810
+ for (const line of splitLines(source)) {
811
+ let text = line;
812
+ if (inBlock) {
813
+ const end = text.indexOf('*/');
814
+ if (end === -1) {
815
+ out.push('');
816
+ continue;
817
+ }
818
+ text = text.slice(end + 2);
819
+ inBlock = false;
820
+ }
821
+ for (;;) {
822
+ const start = text.indexOf('/*');
823
+ if (start === -1)
824
+ break;
825
+ const end = text.indexOf('*/', start + 2);
826
+ if (end === -1) {
827
+ text = text.slice(0, start);
828
+ inBlock = true;
829
+ break;
830
+ }
831
+ text = text.slice(0, start) + text.slice(end + 2);
832
+ }
833
+ const line2 = text.indexOf('//');
834
+ out.push(line2 === -1 ? text : text.slice(0, line2));
835
+ }
836
+ return out.join('\n');
837
+ }
838
+ /**
839
+ * Top-level constructs TypeScript erases entirely at compile time (#562).
840
+ *
841
+ * An ALLOWLIST, not a denylist, and that is the load-bearing choice. A
842
+ * denylist of runtime keywords misses everything it did not enumerate -- a
843
+ * bare `register('widget')` declares nothing and still runs -- and every gap
844
+ * in it suppresses a real finding. An allowlist fails the other way: an
845
+ * unrecognised construct reads as runtime and the finding survives.
846
+ *
847
+ * Plain `import { X } from '...'` is allowed because TypeScript elides an
848
+ * import whose bindings are only used in type positions; if a binding were
849
+ * used as a value, the using statement itself would appear at top level and
850
+ * be rejected. A side-effect `import './x'` carries no binding, is never
851
+ * elided, and is therefore not matched.
852
+ */
853
+ function isErasableTopLevelLine(line) {
854
+ if (line === '')
855
+ return true;
856
+ if (/^[})\];,]+$/.test(line))
857
+ return true;
858
+ if (/^import\s+type\b/.test(line))
859
+ return true;
860
+ if (/^import\b.*\sfrom\s/.test(line))
861
+ return true;
862
+ if (/^export\s+type\b/.test(line))
863
+ return true;
864
+ const bare = line.replace(/^(?:export\s+default\s+|export\s+|declare\s+)+/, '');
865
+ return /^(?:interface|type)\s/.test(bare);
866
+ }
867
+ /**
868
+ * True if every TOP-LEVEL statement in `source` is compile-time-only.
869
+ *
870
+ * Brace depth is tracked so an interface body is never mistaken for
871
+ * statements: only depth-0 lines are judged. An unbalanced file (depth does
872
+ * not return to zero) is treated as unproven rather than type-only -- brace
873
+ * counting is lexical, so a `{` inside a string literal could otherwise hide
874
+ * the rest of the file from inspection.
875
+ */
876
+ function isTypeOnlySource(source) {
877
+ let depth = 0;
878
+ for (const raw of splitLines(stripComments(source))) {
879
+ const line = raw.trim();
880
+ if (depth === 0 && !isErasableTopLevelLine(line))
881
+ return false;
882
+ depth += bracketDelta(line);
883
+ if (depth < 0)
884
+ return false;
885
+ }
886
+ return depth === 0;
887
+ }
888
+ /** Net nesting change across one line: openers minus closers. */
889
+ function bracketDelta(line) {
890
+ let delta = 0;
891
+ for (const ch of line) {
892
+ if (ch === '{' || ch === '(' || ch === '[')
893
+ delta += 1;
894
+ else if (ch === '}' || ch === ')' || ch === ']')
895
+ delta -= 1;
896
+ }
897
+ return delta;
898
+ }
685
899
  /**
686
900
  * Extensions that denote hand-authored, executable program source (#413).
687
901
  *
@@ -1112,18 +1326,86 @@ function escapeRe(s) {
1112
1326
  * is forwarded verbatim to {@link resolveFromGraph}.
1113
1327
  */
1114
1328
  export function resolveCoverage(units, options = {}) {
1329
+ return resolveCoverageWithInput(units, options).results;
1330
+ }
1331
+ /** Classify a {@link CoverageInputState}. Zero matched is never `verified`. */
1332
+ export function coverageStatus(state) {
1333
+ if (state.unitsTotal > 0 && state.unitsMatched === state.unitsTotal) {
1334
+ return 'verified';
1335
+ }
1336
+ return state.unitsMatched > 0 ? 'partial' : 'unavailable';
1337
+ }
1338
+ const COVERAGE_EM_DASH = '\u{2014}';
1339
+ const FALLBACK_TIER = 'judged at graph/heuristic tier only';
1340
+ /**
1341
+ * The human-readable degradation notice for a coverage run, or `null` when the
1342
+ * report covered every changed unit (nothing was degraded, so nothing is said).
1343
+ *
1344
+ * A run that judged nothing (`unitsTotal === 0`) also returns `null` — it makes
1345
+ * no coverage claim in either direction, and the abstention path reports it.
1346
+ */
1347
+ export function coverageDegradedNotice(state) {
1348
+ const { requested, found, parsed, filesInReport } = state;
1349
+ const { unitsMatched: matched, unitsTotal: total } = state;
1350
+ if (total === 0)
1351
+ return null;
1352
+ const status = coverageStatus(state);
1353
+ if (status === 'verified')
1354
+ return null;
1355
+ const dash = ` ${COVERAGE_EM_DASH} `;
1356
+ if (status === 'partial') {
1357
+ return (`coverage partial${dash}report at '${requested}' matched ` +
1358
+ `${matched} of ${total} changed file(s); the other ${total - matched} ` +
1359
+ FALLBACK_TIER);
1360
+ }
1361
+ const head = `coverage unavailable${dash}`;
1362
+ if (requested === null) {
1363
+ return `${head}no coverage report was supplied; ${total} changed file(s) ${FALLBACK_TIER}`;
1364
+ }
1365
+ if (!found) {
1366
+ return `${head}report not found at '${requested}'; ${total} changed file(s) ${FALLBACK_TIER}`;
1367
+ }
1368
+ if (!parsed) {
1369
+ return (`${head}report at '${requested}' yielded no usable records; ` +
1370
+ `${total} changed file(s) ${FALLBACK_TIER}`);
1371
+ }
1372
+ return (`${head}report at '${requested}' covers ${filesInReport} file(s) but ` +
1373
+ `matched 0 of ${total} changed file(s); ${FALLBACK_TIER}`);
1374
+ }
1375
+ /**
1376
+ * {@link resolveCoverage}, additionally reporting which mode the run was in.
1377
+ *
1378
+ * Identical ladder, identical results — the only addition is the
1379
+ * {@link CoverageInputState} record, so a later reader can tell a clean result
1380
+ * from a blind one (#554).
1381
+ */
1382
+ export function resolveCoverageWithInput(units, options = {}) {
1115
1383
  const { coveragePath = null, graphPath = '.harness/graph/graph.json', repoRoot = '.', graphMaxDepth = null, } = options;
1116
1384
  // Reference-keyed map mirrors the Python `id(unit)` bookkeeping, so distinct
1117
1385
  // units that happen to share a path are still tracked independently.
1118
1386
  const resolved = new Map();
1119
1387
  let remaining = [...units];
1388
+ const coverage = {
1389
+ requested: coveragePath,
1390
+ found: false,
1391
+ parsed: false,
1392
+ filesInReport: 0,
1393
+ unitsMatched: 0,
1394
+ unitsTotal: units.length,
1395
+ };
1120
1396
  if (coveragePath !== null) {
1121
- const report = resolveFromReport(remaining, coveragePath);
1397
+ const read = readReportIndex(coveragePath);
1398
+ coverage.found = read.found;
1399
+ coverage.parsed = read.index !== null;
1400
+ coverage.filesInReport =
1401
+ read.index === null ? 0 : Object.keys(read.index).length;
1402
+ const report = read.index === null ? null : matchUnitsToIndex(remaining, read.index);
1122
1403
  // An empty array (no unit matched the report) is falsy-equivalent in the
1123
1404
  // Python `if report:` guard — fall through rather than lock in nothing.
1124
1405
  if (report !== null && report.length > 0) {
1125
1406
  for (const r of report)
1126
1407
  resolved.set(r.unit, r);
1408
+ coverage.unitsMatched = report.length;
1127
1409
  remaining = remaining.filter((u) => !resolved.has(u));
1128
1410
  }
1129
1411
  }
@@ -1140,6 +1422,6 @@ export function resolveCoverage(units, options = {}) {
1140
1422
  resolved.set(r.unit, r);
1141
1423
  }
1142
1424
  }
1143
- return units.map((unit) => resolved.get(unit));
1425
+ return { results: units.map((unit) => resolved.get(unit)), coverage };
1144
1426
  }
1145
1427
  //# sourceMappingURL=coverage.js.map
@@ -0,0 +1,97 @@
1
+ /**
2
+ * GitHub REST pagination for the guardian's read paths (#528).
3
+ *
4
+ * Both guardian clients used to call GitHub's list endpoints bare, which caps
5
+ * silently at the API default of 30 rows. That is the worst shape a denominator
6
+ * can take: a zero would look wrong, but "30 of 30" reads as a complete sample.
7
+ * A PR past 30 comments hid the sticky comment; a sticky past 30 reactions
8
+ * biased the precision tally toward whichever verdicts sorted first.
9
+ *
10
+ * The loop lives here, behind an injected {@link PageReader}, so it is unit
11
+ * tested while the network stays quarantined in the clients that construct the
12
+ * real reader.
13
+ */
14
+ /** GitHub's maximum page size for list endpoints. */
15
+ export const DEFAULT_PER_PAGE = 100;
16
+ /**
17
+ * Page ceiling ({@link DEFAULT_PER_PAGE} * this = 2000 rows). Crossing it
18
+ * throws rather than returning what was read so far: a partial list that the
19
+ * caller cannot distinguish from a complete one is the defect this module
20
+ * exists to remove, and rebuilding it inside the fix would be worse than the
21
+ * original.
22
+ */
23
+ export const MAX_PAGES = 20;
24
+ /**
25
+ * `<url>; rel="next"` entries in a `Link` header. The angle brackets are
26
+ * required — a header with a bare `rel="next"` and no URL yields no match, so
27
+ * a malformed header degrades to "no next page" instead of a guessed URL.
28
+ */
29
+ const LINK_ENTRY = /<([^>]+)>\s*;\s*rel\s*=\s*"?([a-zA-Z]+)"?/g;
30
+ /** The `rel="next"` URL from a `Link` header, or null if there is no next. */
31
+ export function parseNextLink(header) {
32
+ if (header === null || header.trim() === '')
33
+ return null;
34
+ for (const match of header.matchAll(LINK_ENTRY)) {
35
+ if (match[2].toLowerCase() !== 'next')
36
+ continue;
37
+ const url = match[1].trim();
38
+ // Only absolute http(s) — never relay a relative or garbage target.
39
+ return /^https?:\/\//i.test(url) ? url : null;
40
+ }
41
+ return null;
42
+ }
43
+ /**
44
+ * Request the largest page GitHub will serve. An explicit `per_page` already on
45
+ * the URL wins, so a caller can still ask for a small page deliberately.
46
+ */
47
+ export function withPerPage(url, perPage = DEFAULT_PER_PAGE) {
48
+ const parsed = new URL(url);
49
+ if (!parsed.searchParams.has('per_page')) {
50
+ parsed.searchParams.set('per_page', String(perPage));
51
+ }
52
+ return parsed.toString();
53
+ }
54
+ /**
55
+ * Read every page from `startUrl`, following `Link: rel="next"`.
56
+ *
57
+ * Throws on a `Link` cycle or past {@link MAX_PAGES}; a non-array body counts
58
+ * as zero rows (matching the clients' prior defensive shape for an error
59
+ * payload). Never returns a short list quietly.
60
+ */
61
+ export async function readAllPages(startUrl, read) {
62
+ const rows = [];
63
+ const visited = new Set();
64
+ let url = withPerPage(startUrl);
65
+ let pages = 0;
66
+ while (url !== null) {
67
+ if (visited.has(url)) {
68
+ throw new Error(`GitHub paging cycle: ${url} was already read`);
69
+ }
70
+ visited.add(url);
71
+ pages += 1;
72
+ if (pages > MAX_PAGES) {
73
+ throw new Error(`GitHub paging exceeded ${MAX_PAGES} pages (${MAX_PAGES * DEFAULT_PER_PAGE}+ rows) ` +
74
+ `starting at ${startUrl} -- refusing to report a truncated read`);
75
+ }
76
+ const page = await read(url);
77
+ if (Array.isArray(page.body))
78
+ rows.push(...page.body);
79
+ url = parseNextLink(page.linkHeader);
80
+ }
81
+ return rows;
82
+ }
83
+ /**
84
+ * A {@link PageReader} over `fetch`. The ONLY place in this module that touches
85
+ * the network; `onError` lets each client keep its own status mapping (the
86
+ * comment client distinguishes 403 as a permission error, the reactions client
87
+ * does not).
88
+ */
89
+ export function restPageReader(headers, onError) {
90
+ return async (url) => {
91
+ const resp = await fetch(url, { method: 'GET', headers });
92
+ if (!resp.ok)
93
+ throw onError(resp.status, url);
94
+ return { body: await resp.json(), linkHeader: resp.headers.get('link') };
95
+ };
96
+ }
97
+ //# sourceMappingURL=github-paging.js.map