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
9 changes: 6 additions & 3 deletions packages/gittensory-mcp/scripts/gittensor-score-preview.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@ function isTestFile(file) {
/(^|\/)(test|tests|spec|__tests__)\//i.test(file) ||
/(^|\/)src\/test\//i.test(file) ||
/(^|\/)[^/]+_test\.(go|py|rb)$/i.test(file) ||
/(^|\/)test_[^/]*\.py$/i.test(file) || // pytest's default `test_*.py` prefix (the suffix rule above only catches `*_test.py`)
/(^|\/)[^/]+_spec\.rb$/i.test(file) ||
/\.(test|spec)\.(ts|tsx|js|jsx|py|rb|rs)$/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/.NET/Swift PascalCase test-class suffix (case-sensitive, matching the
// signal classifiers) so C#/Swift/Groovy tests aren't counted as source.
/(^|\/)\w*(Tests?|Spec)\.(java|kt|kts|scala|cs|swift|groovy)$/.test(file)
/(^|\/)\w*(Tests?|Spec)\.(java|kt|kts|scala|cs|swift|groovy)$/.test(file) ||
/(^|\/)__snapshots__\//i.test(file)
);
}

function isCodeFile(file) {
return /\.(ts|tsx|js|jsx|py|rb|rs|kt|scala|java|go|sql|cs|swift|groovy)$/i.test(file) && !isTestFile(file);
return /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|py|rb|rs|kt|scala|java|go|sql|cs|swift|groovy)$/i.test(file) && !isTestFile(file);
}

function lineCount(file) {
Expand Down
49 changes: 14 additions & 35 deletions packages/gittensory-mcp/scripts/gittensor-score-preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,43 +12,22 @@
import sys
from pathlib import Path

# JVM/.NET/Swift PascalCase test-class suffix (case-sensitive, on the original
# path) so C#/Swift/Groovy tests aren't counted as source once their extensions
# are recognized as code. Matches the signal classifiers' isTestPath rule.
_JVM_TEST_SUFFIX_RE = re.compile(r"(?:Tests?|Spec)\.(?:java|kt|kts|scala|cs|swift|groovy)$")
# Every rule is an END/SEGMENT-anchored regex, a faithful mirror of the server isTestPath
# (src/signals/test-evidence.ts). Plain substring tokens over-matched: "/__snapshots__/" missed a root-level
# dir, and ".test.mjs" matched non-tests like `dist/widget.test.mjs.map` where the extension is not end-of-path.
_TEST_PATH_RES = (
re.compile(r"(?:^|/)(?:tests?|spec|__tests__|__snapshots__|src/test)/", re.IGNORECASE), # dir conventions
re.compile(r"(?:^|/)[^/]+_test\.(?:go|py|rb)$", re.IGNORECASE), # go/py/rb *_test suffix
re.compile(r"(?:^|/)test_[^/]*\.py$", re.IGNORECASE), # pytest test_*.py prefix
re.compile(r"(?:^|/)[^/]+_spec\.rb$", re.IGNORECASE), # RSpec *_spec.rb suffix
re.compile(r"\.(?:test|spec)\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|py|rb|rs)$", re.IGNORECASE), # .test/.spec.<ext>
re.compile(r"(?:^|/)[^/]+\.(?:cy|e2e)\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$", re.IGNORECASE), # Cypress/Playwright
re.compile(r"(?:^|/)\w*(?:Tests?|Spec)\.(?:java|kt|kts|scala|cs|swift|groovy)$"), # JVM/.NET/Swift (case-sensitive)
)


def is_test_file(path: str) -> bool:
lowered = path.lower()
basename = lowered.rsplit("/", 1)[-1]
patterns = (
"/test/",
"/tests/",
"/spec/",
"/__tests__/",
"/src/test/",
"_test.go",
"_test.py",
"_test.rb",
"_spec.rb",
".test.ts",
".test.tsx",
".test.js",
".test.jsx",
".test.py",
".test.rb",
".test.rs",
".spec.ts",
".spec.tsx",
".spec.js",
".spec.jsx",
".spec.py",
".spec.rb",
".spec.rs",
)
if _JVM_TEST_SUFFIX_RE.search(path):
return True
return any(token in lowered for token in patterns) or any(basename.endswith(suffix) for suffix in ("_test.go", "_test.py", "_test.rb"))
return any(rx.search(path) for rx in _TEST_PATH_RES)


def load_gittensor(gittensor_root: str):
Expand Down Expand Up @@ -160,7 +139,7 @@ def metadata_fallback(metadata: dict) -> dict:
lines = max(int(entry.get("additions") or 0) + int(entry.get("deletions") or 0), 0)
if is_test_file(path):
tests += lines
elif path.endswith((".ts", ".tsx", ".js", ".jsx", ".py", ".rb", ".rs", ".go", ".java", ".kt", ".scala", ".sql", ".cs", ".swift", ".groovy")):
elif path.endswith((".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".py", ".rb", ".rs", ".go", ".java", ".kt", ".scala", ".sql", ".cs", ".swift", ".groovy")):
source += lines
else:
non_code += lines
Expand Down
77 changes: 77 additions & 0 deletions test/unit/score-preview-script.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

// The MCP's local score-preview script inlines its own isTestFile/isCodeFile (it ships in the standalone Node
// bin package and can't import from src/). They must mirror the server's isTestPath/isCodeFile, or a miner's
// LOCAL preview classifies files differently than the gate would. This spawns the real script and checks the
// token classification, which is where the drift would surface.
const scriptsDir = join(dirname(fileURLToPath(import.meta.url)), "../../packages/gittensory-mcp/scripts");
const scriptMjs = join(scriptsDir, "gittensor-score-preview.mjs");
const scriptPy = join(scriptsDir, "gittensor-score-preview.py");

const SAMPLE = [
{ path: "src/loader.mts", additions: 10, deletions: 0 }, // module-ext source — was non-code before the fix
{ path: "e2e/login.cy.ts", additions: 5, deletions: 0 }, // Cypress test — was counted as source before
{ path: "src/test_api.py", additions: 3, deletions: 0 }, // pytest test_*.py prefix — was counted as source before
{ path: "__snapshots__/Card.tsx.snap", additions: 4, deletions: 0 }, // ROOT-LEVEL snapshot dir — segment-aware match
];
const SAMPLE_TEST_LINES = 5 + 3 + 4; // cy + pytest + root-level snapshot

function runPreview(changedFiles: Array<{ path: string; additions: number; deletions: number }>): { sourceTokenScore: number; testTokenScore: number; nonCodeTokenScore: number } {
const res = spawnSync(process.execPath, [scriptMjs], { input: JSON.stringify({ changedFiles }), encoding: "utf8" });
expect(res.status, res.stderr).toBe(0);
return JSON.parse(res.stdout);
}

function findPython(): string | null {
for (const cmd of ["python3", "python"]) {
const r = spawnSync(cmd, ["--version"], { encoding: "utf8" });
if (r.status === 0) return cmd;
}
return null;
}

describe("gittensor-score-preview.mjs classifier parity with the server", () => {
it("counts module-ext source as code and pytest-prefix/Cypress files as tests, matching isTestPath/isCodeFile", () => {
const out = runPreview([...SAMPLE, { path: "app/FooTests.java", additions: 7, deletions: 0 }]); // + JVM test control
expect(out.sourceTokenScore).toBe(10); // only src/loader.mts is source now
expect(out.testTokenScore).toBe(SAMPLE_TEST_LINES + 7); // cy + pytest + root snapshot + JVM tests
expect(out.nonCodeTokenScore).toBe(0); // the .mts is no longer misfiled as non-code
});

it("the .py fallback classifier agrees with the .mjs (skipped when no python is available)", () => {
// metadata_fallback runs when GITTENSOR_ROOT is unset; its source-extension tuple must also carry the module
// extensions so a .mts is counted as source, not non-code.
const python = findPython();
if (!python) return; // environment has no python — the .mjs test above covers the shared intent
const env = { ...process.env };
delete env.GITTENSOR_ROOT;
const res = spawnSync(python, [scriptPy], { input: JSON.stringify({ changedFiles: SAMPLE }), encoding: "utf8", env });
expect(res.status, res.stderr).toBe(0);
const out = JSON.parse(res.stdout);
expect(out.sourceTokenScore).toBe(10);
expect(out.testTokenScore).toBe(SAMPLE_TEST_LINES);
expect(out.nonCodeTokenScore).toBe(0);
});

it("does not misclassify a *.test.mjs.map source-map as a test (extension anchored to end-of-path, matching the server)", () => {
// A substring match on ".test.mjs" wrongly flagged non-tests like dist/widget.test.mjs.map; the rule must be
// end-anchored like isTestPath. It's a source-map — neither test nor code — so it counts as non-code.
const files = [{ path: "dist/widget.test.mjs.map", additions: 2, deletions: 0 }];
const mjs = runPreview(files);
expect(mjs.testTokenScore).toBe(0);
expect(mjs.nonCodeTokenScore).toBe(2);

const python = findPython();
if (!python) return;
const env = { ...process.env };
delete env.GITTENSOR_ROOT;
const res = spawnSync(python, [scriptPy], { input: JSON.stringify({ changedFiles: files }), encoding: "utf8", env });
expect(res.status, res.stderr).toBe(0);
const py = JSON.parse(res.stdout);
expect(py.testTokenScore).toBe(0);
expect(py.nonCodeTokenScore).toBe(2);
});
});
Loading