diff --git a/src/signals/test-evidence.ts b/src/signals/test-evidence.ts index 20fa1db283..85e4d69ef3 100644 --- a/src/signals/test-evidence.ts +++ b/src/signals/test-evidence.ts @@ -11,3 +11,20 @@ export function isTestPath(file: string): boolean { export function hasLocalTestEvidence(input: { tests?: string[] | undefined; testFiles?: string[] | undefined }): boolean { return (input.tests ?? []).length > 0 || (input.testFiles ?? []).some((file) => isTestPath(file)); } + +/** + * Coarse classification of how much test coverage accompanies a set of changed paths. + * Used by slop signals to weight diffs that touch source but include no tests differently + * from those with proportionally strong test changes. + */ +export type TestCoverageClassification = "strong" | "adequate" | "weak" | "absent"; + +export function classifyTestCoverage(changedPaths: string[]): TestCoverageClassification { + if (changedPaths.length === 0) return "absent"; + const testCount = changedPaths.filter(isTestPath).length; + if (testCount === 0) return "absent"; + const ratio = testCount / changedPaths.length; + if (ratio >= 0.4) return "strong"; + if (ratio >= 0.2) return "adequate"; + return "weak"; +} diff --git a/test/unit/test-evidence.test.ts b/test/unit/test-evidence.test.ts index 0e251dab42..09bfc2dcf2 100644 --- a/test/unit/test-evidence.test.ts +++ b/test/unit/test-evidence.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { hasLocalTestEvidence, isTestPath } from "../../src/signals/test-evidence"; +import { classifyTestCoverage, hasLocalTestEvidence, isTestPath } from "../../src/signals/test-evidence"; describe("test evidence helpers", () => { it("detects common test path conventions", () => { @@ -17,3 +17,29 @@ describe("test evidence helpers", () => { expect(hasLocalTestEvidence({})).toBe(false); }); }); + +describe("classifyTestCoverage", () => { + it("classifies an empty path list as absent", () => { + expect(classifyTestCoverage([])).toBe("absent"); + }); + + it("classifies a list with no test files as absent", () => { + expect(classifyTestCoverage(["src/auth.ts", "src/utils.ts"])).toBe("absent"); + }); + + it("classifies >= 40% test ratio as strong", () => { + // 2 source + 2 test = 50% + expect(classifyTestCoverage(["src/a.ts", "src/b.ts", "test/a.test.ts", "test/b.test.ts"])).toBe("strong"); + }); + + it("classifies 20%–39% test ratio as adequate", () => { + // 3 source + 1 test = 25% + expect(classifyTestCoverage(["src/a.ts", "src/b.ts", "src/c.ts", "test/a.test.ts"])).toBe("adequate"); + }); + + it("classifies > 0% but < 20% test ratio as weak", () => { + // 9 source + 1 test ≈ 10% + const sources = Array.from({ length: 9 }, (_, i) => `src/file${i}.ts`); + expect(classifyTestCoverage([...sources, "test/single.test.ts"])).toBe("weak"); + }); +});