Skip to content
Closed
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions packages/gittensory-engine/src/local-scorer.ts
Original file line number Diff line number Diff line change
@@ -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 } : {}),
};
}
38 changes: 38 additions & 0 deletions packages/gittensory-engine/src/test-evidence.ts
Original file line number Diff line number Diff line change
@@ -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";
}
62 changes: 62 additions & 0 deletions src/mcp/check-test-evidence.ts
Original file line number Diff line number Diff line change
@@ -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<TestCoverageClassification, string> = {
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<string>();
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` (#2277). 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(),
};
}
35 changes: 35 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -2223,6 +2249,15 @@ export class GittensoryMcp {
};
}

private async checkTestEvidence(input: z.infer<z.ZodObject<typeof checkTestEvidenceShape>>): Promise<ToolPayload> {
await this.enforceToolRateLimit("gittensory_check_test_evidence");
const report = buildCheckTestEvidenceReport(input);
return {
summary: `Test-evidence classification: ${report.classification}.`,
data: report as unknown as Record<string, unknown>,
};
}

private async predictGate(input: z.infer<z.ZodObject<typeof predictGateShape>>): Promise<ToolPayload> {
this.requireContributorAccess(input.login);
const repoFullName = `${input.owner}/${input.repo}`;
Expand Down
35 changes: 1 addition & 34 deletions src/signals/local-scorer.ts
Original file line number Diff line number Diff line change
@@ -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 "../../packages/gittensory-engine/src/local-scorer.js";
44 changes: 6 additions & 38 deletions src/signals/test-evidence.ts
Original file line number Diff line number Diff line change
@@ -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 "../../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
Expand Down Expand Up @@ -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";
}
25 changes: 25 additions & 0 deletions test/unit/engine-test-evidence-extraction.test.ts
Original file line number Diff line number Diff line change
@@ -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("strong");
});

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);
});
});
Loading
Loading