@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 +201 -0
- package/dist/index.d.mts +233 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +441 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +33 -0
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.
|
package/dist/index.d.mts
ADDED
|
@@ -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
|
+
}
|