Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ import {
buildTestGenSpec,
type LocalWriteActionSpec,
} from "./local-write-tools";
import { classifyTestCoverage, isCodeFile, isTestPath, TEST_FRAMEWORKS } from "../signals/test-evidence";
import { classifyTestCoverage, hasLocalTestEvidence, isCodeFile, isTestPath, TEST_FRAMEWORKS } from "../signals/test-evidence";
import { applyStepResult, buildPlanDag, nextReadySteps, planProgress, validatePlanDag, type PlanDag } from "../services/plan-dag";
import { buildFocusManifestValidation } from "../services/focus-manifest-validation";
import { isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadiness } from "../settings/agent-execution";
Expand Down Expand Up @@ -1182,6 +1182,7 @@ const checkImprovementPotentialOutputSchema = {
const checkTestEvidenceShape = {
changedPaths: z.array(z.string().min(1).max(400)).max(2000),
testFiles: z.array(z.string().min(1).max(400)).max(2000).optional(),
tests: z.array(z.string().max(400)).max(2000).optional(),
};

const checkTestEvidenceOutputSchema = {
Expand Down Expand Up @@ -3640,12 +3641,24 @@ export class LoopoverMcp {
private async checkTestEvidence(input: z.infer<z.ZodObject<typeof checkTestEvidenceShape>>): Promise<ToolPayload> {
await this.enforceToolRateLimit("loopover_check_test_evidence");
const allPaths = [...input.changedPaths, ...(input.testFiles ?? [])];
const classification = classifyTestCoverage(allPaths);
const codeFileCount = input.changedPaths.filter(isCodeFile).length;
const testFileCount = allPaths.filter(isTestPath).length;
let classification = classifyTestCoverage(allPaths);
let testFileCount = allPaths.filter(isTestPath).length;
// Credit free-text `tests` evidence (e.g. "ran `go test ./...` locally, no new file") the same way the
// sibling tools loopover_check_slop_risk / loopover_suggest_boundary_tests already do via
// hasLocalTestEvidence. Only ever LIFT an otherwise-"absent" verdict -- never make this more lenient than
// the path-based signal once real test-file evidence (weak/adequate/strong) already exists.
const creditedByFreeTextTests =
classification === "absent" && hasLocalTestEvidence({ tests: input.tests, testFiles: input.testFiles });
if (creditedByFreeTextTests) {
classification = "adequate";
testFileCount = Math.max(testFileCount, 1);
}
const guidance: string[] = [];
if (codeFileCount === 0) {
guidance.push("No hand-authored code files changed, so the missing-test-evidence signal does not apply (e.g. a docs- or config-only change).");
} else if (creditedByFreeTextTests) {
guidance.push("No test file was detected among the changed paths, but the free-text `tests` evidence you supplied is credited as test evidence (the same way check_slop_risk and suggest_boundary_tests treat it).");
} else if (classification === "absent") {
guidance.push("Changed code files carry no test evidence — add or update a test that exercises the change before opening the PR.");
} else if (classification === "strong") {
Expand Down
40 changes: 40 additions & 0 deletions test/unit/mcp-check-test-evidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,44 @@ describe("MCP loopover_check_test_evidence (#2235)", () => {
expect(data.codeFileCount).toBe(0);
expect(data.guidance.join(" ")).toMatch(/does not apply/i);
});

it("credits free-text tests evidence when no test file is present, lifting absent to adequate (#6618)", async () => {
const client = await connect();
const result = await client.callTool({
name: "loopover_check_test_evidence",
arguments: { changedPaths: ["src/a.ts", "src/b.ts"], tests: ["ran `go test ./internal/entity` locally, no new file"] },
});
const data = result.structuredContent as Result;
expect(data.classification).toBe("adequate"); // lifted from the path-based "absent"
expect(data.testFileCount).toBeGreaterThanOrEqual(1);
expect(data.guidance.join(" ")).toMatch(/free-text `tests`/i); // distinct wording, not the path-derived lines
expect(data.guidance.join(" ")).not.toMatch(/looks strong/i);
});

it("does not credit an empty tests array — classification stays absent (#6618)", async () => {
const client = await connect();
const result = await client.callTool({
name: "loopover_check_test_evidence",
arguments: { changedPaths: ["src/a.ts", "src/b.ts"], tests: [] },
});
const data = result.structuredContent as Result;
expect(data.classification).toBe("absent");
expect(data.testFileCount).toBe(0);
expect(data.guidance.join(" ")).toMatch(/no test evidence/i);
});

it("does not apply the override above absent — a weak path classification stays weak (#6618)", async () => {
const client = await connect();
const result = await client.callTool({
name: "loopover_check_test_evidence",
arguments: {
changedPaths: ["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts", "src/e.ts"],
testFiles: ["test/a.test.ts"],
tests: ["ran the full suite locally"],
},
});
const data = result.structuredContent as Result;
expect(data.classification).toBe("weak"); // real path evidence already present → override must not fire
expect(data.guidance.join(" ")).toMatch(/weak/i);
});
});