From 39bac431cfa88d9babb8677ba7cc5bb4d433523e Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:13:48 +0200 Subject: [PATCH 1/8] feat(mcp): add gittensory_check_test_evidence tool (#2235) Expose the deterministic changed-path test-evidence classifier as a metadata-only MCP tool with coverage-gap guidance. Co-authored-by: Cursor --- src/mcp/check-test-evidence.ts | 62 ++++++++++++ src/mcp/server.ts | 35 +++++++ test/unit/mcp-check-test-evidence.test.ts | 109 ++++++++++++++++++++++ test/unit/mcp-output-schemas.test.ts | 1 + 4 files changed, 207 insertions(+) create mode 100644 src/mcp/check-test-evidence.ts create mode 100644 test/unit/mcp-check-test-evidence.test.ts diff --git a/src/mcp/check-test-evidence.ts b/src/mcp/check-test-evidence.ts new file mode 100644 index 0000000000..32290618cb --- /dev/null +++ b/src/mcp/check-test-evidence.ts @@ -0,0 +1,62 @@ +import { classifyTestCoverage, isTestPath, type TestCoverageClassification } from "../signals/test-evidence"; +import { isCodeFile } from "../signals/path-matchers"; + +export type CheckTestEvidenceInput = { + changedPaths: string[]; + testPaths?: string[] | undefined; +}; + +export type CheckTestEvidenceReport = { + classification: TestCoverageClassification; + codeFileCount: number; + testFileCount: number; + docsOnly: boolean; + guidance: string[]; + generatedAt: string; +}; + +const CLASSIFICATION_GUIDANCE: Record = { + absent: "Add focused regression tests for the changed code paths, or explain why existing coverage is sufficient.", + weak: "Some test files are present, but coverage is proportionally light — add more focused tests for the code you changed.", + adequate: "Test changes are proportionally adequate for the number of code files changed.", + strong: "Test changes are proportionally strong for the number of code files changed.", +}; + +function uniquePaths(paths: readonly string[]): string[] { + const seen = new Set(); + const normalized: string[] = []; + for (const path of paths) { + const trimmed = path.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + normalized.push(trimmed); + } + return normalized; +} + +/** Deterministic coverage-gap report for MCP `gittensory_check_test_evidence` (#2235). Pure — paths only. */ +export function buildCheckTestEvidenceReport(input: CheckTestEvidenceInput): CheckTestEvidenceReport { + const changedPaths = uniquePaths(input.changedPaths ?? []); + const extraTestPaths = uniquePaths(input.testPaths ?? []); + const pathsForClassification = uniquePaths([...changedPaths, ...extraTestPaths]); + const codeFileCount = changedPaths.filter(isCodeFile).length; + const testFileCount = pathsForClassification.filter(isTestPath).length; + const docsOnly = codeFileCount === 0; + const classification: TestCoverageClassification = docsOnly ? "absent" : classifyTestCoverage(pathsForClassification); + + const guidance: string[] = []; + if (docsOnly) { + guidance.push("No code files changed — dedicated test evidence is not required for docs-only churn."); + } else { + guidance.push(CLASSIFICATION_GUIDANCE[classification]); + } + + return { + classification, + codeFileCount, + testFileCount, + docsOnly, + guidance, + generatedAt: new Date().toISOString(), + }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 149ef27761..12c9424880 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -124,6 +124,7 @@ import { buildPredictedGateVerdict } from "../rules/predicted-gate"; import { buildIssueSlopAssessment, buildSlopAssessment } from "../signals/slop"; import { buildRepoDataQuality } from "../signals/data-quality"; import { PREFLIGHT_LIMITS } from "../signals/preflight-limits"; +import { buildCheckTestEvidenceReport } from "./check-test-evidence"; import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENARIO_MAX_REPO_FULL_NAME_CHARS } from "../scenarios/input-model"; import { loadUpstreamStatus } from "../upstream/ruleset"; @@ -768,6 +769,20 @@ const checkIssueSlopShape = { const checkIssueSlopOutputSchema = checkSlopRiskOutputSchema; +const checkTestEvidenceShape = { + changedPaths: z.array(z.string().min(1).max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles), + testPaths: z.array(z.string().min(1).max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), +}; + +const checkTestEvidenceOutputSchema = { + classification: z.enum(["strong", "adequate", "weak", "absent"]).optional(), + codeFileCount: z.number().int().min(0).optional(), + testFileCount: z.number().int().min(0).optional(), + docsOnly: z.boolean().optional(), + guidance: z.array(z.string()).optional(), + generatedAt: z.string().optional(), +}; + const predictGateOutputSchema = { predicted: z.boolean().optional(), basis: z.string().optional(), @@ -1234,6 +1249,17 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.checkIssueSlop(input)), ); + server.registerTool( + "gittensory_check_test_evidence", + { + description: + "Classify whether a planned change carries enough accompanying test evidence from changed paths and optional test-file paths alone — metadata-only, no source upload, no GitHub writes. Returns a coverage-gap band and actionable guidance.", + inputSchema: checkTestEvidenceShape, + outputSchema: checkTestEvidenceOutputSchema, + }, + async (input) => this.toolResult(await this.checkTestEvidence(input)), + ); + server.registerTool( "gittensory_pr_outcome", { @@ -2223,6 +2249,15 @@ export class GittensoryMcp { }; } + private async checkTestEvidence(input: z.infer>): Promise { + await this.enforceToolRateLimit("gittensory_check_test_evidence"); + const report = buildCheckTestEvidenceReport(input); + return { + summary: `Test-evidence classification: ${report.classification}.`, + data: report as unknown as Record, + }; + } + private async predictGate(input: z.infer>): Promise { this.requireContributorAccess(input.login); const repoFullName = `${input.owner}/${input.repo}`; diff --git a/test/unit/mcp-check-test-evidence.test.ts b/test/unit/mcp-check-test-evidence.test.ts new file mode 100644 index 0000000000..041dccae02 --- /dev/null +++ b/test/unit/mcp-check-test-evidence.test.ts @@ -0,0 +1,109 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { buildCheckTestEvidenceReport } from "../../src/mcp/check-test-evidence"; +import { GittensoryMcp } from "../../src/mcp/server"; +import { createTestEnv } from "../helpers/d1"; + +async function connect() { + const server = new GittensoryMcp(createTestEnv()).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "gittensory-test-evidence-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +describe("buildCheckTestEvidenceReport (#2235)", () => { + it("classifies code-only changes without tests as absent", () => { + const report = buildCheckTestEvidenceReport({ changedPaths: ["src/auth.ts", "src/utils.ts"] }); + expect(report.classification).toBe("absent"); + expect(report.codeFileCount).toBe(2); + expect(report.testFileCount).toBe(0); + expect(report.docsOnly).toBe(false); + expect(report.guidance[0]).toMatch(/Add focused regression tests/); + }); + + it("classifies code plus proportionally strong tests as strong", () => { + const report = buildCheckTestEvidenceReport({ + changedPaths: ["src/a.ts", "src/b.ts", "test/a.test.ts", "test/b.test.ts"], + }); + expect(report.classification).toBe("strong"); + expect(report.testFileCount).toBe(2); + }); + + it("classifies adequate and weak threshold bands", () => { + const adequate = buildCheckTestEvidenceReport({ + changedPaths: ["src/a.ts", "src/b.ts", "src/c.ts", "test/a.test.ts"], + }); + expect(adequate.classification).toBe("adequate"); + + const weak = buildCheckTestEvidenceReport({ + changedPaths: [...Array.from({ length: 9 }, (_, i) => `src/file${i}.ts`), "test/single.test.ts"], + }); + expect(weak.classification).toBe("weak"); + }); + + it("treats docs-only churn as not requiring dedicated test evidence", () => { + const report = buildCheckTestEvidenceReport({ changedPaths: ["README.md", "docs/guide.md"] }); + expect(report.docsOnly).toBe(true); + expect(report.codeFileCount).toBe(0); + expect(report.classification).toBe("absent"); + expect(report.guidance[0]).toMatch(/docs-only churn/); + }); + + it("counts optional testPaths supplied separately from changedPaths", () => { + const report = buildCheckTestEvidenceReport({ + changedPaths: ["src/a.ts", "src/b.ts", "src/c.ts"], + testPaths: ["test/a.test.ts"], + }); + expect(report.classification).toBe("adequate"); + expect(report.testFileCount).toBe(1); + }); +}); + +describe("MCP gittensory_check_test_evidence (#2235)", () => { + it("registers the tool and returns a public-safe classification for code-only paths", async () => { + const client = await connect(); + const { tools } = await client.listTools(); + expect(tools.map((tool) => tool.name)).toContain("gittensory_check_test_evidence"); + + const result = await client.callTool({ + name: "gittensory_check_test_evidence", + arguments: { changedPaths: ["src/api/routes.ts"] }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { + classification: string; + guidance: string[]; + docsOnly: boolean; + }; + expect(data.classification).toBe("absent"); + expect(data.docsOnly).toBe(false); + expect(data.guidance.length).toBeGreaterThan(0); + expect(JSON.stringify(data)).not.toMatch(/wallet|hotkey|reward|payout|trust score/i); + }); + + it("returns strong classification when code and tests are supplied together", async () => { + const client = await connect(); + const result = await client.callTool({ + name: "gittensory_check_test_evidence", + arguments: { + changedPaths: ["src/a.ts", "src/b.ts", "test/a.test.ts", "test/b.test.ts"], + }, + }); + const data = result.structuredContent as { classification: string }; + expect(data.classification).toBe("strong"); + }); + + it("returns docs-only guidance without requiring tests", async () => { + const client = await connect(); + const result = await client.callTool({ + name: "gittensory_check_test_evidence", + arguments: { changedPaths: ["docs/miner-goal-spec.md"] }, + }); + const data = result.structuredContent as { docsOnly: boolean; guidance: string[] }; + expect(data.docsOnly).toBe(true); + expect(data.guidance[0]).toMatch(/docs-only churn/); + }); +}); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index b8895672bb..51b75af823 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -26,6 +26,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [ "gittensory_validate_linked_issue", "gittensory_check_before_start", "gittensory_lint_pr_text", + "gittensory_check_test_evidence", "gittensory_get_registry_changes", "gittensory_get_upstream_drift", "gittensory_local_status", From f24a3e6afb6111fea2e9c60ab2e22eaa14b55098 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:28:32 +0200 Subject: [PATCH 2/8] fix(miner): allow nested lib paths in npm pack dry-run checker Calibration module files under lib/calibration/ must pass check-miner-package after #2332. Co-authored-by: Cursor --- scripts/check-miner-package.mjs | 4 ++-- test/unit/check-miner-package.test.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/check-miner-package.mjs b/scripts/check-miner-package.mjs index cc284ba515..f3f6b9ba4d 100644 --- a/scripts/check-miner-package.mjs +++ b/scripts/check-miner-package.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; const ALLOWED = [ /^bin\/gittensory-miner\.js$/, - /^lib\/[a-z0-9-]+\.(js|d\.ts)$/, + /^lib\/(?:[a-z0-9-]+\/)*[a-z0-9-]+\.(js|d\.ts)$/, /^package\.json$/, /^README\.md$/, ]; @@ -26,7 +26,7 @@ export function validateMinerPackFileList(files, readContent) { for (const required of REQUIRED) { if (!paths.includes(required)) throw new Error(`Miner package is missing required file: ${required}`); } - if (!paths.some((file) => /^lib\/[a-z0-9-]+\.js$/.test(file))) { + if (!paths.some((file) => /^lib\/(?:[a-z0-9-]+\/)*[a-z0-9-]+\.js$/.test(file))) { throw new Error("Miner package is missing lib/*.js artifacts"); } return paths; diff --git a/test/unit/check-miner-package.test.ts b/test/unit/check-miner-package.test.ts index 1354932cc9..5aa1001f1b 100644 --- a/test/unit/check-miner-package.test.ts +++ b/test/unit/check-miner-package.test.ts @@ -23,6 +23,18 @@ describe("check-miner-package script", () => { expect(result.out).toContain("package.json"); }); + it("allows nested lib subdirectories such as calibration/", () => { + const result = runChecker({ + CHECK_MINER_PACK_TEST_FILES: JSON.stringify([ + "package.json", + "bin/gittensory-miner.js", + "lib/calibration/index.js", + "lib/calibration/types.d.ts", + ]), + }); + expect(result.status).toBe(0); + }); + it("rejects a forbidden path", () => { const result = runChecker({ CHECK_MINER_PACK_TEST_FILES: JSON.stringify([".env"]) }); expect(result.status).toBe(1); From 6771683bdae146eb7eb3ed57ab5bdbb4c4e0ae79 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:38:24 +0200 Subject: [PATCH 3/8] test(mcp): cover check-test-evidence branch gaps for codecov patch Exercise path deduplication, blank skips, and every classification guidance string. Co-authored-by: Cursor --- test/unit/mcp-check-test-evidence.test.ts | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/unit/mcp-check-test-evidence.test.ts b/test/unit/mcp-check-test-evidence.test.ts index 041dccae02..e78020c7ee 100644 --- a/test/unit/mcp-check-test-evidence.test.ts +++ b/test/unit/mcp-check-test-evidence.test.ts @@ -60,6 +60,33 @@ describe("buildCheckTestEvidenceReport (#2235)", () => { expect(report.classification).toBe("adequate"); expect(report.testFileCount).toBe(1); }); + + it("deduplicates paths and ignores blank entries", () => { + const report = buildCheckTestEvidenceReport({ + changedPaths: [" src/a.ts ", "src/a.ts", "", " "], + testPaths: ["test/a.test.ts", "test/a.test.ts"], + }); + expect(report.codeFileCount).toBe(1); + expect(report.testFileCount).toBe(1); + expect(report.classification).toBe("weak"); + }); + + it("returns absent guidance strings for every non-docs classification band", () => { + const absent = buildCheckTestEvidenceReport({ changedPaths: ["src/only.ts"] }); + const weak = buildCheckTestEvidenceReport({ + changedPaths: [...Array.from({ length: 9 }, (_, i) => `src/file${i}.ts`), "test/one.test.ts"], + }); + const adequate = buildCheckTestEvidenceReport({ + changedPaths: ["src/a.ts", "src/b.ts", "src/c.ts", "test/a.test.ts"], + }); + const strong = buildCheckTestEvidenceReport({ + changedPaths: ["src/a.ts", "src/b.ts", "test/a.test.ts", "test/b.test.ts"], + }); + expect(absent.guidance[0]).toMatch(/Add focused regression tests/); + expect(weak.guidance[0]).toMatch(/proportionally light/); + expect(adequate.guidance[0]).toMatch(/proportionally adequate/); + expect(strong.guidance[0]).toMatch(/proportionally strong/); + }); }); describe("MCP gittensory_check_test_evidence (#2235)", () => { From 19dc8ecbb7333149529cda9611a522b71357c18e Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:38:54 +0200 Subject: [PATCH 4/8] test(mcp): fix dedup classification expectation in coverage test Co-authored-by: Cursor --- test/unit/mcp-check-test-evidence.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/mcp-check-test-evidence.test.ts b/test/unit/mcp-check-test-evidence.test.ts index e78020c7ee..05018cfab1 100644 --- a/test/unit/mcp-check-test-evidence.test.ts +++ b/test/unit/mcp-check-test-evidence.test.ts @@ -68,7 +68,7 @@ describe("buildCheckTestEvidenceReport (#2235)", () => { }); expect(report.codeFileCount).toBe(1); expect(report.testFileCount).toBe(1); - expect(report.classification).toBe("weak"); + expect(report.classification).toBe("strong"); }); it("returns absent guidance strings for every non-docs classification band", () => { From 1a20710f8583989127cc86b7c12537407884658d Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:52:13 +0200 Subject: [PATCH 5/8] test(mcp): cover nullish path inputs for codecov patch (#2235) Exercise changedPaths/testPaths ?? [] branches and cross-list deduplication. Co-authored-by: Cursor --- test/unit/mcp-check-test-evidence.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/unit/mcp-check-test-evidence.test.ts b/test/unit/mcp-check-test-evidence.test.ts index 05018cfab1..786d0020b0 100644 --- a/test/unit/mcp-check-test-evidence.test.ts +++ b/test/unit/mcp-check-test-evidence.test.ts @@ -87,6 +87,29 @@ describe("buildCheckTestEvidenceReport (#2235)", () => { expect(adequate.guidance[0]).toMatch(/proportionally adequate/); expect(strong.guidance[0]).toMatch(/proportionally strong/); }); + + it("defaults nullish changedPaths and testPaths to empty lists", () => { + const emptyChanged = buildCheckTestEvidenceReport({ changedPaths: null as unknown as string[] }); + expect(emptyChanged.docsOnly).toBe(true); + expect(emptyChanged.codeFileCount).toBe(0); + expect(emptyChanged.guidance[0]).toMatch(/docs-only churn/); + + const nullTestPaths = buildCheckTestEvidenceReport({ + changedPaths: ["src/a.ts"], + testPaths: null as unknown as string[], + }); + expect(nullTestPaths.classification).toBe("absent"); + expect(nullTestPaths.testFileCount).toBe(0); + }); + + it("deduplicates a test path repeated across changedPaths and testPaths", () => { + const report = buildCheckTestEvidenceReport({ + changedPaths: ["src/a.ts", "test/shared.test.ts"], + testPaths: ["test/shared.test.ts"], + }); + expect(report.testFileCount).toBe(1); + expect(report.classification).toBe("strong"); + }); }); describe("MCP gittensory_check_test_evidence (#2235)", () => { From 4a3d5935d8aec65f82aff3842b3221aaf4a676b8 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:16:14 +0200 Subject: [PATCH 6/8] chore: drop redundant check-miner-package test from MCP PR Calibration pack-checker coverage already lives on main; keep this PR MCP-only. Co-authored-by: Cursor --- test/unit/check-miner-package.test.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/test/unit/check-miner-package.test.ts b/test/unit/check-miner-package.test.ts index 0fbb5e0d38..7c2f7f1c99 100644 --- a/test/unit/check-miner-package.test.ts +++ b/test/unit/check-miner-package.test.ts @@ -23,18 +23,6 @@ describe("check-miner-package script", () => { expect(result.out).toContain("package.json"); }); - it("allows nested lib subdirectories such as calibration/", () => { - const result = runChecker({ - CHECK_MINER_PACK_TEST_FILES: JSON.stringify([ - "package.json", - "bin/gittensory-miner.js", - "lib/calibration/index.js", - "lib/calibration/types.d.ts", - ]), - }); - expect(result.status).toBe(0); - }); - it("rejects a forbidden path", () => { const result = runChecker({ CHECK_MINER_PACK_TEST_FILES: JSON.stringify([".env"]) }); expect(result.status).toBe(1); From d57d21f4965a26d5ed0b2410c146efd88be57830 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:36:16 +0200 Subject: [PATCH 7/8] feat(engine): extract test-evidence signals and add MCP self-check (#2277) Move isTestPath, classifyTestCoverage, and computeLocalScorerTokens into @gittensory/gittensory-engine with thin src/ shims, then register gittensory_check_test_evidence on the hosted MCP Worker. Closes #2277 Co-authored-by: Cursor --- package-lock.json | 1 + package.json | 1 + packages/gittensory-engine/src/index.ts | 12 ++++ .../gittensory-engine/src/local-scorer.ts | 71 +++++++++++++++++++ .../gittensory-engine/src/test-evidence.ts | 38 ++++++++++ src/mcp/check-test-evidence.ts | 4 +- src/signals/local-scorer.ts | 35 +-------- src/signals/test-evidence.ts | 44 ++---------- .../engine-test-evidence-extraction.test.ts | 25 +++++++ test/unit/mcp-check-test-evidence.test.ts | 4 +- 10 files changed, 159 insertions(+), 76 deletions(-) create mode 100644 packages/gittensory-engine/src/local-scorer.ts create mode 100644 packages/gittensory-engine/src/test-evidence.ts create mode 100644 test/unit/engine-test-evidence-extraction.test.ts diff --git a/package-lock.json b/package-lock.json index 6534d3f0c5..f4d74020d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@asteasolutions/zod-to-openapi": "^8.5.0", "@cloudflare/puppeteer": "^1.1.0", "@hono/node-server": "^2.0.6", + "@jsonbored/gittensory-engine": "0.1.0", "@modelcontextprotocol/sdk": "1.29.0", "@octokit/core": "^7.0.6", "@opentelemetry/api": "^1.9.1", diff --git a/package.json b/package.json index 4008e85f49..a3b0c6bb7d 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "validate": "npm run typecheck && npm run test:coverage" }, "dependencies": { + "@jsonbored/gittensory-engine": "0.1.0", "@asteasolutions/zod-to-openapi": "^8.5.0", "@cloudflare/puppeteer": "^1.1.0", "@hono/node-server": "^2.0.6", diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 05ec54a5cf..73f61aba75 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -5,6 +5,18 @@ // focus-manifest parse/compile core, duplicate-winner adjudication, and their engine-parity fixtures). // More modules land in follow-up issues. export { ENGINE_VERSION } from "./version.js"; +export { + classifyTestCoverage, + hasLocalTestEvidence, + isTestPath, + type TestCoverageClassification, +} from "./test-evidence.js"; +export { + computeLocalScorerTokens, + type LocalBranchChangedFile, + type LocalBranchScorer, + type LocalBranchValidation, +} from "./local-scorer.js"; export { pickTopRankedOpportunities, rankOpportunityScore, diff --git a/packages/gittensory-engine/src/local-scorer.ts b/packages/gittensory-engine/src/local-scorer.ts new file mode 100644 index 0000000000..9697f8e5b8 --- /dev/null +++ b/packages/gittensory-engine/src/local-scorer.ts @@ -0,0 +1,71 @@ +import { isTestPath } from "./test-evidence.js"; + +// Shapes mirrored from `src/signals/local-branch.ts` — types only, no cross-package import (#2277). +export type LocalBranchChangedFile = { + path: string; + additions?: number | undefined; + deletions?: number | undefined; + binary?: boolean | undefined; +}; + +export type LocalBranchValidation = { + command: string; + status: "passed" | "failed" | "not_run" | "skipped" | "focused" | "unknown"; + summary?: string | undefined; + durationMs?: number | undefined; + exitCode?: number | undefined; +}; + +export type LocalBranchScorer = { + mode: "metadata_only" | "external_command" | "gittensor_root"; + activeModel?: string | undefined; + sourceTokenScore?: number | undefined; + totalTokenScore?: number | undefined; + sourceLines?: number | undefined; + testTokenScore?: number | undefined; + nonCodeTokenScore?: number | undefined; + warnings?: string[] | undefined; +}; + +function isTestFile(file: string): boolean { + return isTestPath(file); +} + +/** Mirrors `src/signals/path-matchers.ts` `isCodeFile` for metadata-only local scoring. */ +function isCodeFile(file: string): boolean { + return ( + /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|py|rb|rs|kt|scala|java|go|sql|cs|swift|groovy|php|cpp|cc|c|h|hpp|m|vue|svelte|astro|dart)$/i.test( + file, + ) && !isTestFile(file) + ); +} + +const fileLines = (file: LocalBranchChangedFile): number => Math.max(0, file.additions ?? 0) + Math.max(0, file.deletions ?? 0); + +/** + * Compute token scores from changed-file metadata + the local validation results. `isCodeFile` already excludes + * tests, so source / test / non-code are disjoint. Binary files carry no token value and are dropped. A failed + * validation does not change the scores (they describe the diff) but is surfaced as a warning. Pure. + */ +export function computeLocalScorerTokens(input: { + changedFiles: LocalBranchChangedFile[]; + validation?: LocalBranchValidation[] | undefined; +}): LocalBranchScorer { + const files = input.changedFiles.filter((file) => !file.binary); + const testTokenScore = files.filter((file) => isTestFile(file.path)).reduce((sum, file) => sum + fileLines(file), 0); + const sourceTokenScore = files.filter((file) => isCodeFile(file.path)).reduce((sum, file) => sum + fileLines(file), 0); + const totalTokenScore = files.reduce((sum, file) => sum + fileLines(file), 0); + const nonCodeTokenScore = Math.max(0, totalTokenScore - sourceTokenScore - testTokenScore); + const failed = (input.validation ?? []).some((entry) => entry.status === "failed"); + const warnings = failed ? ["Local validation reported failures — token scores describe the diff, not a passing build."] : []; + return { + mode: "external_command", + activeModel: "gittensory-deterministic", + sourceTokenScore, + totalTokenScore, + sourceLines: Math.max(1, sourceTokenScore || totalTokenScore || 1), + testTokenScore, + nonCodeTokenScore, + ...(warnings.length > 0 ? { warnings } : {}), + }; +} diff --git a/packages/gittensory-engine/src/test-evidence.ts b/packages/gittensory-engine/src/test-evidence.ts new file mode 100644 index 0000000000..b89457fc26 --- /dev/null +++ b/packages/gittensory-engine/src/test-evidence.ts @@ -0,0 +1,38 @@ +export function isTestPath(file: string): boolean { + return ( + /(^|\/)(test|tests|spec|__tests__)\//i.test(file) || + /(^|\/)src\/test\//i.test(file) || + /(^|\/)[^/]+_test\.(go|py|rb|dart)$/i.test(file) || // Dart/Flutter `foo_test.dart` co-located with source + /(^|\/)test_[^/]*\.py$/i.test(file) || // pytest's default `test_*.py` prefix convention (the suffix rule above only catches `*_test.py`) + /(^|\/)[^/]+_spec\.rb$/i.test(file) || + /\.(test|spec)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|py|rb|rs)$/i.test(file) || + /(^|\/)[^/]+\.(cy|e2e)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/i.test(file) || + // JVM / C# / Swift / PHP `SomethingTest(s)`/`SomethingSpec` class-suffix convention + // (JUnit, Kotlin/ScalaTest, Spock, xUnit/NUnit, XCTest, PHPUnit/PHPSpec). Case-sensitive on the + // PascalCase suffix so it can't false-positive on words that merely end in + // "test"/"spec" (Latest.java, Contest.cs, manifest.scala, Latest.php). + /(^|\/)\w*(Tests?|Spec)\.(java|kt|kts|scala|cs|swift|groovy|php)$/.test(file) || + /(^|\/)__snapshots__\//i.test(file) + ); +} + +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/src/mcp/check-test-evidence.ts b/src/mcp/check-test-evidence.ts index 32290618cb..8d2b395997 100644 --- a/src/mcp/check-test-evidence.ts +++ b/src/mcp/check-test-evidence.ts @@ -34,7 +34,7 @@ function uniquePaths(paths: readonly string[]): string[] { return normalized; } -/** Deterministic coverage-gap report for MCP `gittensory_check_test_evidence` (#2235). Pure — paths only. */ +/** Deterministic coverage-gap report for MCP `gittensory_check_test_evidence` (#2277). Pure — paths only. */ export function buildCheckTestEvidenceReport(input: CheckTestEvidenceInput): CheckTestEvidenceReport { const changedPaths = uniquePaths(input.changedPaths ?? []); const extraTestPaths = uniquePaths(input.testPaths ?? []); @@ -48,7 +48,7 @@ export function buildCheckTestEvidenceReport(input: CheckTestEvidenceInput): Che if (docsOnly) { guidance.push("No code files changed — dedicated test evidence is not required for docs-only churn."); } else { - guidance.push(CLASSIFICATION_GUIDANCE[classification]); + guidance.push(CLASSIFICATION_GUIDANCE[classification]!); } return { diff --git a/src/signals/local-scorer.ts b/src/signals/local-scorer.ts index ef4d706b70..f307146831 100644 --- a/src/signals/local-scorer.ts +++ b/src/signals/local-scorer.ts @@ -1,34 +1 @@ -import { isCodeFile, isTestFile, type LocalBranchChangedFile, type LocalBranchScorer, type LocalBranchValidation } from "./local-branch"; - -// #782 deterministic local scorer. Replicates the gittensor-root token-scoring view from changed-file METADATA -// (paths + line counts) — never source content, so the no-upload boundary holds and it runs in every surface -// (stdio package AND hosted Worker). It mirrors buildScorePreview's source/test/non-code classification, so -// feeding its output back in as `localScorer` (mode external_command) flips the preview off metadata-only with -// numbers it would otherwise have derived itself — closing the "miner runs the scorer manually" gap. - -const fileLines = (file: LocalBranchChangedFile): number => Math.max(0, file.additions ?? 0) + Math.max(0, file.deletions ?? 0); - -/** - * Compute token scores from changed-file metadata + the local validation results. `isCodeFile` already excludes - * tests, so source / test / non-code are disjoint. Binary files carry no token value and are dropped. A failed - * validation does not change the scores (they describe the diff) but is surfaced as a warning. Pure. - */ -export function computeLocalScorerTokens(input: { changedFiles: LocalBranchChangedFile[]; validation?: LocalBranchValidation[] | undefined }): LocalBranchScorer { - const files = input.changedFiles.filter((file) => !file.binary); - const testTokenScore = files.filter((file) => isTestFile(file.path)).reduce((sum, file) => sum + fileLines(file), 0); - const sourceTokenScore = files.filter((file) => isCodeFile(file.path)).reduce((sum, file) => sum + fileLines(file), 0); - const totalTokenScore = files.reduce((sum, file) => sum + fileLines(file), 0); - const nonCodeTokenScore = Math.max(0, totalTokenScore - sourceTokenScore - testTokenScore); - const failed = (input.validation ?? []).some((entry) => entry.status === "failed"); - const warnings = failed ? ["Local validation reported failures — token scores describe the diff, not a passing build."] : []; - return { - mode: "external_command", - activeModel: "gittensory-deterministic", - sourceTokenScore, - totalTokenScore, - sourceLines: Math.max(1, sourceTokenScore || totalTokenScore || 1), - testTokenScore, - nonCodeTokenScore, - ...(warnings.length > 0 ? { warnings } : {}), - }; -} +export { computeLocalScorerTokens } from "@jsonbored/gittensory-engine"; diff --git a/src/signals/test-evidence.ts b/src/signals/test-evidence.ts index 5f0630761f..28f8c18ad9 100644 --- a/src/signals/test-evidence.ts +++ b/src/signals/test-evidence.ts @@ -1,24 +1,9 @@ -export function isTestPath(file: string): boolean { - return ( - /(^|\/)(test|tests|spec|__tests__)\//i.test(file) || - /(^|\/)src\/test\//i.test(file) || - /(^|\/)[^/]+_test\.(go|py|rb|dart)$/i.test(file) || // Dart/Flutter `foo_test.dart` co-located with source - /(^|\/)test_[^/]*\.py$/i.test(file) || // pytest's default `test_*.py` prefix convention (the suffix rule above only catches `*_test.py`) - /(^|\/)[^/]+_spec\.rb$/i.test(file) || - /\.(test|spec)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|py|rb|rs)$/i.test(file) || - /(^|\/)[^/]+\.(cy|e2e)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/i.test(file) || - // JVM / C# / Swift / PHP `SomethingTest(s)`/`SomethingSpec` class-suffix convention - // (JUnit, Kotlin/ScalaTest, Spock, xUnit/NUnit, XCTest, PHPUnit/PHPSpec). Case-sensitive on the - // PascalCase suffix so it can't false-positive on words that merely end in - // "test"/"spec" (Latest.java, Contest.cs, manifest.scala, Latest.php). - /(^|\/)\w*(Tests?|Spec)\.(java|kt|kts|scala|cs|swift|groovy|php)$/.test(file) || - /(^|\/)__snapshots__\//i.test(file) - ); -} - -export function hasLocalTestEvidence(input: { tests?: string[] | undefined; testFiles?: string[] | undefined }): boolean { - return (input.tests ?? []).length > 0 || (input.testFiles ?? []).some((file) => isTestPath(file)); -} +export { + classifyTestCoverage, + hasLocalTestEvidence, + isTestPath, + type TestCoverageClassification, +} from "@jsonbored/gittensory-engine"; // A body can mention testing without having actually done it ("No tests run", "Tests not run", "Not // tested locally", "did not run any tests") -- the affirmative keyword match below would otherwise treat @@ -75,20 +60,3 @@ export function hasValidationNote(value: string): boolean { AFFIRMATIVE_TEST_MENTION.test(clause), ); } - -/** - * 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/engine-test-evidence-extraction.test.ts b/test/unit/engine-test-evidence-extraction.test.ts new file mode 100644 index 0000000000..ea52444590 --- /dev/null +++ b/test/unit/engine-test-evidence-extraction.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import * as barrel from "../../packages/gittensory-engine/src/index"; +import { classifyTestCoverage, isTestPath } from "../../packages/gittensory-engine/src/test-evidence"; +import { computeLocalScorerTokens } from "../../packages/gittensory-engine/src/local-scorer"; + +describe("engine test-evidence extraction (#2277)", () => { + it("exports test-evidence helpers from the engine barrel", () => { + expect(typeof barrel.isTestPath).toBe("function"); + expect(typeof barrel.classifyTestCoverage).toBe("function"); + expect(typeof barrel.computeLocalScorerTokens).toBe("function"); + }); + + it("classifies coverage from engine-local helpers", () => { + expect(isTestPath("src/widget.test.ts")).toBe(true); + expect(classifyTestCoverage(["src/a.ts", "src/a.test.ts"])).toBe("adequate"); + }); + + it("scores local metadata from the engine local-scorer port", () => { + const scorer = computeLocalScorerTokens({ + changedFiles: [{ path: "src/a.ts", additions: 4 }], + }); + expect(scorer.sourceTokenScore).toBe(4); + }); +}); diff --git a/test/unit/mcp-check-test-evidence.test.ts b/test/unit/mcp-check-test-evidence.test.ts index 786d0020b0..920ea57be6 100644 --- a/test/unit/mcp-check-test-evidence.test.ts +++ b/test/unit/mcp-check-test-evidence.test.ts @@ -14,7 +14,7 @@ async function connect() { return client; } -describe("buildCheckTestEvidenceReport (#2235)", () => { +describe("buildCheckTestEvidenceReport (#2277)", () => { it("classifies code-only changes without tests as absent", () => { const report = buildCheckTestEvidenceReport({ changedPaths: ["src/auth.ts", "src/utils.ts"] }); expect(report.classification).toBe("absent"); @@ -112,7 +112,7 @@ describe("buildCheckTestEvidenceReport (#2235)", () => { }); }); -describe("MCP gittensory_check_test_evidence (#2235)", () => { +describe("MCP gittensory_check_test_evidence (#2277)", () => { it("registers the tool and returns a public-safe classification for code-only paths", async () => { const client = await connect(); const { tools } = await client.listTools(); From 0835f245b3af19fea65672bfcea14407e66a74c8 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:45:16 +0200 Subject: [PATCH 8/8] fix(engine): re-export engine signals via source paths for CI Typecheck and ui:openapi run before the engine package is built, so src/ shims point at packages/gittensory-engine/src instead of the dist entry. Co-authored-by: Cursor --- src/signals/local-scorer.ts | 2 +- src/signals/test-evidence.ts | 2 +- test/unit/engine-test-evidence-extraction.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/signals/local-scorer.ts b/src/signals/local-scorer.ts index f307146831..f3b6f9deae 100644 --- a/src/signals/local-scorer.ts +++ b/src/signals/local-scorer.ts @@ -1 +1 @@ -export { computeLocalScorerTokens } from "@jsonbored/gittensory-engine"; +export { computeLocalScorerTokens } from "../../packages/gittensory-engine/src/local-scorer.js"; diff --git a/src/signals/test-evidence.ts b/src/signals/test-evidence.ts index 28f8c18ad9..7425663bdd 100644 --- a/src/signals/test-evidence.ts +++ b/src/signals/test-evidence.ts @@ -3,7 +3,7 @@ export { hasLocalTestEvidence, isTestPath, type TestCoverageClassification, -} from "@jsonbored/gittensory-engine"; +} from "../../packages/gittensory-engine/src/test-evidence.js"; // A body can mention testing without having actually done it ("No tests run", "Tests not run", "Not // tested locally", "did not run any tests") -- the affirmative keyword match below would otherwise treat diff --git a/test/unit/engine-test-evidence-extraction.test.ts b/test/unit/engine-test-evidence-extraction.test.ts index ea52444590..9a0af88b3c 100644 --- a/test/unit/engine-test-evidence-extraction.test.ts +++ b/test/unit/engine-test-evidence-extraction.test.ts @@ -13,7 +13,7 @@ describe("engine test-evidence extraction (#2277)", () => { it("classifies coverage from engine-local helpers", () => { expect(isTestPath("src/widget.test.ts")).toBe(true); - expect(classifyTestCoverage(["src/a.ts", "src/a.test.ts"])).toBe("adequate"); + expect(classifyTestCoverage(["src/a.ts", "src/a.test.ts"])).toBe("strong"); }); it("scores local metadata from the engine local-scorer port", () => {