diff --git a/packages/gittensory-mcp/scripts/gittensor-score-preview.mjs b/packages/gittensory-mcp/scripts/gittensor-score-preview.mjs index 196275c45d..155a98e606 100644 --- a/packages/gittensory-mcp/scripts/gittensor-score-preview.mjs +++ b/packages/gittensory-mcp/scripts/gittensor-score-preview.mjs @@ -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) { diff --git a/packages/gittensory-mcp/scripts/gittensor-score-preview.py b/packages/gittensory-mcp/scripts/gittensor-score-preview.py index 721f7a6316..23646a19be 100644 --- a/packages/gittensory-mcp/scripts/gittensor-score-preview.py +++ b/packages/gittensory-mcp/scripts/gittensor-score-preview.py @@ -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. + 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): @@ -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 diff --git a/test/unit/score-preview-script.test.ts b/test/unit/score-preview-script.test.ts new file mode 100644 index 0000000000..0b99bd9135 --- /dev/null +++ b/test/unit/score-preview-script.test.ts @@ -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); + }); +});