@sackville-mcp/coverage 0.0.1-alpha.1 → 0.0.1-alpha.3
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/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +27 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +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
|
|
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":"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"}
|
package/dist/index.mjs
CHANGED
|
@@ -224,6 +224,11 @@ var CoverageGateError = class extends Error {
|
|
|
224
224
|
this[Symbol.for("sackville.gate-denial")] = true;
|
|
225
225
|
}
|
|
226
226
|
};
|
|
227
|
+
/** Keep the last `n` non-empty-trimmed lines of a blob — for surfacing a runner's failure tail. */
|
|
228
|
+
function lastLines(text, n) {
|
|
229
|
+
const lines = text.split("\n");
|
|
230
|
+
return lines.slice(Math.max(0, lines.length - n)).join("\n");
|
|
231
|
+
}
|
|
227
232
|
/** Build the `vitest related` argv: run once, scoped to the changed files, with v8 JSON coverage. */
|
|
228
233
|
function scopedArgv(changedFiles, coverageDir) {
|
|
229
234
|
return [
|
|
@@ -236,13 +241,31 @@ function scopedArgv(changedFiles, coverageDir) {
|
|
|
236
241
|
`--coverage.reportsDirectory=${coverageDir}`
|
|
237
242
|
];
|
|
238
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Build the child env for a spawned runner: prepend the project's own
|
|
246
|
+
* `<cwd>/node_modules/.bin` to PATH. Without this, a bare `vitest`/`pytest` is
|
|
247
|
+
* resolved only from the *invoking* shell's PATH — so running `sackville-cli
|
|
248
|
+
* coverage run-scoped` from a **global** install (whose PATH does not include the
|
|
249
|
+
* target project's `.bin`) fails to start the runner and dies with an opaque
|
|
250
|
+
* "did not produce a coverage report". Returns a fresh env; never mutates input.
|
|
251
|
+
*/
|
|
252
|
+
function runnerEnv(cwd, env = process.env) {
|
|
253
|
+
const localBin = resolve(cwd, "node_modules", ".bin");
|
|
254
|
+
const sep = process.platform === "win32" ? ";" : ":";
|
|
255
|
+
const current = env.PATH;
|
|
256
|
+
return {
|
|
257
|
+
...env,
|
|
258
|
+
PATH: current ? `${localBin}${sep}${current}` : localBin
|
|
259
|
+
};
|
|
260
|
+
}
|
|
239
261
|
/** Spawn a local command as a subprocess, surfacing its exit code (never rejecting on non-zero). */
|
|
240
262
|
function spawnRunner(command) {
|
|
241
263
|
return (argv, opts) => new Promise((res) => {
|
|
242
264
|
execFile(command, argv, {
|
|
243
265
|
cwd: opts.cwd,
|
|
244
266
|
timeout: opts.timeoutMs,
|
|
245
|
-
maxBuffer: 64 * 1024 * 1024
|
|
267
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
268
|
+
env: runnerEnv(opts.cwd)
|
|
246
269
|
}, (err, stdout, stderr) => {
|
|
247
270
|
res({
|
|
248
271
|
exitCode: err && typeof err.code === "number" ? err.code : err ? 1 : 0,
|
|
@@ -278,7 +301,7 @@ async function runScoped(config, input, deps = {}) {
|
|
|
278
301
|
};
|
|
279
302
|
const runner = deps.runner ?? defaultVitestRunner;
|
|
280
303
|
const coverageDir = deps.coverageDir ?? mkdtempSync(join(tmpdir(), "sackville-cov-"));
|
|
281
|
-
const { exitCode } = await runner(scopedArgv(input.changedFiles, coverageDir), {
|
|
304
|
+
const { exitCode, stdout, stderr } = await runner(scopedArgv(input.changedFiles, coverageDir), {
|
|
282
305
|
cwd: config.projectRoot,
|
|
283
306
|
timeoutMs: config.timeoutMs
|
|
284
307
|
});
|
|
@@ -287,7 +310,8 @@ async function runScoped(config, input, deps = {}) {
|
|
|
287
310
|
try {
|
|
288
311
|
coverage = JSON.parse(readFileSync(coveragePath, "utf8"));
|
|
289
312
|
} catch {
|
|
290
|
-
|
|
313
|
+
const tail = lastLines([stderr, stdout].map((s) => s.trim()).filter(Boolean).join("\n"), 25);
|
|
314
|
+
throw new Error(`scoped run did not produce a coverage report at ${coveragePath} (exit code ${exitCode}). The test runner exited without writing coverage — commonly it failed to start (is \`vitest\` installed and resolvable in the project?) or the tests errored.` + (tail ? `\n--- runner output (tail) ---\n${tail}` : " (the runner produced no output.)"));
|
|
291
315
|
}
|
|
292
316
|
const report = input.diff !== void 0 ? uncoveredInDiff(input.diff, coverage, { projectRoot: config.projectRoot }) : void 0;
|
|
293
317
|
return {
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +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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"}
|
|
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/** Keep the last `n` non-empty-trimmed lines of a blob — for surfacing a runner's failure tail. */\nfunction lastLines(text: string, n: number): string {\n const lines = text.split('\\n')\n return lines.slice(Math.max(0, lines.length - n)).join('\\n')\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/**\n * Build the child env for a spawned runner: prepend the project's own\n * `<cwd>/node_modules/.bin` to PATH. Without this, a bare `vitest`/`pytest` is\n * resolved only from the *invoking* shell's PATH — so running `sackville-cli\n * coverage run-scoped` from a **global** install (whose PATH does not include the\n * target project's `.bin`) fails to start the runner and dies with an opaque\n * \"did not produce a coverage report\". Returns a fresh env; never mutates input.\n */\nexport function runnerEnv(cwd: string, env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {\n const localBin = resolve(cwd, 'node_modules', '.bin')\n const sep = process.platform === 'win32' ? ';' : ':'\n const current = env.PATH\n return { ...env, PATH: current ? `${localBin}${sep}${current}` : localBin }\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 {\n cwd: opts.cwd,\n timeout: opts.timeoutMs,\n maxBuffer: 64 * 1024 * 1024,\n env: runnerEnv(opts.cwd),\n },\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, stdout, stderr } = await runner(argv, {\n cwd: config.projectRoot,\n timeoutMs: config.timeoutMs,\n })\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 // The runner died before writing coverage. Surface its own output (the tail) and a\n // hint at the usual cause so the failure is debuggable, not opaque.\n const tail = lastLines(\n [stderr, stdout]\n .map((s) => s.trim())\n .filter(Boolean)\n .join('\\n'),\n 25,\n )\n throw new Error(\n `scoped run did not produce a coverage report at ${coveragePath} (exit code ${exitCode}). ` +\n 'The test runner exited without writing coverage — commonly it failed to start ' +\n '(is `vitest` installed and resolvable in the project?) or the tests errored.' +\n (tail ? `\\n--- runner output (tail) ---\\n${tail}` : ' (the runner produced no output.)'),\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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sackville-mcp/coverage",
|
|
3
|
-
"version": "0.0.1-alpha.
|
|
3
|
+
"version": "0.0.1-alpha.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"exports": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"dist"
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@sackville-mcp/diff": "0.0.1-alpha.
|
|
19
|
+
"@sackville-mcp/diff": "0.0.1-alpha.3"
|
|
20
20
|
},
|
|
21
21
|
"publishConfig": {
|
|
22
22
|
"access": "public"
|
|
@@ -29,5 +29,6 @@
|
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "tsdown src/index.ts --dts",
|
|
31
31
|
"typecheck": "tsc --noEmit"
|
|
32
|
-
}
|
|
32
|
+
},
|
|
33
|
+
"types": "./dist/index.d.mts"
|
|
33
34
|
}
|