@sackville-mcp/coverage 0.0.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or Derivative
95
+ Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Curtis Autery
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,233 @@
1
+ import { DiffFile, changedFiles, parseUnifiedDiff } from "@sackville-mcp/diff";
2
+
3
+ //#region src/uncovered.d.ts
4
+ /**
5
+ * Uncovered-new-line detection — the first, pure slice of the coverage pillar. Given a
6
+ * file's istanbul coverage and the set of lines a diff *added/changed*, classify each
7
+ * new line as covered, uncovered, or non-executable, and surface the
8
+ * executable-but-unhit ones: the **forgotten-assertion catch** that is the genuinely
9
+ * novel win under Sackville's TDD gate (a generic "what's uncovered" report largely
10
+ * duplicates the suite the agent already runs — the new lines a change introduced
11
+ * without a test exercising them is the signal worth isolating).
12
+ *
13
+ * Pure and offline: running the scoped suite to *produce* the coverage needs a
14
+ * child-process boundary (the repo has a single root `vitest.config.ts`, so there is no
15
+ * in-process Vitest-in-Vitest) and is a later slice. Keeping the differ pure is what
16
+ * lets the green gate stay deterministic.
17
+ *
18
+ * THE CORRECTNESS TRAP (ADR 0010): istanbul derives line coverage from `statementMap`,
19
+ * so a line carrying **no statement** (a blank line, a lone brace, a bare comment) is
20
+ * in *neither* the covered nor the uncovered set. A differ that treats "not covered" as
21
+ * "uncovered" would flag those false positives. We model an explicit third state,
22
+ * `nonExecutable`, and never report it as a finding.
23
+ */
24
+ interface IstanbulPosition {
25
+ line: number;
26
+ column?: number;
27
+ }
28
+ interface IstanbulRange {
29
+ start: IstanbulPosition;
30
+ end: IstanbulPosition;
31
+ }
32
+ /**
33
+ * The subset of an istanbul `FileCoverage` we read — the per-file shape inside a
34
+ * `coverage-final.json` (as emitted by `@vitest/coverage-v8`). `s` holds statement hit
35
+ * counts keyed identically to `statementMap`.
36
+ */
37
+ interface FileCoverage {
38
+ path?: string;
39
+ statementMap: Record<string, IstanbulRange>;
40
+ s: Record<string, number>;
41
+ }
42
+ type LineState = 'covered' | 'uncovered' | 'nonExecutable';
43
+ interface ClassifiedLine {
44
+ line: number;
45
+ state: LineState;
46
+ }
47
+ interface UncoveredNewLines {
48
+ /** Every new line, classified, sorted ascending (input deduped). */
49
+ lines: ClassifiedLine[];
50
+ /** The executable new lines with zero hits — the forgotten-assertion catch. */
51
+ uncovered: number[];
52
+ summary: {
53
+ covered: number;
54
+ uncovered: number;
55
+ nonExecutable: number;
56
+ total: number;
57
+ };
58
+ }
59
+ /**
60
+ * Classify a diff's `newLines` against a file's istanbul coverage. New lines are
61
+ * deduped and sorted; each is `covered` (a statement on it was hit), `uncovered` (a
62
+ * statement on it was never hit), or `nonExecutable` (no statement maps to it).
63
+ */
64
+ declare function uncoveredNewLines(fc: FileCoverage, newLines: number[]): UncoveredNewLines;
65
+ //#endregion
66
+ //#region src/coveragepy.d.ts
67
+ /** The per-file shape inside a `coverage json` report (line-number lists). */
68
+ interface CoveragePyFile {
69
+ executed_lines: number[];
70
+ missing_lines: number[];
71
+ /** Lines coverage.py was told to exclude; omitted from the map (→ nonExecutable). */
72
+ excluded_lines?: number[];
73
+ }
74
+ /** A `coverage json` report — `files` keyed by source path (relative or absolute). */
75
+ interface CoveragePyReport {
76
+ files?: Record<string, CoveragePyFile>;
77
+ meta?: Record<string, unknown>;
78
+ }
79
+ /**
80
+ * Convert one coverage.py file entry into a {@link FileCoverage}. Each executed line becomes a
81
+ * synthetic statement hit once; each missing line a statement hit zero times; excluded lines are
82
+ * left out entirely (they classify as `nonExecutable`).
83
+ */
84
+ declare function fileCoverageFromCoveragePy(path: string, file: CoveragePyFile): FileCoverage;
85
+ /**
86
+ * Convert a whole `coverage json` report into the `Record<path, FileCoverage>` map
87
+ * `uncoveredInDiff` consumes, preserving coverage.py's path keys (its own diff-path↔key
88
+ * reconciliation handles relative vs absolute).
89
+ */
90
+ declare function coveragePyToIstanbul(report: CoveragePyReport): Record<string, FileCoverage>;
91
+ //#endregion
92
+ //#region src/report.d.ts
93
+ interface DiffCoverageFile {
94
+ /** The diff (repo-relative) path. */
95
+ path: string;
96
+ /** Whether a coverage entry was confidently matched. */
97
+ found: boolean;
98
+ /** The matched absolute coverage key, when found. */
99
+ coveragePath?: string;
100
+ /** New-side added line numbers from the diff. */
101
+ addedLines: number[];
102
+ /** The per-line classification, present only when coverage was found. */
103
+ result?: UncoveredNewLines;
104
+ }
105
+ interface DiffCoverageReport {
106
+ files: DiffCoverageFile[];
107
+ /** Every executable-but-unhit new line across the diff — the headline finding. */
108
+ uncovered: {
109
+ path: string;
110
+ line: number;
111
+ }[];
112
+ summary: {
113
+ covered: number;
114
+ uncovered: number;
115
+ nonExecutable: number; /** Classified new lines (across files that had coverage). */
116
+ total: number;
117
+ filesWithoutCoverage: number;
118
+ };
119
+ }
120
+ interface UncoveredInDiffOptions {
121
+ /** Absolute project root; when set, a diff path resolves to `<root>/<path>` exactly. */
122
+ projectRoot?: string;
123
+ }
124
+ /**
125
+ * Report the coverage of a diff's added lines. Each diff file is matched to its coverage
126
+ * entry and classified; files with no (or no confident) coverage match are returned with
127
+ * `found:false` and counted in `summary.filesWithoutCoverage`.
128
+ */
129
+ declare function uncoveredInDiff(diff: string, coverage: Record<string, FileCoverage>, opts?: UncoveredInDiffOptions): DiffCoverageReport;
130
+ //#endregion
131
+ //#region src/run.d.ts
132
+ /** Thrown when the paired operator gate denies a run. */
133
+ declare class CoverageGateError extends Error {
134
+ constructor(message: string);
135
+ }
136
+ interface RunScopedConfig {
137
+ /** The project to run tests in. */
138
+ projectRoot: string;
139
+ /** OPERATOR allowlist of roots `runScoped` may execute in. Load-bearing even with allowRun. */
140
+ allowedRoots: string[];
141
+ /** OPERATOR opt-in to actually run tests. Deny-by-default. */
142
+ allowRun: boolean;
143
+ /** Wall-clock cap (ms) passed to the runner. */
144
+ timeoutMs?: number;
145
+ }
146
+ interface ScopedRunInput {
147
+ /** Changed source files to scope the test selection to (`vitest related`). */
148
+ changedFiles: string[];
149
+ /** Optional unified diff; when present the result includes the {@link uncoveredInDiff} report. */
150
+ diff?: string;
151
+ }
152
+ /** Injected command runner — executes `vitest <argv>` and yields its exit status. */
153
+ type TestRunner = (argv: string[], opts: {
154
+ cwd: string;
155
+ timeoutMs?: number;
156
+ }) => Promise<{
157
+ exitCode: number;
158
+ stdout: string;
159
+ stderr: string;
160
+ }>;
161
+ interface ScopedRunResult {
162
+ /** False when there were no changed files (the runner was not invoked). */
163
+ ran: boolean;
164
+ exitCode: number;
165
+ passed: boolean;
166
+ scopedFiles: string[];
167
+ coverage: Record<string, FileCoverage>;
168
+ coveragePath?: string;
169
+ /** Present when a diff was supplied. */
170
+ report?: DiffCoverageReport;
171
+ }
172
+ /** Default live runner: spawn the local `vitest` as a subprocess (used by the bin, not the gate). */
173
+ declare const defaultVitestRunner: TestRunner;
174
+ /** Default live runner: spawn the local `pytest` as a subprocess (used by the bin, not the gate). */
175
+ declare const defaultPytestCovRunner: TestRunner;
176
+ /** The paired deny-by-default operator gate (allowRun + allowlisted root). Shared by both runners. */
177
+ declare function assertAllowed(config: RunScopedConfig): void;
178
+ /**
179
+ * Run the tests related to a change, with coverage, behind the operator gate. Returns the
180
+ * collected coverage (and, when a diff is supplied, the uncovered-new-line report). The
181
+ * actual `vitest` invocation is the injected `runner` (default {@link defaultVitestRunner}).
182
+ */
183
+ declare function runScoped(config: RunScopedConfig, input: ScopedRunInput, deps?: {
184
+ runner?: TestRunner;
185
+ coverageDir?: string;
186
+ }): Promise<ScopedRunResult>;
187
+ //#endregion
188
+ //#region src/run-python.d.ts
189
+ /** Fallback when a changed source file maps to no confident test (operator-visible, ADR 0010 addendum). */
190
+ type ScopeMode = 'report-gap' | 'widen';
191
+ interface ScopedPythonInput extends ScopedRunInput {
192
+ /** coverage.py measurement targets (`--cov=<target>`). Required — coverage.py needs explicit scope. */
193
+ measureTargets: string[];
194
+ /** Fallback when a changed source maps to no test. Default `report-gap`. */
195
+ scopeMode?: ScopeMode;
196
+ }
197
+ interface ScopedPythonResult extends ScopedRunResult {
198
+ /** pytest produced a non-test-result exit (no tests collected / usage / internal) ⇒ not a pass. */
199
+ inconclusive?: boolean;
200
+ /** Changed source files with no confident mirrored test — the coverage gap (never a silent pass). */
201
+ unmatched?: string[];
202
+ /** True when the no-test fallback widened the run to the whole suite. */
203
+ widened?: boolean;
204
+ }
205
+ /** A pytest test selection derived from a change. */
206
+ interface PytestScope {
207
+ /** pytest positional test targets (files). Empty ⇒ run the whole suite. */
208
+ selectors: string[];
209
+ /** Changed source files with no confident mirrored test. */
210
+ unmatched: string[];
211
+ /** True when the run was widened to the whole suite (the `widen` fallback). */
212
+ widened: boolean;
213
+ }
214
+ /**
215
+ * Derive a pytest test scope from the changed files. A changed test file is a selector; a changed
216
+ * source file maps to its mirrored test when `testExists` confirms one. A source with no test is
217
+ * `unmatched`; the `mode` decides whether that widens to the whole suite (`widen`) or is reported
218
+ * as a gap while the matched tests still run (`report-gap`). Pure (FS access via `testExists`).
219
+ */
220
+ declare function selectPytestScope(changedFiles: string[], mode: ScopeMode, testExists: (path: string) => boolean): PytestScope;
221
+ /**
222
+ * Run the pytest tests related to a change, with coverage.py, behind the operator gate. Returns the
223
+ * converted coverage (and, when a diff is supplied, the uncovered-new-line report). The actual
224
+ * `pytest` invocation is the injected `runner` (default {@link defaultPytestCovRunner}).
225
+ */
226
+ declare function runScopedPython(config: RunScopedConfig, input: ScopedPythonInput, deps?: {
227
+ runner?: TestRunner;
228
+ coverageDir?: string; /** Existence check for mirrored tests (FS by default; injected in tests). */
229
+ testExists?: (path: string) => boolean;
230
+ }): Promise<ScopedPythonResult>;
231
+ //#endregion
232
+ export { type ClassifiedLine, CoverageGateError, type CoveragePyFile, type CoveragePyReport, type DiffCoverageFile, type DiffCoverageReport, type DiffFile, type FileCoverage, type IstanbulPosition, type IstanbulRange, type LineState, type PytestScope, type RunScopedConfig, type ScopeMode, type ScopedPythonInput, type ScopedPythonResult, type ScopedRunInput, type ScopedRunResult, type TestRunner, type UncoveredInDiffOptions, type UncoveredNewLines, assertAllowed, changedFiles, coveragePyToIstanbul, defaultPytestCovRunner, defaultVitestRunner, fileCoverageFromCoveragePy, parseUnifiedDiff, runScoped, runScopedPython, selectPytestScope, uncoveredInDiff, uncoveredNewLines };
233
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/uncovered.ts","../src/coveragepy.ts","../src/report.ts","../src/run.ts","../src/run-python.ts"],"mappings":";;;;;;AAqBA;;;;AAEQ;AAGR;;;;;;;;;AAEuB;AAQvB;;UAfiB,gBAAA;EACf,IAAA;EACA,MAAM;AAAA;AAAA,UAGS,aAAA;EACf,KAAA,EAAO,gBAAA;EACP,GAAA,EAAK,gBAAgB;AAAA;;;;;;UAQN,YAAA;EACf,IAAA;EACA,YAAA,EAAc,MAAA,SAAe,aAAA;EAC7B,CAAA,EAAG,MAAA;AAAA;AAAA,KAGO,SAAA;AAAA,UAEK,cAAA;EACf,IAAA;EACA,KAAA,EAAO,SAAS;AAAA;AAAA,UAGD,iBAAA;EAHf;EAKA,KAAA,EAAO,cAAc;EALL;EAOhB,SAAA;EACA,OAAA;IAAW,OAAA;IAAiB,SAAA;IAAmB,aAAA;IAAuB,KAAA;EAAA;AAAA;;;;;;iBAwBxD,iBAAA,CAAkB,EAAA,EAAI,YAAA,EAAc,QAAA,aAAqB,iBAAiB;;;AAlDnE;AAAA,UCRN,cAAA;EACf,cAAA;EACA,aAAA;EDgB6B;ECd7B,cAAA;AAAA;;UAIe,gBAAA;EACf,KAAA,GAAQ,MAAA,SAAe,cAAA;EACvB,IAAA,GAAO,MAAA;AAAA;;;;;ADSE;iBCDK,0BAAA,CAA2B,IAAA,UAAc,IAAA,EAAM,cAAA,GAAiB,YAAY;;;;ADIvE;AAErB;iBCagB,oBAAA,CAAqB,MAAA,EAAQ,gBAAA,GAAmB,MAAA,SAAe,YAAA;;;UCvC9D,gBAAA;EFUM;EERrB,IAAA;EFgBe;EEdf,KAAA;;EAEA,YAAA;EFcc;EEZd,UAAA;EFaS;EEXT,MAAA,GAAS,iBAAiB;AAAA;AAAA,UAGX,kBAAA;EACf,KAAA,EAAO,gBAAgB;EFMM;EEJ7B,SAAA;IAAa,IAAA;IAAc,IAAA;EAAA;EAC3B,OAAA;IACE,OAAA;IACA,SAAA;IACA,aAAA,UFIiB;IEFjB,KAAA;IACA,oBAAA;EAAA;AAAA;AAAA,UAIa,sBAAA;EFCf;EECA,WAAW;AAAA;AFDK;AAGlB;;;;AAHkB,iBEsCF,eAAA,CACd,IAAA,UACA,QAAA,EAAU,MAAA,SAAe,YAAA,GACzB,IAAA,GAAM,sBAAA,GACL,kBAAA;;;;cC5DU,iBAAA,SAA0B,KAAK;cAC9B,OAAA;AAAA;AAAA,UAWG,eAAA;EHHf;EGKA,WAAA;EHJc;EGMd,YAAA;EHLA;EGOA,QAAA;EHPS;EGST,SAAA;AAAA;AAAA,UAGe,cAAA;;EAEf,YAAA;EHXmB;EGanB,IAAI;AAAA;;KAIM,UAAA,IACV,IAAA,YACA,IAAA;EAAQ,GAAA;EAAa,SAAA;AAAA,MAClB,OAAO;EAAG,QAAA;EAAkB,MAAA;EAAgB,MAAA;AAAA;AAAA,UAEhC,eAAA;EHbM;EGerB,GAAA;EACA,QAAA;EACA,MAAA;EACA,WAAA;EACA,QAAA,EAAU,MAAA,SAAe,YAAA;EACzB,YAAA;EHjB+C;EGmB/C,MAAA,GAAS,kBAAA;AAAA;AHnBkE;AAAA,cG0DhE,mBAAA,EAAqB,UAAkC;;cAGvD,sBAAA,EAAwB,UAAkC;;iBAGvD,aAAA,CAAc,MAAuB,EAAf,eAAe;;;;;AHxCqC;iBG4DpE,SAAA,CACpB,MAAA,EAAQ,eAAA,EACR,KAAA,EAAO,cAAA,EACP,IAAA;EAAQ,MAAA,GAAS,UAAA;EAAY,WAAA;AAAA,IAC5B,OAAA,CAAQ,eAAA;;;;KCvGC,SAAA;AAAA,UAEK,iBAAA,SAA0B,cAAc;EJJvD;EIMA,cAAA;EJLc;EIOd,SAAA,GAAY,SAAA;AAAA;AAAA,UAGG,kBAAA,SAA2B,eAAe;EJThD;EIWT,YAAA;EJRU;EIUV,SAAA;;EAEA,OAAA;AAAA;AJVF;AAAA,UIciB,WAAA;;EAEf,SAAA;EJfA;EIiBA,SAAA;EJhBO;EIkBP,OAAA;AAAA;AJfF;;;;;;AAAA,iBI6CgB,iBAAA,CACd,YAAA,YACA,IAAA,EAAM,SAAA,EACN,UAAA,GAAa,IAAA,uBACZ,WAAW;;;;;;iBAkCQ,eAAA,CACpB,MAAA,EAAQ,eAAA,EACR,KAAA,EAAO,iBAAA,EACP,IAAA;EACE,MAAA,GAAS,UAAA;EACT,WAAA,WJ3D6B;EI6D7B,UAAA,IAAc,IAAA;AAAA,IAEf,OAAA,CAAQ,kBAAA"}
package/dist/index.mjs ADDED
@@ -0,0 +1,441 @@
1
+ import { changedFiles, parseUnifiedDiff, parseUnifiedDiff as parseUnifiedDiff$1 } from "@sackville-mcp/diff";
2
+ import { execFile } from "node:child_process";
3
+ import { existsSync, mkdtempSync, readFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, join, resolve } from "node:path";
6
+ //#region src/coveragepy.ts
7
+ /**
8
+ * Convert one coverage.py file entry into a {@link FileCoverage}. Each executed line becomes a
9
+ * synthetic statement hit once; each missing line a statement hit zero times; excluded lines are
10
+ * left out entirely (they classify as `nonExecutable`).
11
+ */
12
+ function fileCoverageFromCoveragePy(path, file) {
13
+ const statementMap = {};
14
+ const s = {};
15
+ let id = 0;
16
+ const add = (line, hits) => {
17
+ const key = String(id++);
18
+ statementMap[key] = {
19
+ start: {
20
+ line,
21
+ column: 0
22
+ },
23
+ end: {
24
+ line,
25
+ column: 0
26
+ }
27
+ };
28
+ s[key] = hits;
29
+ };
30
+ for (const line of file.executed_lines ?? []) add(line, 1);
31
+ for (const line of file.missing_lines ?? []) add(line, 0);
32
+ return {
33
+ path,
34
+ statementMap,
35
+ s
36
+ };
37
+ }
38
+ /**
39
+ * Convert a whole `coverage json` report into the `Record<path, FileCoverage>` map
40
+ * `uncoveredInDiff` consumes, preserving coverage.py's path keys (its own diff-path↔key
41
+ * reconciliation handles relative vs absolute).
42
+ */
43
+ function coveragePyToIstanbul(report) {
44
+ const out = {};
45
+ for (const [path, file] of Object.entries(report.files ?? {})) out[path] = fileCoverageFromCoveragePy(path, file);
46
+ return out;
47
+ }
48
+ //#endregion
49
+ //#region src/uncovered.ts
50
+ /**
51
+ * Map each source line to its statement hit count, mirroring istanbul's
52
+ * `getLineCoverage`: a line's count is the **max** hit count over the statements that
53
+ * *start* on it. A line absent from the map carries no statement (non-executable).
54
+ */
55
+ function lineHitCounts(fc) {
56
+ const hits = /* @__PURE__ */ new Map();
57
+ for (const [id, range] of Object.entries(fc.statementMap)) {
58
+ const line = range.start.line;
59
+ const count = fc.s[id] ?? 0;
60
+ const prev = hits.get(line);
61
+ if (prev === void 0 || count > prev) hits.set(line, count);
62
+ }
63
+ return hits;
64
+ }
65
+ /**
66
+ * Classify a diff's `newLines` against a file's istanbul coverage. New lines are
67
+ * deduped and sorted; each is `covered` (a statement on it was hit), `uncovered` (a
68
+ * statement on it was never hit), or `nonExecutable` (no statement maps to it).
69
+ */
70
+ function uncoveredNewLines(fc, newLines) {
71
+ const hits = lineHitCounts(fc);
72
+ const lines = [];
73
+ const uncovered = [];
74
+ let covered = 0;
75
+ let uncov = 0;
76
+ let nonExecutable = 0;
77
+ for (const line of [...new Set(newLines)].sort((a, b) => a - b)) {
78
+ const count = hits.get(line);
79
+ let state;
80
+ if (count === void 0) {
81
+ state = "nonExecutable";
82
+ nonExecutable++;
83
+ } else if (count > 0) {
84
+ state = "covered";
85
+ covered++;
86
+ } else {
87
+ state = "uncovered";
88
+ uncov++;
89
+ uncovered.push(line);
90
+ }
91
+ lines.push({
92
+ line,
93
+ state
94
+ });
95
+ }
96
+ return {
97
+ lines,
98
+ uncovered,
99
+ summary: {
100
+ covered,
101
+ uncovered: uncov,
102
+ nonExecutable,
103
+ total: lines.length
104
+ }
105
+ };
106
+ }
107
+ //#endregion
108
+ //#region src/report.ts
109
+ /**
110
+ * Diff ↔ coverage integrator — joins the two pure halves of the forgotten-assertion
111
+ * catch: {@link parseUnifiedDiff} (the lines a change added) and {@link uncoveredNewLines}
112
+ * (which of a file's lines are covered/uncovered/non-executable). The result answers the
113
+ * headline question — "of the lines this change introduced, which executable ones did no
114
+ * test exercise" — across every file in the diff.
115
+ *
116
+ * The one real subtlety is **path reconciliation**: a unified diff names files
117
+ * repo-relative (`packages/app/src/math.ts`), while a `coverage-final.json` is keyed by
118
+ * **absolute** path (`/abs/repo/packages/app/src/math.ts`). With a `projectRoot` we match
119
+ * exactly (`<root>/<diffPath>`); without one we fall back to a **unique** path-suffix
120
+ * match and refuse to guess when more than one key matches (so a stray second checkout in
121
+ * the coverage map can't cause a wrong attribution). Pure/offline.
122
+ */
123
+ /** Forward-slash + collapse repeated separators, for cross-platform path comparison. */
124
+ function norm(p) {
125
+ return p.replace(/\\/g, "/").replace(/\/{2,}/g, "/").replace(/^\.\//, "");
126
+ }
127
+ /**
128
+ * Match a repo-relative diff path to a coverage key. Prefer an exact `<root>/<path>`
129
+ * resolution; else require a single key ending in `/<path>` (refusing an ambiguous match).
130
+ */
131
+ function matchCoverageKey(diffPath, keys, projectRoot) {
132
+ const p = norm(diffPath);
133
+ if (projectRoot !== void 0) {
134
+ const target = norm(`${projectRoot}/${p}`);
135
+ const exactRoot = keys.find((k) => k.norm === target);
136
+ if (exactRoot) return exactRoot.orig;
137
+ }
138
+ const exact = keys.find((k) => k.norm === p);
139
+ if (exact) return exact.orig;
140
+ const suffix = keys.filter((k) => k.norm.endsWith(`/${p}`));
141
+ return suffix.length === 1 ? suffix[0]?.orig : void 0;
142
+ }
143
+ /**
144
+ * Report the coverage of a diff's added lines. Each diff file is matched to its coverage
145
+ * entry and classified; files with no (or no confident) coverage match are returned with
146
+ * `found:false` and counted in `summary.filesWithoutCoverage`.
147
+ */
148
+ function uncoveredInDiff(diff, coverage, opts = {}) {
149
+ const keys = Object.keys(coverage).map((orig) => ({
150
+ orig,
151
+ norm: norm(orig)
152
+ }));
153
+ const files = [];
154
+ const uncovered = [];
155
+ let covered = 0;
156
+ let uncov = 0;
157
+ let nonExecutable = 0;
158
+ let filesWithoutCoverage = 0;
159
+ for (const { path, addedLines } of parseUnifiedDiff$1(diff)) {
160
+ const key = matchCoverageKey(path, keys, opts.projectRoot);
161
+ if (key === void 0) {
162
+ filesWithoutCoverage++;
163
+ files.push({
164
+ path,
165
+ found: false,
166
+ addedLines
167
+ });
168
+ continue;
169
+ }
170
+ const result = uncoveredNewLines(coverage[key], addedLines);
171
+ covered += result.summary.covered;
172
+ uncov += result.summary.uncovered;
173
+ nonExecutable += result.summary.nonExecutable;
174
+ for (const line of result.uncovered) uncovered.push({
175
+ path,
176
+ line
177
+ });
178
+ files.push({
179
+ path,
180
+ found: true,
181
+ coveragePath: key,
182
+ addedLines,
183
+ result
184
+ });
185
+ }
186
+ return {
187
+ files,
188
+ uncovered,
189
+ summary: {
190
+ covered,
191
+ uncovered: uncov,
192
+ nonExecutable,
193
+ total: covered + uncov + nonExecutable,
194
+ filesWithoutCoverage
195
+ }
196
+ };
197
+ }
198
+ //#endregion
199
+ //#region src/run.ts
200
+ /**
201
+ * Impact-scoped test runner — the live half of the coverage pillar. Runs ONLY the tests
202
+ * a change touches (via `vitest related <changed files>`) with coverage, then feeds the
203
+ * produced `coverage-final.json` into {@link uncoveredInDiff} to surface the new lines a
204
+ * change introduced that no test exercised.
205
+ *
206
+ * Two ADR-0010 constraints shape this:
207
+ *
208
+ * 1. **It runs code**, so it is behind a *paired* deny-by-default operator gate — an
209
+ * `allowRun` boolean AND an `allowedRoots` allowlist, with a wall-clock cap. Both are
210
+ * operator-set (the bin reads `SACKVILLE_COVERAGE_ALLOW_RUN` / `_PROJECT_ROOTS` /
211
+ * `_TIMEOUT_MS`); no caller input can self-authorize a run.
212
+ * 2. **Child-process boundary.** The repo has a single root `vitest.config.ts`, so the
213
+ * in-process `startVitest` API can't be used from inside the outer Vitest worker
214
+ * (reentrancy). The actual run is therefore an injected {@link TestRunner} that the
215
+ * bin wires to a `vitest` *subprocess*; the engine here owns the gate, argv, coverage
216
+ * collection, and diff wiring, and is unit-tested with a fake runner (no real spawn in
217
+ * the green gate).
218
+ */
219
+ /** Thrown when the paired operator gate denies a run. */
220
+ var CoverageGateError = class extends Error {
221
+ constructor(message) {
222
+ super(message);
223
+ this.name = "CoverageGateError";
224
+ this[Symbol.for("sackville.gate-denial")] = true;
225
+ }
226
+ };
227
+ /** Build the `vitest related` argv: run once, scoped to the changed files, with v8 JSON coverage. */
228
+ function scopedArgv(changedFiles, coverageDir) {
229
+ return [
230
+ "related",
231
+ ...changedFiles,
232
+ "--run",
233
+ "--coverage.enabled=true",
234
+ "--coverage.provider=v8",
235
+ "--coverage.reporter=json",
236
+ `--coverage.reportsDirectory=${coverageDir}`
237
+ ];
238
+ }
239
+ /** Spawn a local command as a subprocess, surfacing its exit code (never rejecting on non-zero). */
240
+ function spawnRunner(command) {
241
+ return (argv, opts) => new Promise((res) => {
242
+ execFile(command, argv, {
243
+ cwd: opts.cwd,
244
+ timeout: opts.timeoutMs,
245
+ maxBuffer: 64 * 1024 * 1024
246
+ }, (err, stdout, stderr) => {
247
+ res({
248
+ exitCode: err && typeof err.code === "number" ? err.code : err ? 1 : 0,
249
+ stdout: String(stdout),
250
+ stderr: String(stderr)
251
+ });
252
+ });
253
+ });
254
+ }
255
+ /** Default live runner: spawn the local `vitest` as a subprocess (used by the bin, not the gate). */
256
+ const defaultVitestRunner = spawnRunner("vitest");
257
+ /** Default live runner: spawn the local `pytest` as a subprocess (used by the bin, not the gate). */
258
+ const defaultPytestCovRunner = spawnRunner("pytest");
259
+ /** The paired deny-by-default operator gate (allowRun + allowlisted root). Shared by both runners. */
260
+ function assertAllowed(config) {
261
+ if (!config.allowRun) throw new CoverageGateError("scoped test execution is not enabled (the operator must set allowRun)");
262
+ const root = resolve(config.projectRoot);
263
+ if (!config.allowedRoots.map((r) => resolve(r)).includes(root)) throw new CoverageGateError(`project root ${config.projectRoot} is not in the operator allowlist`);
264
+ }
265
+ /**
266
+ * Run the tests related to a change, with coverage, behind the operator gate. Returns the
267
+ * collected coverage (and, when a diff is supplied, the uncovered-new-line report). The
268
+ * actual `vitest` invocation is the injected `runner` (default {@link defaultVitestRunner}).
269
+ */
270
+ async function runScoped(config, input, deps = {}) {
271
+ assertAllowed(config);
272
+ if (input.changedFiles.length === 0) return {
273
+ ran: false,
274
+ exitCode: 0,
275
+ passed: true,
276
+ scopedFiles: [],
277
+ coverage: {}
278
+ };
279
+ const runner = deps.runner ?? defaultVitestRunner;
280
+ const coverageDir = deps.coverageDir ?? mkdtempSync(join(tmpdir(), "sackville-cov-"));
281
+ const { exitCode } = await runner(scopedArgv(input.changedFiles, coverageDir), {
282
+ cwd: config.projectRoot,
283
+ timeoutMs: config.timeoutMs
284
+ });
285
+ const coveragePath = join(coverageDir, "coverage-final.json");
286
+ let coverage;
287
+ try {
288
+ coverage = JSON.parse(readFileSync(coveragePath, "utf8"));
289
+ } catch {
290
+ throw new Error(`scoped run did not produce a coverage report at ${coveragePath} (exit code ${exitCode})`);
291
+ }
292
+ const report = input.diff !== void 0 ? uncoveredInDiff(input.diff, coverage, { projectRoot: config.projectRoot }) : void 0;
293
+ return {
294
+ ran: true,
295
+ exitCode,
296
+ passed: exitCode === 0,
297
+ scopedFiles: input.changedFiles,
298
+ coverage,
299
+ coveragePath,
300
+ report
301
+ };
302
+ }
303
+ //#endregion
304
+ //#region src/run-python.ts
305
+ /**
306
+ * Python impact-scoped coverage runner (ADR 0010 addendum) — the coverage.py sibling of
307
+ * {@link runScoped}. Runs `pytest --cov=<target> --cov-report=json` scoped to the tests a change
308
+ * touched, converts the report via the shipped {@link coveragePyToIstanbul} (unchanged), and feeds
309
+ * {@link uncoveredInDiff} (unchanged) to surface the new lines no test exercised.
310
+ *
311
+ * Two coverage.py / pytest specifics drive the design:
312
+ *
313
+ * 1. **No `vitest related`.** pytest has no built-in changed-files test selection, so we derive a
314
+ * scope with {@link selectPytestScope}: a changed TEST file is a selector directly; a changed
315
+ * SOURCE file maps to a mirrored test (`test_<x>.py` / `tests/test_<x>.py`) when one exists. When
316
+ * a changed source maps to NO confident test, the ratified fallback is operator-visible:
317
+ * `report-gap` (default — run the matched tests, report the unmatched source as a coverage gap)
318
+ * or `widen` (run the whole suite). testmon is intentionally NOT used (a stale `.testmondata`
319
+ * silently deselects tests → false clean, violating absence-is-never-a-pass).
320
+ *
321
+ * 2. **pytest exit codes are not vitest's.** Exit 5 (no tests collected), 2/3/4 (usage/internal) are
322
+ * NOT a clean pass — they map to `inconclusive`, and the run never produces a (misleading) clean
323
+ * report. Only 0 (passed) / 1 (tests failed) carry a real result.
324
+ *
325
+ * The `pytest`/coverage.py invocation is the injected {@link TestRunner}; no real spawn in the gate.
326
+ */
327
+ const TEST_FILE = /(?:^|\/)(?:test_[^/]+|[^/]+_test)\.py$/;
328
+ const IN_TEST_DIR = /(?:^|\/)tests?\//;
329
+ function isTestFile(path) {
330
+ return TEST_FILE.test(path) || IN_TEST_DIR.test(path) && path.endsWith(".py");
331
+ }
332
+ /** Candidate mirrored-test paths for a changed source file (same dir + a `tests/` sibling). */
333
+ function mirroredTestCandidates(srcPath) {
334
+ if (!srcPath.endsWith(".py")) return [];
335
+ const slash = srcPath.lastIndexOf("/");
336
+ const dir = slash === -1 ? "" : srcPath.slice(0, slash + 1);
337
+ const stem = basename(srcPath).slice(0, -3);
338
+ return [
339
+ `${dir}test_${stem}.py`,
340
+ `${dir}${stem}_test.py`,
341
+ `${dir}tests/test_${stem}.py`,
342
+ `tests/test_${stem}.py`
343
+ ];
344
+ }
345
+ /**
346
+ * Derive a pytest test scope from the changed files. A changed test file is a selector; a changed
347
+ * source file maps to its mirrored test when `testExists` confirms one. A source with no test is
348
+ * `unmatched`; the `mode` decides whether that widens to the whole suite (`widen`) or is reported
349
+ * as a gap while the matched tests still run (`report-gap`). Pure (FS access via `testExists`).
350
+ */
351
+ function selectPytestScope(changedFiles, mode, testExists) {
352
+ const selectors = /* @__PURE__ */ new Set();
353
+ const unmatched = [];
354
+ for (const file of changedFiles) {
355
+ if (isTestFile(file)) {
356
+ selectors.add(file);
357
+ continue;
358
+ }
359
+ if (!file.endsWith(".py")) continue;
360
+ const found = mirroredTestCandidates(file).filter(testExists);
361
+ if (found.length > 0) for (const t of found) selectors.add(t);
362
+ else unmatched.push(file);
363
+ }
364
+ if (unmatched.length > 0 && mode === "widen") return {
365
+ selectors: [],
366
+ unmatched,
367
+ widened: true
368
+ };
369
+ return {
370
+ selectors: [...selectors],
371
+ unmatched,
372
+ widened: false
373
+ };
374
+ }
375
+ /** Build the `pytest --cov` argv with a JSON report at `jsonPath` and the selected test targets. */
376
+ function pytestArgv(measureTargets, selectors, jsonPath) {
377
+ return [
378
+ ...measureTargets.map((t) => `--cov=${t}`),
379
+ `--cov-report=json:${jsonPath}`,
380
+ ...selectors
381
+ ];
382
+ }
383
+ /** A pytest exit code that is NOT a test result (no tests collected / usage / internal). */
384
+ function isInconclusiveExit(exitCode) {
385
+ return exitCode === 2 || exitCode === 3 || exitCode === 4 || exitCode === 5;
386
+ }
387
+ /**
388
+ * Run the pytest tests related to a change, with coverage.py, behind the operator gate. Returns the
389
+ * converted coverage (and, when a diff is supplied, the uncovered-new-line report). The actual
390
+ * `pytest` invocation is the injected `runner` (default {@link defaultPytestCovRunner}).
391
+ */
392
+ async function runScopedPython(config, input, deps = {}) {
393
+ assertAllowed(config);
394
+ if (input.changedFiles.length === 0) return {
395
+ ran: false,
396
+ exitCode: 0,
397
+ passed: true,
398
+ scopedFiles: [],
399
+ coverage: {}
400
+ };
401
+ const mode = input.scopeMode ?? "report-gap";
402
+ const testExists = deps.testExists ?? ((p) => existsSync(join(config.projectRoot, p)));
403
+ const scope = selectPytestScope(input.changedFiles, mode, testExists);
404
+ if (scope.selectors.length === 0 && !scope.widened && scope.unmatched.length === 0) return {
405
+ ran: false,
406
+ exitCode: 0,
407
+ passed: true,
408
+ scopedFiles: [],
409
+ coverage: {}
410
+ };
411
+ const runner = deps.runner ?? defaultPytestCovRunner;
412
+ const jsonPath = join(deps.coverageDir ?? mkdtempSync(join(tmpdir(), "sackville-cov-py-")), "coverage.json");
413
+ const { exitCode } = await runner(pytestArgv(input.measureTargets, scope.selectors, jsonPath), {
414
+ cwd: config.projectRoot,
415
+ timeoutMs: config.timeoutMs
416
+ });
417
+ const inconclusive = isInconclusiveExit(exitCode);
418
+ let coverage = {};
419
+ try {
420
+ coverage = coveragePyToIstanbul(JSON.parse(readFileSync(jsonPath, "utf8")));
421
+ } catch {
422
+ if (!inconclusive) throw new Error(`scoped pytest run did not produce a coverage report at ${jsonPath} (exit code ${exitCode})`);
423
+ }
424
+ const report = input.diff !== void 0 && !inconclusive && Object.keys(coverage).length > 0 ? uncoveredInDiff(input.diff, coverage, { projectRoot: config.projectRoot }) : void 0;
425
+ return {
426
+ ran: true,
427
+ exitCode,
428
+ passed: exitCode === 0,
429
+ inconclusive: inconclusive || void 0,
430
+ scopedFiles: input.changedFiles,
431
+ unmatched: scope.unmatched.length > 0 ? scope.unmatched : void 0,
432
+ widened: scope.widened || void 0,
433
+ coverage,
434
+ coveragePath: jsonPath,
435
+ report
436
+ };
437
+ }
438
+ //#endregion
439
+ export { CoverageGateError, assertAllowed, changedFiles, coveragePyToIstanbul, defaultPytestCovRunner, defaultVitestRunner, fileCoverageFromCoveragePy, parseUnifiedDiff, runScoped, runScopedPython, selectPytestScope, uncoveredInDiff, uncoveredNewLines };
440
+
441
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":["parseUnifiedDiff"],"sources":["../src/coveragepy.ts","../src/uncovered.ts","../src/report.ts","../src/run.ts","../src/run-python.ts"],"sourcesContent":["/**\n * coverage.py adapter — converts a `coverage json` report into the istanbul\n * {@link FileCoverage} shape the pure differ ({@link uncoveredNewLines}/`uncoveredInDiff`)\n * already consumes. The Python sibling of the `@vitest/coverage-v8` `coverage-final.json`\n * path: the differ is entirely ecosystem-agnostic (it reads `statementMap` + `s`), so the\n * Python adapter is purely this shape converter — no change to the differ.\n *\n * THE GRANULARITY GAP: coverage.py is **line-based** (`executed_lines` / `missing_lines` /\n * `excluded_lines`), while istanbul is **statement-based**. We bridge by minting one synthetic\n * single-line statement per executed/missing line — executed → hit count 1, missing → 0. This\n * is loss-free for the forgotten-assertion catch, because that question is asked per *line*\n * (`uncoveredNewLines` reduces statements to lines via the max hit count on each start line),\n * never per sub-expression. `excluded_lines` are simply omitted from the map, so — exactly like\n * istanbul's blank/brace/comment lines — they fall into the `nonExecutable` third state and are\n * never reported as a finding (the ADR-0010 correctness trap, honoured for free). Pure, offline.\n */\n\nimport type { FileCoverage } from './uncovered.js'\n\n/** The per-file shape inside a `coverage json` report (line-number lists). */\nexport interface CoveragePyFile {\n executed_lines: number[]\n missing_lines: number[]\n /** Lines coverage.py was told to exclude; omitted from the map (→ nonExecutable). */\n excluded_lines?: number[]\n}\n\n/** A `coverage json` report — `files` keyed by source path (relative or absolute). */\nexport interface CoveragePyReport {\n files?: Record<string, CoveragePyFile>\n meta?: Record<string, unknown>\n}\n\n/**\n * Convert one coverage.py file entry into a {@link FileCoverage}. Each executed line becomes a\n * synthetic statement hit once; each missing line a statement hit zero times; excluded lines are\n * left out entirely (they classify as `nonExecutable`).\n */\nexport function fileCoverageFromCoveragePy(path: string, file: CoveragePyFile): FileCoverage {\n const statementMap: FileCoverage['statementMap'] = {}\n const s: FileCoverage['s'] = {}\n let id = 0\n const add = (line: number, hits: number) => {\n const key = String(id++)\n statementMap[key] = { start: { line, column: 0 }, end: { line, column: 0 } }\n s[key] = hits\n }\n for (const line of file.executed_lines ?? []) add(line, 1)\n for (const line of file.missing_lines ?? []) add(line, 0)\n return { path, statementMap, s }\n}\n\n/**\n * Convert a whole `coverage json` report into the `Record<path, FileCoverage>` map\n * `uncoveredInDiff` consumes, preserving coverage.py's path keys (its own diff-path↔key\n * reconciliation handles relative vs absolute).\n */\nexport function coveragePyToIstanbul(report: CoveragePyReport): Record<string, FileCoverage> {\n const out: Record<string, FileCoverage> = {}\n for (const [path, file] of Object.entries(report.files ?? {})) {\n out[path] = fileCoverageFromCoveragePy(path, file)\n }\n return out\n}\n","/**\n * Uncovered-new-line detection — the first, pure slice of the coverage pillar. Given a\n * file's istanbul coverage and the set of lines a diff *added/changed*, classify each\n * new line as covered, uncovered, or non-executable, and surface the\n * executable-but-unhit ones: the **forgotten-assertion catch** that is the genuinely\n * novel win under Sackville's TDD gate (a generic \"what's uncovered\" report largely\n * duplicates the suite the agent already runs — the new lines a change introduced\n * without a test exercising them is the signal worth isolating).\n *\n * Pure and offline: running the scoped suite to *produce* the coverage needs a\n * child-process boundary (the repo has a single root `vitest.config.ts`, so there is no\n * in-process Vitest-in-Vitest) and is a later slice. Keeping the differ pure is what\n * lets the green gate stay deterministic.\n *\n * THE CORRECTNESS TRAP (ADR 0010): istanbul derives line coverage from `statementMap`,\n * so a line carrying **no statement** (a blank line, a lone brace, a bare comment) is\n * in *neither* the covered nor the uncovered set. A differ that treats \"not covered\" as\n * \"uncovered\" would flag those false positives. We model an explicit third state,\n * `nonExecutable`, and never report it as a finding.\n */\n\nexport interface IstanbulPosition {\n line: number\n column?: number\n}\n\nexport interface IstanbulRange {\n start: IstanbulPosition\n end: IstanbulPosition\n}\n\n/**\n * The subset of an istanbul `FileCoverage` we read — the per-file shape inside a\n * `coverage-final.json` (as emitted by `@vitest/coverage-v8`). `s` holds statement hit\n * counts keyed identically to `statementMap`.\n */\nexport interface FileCoverage {\n path?: string\n statementMap: Record<string, IstanbulRange>\n s: Record<string, number>\n}\n\nexport type LineState = 'covered' | 'uncovered' | 'nonExecutable'\n\nexport interface ClassifiedLine {\n line: number\n state: LineState\n}\n\nexport interface UncoveredNewLines {\n /** Every new line, classified, sorted ascending (input deduped). */\n lines: ClassifiedLine[]\n /** The executable new lines with zero hits — the forgotten-assertion catch. */\n uncovered: number[]\n summary: { covered: number; uncovered: number; nonExecutable: number; total: number }\n}\n\n/**\n * Map each source line to its statement hit count, mirroring istanbul's\n * `getLineCoverage`: a line's count is the **max** hit count over the statements that\n * *start* on it. A line absent from the map carries no statement (non-executable).\n */\nfunction lineHitCounts(fc: FileCoverage): Map<number, number> {\n const hits = new Map<number, number>()\n for (const [id, range] of Object.entries(fc.statementMap)) {\n const line = range.start.line\n const count = fc.s[id] ?? 0\n const prev = hits.get(line)\n if (prev === undefined || count > prev) hits.set(line, count)\n }\n return hits\n}\n\n/**\n * Classify a diff's `newLines` against a file's istanbul coverage. New lines are\n * deduped and sorted; each is `covered` (a statement on it was hit), `uncovered` (a\n * statement on it was never hit), or `nonExecutable` (no statement maps to it).\n */\nexport function uncoveredNewLines(fc: FileCoverage, newLines: number[]): UncoveredNewLines {\n const hits = lineHitCounts(fc)\n const lines: ClassifiedLine[] = []\n const uncovered: number[] = []\n let covered = 0\n let uncov = 0\n let nonExecutable = 0\n\n for (const line of [...new Set(newLines)].sort((a, b) => a - b)) {\n const count = hits.get(line)\n let state: LineState\n if (count === undefined) {\n state = 'nonExecutable'\n nonExecutable++\n } else if (count > 0) {\n state = 'covered'\n covered++\n } else {\n state = 'uncovered'\n uncov++\n uncovered.push(line)\n }\n lines.push({ line, state })\n }\n\n return {\n lines,\n uncovered,\n summary: { covered, uncovered: uncov, nonExecutable, total: lines.length },\n }\n}\n","/**\n * Diff ↔ coverage integrator — joins the two pure halves of the forgotten-assertion\n * catch: {@link parseUnifiedDiff} (the lines a change added) and {@link uncoveredNewLines}\n * (which of a file's lines are covered/uncovered/non-executable). The result answers the\n * headline question — \"of the lines this change introduced, which executable ones did no\n * test exercise\" — across every file in the diff.\n *\n * The one real subtlety is **path reconciliation**: a unified diff names files\n * repo-relative (`packages/app/src/math.ts`), while a `coverage-final.json` is keyed by\n * **absolute** path (`/abs/repo/packages/app/src/math.ts`). With a `projectRoot` we match\n * exactly (`<root>/<diffPath>`); without one we fall back to a **unique** path-suffix\n * match and refuse to guess when more than one key matches (so a stray second checkout in\n * the coverage map can't cause a wrong attribution). Pure/offline.\n */\n\nimport { parseUnifiedDiff } from '@sackville-mcp/diff'\nimport { type FileCoverage, type UncoveredNewLines, uncoveredNewLines } from './uncovered.js'\n\nexport interface DiffCoverageFile {\n /** The diff (repo-relative) path. */\n path: string\n /** Whether a coverage entry was confidently matched. */\n found: boolean\n /** The matched absolute coverage key, when found. */\n coveragePath?: string\n /** New-side added line numbers from the diff. */\n addedLines: number[]\n /** The per-line classification, present only when coverage was found. */\n result?: UncoveredNewLines\n}\n\nexport interface DiffCoverageReport {\n files: DiffCoverageFile[]\n /** Every executable-but-unhit new line across the diff — the headline finding. */\n uncovered: { path: string; line: number }[]\n summary: {\n covered: number\n uncovered: number\n nonExecutable: number\n /** Classified new lines (across files that had coverage). */\n total: number\n filesWithoutCoverage: number\n }\n}\n\nexport interface UncoveredInDiffOptions {\n /** Absolute project root; when set, a diff path resolves to `<root>/<path>` exactly. */\n projectRoot?: string\n}\n\n/** Forward-slash + collapse repeated separators, for cross-platform path comparison. */\nfunction norm(p: string): string {\n return p\n .replace(/\\\\/g, '/')\n .replace(/\\/{2,}/g, '/')\n .replace(/^\\.\\//, '')\n}\n\n/**\n * Match a repo-relative diff path to a coverage key. Prefer an exact `<root>/<path>`\n * resolution; else require a single key ending in `/<path>` (refusing an ambiguous match).\n */\nfunction matchCoverageKey(\n diffPath: string,\n keys: { norm: string; orig: string }[],\n projectRoot?: string,\n): string | undefined {\n const p = norm(diffPath)\n if (projectRoot !== undefined) {\n const target = norm(`${projectRoot}/${p}`)\n const exactRoot = keys.find((k) => k.norm === target)\n if (exactRoot) return exactRoot.orig\n }\n const exact = keys.find((k) => k.norm === p)\n if (exact) return exact.orig\n const suffix = keys.filter((k) => k.norm.endsWith(`/${p}`))\n return suffix.length === 1 ? suffix[0]?.orig : undefined\n}\n\n/**\n * Report the coverage of a diff's added lines. Each diff file is matched to its coverage\n * entry and classified; files with no (or no confident) coverage match are returned with\n * `found:false` and counted in `summary.filesWithoutCoverage`.\n */\nexport function uncoveredInDiff(\n diff: string,\n coverage: Record<string, FileCoverage>,\n opts: UncoveredInDiffOptions = {},\n): DiffCoverageReport {\n const keys = Object.keys(coverage).map((orig) => ({ orig, norm: norm(orig) }))\n const files: DiffCoverageFile[] = []\n const uncovered: { path: string; line: number }[] = []\n let covered = 0\n let uncov = 0\n let nonExecutable = 0\n let filesWithoutCoverage = 0\n\n for (const { path, addedLines } of parseUnifiedDiff(diff)) {\n const key = matchCoverageKey(path, keys, opts.projectRoot)\n if (key === undefined) {\n filesWithoutCoverage++\n files.push({ path, found: false, addedLines })\n continue\n }\n const result = uncoveredNewLines(coverage[key] as FileCoverage, addedLines)\n covered += result.summary.covered\n uncov += result.summary.uncovered\n nonExecutable += result.summary.nonExecutable\n for (const line of result.uncovered) uncovered.push({ path, line })\n files.push({ path, found: true, coveragePath: key, addedLines, result })\n }\n\n return {\n files,\n uncovered,\n summary: {\n covered,\n uncovered: uncov,\n nonExecutable,\n total: covered + uncov + nonExecutable,\n filesWithoutCoverage,\n },\n }\n}\n","/**\n * Impact-scoped test runner — the live half of the coverage pillar. Runs ONLY the tests\n * a change touches (via `vitest related <changed files>`) with coverage, then feeds the\n * produced `coverage-final.json` into {@link uncoveredInDiff} to surface the new lines a\n * change introduced that no test exercised.\n *\n * Two ADR-0010 constraints shape this:\n *\n * 1. **It runs code**, so it is behind a *paired* deny-by-default operator gate — an\n * `allowRun` boolean AND an `allowedRoots` allowlist, with a wall-clock cap. Both are\n * operator-set (the bin reads `SACKVILLE_COVERAGE_ALLOW_RUN` / `_PROJECT_ROOTS` /\n * `_TIMEOUT_MS`); no caller input can self-authorize a run.\n * 2. **Child-process boundary.** The repo has a single root `vitest.config.ts`, so the\n * in-process `startVitest` API can't be used from inside the outer Vitest worker\n * (reentrancy). The actual run is therefore an injected {@link TestRunner} that the\n * bin wires to a `vitest` *subprocess*; the engine here owns the gate, argv, coverage\n * collection, and diff wiring, and is unit-tested with a fake runner (no real spawn in\n * the green gate).\n */\n\nimport { execFile } from 'node:child_process'\nimport { mkdtempSync, readFileSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { join, resolve } from 'node:path'\nimport { type DiffCoverageReport, uncoveredInDiff } from './report.js'\nimport type { FileCoverage } from './uncovered.js'\n\n/** Thrown when the paired operator gate denies a run. */\nexport class CoverageGateError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'CoverageGateError'\n // Brand as a gate DENIAL (ADR 0013 Addendum, milestone 5c): the run-driving\n // `@sackville-mcp/verify` reads this global-registry symbol via `isGateDenial` to map a\n // denial to `skipReason:'gate-not-set'` (never `errored`) WITHOUT importing engine\n // code. The `Symbol.for` key string is the cross-package contract.\n ;(this as unknown as Record<symbol, unknown>)[Symbol.for('sackville.gate-denial')] = true\n }\n}\n\nexport interface RunScopedConfig {\n /** The project to run tests in. */\n projectRoot: string\n /** OPERATOR allowlist of roots `runScoped` may execute in. Load-bearing even with allowRun. */\n allowedRoots: string[]\n /** OPERATOR opt-in to actually run tests. Deny-by-default. */\n allowRun: boolean\n /** Wall-clock cap (ms) passed to the runner. */\n timeoutMs?: number\n}\n\nexport interface ScopedRunInput {\n /** Changed source files to scope the test selection to (`vitest related`). */\n changedFiles: string[]\n /** Optional unified diff; when present the result includes the {@link uncoveredInDiff} report. */\n diff?: string\n}\n\n/** Injected command runner — executes `vitest <argv>` and yields its exit status. */\nexport type TestRunner = (\n argv: string[],\n opts: { cwd: string; timeoutMs?: number },\n) => Promise<{ exitCode: number; stdout: string; stderr: string }>\n\nexport interface ScopedRunResult {\n /** False when there were no changed files (the runner was not invoked). */\n ran: boolean\n exitCode: number\n passed: boolean\n scopedFiles: string[]\n coverage: Record<string, FileCoverage>\n coveragePath?: string\n /** Present when a diff was supplied. */\n report?: DiffCoverageReport\n}\n\n/** Build the `vitest related` argv: run once, scoped to the changed files, with v8 JSON coverage. */\nfunction scopedArgv(changedFiles: string[], coverageDir: string): string[] {\n return [\n 'related',\n ...changedFiles,\n '--run',\n '--coverage.enabled=true',\n '--coverage.provider=v8',\n '--coverage.reporter=json',\n `--coverage.reportsDirectory=${coverageDir}`,\n ]\n}\n\n/** Spawn a local command as a subprocess, surfacing its exit code (never rejecting on non-zero). */\nfunction spawnRunner(command: string): TestRunner {\n return (argv, opts) =>\n new Promise((res) => {\n execFile(\n command,\n argv,\n { cwd: opts.cwd, timeout: opts.timeoutMs, maxBuffer: 64 * 1024 * 1024 },\n (err, stdout, stderr) => {\n // The tool exits non-zero on a test failure — surface the code, don't reject.\n const code =\n err && typeof (err as { code?: unknown }).code === 'number'\n ? (err as { code: number }).code\n : err\n ? 1\n : 0\n res({ exitCode: code, stdout: String(stdout), stderr: String(stderr) })\n },\n )\n })\n}\n\n/** Default live runner: spawn the local `vitest` as a subprocess (used by the bin, not the gate). */\nexport const defaultVitestRunner: TestRunner = spawnRunner('vitest')\n\n/** Default live runner: spawn the local `pytest` as a subprocess (used by the bin, not the gate). */\nexport const defaultPytestCovRunner: TestRunner = spawnRunner('pytest')\n\n/** The paired deny-by-default operator gate (allowRun + allowlisted root). Shared by both runners. */\nexport function assertAllowed(config: RunScopedConfig): void {\n if (!config.allowRun) {\n throw new CoverageGateError(\n 'scoped test execution is not enabled (the operator must set allowRun)',\n )\n }\n const root = resolve(config.projectRoot)\n const allowed = config.allowedRoots.map((r) => resolve(r))\n if (!allowed.includes(root)) {\n throw new CoverageGateError(\n `project root ${config.projectRoot} is not in the operator allowlist`,\n )\n }\n}\n\n/**\n * Run the tests related to a change, with coverage, behind the operator gate. Returns the\n * collected coverage (and, when a diff is supplied, the uncovered-new-line report). The\n * actual `vitest` invocation is the injected `runner` (default {@link defaultVitestRunner}).\n */\nexport async function runScoped(\n config: RunScopedConfig,\n input: ScopedRunInput,\n deps: { runner?: TestRunner; coverageDir?: string } = {},\n): Promise<ScopedRunResult> {\n assertAllowed(config)\n\n if (input.changedFiles.length === 0) {\n return { ran: false, exitCode: 0, passed: true, scopedFiles: [], coverage: {} }\n }\n\n const runner = deps.runner ?? defaultVitestRunner\n const coverageDir = deps.coverageDir ?? mkdtempSync(join(tmpdir(), 'sackville-cov-'))\n const argv = scopedArgv(input.changedFiles, coverageDir)\n\n const { exitCode } = await runner(argv, { cwd: config.projectRoot, timeoutMs: config.timeoutMs })\n\n const coveragePath = join(coverageDir, 'coverage-final.json')\n let coverage: Record<string, FileCoverage>\n try {\n coverage = JSON.parse(readFileSync(coveragePath, 'utf8')) as Record<string, FileCoverage>\n } catch {\n throw new Error(\n `scoped run did not produce a coverage report at ${coveragePath} (exit code ${exitCode})`,\n )\n }\n\n const report =\n input.diff !== undefined\n ? uncoveredInDiff(input.diff, coverage, { projectRoot: config.projectRoot })\n : undefined\n\n return {\n ran: true,\n exitCode,\n passed: exitCode === 0,\n scopedFiles: input.changedFiles,\n coverage,\n coveragePath,\n report,\n }\n}\n","/**\n * Python impact-scoped coverage runner (ADR 0010 addendum) — the coverage.py sibling of\n * {@link runScoped}. Runs `pytest --cov=<target> --cov-report=json` scoped to the tests a change\n * touched, converts the report via the shipped {@link coveragePyToIstanbul} (unchanged), and feeds\n * {@link uncoveredInDiff} (unchanged) to surface the new lines no test exercised.\n *\n * Two coverage.py / pytest specifics drive the design:\n *\n * 1. **No `vitest related`.** pytest has no built-in changed-files test selection, so we derive a\n * scope with {@link selectPytestScope}: a changed TEST file is a selector directly; a changed\n * SOURCE file maps to a mirrored test (`test_<x>.py` / `tests/test_<x>.py`) when one exists. When\n * a changed source maps to NO confident test, the ratified fallback is operator-visible:\n * `report-gap` (default — run the matched tests, report the unmatched source as a coverage gap)\n * or `widen` (run the whole suite). testmon is intentionally NOT used (a stale `.testmondata`\n * silently deselects tests → false clean, violating absence-is-never-a-pass).\n *\n * 2. **pytest exit codes are not vitest's.** Exit 5 (no tests collected), 2/3/4 (usage/internal) are\n * NOT a clean pass — they map to `inconclusive`, and the run never produces a (misleading) clean\n * report. Only 0 (passed) / 1 (tests failed) carry a real result.\n *\n * The `pytest`/coverage.py invocation is the injected {@link TestRunner}; no real spawn in the gate.\n */\n\nimport { existsSync, mkdtempSync, readFileSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { basename, join } from 'node:path'\nimport { type CoveragePyReport, coveragePyToIstanbul } from './coveragepy.js'\nimport { uncoveredInDiff } from './report.js'\nimport {\n assertAllowed,\n defaultPytestCovRunner,\n type RunScopedConfig,\n type ScopedRunInput,\n type ScopedRunResult,\n type TestRunner,\n} from './run.js'\nimport type { FileCoverage } from './uncovered.js'\n\n/** Fallback when a changed source file maps to no confident test (operator-visible, ADR 0010 addendum). */\nexport type ScopeMode = 'report-gap' | 'widen'\n\nexport interface ScopedPythonInput extends ScopedRunInput {\n /** coverage.py measurement targets (`--cov=<target>`). Required — coverage.py needs explicit scope. */\n measureTargets: string[]\n /** Fallback when a changed source maps to no test. Default `report-gap`. */\n scopeMode?: ScopeMode\n}\n\nexport interface ScopedPythonResult extends ScopedRunResult {\n /** pytest produced a non-test-result exit (no tests collected / usage / internal) ⇒ not a pass. */\n inconclusive?: boolean\n /** Changed source files with no confident mirrored test — the coverage gap (never a silent pass). */\n unmatched?: string[]\n /** True when the no-test fallback widened the run to the whole suite. */\n widened?: boolean\n}\n\n/** A pytest test selection derived from a change. */\nexport interface PytestScope {\n /** pytest positional test targets (files). Empty ⇒ run the whole suite. */\n selectors: string[]\n /** Changed source files with no confident mirrored test. */\n unmatched: string[]\n /** True when the run was widened to the whole suite (the `widen` fallback). */\n widened: boolean\n}\n\nconst TEST_FILE = /(?:^|\\/)(?:test_[^/]+|[^/]+_test)\\.py$/\nconst IN_TEST_DIR = /(?:^|\\/)tests?\\//\n\nfunction isTestFile(path: string): boolean {\n return TEST_FILE.test(path) || (IN_TEST_DIR.test(path) && path.endsWith('.py'))\n}\n\n/** Candidate mirrored-test paths for a changed source file (same dir + a `tests/` sibling). */\nfunction mirroredTestCandidates(srcPath: string): string[] {\n if (!srcPath.endsWith('.py')) return []\n const slash = srcPath.lastIndexOf('/')\n const dir = slash === -1 ? '' : srcPath.slice(0, slash + 1)\n const stem = basename(srcPath).slice(0, -'.py'.length)\n return [\n `${dir}test_${stem}.py`,\n `${dir}${stem}_test.py`,\n `${dir}tests/test_${stem}.py`,\n `tests/test_${stem}.py`,\n ]\n}\n\n/**\n * Derive a pytest test scope from the changed files. A changed test file is a selector; a changed\n * source file maps to its mirrored test when `testExists` confirms one. A source with no test is\n * `unmatched`; the `mode` decides whether that widens to the whole suite (`widen`) or is reported\n * as a gap while the matched tests still run (`report-gap`). Pure (FS access via `testExists`).\n */\nexport function selectPytestScope(\n changedFiles: string[],\n mode: ScopeMode,\n testExists: (path: string) => boolean,\n): PytestScope {\n const selectors = new Set<string>()\n const unmatched: string[] = []\n for (const file of changedFiles) {\n if (isTestFile(file)) {\n selectors.add(file)\n continue\n }\n if (!file.endsWith('.py')) continue // a non-Python change can't be coverage-scoped\n const found = mirroredTestCandidates(file).filter(testExists)\n if (found.length > 0) for (const t of found) selectors.add(t)\n else unmatched.push(file)\n }\n if (unmatched.length > 0 && mode === 'widen') {\n return { selectors: [], unmatched, widened: true }\n }\n return { selectors: [...selectors], unmatched, widened: false }\n}\n\n/** Build the `pytest --cov` argv with a JSON report at `jsonPath` and the selected test targets. */\nfunction pytestArgv(measureTargets: string[], selectors: string[], jsonPath: string): string[] {\n return [...measureTargets.map((t) => `--cov=${t}`), `--cov-report=json:${jsonPath}`, ...selectors]\n}\n\n/** A pytest exit code that is NOT a test result (no tests collected / usage / internal). */\nfunction isInconclusiveExit(exitCode: number): boolean {\n return exitCode === 2 || exitCode === 3 || exitCode === 4 || exitCode === 5\n}\n\n/**\n * Run the pytest tests related to a change, with coverage.py, behind the operator gate. Returns the\n * converted coverage (and, when a diff is supplied, the uncovered-new-line report). The actual\n * `pytest` invocation is the injected `runner` (default {@link defaultPytestCovRunner}).\n */\nexport async function runScopedPython(\n config: RunScopedConfig,\n input: ScopedPythonInput,\n deps: {\n runner?: TestRunner\n coverageDir?: string\n /** Existence check for mirrored tests (FS by default; injected in tests). */\n testExists?: (path: string) => boolean\n } = {},\n): Promise<ScopedPythonResult> {\n assertAllowed(config)\n\n if (input.changedFiles.length === 0) {\n return { ran: false, exitCode: 0, passed: true, scopedFiles: [], coverage: {} }\n }\n\n const mode = input.scopeMode ?? 'report-gap'\n const testExists = deps.testExists ?? ((p: string) => existsSync(join(config.projectRoot, p)))\n const scope = selectPytestScope(input.changedFiles, mode, testExists)\n\n // Nothing Python to run (e.g. only non-.py files changed, and nothing widened): a no-op.\n if (scope.selectors.length === 0 && !scope.widened && scope.unmatched.length === 0) {\n return { ran: false, exitCode: 0, passed: true, scopedFiles: [], coverage: {} }\n }\n\n const runner = deps.runner ?? defaultPytestCovRunner\n const coverageDir = deps.coverageDir ?? mkdtempSync(join(tmpdir(), 'sackville-cov-py-'))\n const jsonPath = join(coverageDir, 'coverage.json')\n const argv = pytestArgv(input.measureTargets, scope.selectors, jsonPath)\n\n const { exitCode } = await runner(argv, { cwd: config.projectRoot, timeoutMs: config.timeoutMs })\n const inconclusive = isInconclusiveExit(exitCode)\n\n let coverage: Record<string, FileCoverage> = {}\n try {\n const report = JSON.parse(readFileSync(jsonPath, 'utf8')) as CoveragePyReport\n coverage = coveragePyToIstanbul(report)\n } catch {\n // A genuine run (passed/failed) must produce a report; an inconclusive exit may not.\n if (!inconclusive) {\n throw new Error(\n `scoped pytest run did not produce a coverage report at ${jsonPath} (exit code ${exitCode})`,\n )\n }\n }\n\n // Never produce a (misleading) clean report from an inconclusive run.\n const report =\n input.diff !== undefined && !inconclusive && Object.keys(coverage).length > 0\n ? uncoveredInDiff(input.diff, coverage, { projectRoot: config.projectRoot })\n : undefined\n\n return {\n ran: true,\n exitCode,\n passed: exitCode === 0,\n inconclusive: inconclusive || undefined,\n scopedFiles: input.changedFiles,\n unmatched: scope.unmatched.length > 0 ? scope.unmatched : undefined,\n widened: scope.widened || undefined,\n coverage,\n coveragePath: jsonPath,\n report,\n }\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@sackville-mcp/coverage",
3
+ "version": "0.0.1-alpha.0",
4
+ "type": "module",
5
+ "license": "Apache-2.0",
6
+ "exports": {
7
+ ".": {
8
+ "import": {
9
+ "types": "./dist/index.d.mts",
10
+ "default": "./dist/index.mjs"
11
+ }
12
+ }
13
+ },
14
+ "main": "./dist/index.mjs",
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "dependencies": {
19
+ "@sackville-mcp/diff": "0.0.1-alpha.0"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/ceautery/sackville.git",
27
+ "directory": "packages/coverage"
28
+ },
29
+ "scripts": {
30
+ "build": "tsdown src/index.ts --dts",
31
+ "typecheck": "tsc --noEmit"
32
+ }
33
+ }