diff --git a/packages/loopover-engine/src/signals/test-evidence.ts b/packages/loopover-engine/src/signals/test-evidence.ts index c6801ae50..9c6f6e166 100644 --- a/packages/loopover-engine/src/signals/test-evidence.ts +++ b/packages/loopover-engine/src/signals/test-evidence.ts @@ -125,3 +125,53 @@ export function classifyTestCoverage(changedPaths: string[]): TestCoverageClassi // spec for a framework this engine doesn't recognize. export const TEST_FRAMEWORKS = ["vitest", "jest", "pytest", "go-test", "rspec", "cargo-test"] as const; export type TestFramework = (typeof TEST_FRAMEWORKS)[number]; + +/** The test-evidence report shape returned identically by the MCP tool, the REST route, and the CLI mirror. */ +export type TestEvidenceReport = { + classification: TestCoverageClassification; + changedFileCount: number; + codeFileCount: number; + testFileCount: number; + guidance: string[]; +}; + +/** + * PURE: classify a planned change's test evidence from path metadata (plus optional free-text `tests` notes) and + * render actionable guidance. Extracted (#6749) so the remote MCP tool, `POST /v1/lint/test-evidence`, and the + * local CLI mirror all derive a byte-identical verdict from ONE implementation instead of three drifting copies. + */ +export function buildTestEvidenceReport(input: { + changedPaths: readonly string[]; + testFiles?: readonly string[] | undefined; + tests?: readonly string[] | undefined; +}): TestEvidenceReport { + const allPaths = [...input.changedPaths, ...(input.testFiles ?? [])]; + const codeFileCount = input.changedPaths.filter(isCodeFile).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. + // hasLocalTestEvidence takes mutable arrays, so copy rather than passing the readonly inputs through. + const creditedByFreeTextTests = + classification === "absent" && + hasLocalTestEvidence({ tests: input.tests ? [...input.tests] : undefined, testFiles: input.testFiles ? [...input.testFiles] : undefined }); + 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") { + guidance.push("Test coverage looks strong for this change."); + } else { + guidance.push(`Test coverage is ${classification} for this change — adding another focused test would strengthen the evidence.`); + } + return { classification, changedFileCount: allPaths.length, codeFileCount, testFileCount, guidance }; +} diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index d6e4f64f0..ecee206bb 100755 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -26,6 +26,8 @@ import { computeLocalScorerTokens, } from "@loopover/engine"; import { buildSlopAssessment, SLOP_RUBRIC_MARKDOWN } from "@loopover/engine/signals/slop"; +// #6749: the same pure builder the remote MCP tool + /v1/lint/test-evidence both call. +import { buildTestEvidenceReport } from "@loopover/engine/signals/test-evidence"; // #6754: the same pure evaluator the remote MCP tool + /v1/loop/evaluate-escalation both call. import { evaluateEscalation } from "@loopover/engine"; import { z } from "zod"; @@ -543,6 +545,13 @@ const evaluateEscalationShape = { killRequested: z.boolean().optional(), }; +// #6749: mirrors checkTestEvidenceShape in src/mcp/server.ts VERBATIM (same bounds, same optionality). +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 checkSlopRiskShape = { changedFiles: z .array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() })) @@ -852,6 +861,12 @@ const STDIO_TOOL_DESCRIPTORS = [ category: "review", description: "Assess the deterministic slop risk of a planned change from local diff metadata (paths + line counts) + the PR description — an agent-native, source-free quality self-check. Returns slopRisk (0-100), band, findings, and the rubric. Computed in-process; no repo data and no API round-trip.", }, + { + name: "loopover_check_test_evidence", + category: "review", + description: + "Classify whether a planned change's changed files carry enough test evidence, from path metadata alone (no source uploaded) — an agent-native coverage-gap self-check before opening a PR. Returns a coverage band (strong/adequate/weak/absent) plus actionable guidance. Computed in-process; no API round-trip.", + }, { name: "loopover_evaluate_escalation", category: "agent", @@ -1418,6 +1433,18 @@ registerStdioTool( (input) => toolResult("LoopOver slop-risk self-check.", { ...buildSlopAssessment(input), rubric: SLOP_RUBRIC_MARKDOWN }), ); +registerStdioTool( + "loopover_check_test_evidence", + { + description: stdioToolDescription("loopover_check_test_evidence"), + inputSchema: checkTestEvidenceShape, + }, + // Computed in-process from @loopover/engine (#6749) — the same buildTestEvidenceReport the remote server + // (src/mcp/server.ts) and the /v1/lint/test-evidence route both call, so all three surfaces return a + // byte-identical verdict and coverage self-checks work fully offline. + (input) => toolResult("LoopOver test-evidence check.", buildTestEvidenceReport(input)), +); + registerStdioTool( "loopover_evaluate_escalation", { diff --git a/src/api/routes.ts b/src/api/routes.ts index 935f762d4..6386296d6 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -197,6 +197,7 @@ import { } from "../services/control-panel-roles"; import { runFindOpportunities, validateFindOpportunitiesInput, type FindOpportunitiesInput } from "../mcp/find-opportunities"; import { runIssueRagRetrieval, validateIssueRagInput, type IssueRagInput } from "../mcp/issue-rag"; +import { buildTestEvidenceReport } from "../signals/test-evidence"; import { evaluateEscalation } from "../loop-escalation"; import { loadPrAiReviewFindings } from "../mcp/pr-ai-review-findings"; import { @@ -488,6 +489,14 @@ const evaluateEscalationSchema = z.object({ killRequested: z.boolean().optional(), }); +// #6749: mirrors checkTestEvidenceShape in src/mcp/server.ts VERBATIM (same bounds, same optionality) so the +// REST surface can never accept an input the MCP tool would reject, or vice versa. +const testEvidenceSchema = z.object({ + 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 slopRiskSchema = z.object({ changedFiles: z .array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() })) @@ -3228,6 +3237,16 @@ export function createApp() { return c.json({ ...buildSlopAssessment(parsed.data), rubric: SLOP_RUBRIC_MARKDOWN }); }); + // #6749: REST mirror of the loopover_check_test_evidence MCP tool, bringing it to the same parity its + // same-tier deterministic-lint sibling /v1/lint/slop-risk (directly above) already has. Delegates to the + // engine's buildTestEvidenceReport -- the same function the MCP tool and the CLI mirror call. + app.post("/v1/lint/test-evidence", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = testEvidenceSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_test_evidence_request", issues: parsed.error.issues }, 400); + return c.json(buildTestEvidenceReport(parsed.data)); + }); + // #6754: REST mirror of the loopover_evaluate_escalation MCP tool, bringing it to the same REST/CLI parity // its same-tier sibling loopover_check_slop_risk (/v1/lint/slop-risk, directly above) already has. Both are // pure, source-free evaluators over caller-supplied data, so this route delegates to the same diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 127f2c23b..c567ffd8b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -156,7 +156,7 @@ import { buildTestGenSpec, type LocalWriteActionSpec, } from "./local-write-tools"; -import { classifyTestCoverage, hasLocalTestEvidence, isCodeFile, isTestPath, TEST_FRAMEWORKS } from "../signals/test-evidence"; +import { buildTestEvidenceReport, 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"; @@ -3652,35 +3652,12 @@ export class LoopoverMcp { private async checkTestEvidence(input: z.infer>): Promise { await this.enforceToolRateLimit("loopover_check_test_evidence"); - const allPaths = [...input.changedPaths, ...(input.testFiles ?? [])]; - const codeFileCount = input.changedPaths.filter(isCodeFile).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") { - guidance.push("Test coverage looks strong for this change."); - } else { - guidance.push(`Test coverage is ${classification} for this change — adding another focused test would strengthen the evidence.`); - } + // #6749: the classification/guidance logic now lives in the engine's buildTestEvidenceReport, shared with + // POST /v1/lint/test-evidence and the local CLI mirror so all three surfaces agree by construction. + const report = buildTestEvidenceReport(input); return { - summary: `Test evidence: ${classification}.`, - data: { classification, changedFileCount: allPaths.length, codeFileCount, testFileCount, guidance } as unknown as Record, + summary: `Test evidence: ${report.classification}.`, + data: report as unknown as Record, }; } diff --git a/test/unit/mcp-cli-test-evidence-tool.test.ts b/test/unit/mcp-cli-test-evidence-tool.test.ts new file mode 100644 index 000000000..f3ded94bf --- /dev/null +++ b/test/unit/mcp-cli-test-evidence-tool.test.ts @@ -0,0 +1,65 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { buildTestEvidenceReport } from "../../src/signals/test-evidence"; + +// #6749: the local mirror of loopover_check_test_evidence. Like its same-tier sibling loopover_check_slop_risk +// it computes IN-PROCESS from @loopover/engine — no API round-trip — so coverage self-checks work offline. +// These assert cross-surface parity with the same buildTestEvidenceReport the route + MCP tool call; the +// builder's own correctness is pinned independently by test-evidence-report.test.ts. +const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); +let client: Client; +let transport: StdioClientTransport; +let configDir: string; + +beforeEach(async () => { + configDir = mkdtempSync(join(tmpdir(), "loopover-test-evidence-")); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + // Black-holed API URL: a residual round-trip would fail every case, proving the in-process claim. + env: { ...process.env, LOOPOVER_CONFIG_DIR: configDir, LOOPOVER_TOKEN: "session-token", LOOPOVER_API_URL: "http://127.0.0.1:1", LOOPOVER_API_TIMEOUT_MS: "1000" }, + }); + client = new Client({ name: "test-evidence-tool-test", version: "0.0.1" }); + await client.connect(transport); +}); + +afterEach(async () => { + await client?.close().catch(() => undefined); + if (configDir) rmSync(configDir, { recursive: true, force: true }); +}); + +describe("loopover_check_test_evidence stdio mirror (#6749)", () => { + it("registers alongside its same-tier check_slop_risk sibling", async () => { + const names = new Set((await client.listTools()).tools.map((t) => t.name)); + expect(names).toContain("loopover_check_test_evidence"); + expect(names).toContain("loopover_check_slop_risk"); + }); + + it("matches the shared builder for every arm — offline, with no API reachable", async () => { + const cases = [ + { changedPaths: ["src/a.ts"] }, + { changedPaths: ["src/a.ts"], testFiles: ["test/a.test.ts"] }, + { changedPaths: ["src/a.ts"], tests: ["ran `go test ./...` locally, no new file"] }, + { changedPaths: ["README.md"] }, + { changedPaths: ["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts", "src/e.ts"], testFiles: ["test/a.test.ts"] }, + ]; + for (const args of cases) { + const result = await client.callTool({ name: "loopover_check_test_evidence", arguments: args }); + expect(result.isError, JSON.stringify(args)).toBeFalsy(); + expect((result as { structuredContent?: unknown }).structuredContent, JSON.stringify(args)).toEqual( + JSON.parse(JSON.stringify(buildTestEvidenceReport(args))), + ); + } + }); + + it("rejects invalid input (zod) — including free text where an array is required", async () => { + for (const args of [{}, { changedPaths: "nope" }, { changedPaths: ["src/a.ts"], tests: "free text is not an array" }, { changedPaths: [""] }]) { + const rejected = await client.callTool({ name: "loopover_check_test_evidence", arguments: args }).then((r) => Boolean(r.isError), () => true); + expect(rejected, `${JSON.stringify(args)} should be rejected`).toBe(true); + } + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 12e3cde60..e77b0fb66 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -53,14 +53,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 65 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 66 loopover_ tools and zero gittensory_-prefixed aliases", async () => { const { tools } = await client.listTools(); const names = tools.map((t) => t.name); const primary = names.filter((n) => n.startsWith("loopover_")); const legacy = names.filter((n) => n.startsWith("gittensory_")); - expect(primary.length).toBe(65); + expect(primary.length).toBe(66); expect(legacy.length).toBe(0); - expect(names.length).toBe(65); + expect(names.length).toBe(66); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -70,11 +70,11 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 65-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 66-tool count the live server registers", async () => { const { tools } = await client.listTools(); const payload = JSON.parse(run(["tools", "--json"])) as { count: number; tools: Array<{ name: string }> }; expect(payload.count).toBe(tools.length); - expect(payload.count).toBe(65); + expect(payload.count).toBe(66); expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort()); }); }); diff --git a/test/unit/routes-test-evidence.test.ts b/test/unit/routes-test-evidence.test.ts new file mode 100644 index 000000000..1e21bb94b --- /dev/null +++ b/test/unit/routes-test-evidence.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { buildTestEvidenceReport } from "../../src/signals/test-evidence"; +import { createTestEnv } from "../helpers/d1"; + +// #6749: POST /v1/lint/test-evidence — the REST mirror bringing loopover_check_test_evidence to the parity its +// same-tier sibling /v1/lint/slop-risk already has. The builder's own correctness is pinned independently by +// test-evidence-report.test.ts; these assert the ROUTE contract: schema parity with the MCP tool's shape, the +// verdict passed through unmodified, and 400s on invalid input. +const apiHeaders = (env: Env) => ({ authorization: `Bearer ${env.LOOPOVER_API_TOKEN}`, "content-type": "application/json" }); +const PATH = "/v1/lint/test-evidence"; +const post = (env: Env, body: unknown) => createApp().request(PATH, { method: "POST", headers: apiHeaders(env), body: JSON.stringify(body) }, env); + +describe("POST /v1/lint/test-evidence (#6749)", () => { + it("returns the shared builder's verdict for every arm", async () => { + const env = createTestEnv(); + const cases = [ + { changedPaths: ["src/a.ts"] }, + { changedPaths: ["src/a.ts"], testFiles: ["test/a.test.ts"] }, + { changedPaths: ["src/a.ts"], tests: ["ran `go test ./...` locally, no new file"] }, + { changedPaths: ["README.md"] }, + { changedPaths: ["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts"], testFiles: ["test/a.test.ts"] }, + { changedPaths: ["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts", "src/e.ts"], testFiles: ["test/a.test.ts"] }, + { changedPaths: [] }, + ]; + for (const body of cases) { + const response = await post(env, body); + expect(response.status, JSON.stringify(body)).toBe(200); + await expect(response.json()).resolves.toEqual(JSON.parse(JSON.stringify(buildTestEvidenceReport(body)))); + } + }); + + it("accepts exactly what the MCP tool's checkTestEvidenceShape accepts (schema parity)", async () => { + const env = createTestEnv(); + // `tests` is an ARRAY of strings on the tool, not free text — the mirror must agree. + expect((await post(env, { changedPaths: ["src/a.ts"], tests: ["a", "b"] })).status).toBe(200); + // An empty changedPaths array is valid for the tool (it yields "absent"), so the route must not reject it. + expect((await post(env, { changedPaths: [] })).status).toBe(200); + }); + + it("rejects an invalid or unparseable body with 400", async () => { + const env = createTestEnv(); + for (const body of [{}, { changedPaths: "nope" }, { changedPaths: ["src/a.ts"], tests: "free text is not an array" }, { changedPaths: [""] }]) { + const response = await post(env, body); + expect(response.status, JSON.stringify(body)).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_test_evidence_request" }); + } + const malformed = await createApp().request(PATH, { method: "POST", headers: apiHeaders(createTestEnv()), body: "{not json" }, createTestEnv()); + expect(malformed.status).toBe(400); + }); + + it("uploads no source and leaks no private terms — path metadata only", async () => { + const env = createTestEnv(); + const text = JSON.stringify(await (await post(env, { changedPaths: ["src/a.ts"] })).json()); + expect(text).not.toMatch(/wallet|hotkey|coldkey|trust score|reward/i); + }); +}); diff --git a/test/unit/test-evidence-report.test.ts b/test/unit/test-evidence-report.test.ts new file mode 100644 index 000000000..9b190fa07 --- /dev/null +++ b/test/unit/test-evidence-report.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { buildTestEvidenceReport } from "../../src/signals/test-evidence"; + +// #6749: buildTestEvidenceReport is the single implementation the MCP tool, POST /v1/lint/test-evidence, and the +// CLI mirror all share. Its consumers' tests assert parity WITH this builder, which proves wiring but not the +// builder's own correctness — so these assert exact classification values independently, pinning both ratio +// boundaries (0.4 strong, 0.2 adequate) that classifyTestCoverage uses. +describe("buildTestEvidenceReport (#6749)", () => { + it("classifies each ratio band exactly, at and around the 0.4 / 0.2 boundaries", () => { + // 2 tests / 5 total = 0.4 -> exactly ON the strong boundary. + expect(buildTestEvidenceReport({ changedPaths: ["src/a.ts", "src/b.ts", "src/c.ts"], testFiles: ["test/a.test.ts", "test/b.test.ts"] }).classification).toBe("strong"); + // 1 test / 5 total = 0.2 -> exactly ON the adequate boundary. + expect(buildTestEvidenceReport({ changedPaths: ["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts"], testFiles: ["test/a.test.ts"] }).classification).toBe("adequate"); + // 1 test / 6 total ≈ 0.17 -> just BELOW adequate. + expect(buildTestEvidenceReport({ changedPaths: ["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts", "src/e.ts"], testFiles: ["test/a.test.ts"] }).classification).toBe("weak"); + // no test paths at all -> absent. + expect(buildTestEvidenceReport({ changedPaths: ["src/a.ts"] }).classification).toBe("absent"); + // no paths at all -> absent. + expect(buildTestEvidenceReport({ changedPaths: [] }).classification).toBe("absent"); + }); + + it("counts changed/code/test files independently of the classification", () => { + const report = buildTestEvidenceReport({ changedPaths: ["src/a.ts", "README.md"], testFiles: ["test/a.test.ts"] }); + expect(report).toMatchObject({ changedFileCount: 3, codeFileCount: 1, testFileCount: 1 }); + }); + + it("credits free-text tests evidence ONLY to lift an absent verdict — never to loosen a real one", () => { + const lifted = buildTestEvidenceReport({ changedPaths: ["src/a.ts"], tests: ["ran `go test ./...` locally"] }); + expect(lifted.classification).toBe("adequate"); + expect(lifted.testFileCount).toBe(1); + expect(lifted.guidance.join(" ")).toMatch(/free-text/i); + + // A weak verdict already has real path evidence, so free-text notes must NOT upgrade it. + const weak = buildTestEvidenceReport({ + changedPaths: ["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts", "src/e.ts"], + testFiles: ["test/a.test.ts"], + tests: ["ran everything, honest"], + }); + expect(weak.classification).toBe("weak"); + + // An empty free-text array is not evidence. + expect(buildTestEvidenceReport({ changedPaths: ["src/a.ts"], tests: [] }).classification).toBe("absent"); + + // testFiles supplied but containing NO real test path: the verdict is still absent, and the free-text + // credit path still inspects those files (hasLocalTestEvidence re-checks them) rather than skipping them. + expect(buildTestEvidenceReport({ changedPaths: ["src/a.ts"], testFiles: ["src/helper.ts"] }).classification).toBe("absent"); + // …and the same non-test testFiles DO get credited once a real test path is among them. + expect(buildTestEvidenceReport({ changedPaths: ["src/a.ts"], testFiles: ["src/helper.ts", "test/a.test.ts"] }).classification).not.toBe("absent"); + }); + + it("renders the right guidance arm for each outcome", () => { + expect(buildTestEvidenceReport({ changedPaths: ["README.md"] }).guidance.join(" ")).toMatch(/does not apply/i); + expect(buildTestEvidenceReport({ changedPaths: ["src/a.ts"] }).guidance.join(" ")).toMatch(/no test evidence/i); + expect(buildTestEvidenceReport({ changedPaths: ["src/a.ts"], testFiles: ["test/a.test.ts"] }).guidance.join(" ")).toMatch(/strong/i); + expect( + buildTestEvidenceReport({ changedPaths: ["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts", "src/e.ts"], testFiles: ["test/a.test.ts"] }).guidance.join(" "), + ).toMatch(/adding another focused test/i); + }); + + it("accepts readonly inputs without mutating the caller's arrays", () => { + const changedPaths: readonly string[] = Object.freeze(["src/a.ts"]); + const tests: readonly string[] = Object.freeze(["ran the suite"]); + expect(() => buildTestEvidenceReport({ changedPaths, tests })).not.toThrow(); + expect(changedPaths).toEqual(["src/a.ts"]); + }); +});