canary-test-cli 6.5.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.
@@ -53,12 +53,12 @@ import { AuthoringContext, InSessionAgentProbe, InSessionAgentTier, decideBlock,
53
53
  import { RestReactionsClient, collectAdjudications, loadAdjudicationRecords, renderPrecision, summarizePrecision, } from './adjudication.js';
54
54
  import { emitAnalysis } from './analysis-emit.js';
55
55
  import { gateOutcome } from '../core/gate-result.js';
56
- import { resolveCoverage, validateCoverageJson, } from './coverage.js';
56
+ import { coverageDegradedNotice, resolveCoverage, resolveCoverageWithInput, validateCoverageJson, } from './coverage.js';
57
57
  import { buildApiDelta, writeApiDelta } from './delta-emitter.js';
58
58
  import { extractApiDiff } from './diff-extractor.js';
59
59
  import { HardGateAbstained, HardGateBlocked, RestBranchProtectionClient, applyHardGate, renderPlaybook, } from './hard-gate.js';
60
60
  import { mapImpact } from './impact-mapper.js';
61
- import { applySuppressions, buildFindings, buildWeakTestFindings, computeExitCode, effectiveGraphDepth, filterHeuristicNoise, filterSkipped, filterTestUnits, findReexportOnly, loadGuardianConfig, render, scopeDiff, } from './pr-check.js';
61
+ import { applySuppressions, buildFindings, buildWeakTestFindings, computeExitCode, effectiveGraphDepth, filterHeuristicNoise, filterSkipped, filterTestSupportUnits, filterTestUnits, filterTypeOnlyUnits, findReexportOnly, loadGuardianConfig, render, scopeDiff, } from './pr-check.js';
62
62
  import { RestGitHubClient, degradationAnnotation, upsertStickyComment, } from './pr-comment.js';
63
63
  import { buildSummary } from './summary-emitter.js';
64
64
  import { resolveTier } from './tier.js';
@@ -202,6 +202,53 @@ export function prContextFromEnv(env) {
202
202
  }
203
203
  return null;
204
204
  }
205
+ /**
206
+ * The `https://github.com/<owner>/<repo>/blob/<sha>` prefix for comment
207
+ * permalinks, or `null` when it cannot be resolved.
208
+ *
209
+ * Prefers `pull_request.head.sha` from the event payload over `GITHUB_SHA`: on
210
+ * a `pull_request` event `GITHUB_SHA` is the ephemeral merge commit, and a blob
211
+ * URL against it can 404 once the ref is gone. The head SHA is a real commit on
212
+ * the contributor's branch and stays resolvable.
213
+ *
214
+ * Returns `null` rather than a partial URL whenever repo or SHA is missing, so
215
+ * {@link render} falls back to plain code text. That degradation is deliberate:
216
+ * an unresolvable link still *looks* clickable, which is worse than no link.
217
+ */
218
+ export function blobBaseFromEnv(env) {
219
+ const repo = env['GITHUB_REPOSITORY'];
220
+ if (!repo || !repo.includes('/'))
221
+ return null;
222
+ const sha = headShaFromEvent(env['GITHUB_EVENT_PATH']) ?? env['GITHUB_SHA'];
223
+ if (!sha)
224
+ return null;
225
+ return `https://github.com/${repo}/blob/${sha}`;
226
+ }
227
+ /**
228
+ * `pull_request.head.sha` from the event payload at `eventPath`, or `null`.
229
+ *
230
+ * Every failure — no path, unreadable file, non-JSON body, a payload without a
231
+ * `pull_request` — returns `null` so {@link blobBaseFromEnv} falls through to
232
+ * `GITHUB_SHA` rather than dropping links entirely: a merge-commit link still
233
+ * beats no link.
234
+ */
235
+ function headShaFromEvent(eventPath) {
236
+ if (!eventPath)
237
+ return null;
238
+ try {
239
+ // Optional chaining carries the shape check: on a payload that is not an
240
+ // object (a bare number or string), `?.` short-circuits to `undefined`
241
+ // exactly as an explicit `typeof === 'object'` guard would.
242
+ const event = JSON.parse(readFileSync(eventPath, 'utf-8'));
243
+ const head = event?.pull_request?.head?.sha;
244
+ if (typeof head !== 'string')
245
+ return null;
246
+ return head || null;
247
+ }
248
+ catch {
249
+ return null;
250
+ }
251
+ }
205
252
  /**
206
253
  * At-desk fork signal (guard b), FAIL-CLOSED on ambiguity.
207
254
  *
@@ -787,8 +834,8 @@ function precisionCmd(opts, deps) {
787
834
  * PR context is resolvable from env, prints the body instead of crashing; a
788
835
  * read-only-token degradation is surfaced LOUDLY.
789
836
  */
790
- async function postStickyComment(findings, resolution, deps) {
791
- const body = render(findings, 'comment', resolution.effective, resolution.degraded_notice);
837
+ async function postStickyComment(findings, resolution, deps, gateMeta = null) {
838
+ const body = render(findings, 'comment', resolution.effective, resolution.degraded_notice, gateMeta, blobBaseFromEnv(deps.env));
792
839
  const ctx = prContextFromEnv(deps.env);
793
840
  if (ctx === null) {
794
841
  deps.out(`guardian: no PR context in env ${EM_DASH} printing instead.`);
@@ -806,10 +853,17 @@ async function postStickyComment(findings, resolution, deps) {
806
853
  // D7: every filtered path stays visible as a SkipEntry, never folded
807
854
  // into "passed". One entry per path so the rendered count still equals
808
855
  // the path count the old `N path(s) skipped` line reported.
809
- function prCheckSkipEntries(skipped, testUnits, barrelUnits, noisePaths = []) {
856
+ function prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits = [], noisePaths = [], typeOnlyUnits = []) {
810
857
  return [
811
858
  ...skipped.map((u) => ({ name: u.path, reason: 'skipGlobs' })),
812
859
  ...testUnits.map((u) => ({ name: u.path, reason: 'test path' })),
860
+ // #565: distinct from 'test path' on purpose -- these are test
861
+ // infrastructure recognised by filename idiom, and adjudication needs to
862
+ // tell the two suppression causes apart.
863
+ ...supportUnits.map((u) => ({ name: u.path, reason: 'test support' })),
864
+ // #562: likewise distinct -- adjudication has to be able to measure this
865
+ // class separately, since it is the one that held precision at 13/20.
866
+ ...typeOnlyUnits.map((u) => ({ name: u.path, reason: 'type-only module' })),
813
867
  ...barrelUnits.map((u) => ({
814
868
  name: u.path,
815
869
  reason: 're-export barrel',
@@ -839,7 +893,12 @@ function abstainPrCheck(skipped, format, deps) {
839
893
  for (const line of PR_CHECK_ABSTAIN_REMEDIATION)
840
894
  deps.out(line);
841
895
  if (format === 'json') {
842
- deps.out(ensureAscii(JSON.stringify({ findings: [], tier: 0, checked: 0, abstained: true }, null, 2)));
896
+ deps.out(ensureAscii(JSON.stringify(
897
+ // #579: `skipped` carries the denominator the abstention collapsed
898
+ // to. Without it a consumer sees `abstained: true` and cannot tell
899
+ // WHAT was dropped or why -- the #508 class one layer down, on the
900
+ // only surface a machine can read.
901
+ { findings: [], tier: 0, checked: 0, abstained: true, skipped }, null, 2)));
843
902
  }
844
903
  throw new CliExit(outcome.exitCode); // EXIT_ABSTAINED
845
904
  }
@@ -923,19 +982,31 @@ async function prCheckCmd(opts, deps) {
923
982
  const [keptSkip, skipped] = filterSkipped(units, config.skip_globs);
924
983
  // FIX A: drop test-path units -- a test does not itself need a test.
925
984
  const [keptTest, testUnits] = filterTestUnits(keptSkip);
985
+ // #565: drop test *support* units -- a conftest / fixture module is the
986
+ // harness the tests run inside, so no test can cover it at any tier.
987
+ const [keptSupport, supportUnits] = filterTestSupportUnits(keptTest);
988
+ // #562: drop modules with no runtime content -- an interface cannot be
989
+ // executed, so its uncovered lines are a finding no test could ever satisfy.
990
+ // `.` (cwd), matching the repoRoot convention `resolveCoverageWithInput`
991
+ // already uses for the heuristic tier's own file reads.
992
+ const [keptTyped, typeOnlyUnits] = filterTypeOnlyUnits(keptSupport, '.');
926
993
  // FIX 2: drop pure re-export/barrel files.
927
994
  const reexportPaths = findReexportOnly(diffText);
928
- const barrelUnits = keptTest.filter((u) => reexportPaths.has(u.path));
929
- const kept = keptTest.filter((u) => !reexportPaths.has(u.path));
995
+ const barrelUnits = keptTyped.filter((u) => reexportPaths.has(u.path));
996
+ const kept = keptTyped.filter((u) => !reexportPaths.has(u.path));
930
997
  // Advisory weak-test findings for added tests that assert nothing.
931
998
  const weakFindings = config.weak_tests
932
999
  ? buildWeakTestFindings(testUnits, diffText)
933
1000
  : [];
934
- const preFilterSkipped = skipped.length + testUnits.length + barrelUnits.length;
1001
+ const preFilterSkipped = skipped.length +
1002
+ testUnits.length +
1003
+ barrelUnits.length +
1004
+ supportUnits.length +
1005
+ typeOnlyUnits.length;
935
1006
  if (kept.length === 0 && weakFindings.length === 0) {
936
- abstainPrCheck(prCheckSkipEntries(skipped, testUnits, barrelUnits), opts.format, deps);
1007
+ abstainPrCheck(prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits, [], typeOnlyUnits), opts.format, deps);
937
1008
  }
938
- const results = resolveCoverage(kept, {
1009
+ const { results, coverage } = resolveCoverageWithInput(kept, {
939
1010
  coveragePath: opts.coverage ?? null,
940
1011
  // #320: under a hard gate the graph tier requires a DIRECT test->source edge
941
1012
  // (depth 1); soft stays unbounded. An explicit config value wins.
@@ -953,7 +1024,7 @@ async function prCheckCmd(opts, deps) {
953
1024
  // SKIP rather than rendering an empty "0 unaddressed" report -- an adopter
954
1025
  // must be able to tell "nothing was judgeable" from "everything passed".
955
1026
  if (scoredResults.length === 0 && findings.length === 0) {
956
- abstainPrCheck(prCheckSkipEntries(skipped, testUnits, barrelUnits, noiseResults.map((r) => r.unit.path)), opts.format, deps);
1027
+ abstainPrCheck(prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits, noiseResults.map((r) => r.unit.path), typeOnlyUnits), opts.format, deps);
957
1028
  }
958
1029
  // SC-5 (PR half): resolve the requested tier against actual capability. No
959
1030
  // agent runtime exists (default NoAgentProbe), so any `pr.tier > 0` drops to
@@ -966,6 +1037,22 @@ async function prCheckCmd(opts, deps) {
966
1037
  // Compute the gate result once, up front: the emitted record carries it and it
967
1038
  // is the process exit at the end (SC-4 -- emit never changes the exit logic).
968
1039
  const exitCode = computeExitCode(findings, effectiveGate);
1040
+ // #554: every surface below carries the coverage-input state, so a run that
1041
+ // never saw a coverage report cannot present as one that checked and passed.
1042
+ const gateMeta = {
1043
+ checked: scoredResults.length,
1044
+ abstained: false,
1045
+ coverage,
1046
+ };
1047
+ const coverageNotice = coverageDegradedNotice(coverage);
1048
+ if (coverageNotice) {
1049
+ // `--format json` owns stdout: a `::warning::` line there would make the
1050
+ // document unparseable, so the annotation goes to stderr on that path. Both
1051
+ // streams are scanned for workflow commands, so CI still sees it.
1052
+ const machineStdout = !opts.postComment && !opts.emitAnalysis && opts.format === 'json';
1053
+ (machineStdout ? deps.err : deps.out)(degradationAnnotation(coverageNotice));
1054
+ appendStepSummary(deps.env, coverageNotice);
1055
+ }
969
1056
  let commentPosted = false;
970
1057
  if (opts.emitAnalysis) {
971
1058
  // SC-10 producer half: write ONE record to the analyses channel. On an
@@ -981,6 +1068,7 @@ async function prCheckCmd(opts, deps) {
981
1068
  exit_code: exitCode,
982
1069
  checked: scoredResults.length,
983
1070
  abstained: false, // an abstained run exits before emit (see plan)
1071
+ coverage,
984
1072
  });
985
1073
  if (res.action === 'emitted') {
986
1074
  deps.out(`guardian: wrote analysis record ${RIGHT_ARROW} ${res.path}`);
@@ -991,18 +1079,18 @@ async function prCheckCmd(opts, deps) {
991
1079
  deps.out(degradationAnnotation(res.notice));
992
1080
  appendStepSummary(deps.env, res.notice);
993
1081
  deps.err(res.notice);
994
- await postStickyComment(findings, resolution, deps);
1082
+ await postStickyComment(findings, resolution, deps, gateMeta);
995
1083
  commentPosted = true;
996
1084
  }
997
1085
  }
998
1086
  if (opts.postComment && !commentPosted) {
999
1087
  // Explicit `--post-comment`: post/upsert unless the SC-10 fallback already
1000
1088
  // posted this run.
1001
- await postStickyComment(findings, resolution, deps);
1089
+ await postStickyComment(findings, resolution, deps, gateMeta);
1002
1090
  }
1003
1091
  else if (!opts.emitAnalysis && !opts.postComment) {
1004
1092
  // Local, non-posting default: render to stdout in `--format`.
1005
- deps.out(render(findings, opts.format, resolution.effective, resolution.degraded_notice, { checked: scoredResults.length, abstained: false }));
1093
+ deps.out(render(findings, opts.format, resolution.effective, resolution.degraded_notice, gateMeta, blobBaseFromEnv(deps.env)));
1006
1094
  }
1007
1095
  throw new CliExit(exitCode);
1008
1096
  }
@@ -1016,8 +1104,17 @@ function buildGaps(diffText, config, coveragePath, graphMaxDepth) {
1016
1104
  const units = scopeDiff(diffText);
1017
1105
  const [keptSkip] = filterSkipped(units, config.skip_globs);
1018
1106
  const [keptTest] = filterTestUnits(keptSkip);
1107
+ // #565: never hand the authoring tier a conftest/fixture module either -- a
1108
+ // generated "test for the fixture" is exactly the inversion the gate exists
1109
+ // to prevent, except it also writes a file (measured: this surface proposed
1110
+ // `scripts/otel_bootstrap/test_conftest_otel.py`).
1111
+ const [keptSupport] = filterTestSupportUnits(keptTest);
1112
+ // #562: nor a type-only module -- measured, this surface proposed writing
1113
+ // `src/ConfirmModal/types.test.ts` to test an interface, a file whose only
1114
+ // possible content is an assertion about nothing.
1115
+ const [keptTyped] = filterTypeOnlyUnits(keptSupport, '.');
1019
1116
  const reexportPaths = findReexportOnly(diffText);
1020
- const kept = keptTest.filter((u) => !reexportPaths.has(u.path));
1117
+ const kept = keptTyped.filter((u) => !reexportPaths.has(u.path));
1021
1118
  if (kept.length === 0)
1022
1119
  return [];
1023
1120
  const results = resolveCoverage(kept, { coveragePath, graphMaxDepth });
@@ -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