canary-test-cli 7.1.0 → 8.0.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 (128) hide show
  1. package/agents/skills/README.md +327 -0
  2. package/agents/skills/canary:generate.md +49 -0
  3. package/agents/skills/canary:init.md +37 -0
  4. package/agents/skills/canary:migrate.md +66 -0
  5. package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
  6. package/agents/skills/claude-code/canary-batwoman/SKILL.md +119 -0
  7. package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
  8. package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
  9. package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
  10. package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
  11. package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
  12. package/agents/skills/claude-code/canary-cassandra/SKILL.md +187 -0
  13. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +270 -0
  14. package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
  15. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +178 -0
  16. package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
  17. package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
  18. package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
  19. package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
  20. package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
  21. package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
  22. package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
  23. package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
  24. package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
  25. package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
  26. package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
  27. package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
  28. package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
  29. package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
  30. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +197 -0
  31. package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
  32. package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
  33. package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
  34. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
  35. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
  36. package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
  37. package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
  38. package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
  39. package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
  40. package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
  41. package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
  42. package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
  43. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +144 -0
  44. package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
  45. package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
  46. package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
  47. package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
  48. package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
  49. package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
  50. package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
  51. package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
  52. package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
  53. package/agents/skills/claude-code/canary-screech/SKILL.md +109 -0
  54. package/agents/skills/claude-code/canary-screech/scripts/blast.mjs +125 -0
  55. package/agents/skills/claude-code/canary-screech/scripts/cli.mjs +128 -0
  56. package/agents/skills/claude-code/canary-screech/scripts/cluster.mjs +97 -0
  57. package/agents/skills/claude-code/canary-screech/scripts/history.mjs +73 -0
  58. package/agents/skills/claude-code/canary-screech/scripts/redness.mjs +94 -0
  59. package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
  60. package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
  61. package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
  62. package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
  63. package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
  64. package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
  65. package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
  66. package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
  67. package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
  68. package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
  69. package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
  70. package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
  71. package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
  72. package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
  73. package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
  74. package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
  75. package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
  76. package/agents/skills/lib/parse-args.mjs +275 -0
  77. package/dist/engine/analysis/batwoman/audit.js +39 -0
  78. package/dist/engine/analysis/batwoman/closure.js +159 -0
  79. package/dist/engine/analysis/batwoman/gh-history.js +119 -0
  80. package/dist/engine/analysis/batwoman/probes.js +195 -0
  81. package/dist/engine/analysis/batwoman/registry.js +142 -0
  82. package/dist/engine/analysis/batwoman/render.js +194 -0
  83. package/dist/engine/analysis/batwoman/run-window.js +122 -0
  84. package/dist/engine/analysis/batwoman/text.js +84 -0
  85. package/dist/engine/analysis/batwoman/triggers.js +122 -0
  86. package/dist/engine/analysis/batwoman/verdict.js +64 -0
  87. package/dist/engine/analysis/cli.js +47 -14
  88. package/dist/engine/analysis/gh-flaky/gh-run-attempts.js +206 -0
  89. package/dist/engine/batwoman-cli.js +119 -0
  90. package/dist/engine/ci-ready-cli.js +71 -0
  91. package/dist/engine/cli-commands.js +49 -72
  92. package/dist/engine/cli.core.js +16 -0
  93. package/dist/engine/company-knowledge-cli.js +10 -2
  94. package/dist/engine/core/ci-ready.js +112 -0
  95. package/dist/engine/core/company-knowledge.js +8 -0
  96. package/dist/engine/core/migrator.js +147 -20
  97. package/dist/engine/core/permission-matrix.js +219 -0
  98. package/dist/engine/core/quality-scorer.js +27 -19
  99. package/dist/engine/core/scaling-curve.js +143 -0
  100. package/dist/engine/core/skill-dispatch.js +115 -0
  101. package/dist/engine/core/skill-examples.js +103 -3
  102. package/dist/engine/core/skill-registry.js +59 -4
  103. package/dist/engine/core/string-literals.js +3 -1
  104. package/dist/engine/core/test-files.js +77 -0
  105. package/dist/engine/core/vacuity-scanner.js +330 -15
  106. package/dist/engine/core/workflow-discovery.js +41 -23
  107. package/dist/engine/guardian/adjudication-github.js +136 -0
  108. package/dist/engine/guardian/adjudication.js +119 -340
  109. package/dist/engine/guardian/analysis-emit.js +7 -2
  110. package/dist/engine/guardian/cli.js +277 -249
  111. package/dist/engine/guardian/coverage.js +2 -1
  112. package/dist/engine/guardian/diff-coverage/coverage-delta.js +162 -0
  113. package/dist/engine/guardian/diff-coverage/formats/cobertura.js +45 -1
  114. package/dist/engine/guardian/diff-coverage/orchestrator.js +25 -21
  115. package/dist/engine/guardian/diff-coverage/paths.js +5 -9
  116. package/dist/engine/guardian/diff-coverage/report-tier.js +88 -12
  117. package/dist/engine/guardian/diff-extractor.js +31 -32
  118. package/dist/engine/guardian/pr-check.js +354 -223
  119. package/dist/engine/guardian/pr-comment.js +35 -58
  120. package/dist/engine/guardian/weak-test.js +236 -0
  121. package/dist/engine/mcp-server.js +67 -4
  122. package/dist/engine/permission-matrix-cli.js +51 -0
  123. package/dist/engine/scaling-curve-cli.js +147 -0
  124. package/dist/engine/skills-cli.js +171 -51
  125. package/dist/engine/workflow-cli.js +85 -65
  126. package/dist/reporters/testtracker.d.ts +1 -1
  127. package/dist/reporters/testtracker.js +1 -1
  128. package/package.json +3 -2
@@ -35,9 +35,10 @@
35
35
  export { coverageLimits } from './diff-coverage/formats/cobertura.js';
36
36
  export { parseCoverageJson } from './diff-coverage/formats/coverage-json.js';
37
37
  export { validateCoverageJson, } from './diff-coverage/formats/coverage-json-lint.js';
38
+ export { coverageDeltaNotice, coverageDeltaStatus, resolveCoverageDelta, } from './diff-coverage/coverage-delta.js';
38
39
  export { resolveFromGraph } from './diff-coverage/graph-tier.js';
39
40
  export { resolveFromHeuristic } from './diff-coverage/heuristic-tier.js';
40
- export { coverageDegradedNotice, coverageStatus, resolveCoverage, resolveCoverageWithInput, } from './diff-coverage/orchestrator.js';
41
+ export { coverageCauses, coverageDegradedNotice, coverageStatus, resolveCoverage, resolveCoverageWithInput, } from './diff-coverage/orchestrator.js';
41
42
  export { isSourcePath, isTestPath, isTestSupportPath, } from './diff-coverage/paths.js';
42
43
  export { resolveFromReport } from './diff-coverage/report-tier.js';
43
44
  export { isTypeOnlyModule } from './diff-coverage/type-only.js';
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Coverage **regression** on units a PR touches, versus base (#606).
3
+ *
4
+ * The fidelity ladder in `orchestrator.ts` answers one question — *is this
5
+ * changed unit covered at all?* — and is blind to the case that actually ships
6
+ * regressions: a file that was 95% covered on `main` and is 60% covered on the
7
+ * PR head. Every line is still "covered", so the ladder reports nothing. This
8
+ * module asks the second question by comparing two coverage reports over the
9
+ * same touched files.
10
+ *
11
+ * It is a leaf of the diff-coverage package: it reads report indexes through
12
+ * `report-tier.js` and speaks only in the shapes `types.js` defines. Finding
13
+ * construction and severity live in `pr-check.ts`, which owns
14
+ * `GuardianFinding` — nothing here imports upward.
15
+ *
16
+ * **Why a ratio and not line identity.** A diff shifts line numbers, so base
17
+ * line N and head line N are not the same line. A per-line "was hit, now
18
+ * unhit" rule would manufacture findings out of an insertion. The trigger is
19
+ * therefore the whole-file covered ratio; the raw counts travel alongside it so
20
+ * a reviewer can see the shape of the drop rather than a bare percentage.
21
+ *
22
+ * **Why the state object.** Most CI never uploads a base-branch coverage
23
+ * artifact, which is the accepted risk #606 was filed with. A delta that was
24
+ * never computed must not read as a delta that came back clean, so — exactly
25
+ * as `CoverageInputState` does for the ladder (#554) — every field here is a
26
+ * count or a fact about what the run *observed*, never a verdict, and a zero
27
+ * denominator classifies as `unavailable` rather than as agreement (ADR 0010).
28
+ */
29
+ import { countEligible, readReportIndex, zeroMatchClause, } from './report-tier.js';
30
+ import { matchFile } from './types.js';
31
+ /** Classify a {@link CoverageDeltaState}. Zero compared is never `compared`. */
32
+ export function coverageDeltaStatus(state) {
33
+ if (state.unitsTotal > 0 && state.unitsCompared === state.unitsTotal) {
34
+ return 'compared';
35
+ }
36
+ return state.unitsCompared > 0 ? 'partial' : 'unavailable';
37
+ }
38
+ /**
39
+ * Float-noise guard, in percentage points. Two reports of the same coverage can
40
+ * differ in the last bits of a division; a drop under a tenth of a point is
41
+ * arithmetic, not a regression.
42
+ */
43
+ const DROP_EPSILON_POINTS = 0.1;
44
+ const EM_DASH = '\u{2014}';
45
+ const HEAD_ONLY = 'were judged against the head report only, so a coverage REGRESSION on ' +
46
+ 'them cannot be detected by this run';
47
+ /**
48
+ * The LOUD degradation line for a delta run, or `null` when every touched unit
49
+ * was compared (nothing degraded, so nothing is said).
50
+ *
51
+ * A run that judged nothing (`unitsTotal === 0`) also returns `null`: it makes
52
+ * no claim in either direction, and the caller's abstention path reports that.
53
+ */
54
+ export function coverageDeltaNotice(state) {
55
+ const { baseRequested, baseFound, baseParsed, filesInBaseReport } = state;
56
+ const { unitsCompared: compared, unitsTotal: total } = state;
57
+ if (total === 0)
58
+ return null;
59
+ const status = coverageDeltaStatus(state);
60
+ if (status === 'compared')
61
+ return null;
62
+ const dash = ` ${EM_DASH} `;
63
+ if (status === 'partial') {
64
+ return (`coverage delta partial${dash}head-only for ${total - compared} of ` +
65
+ `${total} changed file(s); base report '${baseRequested}' matched ` +
66
+ `${compared}. The unmatched file(s) ${HEAD_ONLY}`);
67
+ }
68
+ const head = `coverage delta unavailable${dash}head-only: `;
69
+ const tail = `; ${total} changed file(s) ${HEAD_ONLY}`;
70
+ if (baseRequested === null) {
71
+ return `${head}no base coverage report was supplied${tail}`;
72
+ }
73
+ if (!baseFound) {
74
+ return `${head}base report not found at '${baseRequested}'${tail}`;
75
+ }
76
+ if (!baseParsed) {
77
+ return `${head}base report at '${baseRequested}' yielded no usable records${tail}`;
78
+ }
79
+ return (`${head}base report at '${baseRequested}' covers ${filesInBaseReport} ` +
80
+ `file(s) but ${zeroMatchClause(state.unitsEligible, total)}${tail}`);
81
+ }
82
+ /**
83
+ * Count a file's covered/coverable lines in one report index, or `null` when
84
+ * the report cannot speak to the path with a usable denominator.
85
+ *
86
+ * `coverable` is the report's own instrumented set where the format declares
87
+ * one (lcov, Cobertura), else the lines it recorded. A file present with an
88
+ * empty record has a denominator of zero: a ratio there would be invented, so
89
+ * the unit is skipped rather than scored — the same reason the ladder never
90
+ * grades a unit it could not measure.
91
+ */
92
+ function ratioFor(path, index) {
93
+ const file = matchFile(path, index);
94
+ if (file === null)
95
+ return null;
96
+ const coverableLines = file.coverable !== null
97
+ ? [...file.coverable]
98
+ : Object.keys(file.hits).map(Number);
99
+ if (coverableLines.length === 0)
100
+ return null;
101
+ let covered = 0;
102
+ for (const line of coverableLines) {
103
+ if ((file.hits[line] ?? 0) > 0)
104
+ covered++;
105
+ }
106
+ return { covered, coverable: coverableLines.length };
107
+ }
108
+ const percent = (r) => (r.covered / r.coverable) * 100;
109
+ /**
110
+ * Compare base-branch and head coverage over the touched units (#606).
111
+ *
112
+ * Returns one {@link UnitCoverageDelta} per unit **both** reports could score,
113
+ * plus the {@link CoverageDeltaState} denominator. A unit either side cannot
114
+ * score is silently absent from `deltas` and loudly absent from
115
+ * `unitsCompared` — the notice, not the empty array, is what reports it.
116
+ */
117
+ export function resolveCoverageDelta(units, options = {}) {
118
+ const { baseCoveragePath = null, headCoveragePath = null } = options;
119
+ const repoRoot = options.repoRoot ?? '.';
120
+ const state = {
121
+ baseRequested: baseCoveragePath,
122
+ baseFound: false,
123
+ baseParsed: false,
124
+ filesInBaseReport: 0,
125
+ unitsCompared: 0,
126
+ unitsTotal: units.length,
127
+ };
128
+ if (baseCoveragePath === null)
129
+ return { deltas: [], state };
130
+ const baseRead = readReportIndex(baseCoveragePath);
131
+ state.baseFound = baseRead.found;
132
+ state.baseParsed = baseRead.index !== null;
133
+ state.filesInBaseReport =
134
+ baseRead.index === null ? 0 : Object.keys(baseRead.index).length;
135
+ if (baseRead.index === null)
136
+ return { deltas: [], state };
137
+ const paths = units.map((u) => u.path);
138
+ state.unitsEligible = countEligible(paths, baseRead.index, baseCoveragePath, repoRoot);
139
+ if (headCoveragePath === null)
140
+ return { deltas: [], state };
141
+ const headRead = readReportIndex(headCoveragePath);
142
+ if (headRead.index === null)
143
+ return { deltas: [], state };
144
+ const deltas = [];
145
+ for (const unit of units) {
146
+ const base = ratioFor(unit.path, baseRead.index);
147
+ const head = ratioFor(unit.path, headRead.index);
148
+ if (base === null || head === null)
149
+ continue;
150
+ state.unitsCompared++;
151
+ const dropPoints = percent(base) - percent(head);
152
+ deltas.push({
153
+ path: unit.path,
154
+ base,
155
+ head,
156
+ dropPoints,
157
+ regressed: dropPoints > DROP_EPSILON_POINTS,
158
+ });
159
+ }
160
+ return { deltas, state };
161
+ }
162
+ //# sourceMappingURL=coverage-delta.js.map
@@ -59,12 +59,56 @@ function coberturaBody(text) {
59
59
  // Pin to the canonical (namespace-free) Cobertura root; anything else is a
60
60
  // different XML format and is rejected rather than guessed at. Strip comments
61
61
  // first so a `<foo>` inside a comment can't masquerade as the root element.
62
- const withoutComments = text.replace(/<!--[\s\S]*?-->/g, '');
62
+ const withoutComments = stripComments(text);
63
63
  const rootMatch = /<(?![?!])([A-Za-z_][\w.:-]*)/.exec(withoutComments);
64
64
  if (rootMatch === null || rootMatch[1] !== 'coverage')
65
65
  return null;
66
66
  return withoutComments;
67
67
  }
68
+ const CDATA_OPEN = '<![CDATA[';
69
+ const CDATA_CLOSE = ']]>';
70
+ const COMMENT_OPEN = '<!--';
71
+ const COMMENT_CLOSE = '-->';
72
+ /**
73
+ * Drop every XML comment, leaving CDATA sections intact.
74
+ *
75
+ * Must not be a plain `/<!--[\s\S]*?-->/g` replace: inside CDATA those markers
76
+ * are ordinary character data, so a document carrying `<!--` in one CDATA
77
+ * section and `-->` in a later one would have every real element between them
78
+ * deleted. The damage is worse than data loss — the surviving `</class>` then
79
+ * rebinds the next class's `<line>` records to the previous class's filename,
80
+ * so a crafted report can attribute one file's hit counts to another.
81
+ *
82
+ * Chunked with `indexOf` rather than scanned per character so the cost stays
83
+ * proportional to the number of markers, not the size of the capped document.
84
+ */
85
+ function stripComments(text) {
86
+ const out = [];
87
+ let i = 0;
88
+ for (;;) {
89
+ const cdata = text.indexOf(CDATA_OPEN, i);
90
+ const comment = text.indexOf(COMMENT_OPEN, i);
91
+ if (comment === -1)
92
+ break;
93
+ // A CDATA section that opens first swallows this comment marker: copy the
94
+ // whole section verbatim and resume looking after it.
95
+ if (cdata !== -1 && cdata < comment) {
96
+ const end = text.indexOf(CDATA_CLOSE, cdata + CDATA_OPEN.length);
97
+ if (end === -1)
98
+ break; // unterminated CDATA — nothing further is markup
99
+ out.push(text.slice(i, end + CDATA_CLOSE.length));
100
+ i = end + CDATA_CLOSE.length;
101
+ continue;
102
+ }
103
+ const end = text.indexOf(COMMENT_CLOSE, comment + COMMENT_OPEN.length);
104
+ if (end === -1)
105
+ break; // unterminated comment — leave the tail as-is
106
+ out.push(text.slice(i, comment));
107
+ i = end + COMMENT_CLOSE.length;
108
+ }
109
+ out.push(text.slice(i));
110
+ return out.join('');
111
+ }
68
112
  const CLOSE_CLASS = '</class>';
69
113
  /**
70
114
  * Walk each `<class>` open tag, folding its lines into `index` by filename.
@@ -5,27 +5,23 @@
5
5
  */
6
6
  import { resolveFromGraph } from './graph-tier.js';
7
7
  import { resolveFromHeuristic } from './heuristic-tier.js';
8
- import { matchUnitsToIndex, readReportIndex } from './report-tier.js';
8
+ import { countEligible, instrumentedTrees, matchUnitsToIndex, readReportIndex, zeroMatchClause, } from './report-tier.js';
9
9
  /**
10
- * SC-3 orchestrator: resolve each unit at the highest available fidelity.
11
- *
12
- * The ladder is applied **per unit**, not per batch. For each unit the first
13
- * tier that has a signal for *that* unit wins:
14
- *
15
- * 1. `coveragePath` lists the unit's path → `COVERAGE_VERIFIED`
16
- * 2. else a graph node for the unit exists → `GRAPH_VERIFIED`
17
- * 3. else the naming heuristic → `HEURISTIC` (always returns)
18
- *
19
- * A unit absent from the report is NOT judged COVERAGE_VERIFIED-uncovered; it
20
- * falls through to the graph then heuristic tier (FIX 2). Returns exactly one
21
- * {@link CoverageResult} per input unit, in input order, fidelity-labeled.
22
- *
23
- * `graphMaxDepth` bounds the graph tier's reverse-BFS hop distance (#320) and
24
- * is forwarded verbatim to `resolveFromGraph`.
10
+ * SC-3 orchestrator, applied per unit: the report (`COVERAGE_VERIFIED`), else
11
+ * the graph (`GRAPH_VERIFIED`, bounded by `graphMaxDepth`, #320), else the
12
+ * naming heuristic. An absent unit falls through, never uncovered (FIX 2).
13
+ * Returns one {@link CoverageResult} per unit, in input order.
25
14
  */
26
15
  export function resolveCoverage(units, options = {}) {
27
16
  return resolveCoverageWithInput(units, options).results;
28
17
  }
18
+ /** The units the report did not verify, split by cause (#928). */
19
+ export function coverageCauses(state) {
20
+ const nonCoverable = state.unitsNonCoverable ?? 0;
21
+ const stale = state.unitsEligible ?? 0;
22
+ const absent = state.unitsTotal - state.unitsMatched - nonCoverable;
23
+ return { stale, scopeGap: absent - stale, nonCoverable };
24
+ }
29
25
  /** Classify a {@link CoverageInputState}. Zero matched is never `verified`. */
30
26
  export function coverageStatus(state) {
31
27
  if (state.unitsTotal > 0 && state.unitsMatched === state.unitsTotal) {
@@ -47,6 +43,10 @@ export function coverageDegradedNotice(state) {
47
43
  const { unitsMatched: matched, unitsTotal: total } = state;
48
44
  if (total === 0)
49
45
  return null;
46
+ const { stale, scopeGap } = coverageCauses(state);
47
+ // The report spoke to every unit, even if only to say "nothing coverable".
48
+ if (parsed && stale + scopeGap === 0)
49
+ return null;
50
50
  const status = coverageStatus(state);
51
51
  if (status === 'verified')
52
52
  return null;
@@ -68,7 +68,7 @@ export function coverageDegradedNotice(state) {
68
68
  `${total} changed file(s) ${FALLBACK_TIER}`);
69
69
  }
70
70
  return (`${head}report at '${requested}' covers ${filesInReport} file(s) but ` +
71
- `matched 0 of ${total} changed file(s); ${FALLBACK_TIER}`);
71
+ `${zeroMatchClause(state.unitsEligible, stale + scopeGap)}; ${FALLBACK_TIER}`);
72
72
  }
73
73
  /**
74
74
  * {@link resolveCoverage}, additionally reporting which mode the run was in.
@@ -97,14 +97,18 @@ export function resolveCoverageWithInput(units, options = {}) {
97
97
  coverage.parsed = read.index !== null;
98
98
  coverage.filesInReport =
99
99
  read.index === null ? 0 : Object.keys(read.index).length;
100
- const report = read.index === null ? null : matchUnitsToIndex(remaining, read.index);
101
- // An empty array (no unit matched the report) is falsy-equivalent in the
102
- // Python `if report:` guard — fall through rather than lock in nothing.
103
- if (report !== null && report.length > 0) {
100
+ if (read.index !== null) {
101
+ const nonCoverable = [];
102
+ const report = matchUnitsToIndex(remaining, read.index, nonCoverable);
104
103
  for (const r of report)
105
104
  resolved.set(r.unit, r);
106
105
  coverage.unitsMatched = report.length;
106
+ coverage.unitsNonCoverable = nonCoverable.length;
107
107
  remaining = remaining.filter((u) => !resolved.has(u));
108
+ // #928: only ABSENT units can make a report stale.
109
+ const absent = remaining.filter((u) => !nonCoverable.includes(u));
110
+ coverage.unitsEligible = countEligible(absent.map((u) => u.path), read.index, coveragePath, repoRoot);
111
+ coverage.instrumentedTrees = instrumentedTrees(read.index, coveragePath, repoRoot);
108
112
  }
109
113
  }
110
114
  if (remaining.length > 0) {
@@ -75,9 +75,11 @@ export function isTestSupportPath(path) {
75
75
  * Extensions that denote hand-authored, executable program source (#413).
76
76
  *
77
77
  * The membership rule is deliberately simple and defensible: **a programming
78
- * language belongs; data, config, markup, and style do not.** `.sh` is in (it is
79
- * executable logic — bats/shunit2 exist); `.json`, `.yaml`, `.sql`, `.css`, and
80
- * `.html` are out (nothing a naming heuristic could meaningfully judge).
78
+ * language belongs; data, config, markup, and style do not.** Shell (`.sh`,
79
+ * `.bash`, `.zsh`, `.ps1`, `.psm1`) is out since #933: ops and seed scripts drew
80
+ * "no test file references" findings no repo will ever satisfy. `.json`,
81
+ * `.yaml`, `.sql`, `.css`, and `.html` are out (nothing to meaningfully judge).
82
+ * This one set feeds both the heuristic noise filter and the coverage-unit floor.
81
83
  *
82
84
  * A repo that disagrees at the margins tunes the glob layer
83
85
  * (`canary.guardian.pr.heuristicExclude`) rather than this list.
@@ -134,12 +136,6 @@ const SOURCE_EXTENSIONS = new Set([
134
136
  '.dart',
135
137
  '.r',
136
138
  '.jl',
137
- // Shell.
138
- '.sh',
139
- '.bash',
140
- '.zsh',
141
- '.ps1',
142
- '.psm1',
143
139
  ]);
144
140
  /**
145
141
  * True if `path` looks like hand-authored program source (#413).
@@ -5,7 +5,7 @@
5
5
  * and the per-unit matching that turns a report index into verdicts.
6
6
  */
7
7
  import { existsSync, readFileSync } from 'node:fs';
8
- import { basename } from 'node:path';
8
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from 'node:path';
9
9
  import { parseCobertura } from './formats/cobertura.js';
10
10
  import { parseCoverageJson } from './formats/coverage-json.js';
11
11
  import { expandRanges, makeResult, matchFile, rangesStr, selfDescribing, splitLines, pyInt, Fidelity, } from './types.js';
@@ -88,6 +88,81 @@ export function readReportIndex(reportPath) {
88
88
  return unusable(true);
89
89
  return { found: true, index };
90
90
  }
91
+ /**
92
+ * The repo-relative prefix a report's relative paths are rooted at (#883): the
93
+ * nearest ancestor of the report's directory under which any of `samples`
94
+ * exists (vitest under `ts/` writes `src/cli.ts`). Every sample is tried, since
95
+ * a stale report names deleted files. No hit, or outside the repo: the root.
96
+ */
97
+ function anchorPrefix(samples, reportPath, root) {
98
+ const dir = relative(root, resolve(dirname(reportPath))).split(sep);
99
+ if (dir[0] === '..' || isAbsolute(dir.join('/')))
100
+ return '';
101
+ for (let i = dir.length; i > 0 && dir[0] !== ''; i--) {
102
+ const prefix = dir.slice(0, i).join('/');
103
+ if (samples.some((s) => existsSync(join(root, prefix, s)))) {
104
+ return `${prefix}/`;
105
+ }
106
+ }
107
+ return '';
108
+ }
109
+ /** The deepest directory shared by every path in `paths` ('' when none). */
110
+ function commonDir(paths) {
111
+ const dirs = paths.map((p) => p.split('/').slice(0, -1));
112
+ const first = dirs[0] ?? [];
113
+ let n = first.length;
114
+ for (const d of dirs) {
115
+ while (n > 0 && d.slice(0, n).join('/') !== first.slice(0, n).join('/'))
116
+ n--;
117
+ }
118
+ return first.slice(0, n).join('/');
119
+ }
120
+ /**
121
+ * The repo-relative source trees a report instruments (#883): one per top-level
122
+ * segment of its (anchored) paths, narrowed to the deepest shared directory.
123
+ * `''` means the report is rooted at the repo itself and covers everything.
124
+ */
125
+ export function instrumentedTrees(index, reportPath, repoRoot) {
126
+ const root = resolve(repoRoot);
127
+ const groups = new Map();
128
+ for (const raw of Object.keys(index)) {
129
+ const p = isAbsolute(raw) ? relative(root, raw).split(sep).join('/') : raw;
130
+ const clean = p.replace(/^\.\//, '');
131
+ const top = clean.split('/')[0];
132
+ groups.set(top, [...(groups.get(top) ?? []), clean]);
133
+ }
134
+ return [...groups.values()].map((paths) => {
135
+ const tree = anchorPrefix(paths, reportPath, root) + commonDir(paths);
136
+ return tree.replace(/\/$/, '');
137
+ });
138
+ }
139
+ /**
140
+ * How many changed `paths` lie inside a tree the report instruments (#883) —
141
+ * the denominator a zero-match abstention needs. Zero eligible means the files
142
+ * are outside the instrumentation scope, so no fresh report could match them;
143
+ * eligible-but-unmatched means the report itself is stale.
144
+ */
145
+ export function countEligible(paths, index, reportPath, repoRoot) {
146
+ const trees = instrumentedTrees(index, reportPath, repoRoot);
147
+ const inside = (p) => trees.some((t) => t === '' || p.startsWith(`${t}/`));
148
+ return paths.filter(inside).length;
149
+ }
150
+ /**
151
+ * The "matched 0" clause for a zero-match notice, naming its cause (#883).
152
+ * `eligible` is undefined for a producer that never computed it.
153
+ */
154
+ export function zeroMatchClause(eligible, total) {
155
+ const matched = `matched 0 of ${total} changed file(s)`;
156
+ if (eligible === undefined)
157
+ return matched;
158
+ if (eligible === 0) {
159
+ return (`${matched}: 0 of ${total} changed file(s) lie inside a tree it ` +
160
+ 'instruments, so they are outside every tree the report covers ' +
161
+ '(an instrumentation-scope gap; regenerating the report cannot fix it)');
162
+ }
163
+ return (`${matched}, though ${eligible} of ${total} changed file(s) lie inside a ` +
164
+ 'tree it instruments (the report is likely stale or from another checkout)');
165
+ }
91
166
  /** Pick the reader by report filename; `null` for a format we don't know. */
92
167
  function parseByFormat(name, text) {
93
168
  if (name.endsWith('.json')) {
@@ -107,18 +182,19 @@ function parseByFormat(name, text) {
107
182
  // Unrecognized format → fall through to a lower fidelity tier.
108
183
  return null;
109
184
  }
110
- /** Resolve every unit the report index can speak to (COVERAGE_VERIFIED). */
111
- export function matchUnitsToIndex(units, index) {
185
+ /**
186
+ * Resolve every unit the report index can speak to (COVERAGE_VERIFIED). Units
187
+ * the report lists but whose changed lines are all non-coverable are pushed to
188
+ * `nonCoverable`, so a caller can tell them from absent units (#928).
189
+ */
190
+ export function matchUnitsToIndex(units, index, nonCoverable = []) {
112
191
  const results = [];
113
192
  for (const unit of units) {
114
193
  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).
194
+ // Absent → "not instrumented", NOT "instrumented and unhit": no result, so
195
+ // the unit falls through to a lower-fidelity tier (FIX 2).
196
+ if (file === null)
120
197
  continue;
121
- }
122
198
  const { hits, coverable: measured } = file;
123
199
  const added = expandRanges(unit.added_ranges);
124
200
  // The per-line form of the check above (#655/#657): where the report says
@@ -127,9 +203,9 @@ export function matchUnitsToIndex(units, index) {
127
203
  // every changed line counts and absence means uncovered.
128
204
  const coverable = measured === null ? added : added.filter((ln) => measured.has(ln));
129
205
  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.
206
+ // Nothing coverable changed: never a pass or a finding here, and it falls
207
+ // through like an absent path — but it is recorded as its own cause.
208
+ nonCoverable.push(unit);
133
209
  continue;
134
210
  }
135
211
  const uncovered = coverable.filter((ln) => (hits[ln] ?? 0) <= 0);
@@ -103,44 +103,43 @@ function pyGet(obj, key, fallback) {
103
103
  return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : fallback;
104
104
  }
105
105
  /**
106
- * Deep equality mirroring Python `==` on JSON-shaped data: arrays compare
107
- * order-sensitively, objects compare by key set (order-insensitive), and
108
- * `None`/`undefined` are interchangeable.
106
+ * Deep equality for spec values: arrays by order, objects by key set, null and
107
+ * undefined interchangeable. A boolean never equals a number (#922): a spec's
108
+ * true -> 1 rewrite changes the type consumers see, so it is a change.
109
109
  */
110
110
  function pyEqual(a, b) {
111
111
  if (a === b)
112
112
  return true;
113
- if (a === null || a === undefined)
114
- return b === null || b === undefined;
115
- if (b === null || b === undefined)
113
+ if (isNone(a) || isNone(b))
114
+ return isNone(a) && isNone(b);
115
+ if (Array.isArray(a) || Array.isArray(b))
116
+ return pyListEqual(a, b);
117
+ return pyDictEqual(a, b);
118
+ }
119
+ /** Python `None`: JSON `null` or a missing key read as `undefined`. */
120
+ function isNone(value) {
121
+ return value === null || value === undefined;
122
+ }
123
+ /** Python `list == list`: same length, element-wise equal in order. */
124
+ function pyListEqual(a, b) {
125
+ if (!Array.isArray(a) || !Array.isArray(b))
116
126
  return false;
117
- const aArr = Array.isArray(a);
118
- const bArr = Array.isArray(b);
119
- if (aArr || bArr) {
120
- if (!aArr || !bArr || a.length !== b.length)
121
- return false;
122
- for (let i = 0; i < a.length; i++) {
123
- if (!pyEqual(a[i], b[i]))
124
- return false;
125
- }
126
- return true;
127
- }
128
- if (typeof a === 'object' && typeof b === 'object') {
129
- const ao = a;
130
- const bo = b;
131
- const aKeys = Object.keys(ao);
132
- const bKeys = Object.keys(bo);
133
- if (aKeys.length !== bKeys.length)
127
+ if (a.length !== b.length)
128
+ return false;
129
+ for (let i = 0; i < a.length; i++)
130
+ if (!pyEqual(a[i], b[i]))
134
131
  return false;
135
- for (const key of aKeys) {
136
- if (!Object.prototype.hasOwnProperty.call(bo, key))
137
- return false;
138
- if (!pyEqual(ao[key], bo[key]))
139
- return false;
140
- }
141
- return true;
142
- }
143
- return false;
132
+ return true;
133
+ }
134
+ /** Python `dict == dict`: same key set in any order, values equal. */
135
+ function pyDictEqual(a, b) {
136
+ if (typeof a !== 'object' || typeof b !== 'object')
137
+ return false;
138
+ const [ao, bo] = [a, b];
139
+ const keys = Object.keys(ao);
140
+ if (keys.length !== Object.keys(bo).length)
141
+ return false;
142
+ return keys.every((k) => Object.hasOwn(bo, k) && pyEqual(ao[k], bo[k]));
144
143
  }
145
144
  function setEqual(a, b) {
146
145
  if (a.size !== b.size)