From 1d01d846695ebc62abef80dfbbfa0c67ce623360 Mon Sep 17 00:00:00 2001 From: monsterdavidliu-ux Date: Thu, 28 May 2026 23:42:30 +0000 Subject: [PATCH 1/4] feat(mcp): harden local scorer adapter setup Add structured scorer failure codes, doctor diagnostics, reference wrapper scripts, and adapter unit tests so MCP users get actionable fallback guidance when GITTENSOR_SCORE_PREVIEW_CMD is missing or misconfigured. Closes #24 --- packages/gittensory-mcp/README.md | 35 ++++ packages/gittensory-mcp/bin/gittensory-mcp.js | 35 +++- packages/gittensory-mcp/lib/local-branch.js | 166 ++++++++++++++-- packages/gittensory-mcp/package.json | 3 +- .../scripts/gittensor-score-preview.mjs | 75 ++++++++ .../scripts/gittensor-score-preview.py | 182 ++++++++++++++++++ scripts/check-mcp-package.mjs | 2 +- test/unit/local-scorer-adapter.test.ts | 98 ++++++++++ test/unit/mcp-cli.test.ts | 1 + 9 files changed, 582 insertions(+), 15 deletions(-) create mode 100644 packages/gittensory-mcp/scripts/gittensor-score-preview.mjs create mode 100644 packages/gittensory-mcp/scripts/gittensor-score-preview.py create mode 100644 test/unit/local-scorer-adapter.test.ts diff --git a/packages/gittensory-mcp/README.md b/packages/gittensory-mcp/README.md index 3cfb85dd77..7c3a438408 100644 --- a/packages/gittensory-mcp/README.md +++ b/packages/gittensory-mcp/README.md @@ -91,11 +91,46 @@ The same capabilities are exposed to MCP clients as: - `GITHUB_TOKEN` for non-interactive login bootstrap - `GITTENSOR_SCORE_PREVIEW_CMD` - `GITTENSOR_ROOT` +- `GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS` (default `15000`) - `GITTENSORY_UPLOAD_SOURCE=false` - `GITTENSORY_SKIP_NPM_VERSION_CHECK=true` `GITTENSORY_UPLOAD_SOURCE=true` is not supported and fails closed. +### Local score preview adapter + +Branch analysis can call a local scorer command that reads branch metadata JSON from stdin and prints one JSON object to stdout. Gittensory never uploads source contents; the scorer runs on your machine. + +Metadata-only fallback is used when the command is missing or fails. Run `gittensory-mcp doctor` for setup diagnostics. + +Reference wrappers ship with the package: + +```sh +export GITTENSOR_SCORE_PREVIEW_CMD="node $(npm root -g)/@jsonbored/gittensory-mcp/scripts/gittensor-score-preview.mjs" +``` + +For tree-sitter scoring with a local [entrius/gittensor](https://github.com/entrius/gittensor) checkout: + +```sh +export GITTENSOR_ROOT=/path/to/gittensor +export GITTENSOR_SCORE_PREVIEW_CMD="python3 $(npm root -g)/@jsonbored/gittensory-mcp/scripts/gittensor-score-preview.py" +``` + +Expected stdout shape: + +```json +{ + "sourceTokenScore": 42, + "totalTokenScore": 58, + "sourceLines": 40, + "testTokenScore": 16, + "nonCodeTokenScore": 0, + "warnings": [] +} +``` + +Snake_case aliases such as `source_token_score` are also accepted. + ## Release Notes The package ships with `CHANGELOG.md`. Run: diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index c28ba783c3..d1d8f49843 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -5,7 +5,7 @@ import { delimiter, dirname, join } from "node:path"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; -import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, setupGuidanceForLocalScorer } from "../lib/local-branch.js"; +import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, referenceScorePreviewCommand, resolveScorePreviewCommand, setupGuidanceForLocalScorer } from "../lib/local-branch.js"; const defaultApiUrl = "https://gittensory-api.aethereal.dev"; const legacyDefaultApiUrls = new Set(["https://gittensory-api.zeronode.workers.dev"]); @@ -493,6 +493,12 @@ async function runCli(args) { } process.stdout.write(`Preflight: ${result.analysis.preflight.status}\n`); process.stdout.write(`Source upload: disabled\n`); + if (result.local?.localScorerStatus?.ok === false) { + process.stdout.write(`Local scorer: ${result.local.localScorerStatus.code ?? "metadata_only"}\n`); + for (const line of result.local.setupGuidance ?? setupGuidanceForLocalScorer(result.local.localScorerStatus)) { + process.stdout.write(`- ${line}\n`); + } + } } async function runAgentCli(args) { @@ -581,6 +587,7 @@ Environment: GITHUB_TOKEN for non-interactive login bootstrap GITTENSOR_SCORE_PREVIEW_CMD GITTENSOR_ROOT + GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS GITTENSORY_UPLOAD_SOURCE=false `); } @@ -777,7 +784,31 @@ async function doctor(options) { const commandPath = findExecutable("gittensory-mcp"); if (commandPath) add("client_path", "pass", `gittensory-mcp is visible on PATH at ${commandPath}.`); - else add("client_path", "warn", "gittensory-mcp was not found on PATH.", "Use an absolute command path in Codex, Claude, or Cursor config."); + else add("client_path", "warn", "gittensory-mcp was not found on PATH.", "Use an absolute command path in Codex, Claude, or MCP client config."); + + const scorerCommand = resolveScorePreviewCommand(); + if (!scorerCommand) { + add( + "local_scorer", + "warn", + "GITTENSOR_SCORE_PREVIEW_CMD is not configured; branch analysis will fall back to metadata-only scoring.", + `Example: export GITTENSOR_SCORE_PREVIEW_CMD="${referenceScorePreviewCommand("metadata")}"`, + ); + } else { + const probe = probeLocalScorer(scorerCommand); + if (probe.ok) { + add("local_scorer", "pass", `Configured scorer responded in ${probe.durationMs ?? 0}ms (${scorerCommand}).`); + } else { + const remediation = setupGuidanceForLocalScorer(probe).slice(1).join(" "); + add("local_scorer", "warn", `Configured scorer failed (${probe.code ?? "scorer_failed"}): ${probe.reason}`, remediation || "Run gittensory-mcp doctor --json for structured diagnostics."); + } + } + + if (process.env.GITTENSOR_ROOT) { + add("gittensor_root", "pass", `GITTENSOR_ROOT is set to ${process.env.GITTENSOR_ROOT}.`); + } else if (scorerCommand?.includes("gittensor-score-preview.py")) { + add("gittensor_root", "warn", "Python gittensor scorer is configured but GITTENSOR_ROOT is unset.", "Set GITTENSOR_ROOT to a local entrius/gittensor checkout."); + } const payload = { status: checks.some((check) => check.status === "fail") ? "needs_attention" : checks.some((check) => check.status === "warn") ? "warnings" : "ok", diff --git a/packages/gittensory-mcp/lib/local-branch.js b/packages/gittensory-mcp/lib/local-branch.js index 8b00a92140..edca7d184a 100644 --- a/packages/gittensory-mcp/lib/local-branch.js +++ b/packages/gittensory-mcp/lib/local-branch.js @@ -1,4 +1,8 @@ import { execFileSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); export function parseGitRemote(remoteUrl) { const trimmed = String(remoteUrl ?? "").trim(); @@ -74,7 +78,8 @@ export function collectLocalBranchMetadata(input) { export function buildBranchAnalysisPayload(input) { const metadata = collectLocalBranchMetadata(input); - const scorerCommand = input.scorePreviewCommand ?? process.env.GITTENSOR_SCORE_PREVIEW_CMD; + metadata.repoRoot = input.cwd ?? process.cwd(); + const scorerCommand = resolveScorePreviewCommand(input); const externalPreview = runExternalScorePreview(metadata, scorerCommand); const localScorer = externalPreview.ok ? normalizeScorerOutput(externalPreview.payload) : metadataOnlyScorer(externalPreview); return { @@ -84,33 +89,124 @@ export function buildBranchAnalysisPayload(input) { }; } +export function resolveScorePreviewCommand(input = {}) { + const explicit = input.scorePreviewCommand ?? process.env.GITTENSOR_SCORE_PREVIEW_CMD; + if (typeof explicit === "string" && explicit.trim()) return explicit.trim(); + return undefined; +} + +export function referenceScorePreviewCommand(kind = "metadata") { + const script = kind === "gittensor" ? "gittensor-score-preview.py" : "gittensor-score-preview.mjs"; + const interpreter = kind === "gittensor" ? "python3" : "node"; + return `${interpreter} ${join(packageRoot, "scripts", script)}`; +} + export function runExternalScorePreview(metadata, scorerCommand) { - if (!scorerCommand) return { ok: false, reason: "missing_scorer_command" }; + const timeoutMs = scorePreviewTimeoutMs(); + if (!scorerCommand) { + return scorerFailure("missing_scorer_command", "GITTENSOR_SCORE_PREVIEW_CMD is not configured."); + } + const parts = splitCommand(scorerCommand); + const command = parts[0]; + const args = parts.slice(1); + if (!command) { + return scorerFailure("empty_scorer_command", "GITTENSOR_SCORE_PREVIEW_CMD is empty."); + } + + const startedAt = Date.now(); try { - const [command, ...args] = splitCommand(scorerCommand); - if (!command) return { ok: false, reason: "empty_scorer_command" }; const output = execFileSync(command, args, { input: JSON.stringify({ ...metadata, + repoRoot: metadata.repoRoot ?? metadata.cwd, gittensorRoot: process.env.GITTENSOR_ROOT, }), encoding: "utf8", - timeout: 15000, + timeout: timeoutMs, stdio: ["pipe", "pipe", "pipe"], }); - return { ok: true, payload: JSON.parse(output) }; + const durationMs = Date.now() - startedAt; + let payload; + try { + payload = JSON.parse(output); + } catch { + return scorerFailure("malformed_json", "External scorer stdout was not valid JSON.", { + durationMs, + stderr: truncateText(output), + fallbackMode: "metadata_only", + }); + } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return scorerFailure("malformed_json", "External scorer stdout must be a JSON object.", { + durationMs, + fallbackMode: "metadata_only", + }); + } + const normalized = normalizeScorerOutput(payload); + if (normalized.sourceTokenScore === undefined && normalized.totalTokenScore === undefined) { + return scorerFailure("malformed_json", "External scorer JSON must include sourceTokenScore or totalTokenScore.", { + durationMs, + fallbackMode: "metadata_only", + }); + } + return stripUndefined({ + ok: true, + code: "success", + reason: "external_scorer_succeeded", + durationMs, + payload, + fallbackMode: "external_command", + }); } catch (error) { - return { ok: false, reason: error instanceof Error ? error.message : "external_scorer_failed" }; + return classifyScorerExecFailure(error, Date.now() - startedAt); } } export function setupGuidanceForLocalScorer(status) { if (status.ok) return []; - return [ + const code = status.code ?? inferScorerCode(status.reason); + const guidance = [ "Gittensory used metadata-only analysis because no external scorer succeeded.", - "Set GITTENSOR_SCORE_PREVIEW_CMD to a command that reads branch metadata JSON from stdin and emits scoring metrics JSON.", - "Set GITTENSOR_ROOT if your scorer needs a local entrius/gittensor checkout.", ]; + switch (code) { + case "missing_scorer_command": + guidance.push(`Set GITTENSOR_SCORE_PREVIEW_CMD, for example: export GITTENSOR_SCORE_PREVIEW_CMD="${referenceScorePreviewCommand("metadata")}"`); + guidance.push(`For tree-sitter scoring with a local gittensor checkout: export GITTENSOR_ROOT=/path/to/gittensor && export GITTENSOR_SCORE_PREVIEW_CMD="${referenceScorePreviewCommand("gittensor")}"`); + break; + case "empty_scorer_command": + guidance.push("GITTENSOR_SCORE_PREVIEW_CMD is set but empty; provide a command that reads branch metadata JSON from stdin."); + break; + case "timeout": + guidance.push(`External scorer exceeded ${scorePreviewTimeoutMs()}ms; simplify the scorer or raise GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS.`); + break; + case "malformed_json": + guidance.push("External scorer must print one JSON object with sourceTokenScore/totalTokenScore fields to stdout."); + if (status.stderr) guidance.push(`Last scorer stdout snippet: ${truncateText(status.stderr, 160)}`); + break; + case "non_zero_exit": + guidance.push("External scorer exited with a non-zero status; inspect stderr and run gittensory-mcp doctor."); + if (status.stderr) guidance.push(`Scorer stderr: ${truncateText(status.stderr, 160)}`); + if (typeof status.exitCode === "number") guidance.push(`Exit code: ${status.exitCode}`); + break; + default: + guidance.push("Set GITTENSOR_SCORE_PREVIEW_CMD to a command that reads branch metadata JSON from stdin and emits scoring metrics JSON."); + if (status.reason) guidance.push(`Last scorer error: ${status.reason}`); + break; + } + guidance.push("Local scorer output stays on your machine; Gittensory never uploads source contents."); + return guidance; +} + +export function probeLocalScorer(scorerCommand = resolveScorePreviewCommand()) { + return runExternalScorePreview( + { + repoFullName: "JSONbored/gittensory", + branchName: "doctor-probe", + changedFiles: [{ path: "src/example.ts", additions: 12, deletions: 2, status: "modified" }], + repoRoot: process.cwd(), + }, + scorerCommand, + ); } export function gitLines(cwd, args) { @@ -207,10 +303,58 @@ function normalizeScorerOutput(payload) { function metadataOnlyScorer(status) { return { mode: "metadata_only", - warnings: [status.reason ?? "external_scorer_unavailable"], + warnings: [status.reason ?? status.code ?? "external_scorer_unavailable"], }; } +function scorerFailure(code, reason, extra = {}) { + return stripUndefined({ + ok: false, + code, + reason, + fallbackMode: "metadata_only", + ...extra, + }); +} + +function classifyScorerExecFailure(error, durationMs) { + const execError = error && typeof error === "object" ? error : undefined; + const stderr = truncateText(execError?.stderr ?? execError?.output?.[2] ?? ""); + const exitCode = typeof execError?.status === "number" ? execError.status : undefined; + if (execError?.code === "ETIMEDOUT" || (execError?.killed && execError?.signal === "SIGTERM")) { + return scorerFailure("timeout", `External scorer timed out after ${scorePreviewTimeoutMs()}ms.`, { durationMs, stderr }); + } + if (typeof exitCode === "number" && exitCode !== 0) { + return scorerFailure("non_zero_exit", `External scorer exited with status ${exitCode}.`, { durationMs, stderr, exitCode }); + } + const message = error instanceof Error ? error.message : "external_scorer_failed"; + if (/JSON/i.test(message)) { + return scorerFailure("malformed_json", message, { durationMs, stderr }); + } + return scorerFailure("scorer_failed", message, { durationMs, stderr, exitCode }); +} + +function inferScorerCode(reason) { + const text = String(reason ?? ""); + if (text.includes("missing_scorer_command")) return "missing_scorer_command"; + if (text.includes("empty_scorer_command")) return "empty_scorer_command"; + if (/timed out|ETIMEDOUT/i.test(text)) return "timeout"; + if (/JSON/i.test(text)) return "malformed_json"; + if (/status \d+/i.test(text)) return "non_zero_exit"; + return "scorer_failed"; +} + +function scorePreviewTimeoutMs() { + const parsed = Number(process.env.GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS ?? 15000); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 15000; +} + +function truncateText(value, maxLength = 240) { + const text = String(value ?? "").trim(); + if (!text) return undefined; + return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3)}...`; +} + function splitCommand(command) { return String(command).match(/(?:[^\s"]+|"[^"]*")+/g)?.map((part) => part.replace(/^"|"$/g, "")) ?? []; } diff --git a/packages/gittensory-mcp/package.json b/packages/gittensory-mcp/package.json index 35f35b52d6..827d61bc30 100644 --- a/packages/gittensory-mcp/package.json +++ b/packages/gittensory-mcp/package.json @@ -31,10 +31,11 @@ "files": [ "bin", "lib", + "scripts", "CHANGELOG.md" ], "scripts": { - "build": "node --check bin/gittensory-mcp.js && node --check lib/local-branch.js" + "build": "node --check bin/gittensory-mcp.js && node --check lib/local-branch.js && node --check scripts/gittensor-score-preview.mjs" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/packages/gittensory-mcp/scripts/gittensor-score-preview.mjs b/packages/gittensory-mcp/scripts/gittensor-score-preview.mjs new file mode 100644 index 0000000000..8f01380044 --- /dev/null +++ b/packages/gittensory-mcp/scripts/gittensor-score-preview.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +function isTestFile(file) { + return ( + /(^|\/)(test|tests|spec|__tests__)\//i.test(file) || + /(^|\/)src\/test\//i.test(file) || + /(^|\/)[^/]+_test\.(go|py|rb)$/i.test(file) || + /(^|\/)[^/]+_spec\.rb$/i.test(file) || + /\.(test|spec)\.(ts|tsx|js|jsx|py|rb|rs)$/i.test(file) + ); +} + +function isCodeFile(file) { + return /\.(ts|tsx|js|jsx|py|rb|rs|kt|scala|java|go|sql)$/i.test(file) && !isTestFile(file); +} + +function lineCount(file) { + const additions = Number(file.additions ?? 0); + const deletions = Number(file.deletions ?? 0); + const total = additions + deletions; + return Number.isFinite(total) && total > 0 ? total : 1; +} + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +function estimateFromMetadata(metadata) { + const changedFiles = Array.isArray(metadata.changedFiles) ? metadata.changedFiles : []; + let sourceTokenScore = 0; + let testTokenScore = 0; + let nonCodeTokenScore = 0; + let sourceLines = 0; + + for (const file of changedFiles) { + const path = String(file.path ?? ""); + const lines = lineCount(file); + if (isTestFile(path)) { + testTokenScore += lines; + continue; + } + if (isCodeFile(path)) { + sourceTokenScore += lines; + sourceLines += lines; + continue; + } + nonCodeTokenScore += lines; + } + + return { + sourceTokenScore, + totalTokenScore: sourceTokenScore + testTokenScore + nonCodeTokenScore, + sourceLines, + testTokenScore, + nonCodeTokenScore, + warnings: [ + "Reference scorer used metadata line counts only; point GITTENSOR_SCORE_PREVIEW_CMD at scripts/gittensor-score-preview.py with GITTENSOR_ROOT for tree-sitter scoring.", + ], + }; +} + +async function main() { + const raw = await readStdin(); + const metadata = raw.trim() ? JSON.parse(raw) : {}; + process.stdout.write(`${JSON.stringify(estimateFromMetadata(metadata))}\n`); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : "reference_scorer_failed"}\n`); + process.exit(1); +}); diff --git a/packages/gittensory-mcp/scripts/gittensor-score-preview.py b/packages/gittensory-mcp/scripts/gittensor-score-preview.py new file mode 100644 index 0000000000..9c1db87a27 --- /dev/null +++ b/packages/gittensory-mcp/scripts/gittensor-score-preview.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Reference local scorer for GITTENSOR_SCORE_PREVIEW_CMD. + +Reads branch metadata JSON from stdin, reads changed files from repoRoot on disk, +and emits normalized score preview JSON to stdout. Requires a local entrius/gittensor checkout. +""" +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + + +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", + ) + return any(token in lowered for token in patterns) or any(basename.endswith(suffix) for suffix in ("_test.go", "_test.py", "_test.rb")) + + +def load_gittensor(gittensor_root: str): + root = Path(gittensor_root).resolve() + if not root.exists(): + raise RuntimeError(f"GITTENSOR_ROOT does not exist: {root}") + sys.path.insert(0, str(root)) + from gittensor.classes import FileChange + from gittensor.utils.github_api_tools import FileContentPair + from gittensor.validator.utils.load_weights import load_programming_languages, load_token_config + from gittensor.validator.utils.tree_sitter_scoring import calculate_token_score_from_file_changes + + return FileChange, FileContentPair, load_programming_languages, load_token_config, calculate_token_score_from_file_changes + + +def score_with_gittensor(metadata: dict) -> dict: + gittensor_root = metadata.get("gittensorRoot") or os.environ.get("GITTENSOR_ROOT") + if not gittensor_root: + raise RuntimeError("Set GITTENSOR_ROOT to a local entrius/gittensor checkout.") + + repo_root = Path(metadata.get("repoRoot") or os.getcwd()).resolve() + ( + FileChange, + FileContentPair, + load_programming_languages, + load_token_config, + calculate_token_score_from_file_changes, + ) = load_gittensor(gittensor_root) + + file_changes = [] + file_contents = {} + source_lines = 0 + test_token_score = 0.0 + non_code_token_score = 0.0 + + for entry in metadata.get("changedFiles") or []: + path = str(entry.get("path") or "") + if not path: + continue + additions = int(entry.get("additions") or 0) + deletions = int(entry.get("deletions") or 0) + status = str(entry.get("status") or "modified") + file_changes.append( + FileChange( + pr_number=0, + repository_full_name=str(metadata.get("repoFullName") or "local/repo"), + filename=path, + changes=max(additions + deletions, 0), + additions=additions, + deletions=deletions, + status=status, + previous_filename=entry.get("previousPath"), + ) + ) + absolute = repo_root / path + old_content = None + new_content = None + if status != "added" and absolute.exists(): + try: + old_content = absolute.read_text(encoding="utf-8") + except OSError: + old_content = None + if status != "removed": + try: + new_content = absolute.read_text(encoding="utf-8") if absolute.exists() else "" + except OSError: + new_content = None + file_contents[path] = FileContentPair(old_content=old_content, new_content=new_content) + if is_test_file(path): + test_token_score += float(max(additions + deletions, 0)) + elif path.endswith((".md", ".txt", ".json", ".yaml", ".yml")): + non_code_token_score += float(max(additions + deletions, 0)) + else: + source_lines += max(additions + deletions, 0) + + weights = load_token_config() + programming_languages = load_programming_languages() + result = calculate_token_score_from_file_changes(file_changes, file_contents, weights, programming_languages) + + source_token_score = 0.0 + for file_result in result.file_results: + if file_result.is_test_file: + test_token_score = max(test_token_score, file_result.score) + elif file_result.scoring_method == "line-count": + non_code_token_score += file_result.score + else: + source_token_score += file_result.score + + total_token_score = float(result.total_score) + if source_token_score <= 0 and total_token_score > 0: + source_token_score = max(total_token_score - test_token_score - non_code_token_score, 0.0) + + return { + "sourceTokenScore": round(source_token_score, 2), + "totalTokenScore": round(total_token_score, 2), + "sourceLines": int(result.total_lines or source_lines), + "testTokenScore": round(test_token_score, 2), + "nonCodeTokenScore": round(non_code_token_score, 2), + "activeModel": "gittensor_tree_sitter_reference", + } + + +def metadata_fallback(metadata: dict) -> dict: + source = 0 + tests = 0 + non_code = 0 + for entry in metadata.get("changedFiles") or []: + path = str(entry.get("path") or "") + 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")): + source += lines + else: + non_code += lines + return { + "sourceTokenScore": source, + "totalTokenScore": source + tests + non_code, + "sourceLines": source, + "testTokenScore": tests, + "nonCodeTokenScore": non_code, + "warnings": ["Fell back to metadata line counts because gittensor scoring was unavailable."], + } + + +def main() -> int: + raw = sys.stdin.read() + metadata = json.loads(raw) if raw.strip() else {} + try: + payload = score_with_gittensor(metadata) + except Exception as error: + payload = metadata_fallback(metadata) + payload.setdefault("warnings", []).insert(0, str(error)) + sys.stdout.write(json.dumps(payload)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-mcp-package.mjs b/scripts/check-mcp-package.mjs index 61f7fdd051..dfab02831a 100644 --- a/scripts/check-mcp-package.mjs +++ b/scripts/check-mcp-package.mjs @@ -14,7 +14,7 @@ if (result.status !== 0) { const [pack] = JSON.parse(result.stdout); const files = pack.files.map((file) => file.path).sort(); -const allowed = [/^bin\/gittensory-mcp\.js$/, /^lib\/local-branch\.js$/, /^package\.json$/, /^README\.md$/, /^CHANGELOG\.md$/, /^LICENSE$/]; +const allowed = [/^bin\/gittensory-mcp\.js$/, /^lib\/local-branch\.js$/, /^scripts\/gittensor-score-preview\.(mjs|py)$/, /^package\.json$/, /^README\.md$/, /^CHANGELOG\.md$/, /^LICENSE$/]; const forbiddenPath = /(^|\/)(\.dev\.vars|\.env|\.npmrc|.*\.pem|.*private.*key.*|.*secret.*)$/i; const forbiddenContent = /(BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]+|gts_[0-9a-f]{64}|[A-Z0-9_]*(TOKEN|SECRET|PRIVATE_KEY)=)/; const stalePackageText = /(private beta|zeronode\.workers\.dev|preview URL)/i; diff --git a/test/unit/local-scorer-adapter.test.ts b/test/unit/local-scorer-adapter.test.ts new file mode 100644 index 0000000000..c0719f8d8a --- /dev/null +++ b/test/unit/local-scorer-adapter.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it } from "vitest"; + +describe("local scorer adapter", () => { + const metadata = { + repoFullName: "entrius/allways-ui", + branchName: "fix-cache", + repoRoot: process.cwd(), + changedFiles: [ + { path: "src/cache.ts", additions: 12, deletions: 2, status: "modified" }, + { path: "test/cache.test.ts", additions: 8, deletions: 0, status: "added" }, + ], + }; + + let previousCommand: string | undefined; + let previousTimeout: string | undefined; + + afterEach(() => { + if (previousCommand === undefined) delete process.env.GITTENSOR_SCORE_PREVIEW_CMD; + else process.env.GITTENSOR_SCORE_PREVIEW_CMD = previousCommand; + if (previousTimeout === undefined) delete process.env.GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS; + else process.env.GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS = previousTimeout; + }); + + it("returns structured success output from a working scorer command", async () => { + // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. + const { runExternalScorePreview } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); + const command = + 'node -e "let d=\'\';process.stdin.on(\'data\',c=>d+=c);process.stdin.on(\'end\',()=>{const m=JSON.parse(d);process.stdout.write(JSON.stringify({sourceTokenScore:42,totalTokenScore:50,sourceLines:40,testTokenScore:8}))})"'; + const result = runExternalScorePreview(metadata, command); + expect(result).toMatchObject({ + ok: true, + code: "success", + fallbackMode: "external_command", + payload: { sourceTokenScore: 42, totalTokenScore: 50 }, + }); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + }); + + it("reports missing scorer command with setup guidance", async () => { + // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. + const { runExternalScorePreview, setupGuidanceForLocalScorer } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); + const result = runExternalScorePreview(metadata, undefined); + expect(result).toMatchObject({ ok: false, code: "missing_scorer_command", fallbackMode: "metadata_only" }); + expect(setupGuidanceForLocalScorer(result).join(" ")).toMatch(/GITTENSOR_SCORE_PREVIEW_CMD/); + }); + + it("handles scorer timeouts without crashing analysis", async () => { + // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. + const { runExternalScorePreview } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); + previousTimeout = process.env.GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS; + process.env.GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS = "200"; + const result = runExternalScorePreview(metadata, process.platform === "win32" ? "ping -n 3 127.0.0.1" : "sleep 2"); + expect(result.ok).toBe(false); + expect(result.code).toBe("timeout"); + expect(result.fallbackMode).toBe("metadata_only"); + }); + + it("handles malformed scorer JSON and non-zero exits", async () => { + // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. + const { runExternalScorePreview } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); + const malformed = runExternalScorePreview(metadata, process.platform === "win32" ? "cmd /c echo not-json" : "echo not-json"); + expect(malformed).toMatchObject({ ok: false, code: "malformed_json", fallbackMode: "metadata_only" }); + + const failing = runExternalScorePreview(metadata, process.platform === "win32" ? "cmd /c exit 7" : "sh -c 'exit 7'"); + expect(failing).toMatchObject({ ok: false, code: "non_zero_exit", fallbackMode: "metadata_only" }); + expect(failing.exitCode).toBe(7); + }); + + it("falls back to metadata-only scorer output and keeps source upload disabled", async () => { + // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. + const { buildBranchAnalysisPayload, collectLocalBranchMetadata } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); + const payload = buildBranchAnalysisPayload({ + cwd: process.cwd(), + repoFullName: "JSONbored/gittensory", + baseRef: "HEAD", + login: "local", + scorePreviewCommand: process.platform === "win32" ? "cmd /c exit 2" : "sh -c 'exit 2'", + }); + expect(payload.localScorer).toMatchObject({ mode: "metadata_only" }); + expect(payload.localScorerStatus.ok).toBe(false); + expect(JSON.stringify(payload)).not.toMatch(/BEGIN (RSA )?PRIVATE KEY/); + + process.env.GITTENSORY_UPLOAD_SOURCE = "true"; + expect(() => collectLocalBranchMetadata({ cwd: process.cwd(), repoFullName: "JSONbored/gittensory", login: "local" })).toThrow(/not supported/); + delete process.env.GITTENSORY_UPLOAD_SOURCE; + }); + + it("runs the packaged reference scorer against metadata only", async () => { + // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. + const { referenceScorePreviewCommand, runExternalScorePreview } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); + const result = runExternalScorePreview(metadata, referenceScorePreviewCommand("metadata")); + expect(result.ok).toBe(true); + expect(result.payload).toMatchObject({ + sourceTokenScore: expect.any(Number), + totalTokenScore: expect.any(Number), + }); + }); +}); diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index cc0d625254..6e30ea4143 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -46,6 +46,7 @@ describe("gittensory-mcp CLI", () => { expect.objectContaining({ name: "auth", status: "pass", detail: expect.stringContaining("JSONbored") }), expect.objectContaining({ name: "source_upload", status: "pass" }), expect.objectContaining({ name: "git_metadata", status: "pass" }), + expect.objectContaining({ name: "local_scorer", status: "warn" }), ]), ); }); From ab066fec05d5b0d9698a6dd0f57bbbcc7f1436ea Mon Sep 17 00:00:00 2001 From: monsterdavidliu-ux Date: Fri, 29 May 2026 05:03:38 +0000 Subject: [PATCH 2/4] fix(mcp): address PR review for local scorer adapter Classify malformed scorer stdout reliably, use portable node test fixtures, and redact local paths from doctor and scorer diagnostics. --- packages/gittensory-mcp/bin/gittensory-mcp.js | 14 +-- packages/gittensory-mcp/lib/local-branch.js | 89 +++++++++++++++---- .../test-fixtures/scorer-malformed.mjs | 1 + .../scripts/test-fixtures/scorer-nonzero.mjs | 1 + .../scripts/test-fixtures/scorer-success.mjs | 4 + .../scripts/test-fixtures/scorer-timeout.mjs | 1 + test/unit/local-scorer-adapter.test.ts | 53 +++++++++-- 7 files changed, 132 insertions(+), 31 deletions(-) create mode 100644 packages/gittensory-mcp/scripts/test-fixtures/scorer-malformed.mjs create mode 100644 packages/gittensory-mcp/scripts/test-fixtures/scorer-nonzero.mjs create mode 100644 packages/gittensory-mcp/scripts/test-fixtures/scorer-success.mjs create mode 100644 packages/gittensory-mcp/scripts/test-fixtures/scorer-timeout.mjs diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index d1d8f49843..3ad040d473 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -5,7 +5,7 @@ import { delimiter, dirname, join } from "node:path"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; -import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, referenceScorePreviewCommand, resolveScorePreviewCommand, setupGuidanceForLocalScorer } from "../lib/local-branch.js"; +import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, referenceScorePreviewExample, resolveScorePreviewCommand, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer } from "../lib/local-branch.js"; const defaultApiUrl = "https://gittensory-api.aethereal.dev"; const legacyDefaultApiUrls = new Set(["https://gittensory-api.zeronode.workers.dev"]); @@ -783,7 +783,7 @@ async function doctor(options) { } const commandPath = findExecutable("gittensory-mcp"); - if (commandPath) add("client_path", "pass", `gittensory-mcp is visible on PATH at ${commandPath}.`); + if (commandPath) add("client_path", "pass", "gittensory-mcp is visible on PATH."); else add("client_path", "warn", "gittensory-mcp was not found on PATH.", "Use an absolute command path in Codex, Claude, or MCP client config."); const scorerCommand = resolveScorePreviewCommand(); @@ -792,12 +792,12 @@ async function doctor(options) { "local_scorer", "warn", "GITTENSOR_SCORE_PREVIEW_CMD is not configured; branch analysis will fall back to metadata-only scoring.", - `Example: export GITTENSOR_SCORE_PREVIEW_CMD="${referenceScorePreviewCommand("metadata")}"`, + `Example: export GITTENSOR_SCORE_PREVIEW_CMD="${referenceScorePreviewExample("metadata")}"`, ); } else { const probe = probeLocalScorer(scorerCommand); if (probe.ok) { - add("local_scorer", "pass", `Configured scorer responded in ${probe.durationMs ?? 0}ms (${scorerCommand}).`); + add("local_scorer", "pass", `Configured scorer responded in ${probe.durationMs ?? 0}ms.`); } else { const remediation = setupGuidanceForLocalScorer(probe).slice(1).join(" "); add("local_scorer", "warn", `Configured scorer failed (${probe.code ?? "scorer_failed"}): ${probe.reason}`, remediation || "Run gittensory-mcp doctor --json for structured diagnostics."); @@ -805,7 +805,7 @@ async function doctor(options) { } if (process.env.GITTENSOR_ROOT) { - add("gittensor_root", "pass", `GITTENSOR_ROOT is set to ${process.env.GITTENSOR_ROOT}.`); + add("gittensor_root", "pass", "GITTENSOR_ROOT is configured."); } else if (scorerCommand?.includes("gittensor-score-preview.py")) { add("gittensor_root", "warn", "Python gittensor scorer is configured but GITTENSOR_ROOT is unset.", "Set GITTENSOR_ROOT to a local entrius/gittensor checkout."); } @@ -999,7 +999,7 @@ async function analyzeCurrentBranch(input) { changedFileCount: body.changedFiles?.length ?? 0, testFileCount: body.changedFiles?.filter((file) => /(^|\/)(test|tests|spec|__tests__)\/|(^|\/)src\/test\/|(^|\/)[^/]+_test\.(go|py|rb)$|(^|\/)[^/]+_spec\.rb$|\.(test|spec)\.(ts|tsx|js|jsx|py|rb|rs)$/i.test(file.path)).length ?? 0, passedValidationCount: body.validation?.filter((entry) => entry.status === "passed").length ?? 0, - localScorerStatus, + localScorerStatus: sanitizeLocalScorerStatus(localScorerStatus), setupGuidance: setupGuidanceForLocalScorer(localScorerStatus), }, analysis, @@ -1048,7 +1048,7 @@ async function previewLocalScore(input) { codeFiles: diff.codeFiles, commitMessage: input.commitMessage ?? diff.commitMessage, }, - upstreamPreview, + upstreamPreview: sanitizeLocalScorerStatus(upstreamPreview), remotePreview: await apiPost("/v1/scoring/preview", body), setupGuidance: upstreamPreview.ok ? [] diff --git a/packages/gittensory-mcp/lib/local-branch.js b/packages/gittensory-mcp/lib/local-branch.js index edca7d184a..8d16eaf3dd 100644 --- a/packages/gittensory-mcp/lib/local-branch.js +++ b/packages/gittensory-mcp/lib/local-branch.js @@ -85,7 +85,7 @@ export function buildBranchAnalysisPayload(input) { return { ...metadata, localScorer, - localScorerStatus: externalPreview, + localScorerStatus: sanitizeLocalScorerStatus(externalPreview), }; } @@ -101,6 +101,40 @@ export function referenceScorePreviewCommand(kind = "metadata") { return `${interpreter} ${join(packageRoot, "scripts", script)}`; } +export function referenceScorePreviewExample(kind = "metadata") { + const script = kind === "gittensor" ? "gittensor-score-preview.py" : "gittensor-score-preview.mjs"; + const interpreter = kind === "gittensor" ? "python3" : "node"; + return `${interpreter} ./node_modules/@jsonbored/gittensory-mcp/scripts/${script}`; +} + +export function redactLocalPath(value) { + const text = String(value ?? ""); + if (!text) return text; + return text + .replace(/(?:~\/|[A-Za-z]:\\)[^\s"'`,;)]+/g, "") + .replace(/(^|[\s"'`=])\/(?:[^\s"'`,;)]+(?:\/[^\s"'`,;)]+)*)/g, (_, prefix) => `${prefix}`); +} + +export function redactScorerCommand(command) { + const text = String(command ?? "").trim(); + if (!text) return text; + const parts = splitCommand(text); + const interpreter = parts[0] ?? "command"; + const script = parts.at(-1)?.split(/[\\/]/).pop(); + if (script && /\.(mjs|js|cjs|py)$/i.test(script)) return `${interpreter} /${script}`; + return ""; +} + +export function sanitizeLocalScorerStatus(status) { + if (!status || typeof status !== "object") return status; + return stripUndefined({ + ...status, + reason: status.reason ? redactLocalPath(String(status.reason)) : undefined, + stderr: status.stderr ? redactLocalPath(String(status.stderr)) : undefined, + scorerCommand: status.scorerCommand ? redactScorerCommand(status.scorerCommand) : undefined, + }); +} + export function runExternalScorePreview(metadata, scorerCommand) { const timeoutMs = scorePreviewTimeoutMs(); if (!scorerCommand) { @@ -158,20 +192,21 @@ export function runExternalScorePreview(metadata, scorerCommand) { fallbackMode: "external_command", }); } catch (error) { - return classifyScorerExecFailure(error, Date.now() - startedAt); + return classifyScorerExecFailure(error, Date.now() - startedAt, scorerCommand); } } export function setupGuidanceForLocalScorer(status) { if (status.ok) return []; - const code = status.code ?? inferScorerCode(status.reason); + const safeStatus = sanitizeLocalScorerStatus(status); + const code = safeStatus.code ?? inferScorerCode(safeStatus.reason); const guidance = [ "Gittensory used metadata-only analysis because no external scorer succeeded.", ]; switch (code) { case "missing_scorer_command": - guidance.push(`Set GITTENSOR_SCORE_PREVIEW_CMD, for example: export GITTENSOR_SCORE_PREVIEW_CMD="${referenceScorePreviewCommand("metadata")}"`); - guidance.push(`For tree-sitter scoring with a local gittensor checkout: export GITTENSOR_ROOT=/path/to/gittensor && export GITTENSOR_SCORE_PREVIEW_CMD="${referenceScorePreviewCommand("gittensor")}"`); + guidance.push(`Set GITTENSOR_SCORE_PREVIEW_CMD, for example: export GITTENSOR_SCORE_PREVIEW_CMD="${referenceScorePreviewExample("metadata")}"`); + guidance.push(`For tree-sitter scoring with a local gittensor checkout: export GITTENSOR_ROOT= && export GITTENSOR_SCORE_PREVIEW_CMD="${referenceScorePreviewExample("gittensor")}"`); break; case "empty_scorer_command": guidance.push("GITTENSOR_SCORE_PREVIEW_CMD is set but empty; provide a command that reads branch metadata JSON from stdin."); @@ -181,16 +216,16 @@ export function setupGuidanceForLocalScorer(status) { break; case "malformed_json": guidance.push("External scorer must print one JSON object with sourceTokenScore/totalTokenScore fields to stdout."); - if (status.stderr) guidance.push(`Last scorer stdout snippet: ${truncateText(status.stderr, 160)}`); + if (safeStatus.stderr) guidance.push(`Last scorer stdout snippet: ${truncateText(safeStatus.stderr, 160)}`); break; case "non_zero_exit": guidance.push("External scorer exited with a non-zero status; inspect stderr and run gittensory-mcp doctor."); - if (status.stderr) guidance.push(`Scorer stderr: ${truncateText(status.stderr, 160)}`); - if (typeof status.exitCode === "number") guidance.push(`Exit code: ${status.exitCode}`); + if (safeStatus.stderr) guidance.push(`Scorer stderr: ${truncateText(safeStatus.stderr, 160)}`); + if (typeof safeStatus.exitCode === "number") guidance.push(`Exit code: ${safeStatus.exitCode}`); break; default: guidance.push("Set GITTENSOR_SCORE_PREVIEW_CMD to a command that reads branch metadata JSON from stdin and emits scoring metrics JSON."); - if (status.reason) guidance.push(`Last scorer error: ${status.reason}`); + if (safeStatus.reason) guidance.push(`Last scorer error: ${safeStatus.reason}`); break; } guidance.push("Local scorer output stays on your machine; Gittensory never uploads source contents."); @@ -198,14 +233,16 @@ export function setupGuidanceForLocalScorer(status) { } export function probeLocalScorer(scorerCommand = resolveScorePreviewCommand()) { - return runExternalScorePreview( + return sanitizeLocalScorerStatus( + runExternalScorePreview( { repoFullName: "JSONbored/gittensory", branchName: "doctor-probe", changedFiles: [{ path: "src/example.ts", additions: 12, deletions: 2, status: "modified" }], repoRoot: process.cwd(), }, - scorerCommand, + scorerCommand, + ), ); } @@ -317,21 +354,41 @@ function scorerFailure(code, reason, extra = {}) { }); } -function classifyScorerExecFailure(error, durationMs) { +function classifyScorerExecFailure(error, durationMs, scorerCommand) { const execError = error && typeof error === "object" ? error : undefined; + const stdout = String(execError?.stdout ?? execError?.output?.[1] ?? "").trim(); const stderr = truncateText(execError?.stderr ?? execError?.output?.[2] ?? ""); const exitCode = typeof execError?.status === "number" ? execError.status : undefined; + if (stdout && !looksLikeScorerJson(stdout)) { + return scorerFailure("malformed_json", "External scorer stdout was not valid JSON.", { + durationMs, + stderr: truncateText(stdout), + scorerCommand: redactScorerCommand(scorerCommand), + fallbackMode: "metadata_only", + }); + } if (execError?.code === "ETIMEDOUT" || (execError?.killed && execError?.signal === "SIGTERM")) { - return scorerFailure("timeout", `External scorer timed out after ${scorePreviewTimeoutMs()}ms.`, { durationMs, stderr }); + return scorerFailure("timeout", `External scorer timed out after ${scorePreviewTimeoutMs()}ms.`, { durationMs, stderr, scorerCommand: redactScorerCommand(scorerCommand) }); } if (typeof exitCode === "number" && exitCode !== 0) { - return scorerFailure("non_zero_exit", `External scorer exited with status ${exitCode}.`, { durationMs, stderr, exitCode }); + return scorerFailure("non_zero_exit", `External scorer exited with status ${exitCode}.`, { durationMs, stderr, exitCode, scorerCommand: redactScorerCommand(scorerCommand) }); } const message = error instanceof Error ? error.message : "external_scorer_failed"; if (/JSON/i.test(message)) { - return scorerFailure("malformed_json", message, { durationMs, stderr }); + return scorerFailure("malformed_json", "External scorer stdout was not valid JSON.", { durationMs, stderr, scorerCommand: redactScorerCommand(scorerCommand) }); + } + return scorerFailure("scorer_failed", redactLocalPath(message), { durationMs, stderr, exitCode, scorerCommand: redactScorerCommand(scorerCommand) }); +} + +function looksLikeScorerJson(output) { + try { + const payload = JSON.parse(output); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const normalized = normalizeScorerOutput(payload); + return normalized.sourceTokenScore !== undefined || normalized.totalTokenScore !== undefined; + } catch { + return false; } - return scorerFailure("scorer_failed", message, { durationMs, stderr, exitCode }); } function inferScorerCode(reason) { diff --git a/packages/gittensory-mcp/scripts/test-fixtures/scorer-malformed.mjs b/packages/gittensory-mcp/scripts/test-fixtures/scorer-malformed.mjs new file mode 100644 index 0000000000..8cad345ff0 --- /dev/null +++ b/packages/gittensory-mcp/scripts/test-fixtures/scorer-malformed.mjs @@ -0,0 +1 @@ +process.stdout.write("not-json\n"); diff --git a/packages/gittensory-mcp/scripts/test-fixtures/scorer-nonzero.mjs b/packages/gittensory-mcp/scripts/test-fixtures/scorer-nonzero.mjs new file mode 100644 index 0000000000..3fec13a836 --- /dev/null +++ b/packages/gittensory-mcp/scripts/test-fixtures/scorer-nonzero.mjs @@ -0,0 +1 @@ +process.exit(7); diff --git a/packages/gittensory-mcp/scripts/test-fixtures/scorer-success.mjs b/packages/gittensory-mcp/scripts/test-fixtures/scorer-success.mjs new file mode 100644 index 0000000000..011ea4cbec --- /dev/null +++ b/packages/gittensory-mcp/scripts/test-fixtures/scorer-success.mjs @@ -0,0 +1,4 @@ +const chunks = []; +for await (const chunk of process.stdin) chunks.push(chunk); +void chunks; +process.stdout.write(JSON.stringify({ sourceTokenScore: 42, totalTokenScore: 50, sourceLines: 40, testTokenScore: 8 })); diff --git a/packages/gittensory-mcp/scripts/test-fixtures/scorer-timeout.mjs b/packages/gittensory-mcp/scripts/test-fixtures/scorer-timeout.mjs new file mode 100644 index 0000000000..06ec245f49 --- /dev/null +++ b/packages/gittensory-mcp/scripts/test-fixtures/scorer-timeout.mjs @@ -0,0 +1 @@ +await new Promise((resolve) => setTimeout(resolve, 60_000)); diff --git a/test/unit/local-scorer-adapter.test.ts b/test/unit/local-scorer-adapter.test.ts index c0719f8d8a..593e3722ef 100644 --- a/test/unit/local-scorer-adapter.test.ts +++ b/test/unit/local-scorer-adapter.test.ts @@ -1,5 +1,10 @@ +import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +function fixtureCommand(name: string) { + return `node ${join(process.cwd(), "packages/gittensory-mcp/scripts/test-fixtures", name)}`; +} + describe("local scorer adapter", () => { const metadata = { repoFullName: "entrius/allways-ui", @@ -13,20 +18,21 @@ describe("local scorer adapter", () => { let previousCommand: string | undefined; let previousTimeout: string | undefined; + let previousGittensorRoot: string | undefined; afterEach(() => { if (previousCommand === undefined) delete process.env.GITTENSOR_SCORE_PREVIEW_CMD; else process.env.GITTENSOR_SCORE_PREVIEW_CMD = previousCommand; if (previousTimeout === undefined) delete process.env.GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS; else process.env.GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS = previousTimeout; + if (previousGittensorRoot === undefined) delete process.env.GITTENSOR_ROOT; + else process.env.GITTENSOR_ROOT = previousGittensorRoot; }); it("returns structured success output from a working scorer command", async () => { // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { runExternalScorePreview } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); - const command = - 'node -e "let d=\'\';process.stdin.on(\'data\',c=>d+=c);process.stdin.on(\'end\',()=>{const m=JSON.parse(d);process.stdout.write(JSON.stringify({sourceTokenScore:42,totalTokenScore:50,sourceLines:40,testTokenScore:8}))})"'; - const result = runExternalScorePreview(metadata, command); + const result = runExternalScorePreview(metadata, fixtureCommand("scorer-success.mjs")); expect(result).toMatchObject({ ok: true, code: "success", @@ -41,7 +47,9 @@ describe("local scorer adapter", () => { const { runExternalScorePreview, setupGuidanceForLocalScorer } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); const result = runExternalScorePreview(metadata, undefined); expect(result).toMatchObject({ ok: false, code: "missing_scorer_command", fallbackMode: "metadata_only" }); - expect(setupGuidanceForLocalScorer(result).join(" ")).toMatch(/GITTENSOR_SCORE_PREVIEW_CMD/); + const guidance = setupGuidanceForLocalScorer(result).join(" "); + expect(guidance).toMatch(/GITTENSOR_SCORE_PREVIEW_CMD/); + expect(guidance).not.toMatch(process.cwd()); }); it("handles scorer timeouts without crashing analysis", async () => { @@ -49,7 +57,7 @@ describe("local scorer adapter", () => { const { runExternalScorePreview } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); previousTimeout = process.env.GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS; process.env.GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS = "200"; - const result = runExternalScorePreview(metadata, process.platform === "win32" ? "ping -n 3 127.0.0.1" : "sleep 2"); + const result = runExternalScorePreview(metadata, fixtureCommand("scorer-timeout.mjs")); expect(result.ok).toBe(false); expect(result.code).toBe("timeout"); expect(result.fallbackMode).toBe("metadata_only"); @@ -58,10 +66,10 @@ describe("local scorer adapter", () => { it("handles malformed scorer JSON and non-zero exits", async () => { // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { runExternalScorePreview } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); - const malformed = runExternalScorePreview(metadata, process.platform === "win32" ? "cmd /c echo not-json" : "echo not-json"); + const malformed = runExternalScorePreview(metadata, fixtureCommand("scorer-malformed.mjs")); expect(malformed).toMatchObject({ ok: false, code: "malformed_json", fallbackMode: "metadata_only" }); - const failing = runExternalScorePreview(metadata, process.platform === "win32" ? "cmd /c exit 7" : "sh -c 'exit 7'"); + const failing = runExternalScorePreview(metadata, fixtureCommand("scorer-nonzero.mjs")); expect(failing).toMatchObject({ ok: false, code: "non_zero_exit", fallbackMode: "metadata_only" }); expect(failing.exitCode).toBe(7); }); @@ -74,7 +82,7 @@ describe("local scorer adapter", () => { repoFullName: "JSONbored/gittensory", baseRef: "HEAD", login: "local", - scorePreviewCommand: process.platform === "win32" ? "cmd /c exit 2" : "sh -c 'exit 2'", + scorePreviewCommand: fixtureCommand("scorer-nonzero.mjs"), }); expect(payload.localScorer).toMatchObject({ mode: "metadata_only" }); expect(payload.localScorerStatus.ok).toBe(false); @@ -95,4 +103,33 @@ describe("local scorer adapter", () => { totalTokenScore: expect.any(Number), }); }); + + it("redacts local paths from scorer diagnostics and setup guidance", async () => { + // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. + const { probeLocalScorer, redactLocalPath, redactScorerCommand, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); + + previousGittensorRoot = process.env.GITTENSOR_ROOT; + previousCommand = process.env.GITTENSOR_SCORE_PREVIEW_CMD; + process.env.GITTENSOR_ROOT = "/secret/home/user/gittensor"; + process.env.GITTENSOR_SCORE_PREVIEW_CMD = `/secret/opt/tools/node /secret/home/user/gittensory-mcp/scripts/gittensor-score-preview.mjs`; + + expect(redactLocalPath("/secret/home/user/gittensor")).not.toContain("/secret/home/user"); + expect(redactScorerCommand(process.env.GITTENSOR_SCORE_PREVIEW_CMD)).toBe("node /gittensor-score-preview.mjs"); + + const status = sanitizeLocalScorerStatus({ + ok: false, + code: "scorer_failed", + reason: "failed under /secret/home/user/gittensor", + stderr: "/secret/home/user/output.txt", + scorerCommand: process.env.GITTENSOR_SCORE_PREVIEW_CMD, + }); + expect(JSON.stringify(status)).not.toMatch(/\/secret\/home\/user/); + + const guidance = setupGuidanceForLocalScorer({ ok: false, code: "missing_scorer_command" }).join("\n"); + expect(guidance).not.toMatch(/\/secret\/home\/user/); + expect(guidance).toMatch(/node_modules\/@jsonbored\/gittensory-mcp\/scripts\//); + + const probe = probeLocalScorer(process.env.GITTENSOR_SCORE_PREVIEW_CMD); + expect(JSON.stringify(probe)).not.toMatch(/\/secret\/home\/user/); + }); }); From 424f1f9222714630da8a3208cca32dd25b6814f2 Mon Sep 17 00:00:00 2001 From: monsterdavidliu-ux Date: Fri, 29 May 2026 05:07:39 +0000 Subject: [PATCH 3/4] fix(test): keep scorer fixtures out of npm package Move adapter test fixtures to test/fixtures/local-scorer and redact absolute interpreter paths in scorer command diagnostics. --- package-lock.json | 117 ------------------ packages/gittensory-mcp/lib/local-branch.js | 2 +- .../local-scorer}/scorer-malformed.mjs | 0 .../fixtures/local-scorer}/scorer-nonzero.mjs | 0 .../fixtures/local-scorer}/scorer-success.mjs | 0 .../fixtures/local-scorer}/scorer-timeout.mjs | 0 test/unit/local-scorer-adapter.test.ts | 2 +- 7 files changed, 2 insertions(+), 119 deletions(-) rename {packages/gittensory-mcp/scripts/test-fixtures => test/fixtures/local-scorer}/scorer-malformed.mjs (100%) rename {packages/gittensory-mcp/scripts/test-fixtures => test/fixtures/local-scorer}/scorer-nonzero.mjs (100%) rename {packages/gittensory-mcp/scripts/test-fixtures => test/fixtures/local-scorer}/scorer-success.mjs (100%) rename {packages/gittensory-mcp/scripts/test-fixtures => test/fixtures/local-scorer}/scorer-timeout.mjs (100%) diff --git a/package-lock.json b/package-lock.json index e03b30830f..d572b2a0aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1021,9 +1021,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1041,9 +1038,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1061,9 +1055,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1081,9 +1072,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1101,9 +1089,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1121,9 +1106,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1141,9 +1123,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1161,9 +1140,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1181,9 +1157,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1207,9 +1180,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1233,9 +1203,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1259,9 +1226,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1285,9 +1249,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1311,9 +1272,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1337,9 +1295,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1363,9 +1318,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1821,9 +1773,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1841,9 +1790,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1861,9 +1807,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1881,9 +1824,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1901,9 +1841,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1921,9 +1858,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2102,9 +2036,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2119,9 +2050,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2136,9 +2064,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2153,9 +2078,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2170,9 +2092,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2187,9 +2106,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2204,9 +2120,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2221,9 +2134,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2238,9 +2148,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2255,9 +2162,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2272,9 +2176,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2289,9 +2190,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2306,9 +2204,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5155,9 +5050,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5179,9 +5071,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5203,9 +5092,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5227,9 +5113,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/packages/gittensory-mcp/lib/local-branch.js b/packages/gittensory-mcp/lib/local-branch.js index 8d16eaf3dd..d24f07fd73 100644 --- a/packages/gittensory-mcp/lib/local-branch.js +++ b/packages/gittensory-mcp/lib/local-branch.js @@ -119,7 +119,7 @@ export function redactScorerCommand(command) { const text = String(command ?? "").trim(); if (!text) return text; const parts = splitCommand(text); - const interpreter = parts[0] ?? "command"; + const interpreter = parts[0]?.split(/[\\/]/).pop() ?? "command"; const script = parts.at(-1)?.split(/[\\/]/).pop(); if (script && /\.(mjs|js|cjs|py)$/i.test(script)) return `${interpreter} /${script}`; return ""; diff --git a/packages/gittensory-mcp/scripts/test-fixtures/scorer-malformed.mjs b/test/fixtures/local-scorer/scorer-malformed.mjs similarity index 100% rename from packages/gittensory-mcp/scripts/test-fixtures/scorer-malformed.mjs rename to test/fixtures/local-scorer/scorer-malformed.mjs diff --git a/packages/gittensory-mcp/scripts/test-fixtures/scorer-nonzero.mjs b/test/fixtures/local-scorer/scorer-nonzero.mjs similarity index 100% rename from packages/gittensory-mcp/scripts/test-fixtures/scorer-nonzero.mjs rename to test/fixtures/local-scorer/scorer-nonzero.mjs diff --git a/packages/gittensory-mcp/scripts/test-fixtures/scorer-success.mjs b/test/fixtures/local-scorer/scorer-success.mjs similarity index 100% rename from packages/gittensory-mcp/scripts/test-fixtures/scorer-success.mjs rename to test/fixtures/local-scorer/scorer-success.mjs diff --git a/packages/gittensory-mcp/scripts/test-fixtures/scorer-timeout.mjs b/test/fixtures/local-scorer/scorer-timeout.mjs similarity index 100% rename from packages/gittensory-mcp/scripts/test-fixtures/scorer-timeout.mjs rename to test/fixtures/local-scorer/scorer-timeout.mjs diff --git a/test/unit/local-scorer-adapter.test.ts b/test/unit/local-scorer-adapter.test.ts index 593e3722ef..f103e25773 100644 --- a/test/unit/local-scorer-adapter.test.ts +++ b/test/unit/local-scorer-adapter.test.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; function fixtureCommand(name: string) { - return `node ${join(process.cwd(), "packages/gittensory-mcp/scripts/test-fixtures", name)}`; + return `node ${join(process.cwd(), "test/fixtures/local-scorer", name)}`; } describe("local scorer adapter", () => { From 9c754611032ee1321a71713339695a33fd4982b0 Mon Sep 17 00:00:00 2001 From: monsterdavidliu-ux Date: Fri, 29 May 2026 07:47:22 +0000 Subject: [PATCH 4/4] fix(mcp): restore lockfile and tighten scorer doctor redaction Restore package-lock.json from main to undo accidental libc metadata removals, redact config paths in doctor JSON output, classify stderr-only malformed scorer output as malformed_json, and extend doctor tests. --- package-lock.json | 117 ++++++++++++++++++ packages/gittensory-mcp/bin/gittensory-mcp.js | 4 +- packages/gittensory-mcp/lib/local-branch.js | 8 ++ test/unit/mcp-cli.test.ts | 15 ++- 4 files changed, 140 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index d572b2a0aa..e03b30830f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1021,6 +1021,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1038,6 +1041,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1055,6 +1061,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1072,6 +1081,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1089,6 +1101,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1106,6 +1121,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1123,6 +1141,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1140,6 +1161,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1157,6 +1181,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1180,6 +1207,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1203,6 +1233,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1226,6 +1259,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1249,6 +1285,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1272,6 +1311,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1295,6 +1337,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1318,6 +1363,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1773,6 +1821,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1790,6 +1841,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1807,6 +1861,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1824,6 +1881,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1841,6 +1901,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1858,6 +1921,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2036,6 +2102,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2050,6 +2119,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2064,6 +2136,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2078,6 +2153,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2092,6 +2170,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2106,6 +2187,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2120,6 +2204,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2134,6 +2221,9 @@ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2148,6 +2238,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2162,6 +2255,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2176,6 +2272,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2190,6 +2289,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2204,6 +2306,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5050,6 +5155,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5071,6 +5179,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5092,6 +5203,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5113,6 +5227,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 3ad040d473..e1e90398c7 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -5,7 +5,7 @@ import { delimiter, dirname, join } from "node:path"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; -import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, referenceScorePreviewExample, resolveScorePreviewCommand, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer } from "../lib/local-branch.js"; +import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, redactLocalPath, referenceScorePreviewExample, resolveScorePreviewCommand, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer } from "../lib/local-branch.js"; const defaultApiUrl = "https://gittensory-api.aethereal.dev"; const legacyDefaultApiUrls = new Set(["https://gittensory-api.zeronode.workers.dev"]); @@ -813,7 +813,7 @@ async function doctor(options) { const payload = { status: checks.some((check) => check.status === "fail") ? "needs_attention" : checks.some((check) => check.status === "warn") ? "warnings" : "ok", apiUrl, - configPath, + configPath: redactLocalPath(configPath), sourceUploadSupported: false, checks, }; diff --git a/packages/gittensory-mcp/lib/local-branch.js b/packages/gittensory-mcp/lib/local-branch.js index d24f07fd73..3b2c89fd9b 100644 --- a/packages/gittensory-mcp/lib/local-branch.js +++ b/packages/gittensory-mcp/lib/local-branch.js @@ -377,6 +377,14 @@ function classifyScorerExecFailure(error, durationMs, scorerCommand) { if (/JSON/i.test(message)) { return scorerFailure("malformed_json", "External scorer stdout was not valid JSON.", { durationMs, stderr, scorerCommand: redactScorerCommand(scorerCommand) }); } + if (stderr && !looksLikeScorerJson(stderr)) { + return scorerFailure("malformed_json", "External scorer stdout was not valid JSON.", { + durationMs, + stderr: truncateText(stderr), + scorerCommand: redactScorerCommand(scorerCommand), + fallbackMode: "metadata_only", + }); + } return scorerFailure("scorer_failed", redactLocalPath(message), { durationMs, stderr, exitCode, scorerCommand: redactScorerCommand(scorerCommand) }); } diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index 6e30ea4143..6b5e1b62c8 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -31,15 +31,22 @@ describe("gittensory-mcp CLI", () => { it("runs doctor against a local health/session fixture", async () => { tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); const url = await startFixtureServer(); + const secretRoot = join(tempDir, "secret-gittensor"); + const secretConfigDir = join(tempDir, "secret-config"); const payload = JSON.parse( await runAsync(["doctor", "--cwd", tempDir, "--repo", "JSONbored/gittensory", "--json"], { GITTENSORY_API_URL: url, GITTENSORY_TOKEN: "session-token", - GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_CONFIG_DIR: secretConfigDir, + GITTENSOR_ROOT: secretRoot, + GITTENSOR_SCORE_PREVIEW_CMD: `node ${join(process.cwd(), "test/fixtures/local-scorer/scorer-malformed.mjs")}`, }), - ) as { status: string; checks: Array<{ name: string; status: string; detail: string }> }; + ) as { status: string; configPath: string; checks: Array<{ name: string; status: string; detail: string; remediation?: string }> }; + const serialized = JSON.stringify(payload); expect(payload.status).toMatch(/ok|warnings/); + expect(serialized).not.toMatch(/secret-gittensor|secret-config/); + expect(payload.configPath).not.toContain(secretConfigDir); expect(payload.checks).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "api_health", status: "pass" }), @@ -47,8 +54,12 @@ describe("gittensory-mcp CLI", () => { expect.objectContaining({ name: "source_upload", status: "pass" }), expect.objectContaining({ name: "git_metadata", status: "pass" }), expect.objectContaining({ name: "local_scorer", status: "warn" }), + expect.objectContaining({ name: "gittensor_root", status: "pass" }), ]), ); + const localScorer = payload.checks.find((check) => check.name === "local_scorer"); + expect(localScorer?.detail).toMatch(/malformed_json/); + expect(localScorer?.detail).not.toMatch(join(process.cwd(), "test/fixtures")); }); it("reports package status and prints the packaged changelog", async () => {