From fd9205cd40933ccf2379eb646cca37a995e6529e Mon Sep 17 00:00:00 2001 From: realDiligent Date: Tue, 7 Jul 2026 21:51:46 +0800 Subject: [PATCH 1/2] feat(mcp): add gittensory_validate_config tool for .gittensory.yml Expose manifest pre-validation via hosted MCP, stdio MCP, API route, and CLI using parseFocusManifestContent as the single parser source. Closes #2057 Co-authored-by: Cursor --- .../contributing-to-gittensory/reference.md | 4 +- packages/gittensory-mcp/README.md | 1 + packages/gittensory-mcp/bin/gittensory-mcp.js | 53 +++++++++++++ src/api/routes.ts | 16 +++- src/mcp/server.ts | 32 ++++++++ src/services/focus-manifest-validation.ts | 74 +++++++++++++++++ test/integration/api.test.ts | 15 ++++ test/unit/focus-manifest-validation.test.ts | 79 +++++++++++++++++++ test/unit/mcp-cli-validate-config.test.ts | 48 +++++++++++ test/unit/mcp-output-schemas.test.ts | 27 +++++++ test/unit/support/mcp-cli-harness.ts | 13 +++ 11 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 src/services/focus-manifest-validation.ts create mode 100644 test/unit/focus-manifest-validation.test.ts create mode 100644 test/unit/mcp-cli-validate-config.test.ts diff --git a/.claude/skills/contributing-to-gittensory/reference.md b/.claude/skills/contributing-to-gittensory/reference.md index 000f2a5b53..bd908e4d8c 100644 --- a/.claude/skills/contributing-to-gittensory/reference.md +++ b/.claude/skills/contributing-to-gittensory/reference.md @@ -144,7 +144,9 @@ All tools are metadata-only (no source upload). Run in this order: testFiles}` → slopRisk 0–100 + band + findings. 4. `gittensory_lint_pr_text` — `{commitMessages[], prBody, linkedIssue}` → verdict strong/adequate/weak + specific fixes. -5. `gittensory_predict_gate` — `{login, owner, repo, title, body, labels, linkedIssues}` → predicted +5. `gittensory_validate_config` — `{content, source?}` → normalized manifest fields, + warnings, and ok/warn/error status. +6. `gittensory_predict_gate` — `{login, owner, repo, title, body, labels, linkedIssues}` → predicted conclusion + blockers + warnings + readiness score. (Auth'd extras: `gittensory_preflight_pr` / `…_local_diff` for lane fit + collision + queue health.) diff --git a/packages/gittensory-mcp/README.md b/packages/gittensory-mcp/README.md index 8b8e84e8c9..ffa0fac775 100644 --- a/packages/gittensory-mcp/README.md +++ b/packages/gittensory-mcp/README.md @@ -59,6 +59,7 @@ gittensory-mcp analyze-branch --login jsonbored --json gittensory-mcp preflight --login jsonbored --json gittensory-mcp review-pr --login jsonbored --commit "feat(mcp): add doctor grouping" --body "Fixes #160. Validated with npm test." --linked-issue 160 --json gittensory-mcp lint-pr-text --commit "feat(mcp): add doctor grouping" --body "Fixes #160. Validated with npm test." --linked-issue 160 --json +gittensory-mcp validate-config --file ./.gittensory.yml --json gittensory-mcp slop-risk --changed-file src/widget.ts:80:2 --description "Adds retry handling." --test-file test/unit/widget.test.ts --json gittensory-mcp issue-slop --title "Add retry handling" --body "Widget reconnects fail without bounded retries." --json gittensory-mcp agent plan --login jsonbored --json diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 8a0a82dd92..11ae04f6db 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -42,6 +42,7 @@ const CLI_COMMAND_SPEC = { preflight: [], "review-pr": [], "lint-pr-text": [], + "validate-config": [], "slop-risk": [], "issue-slop": [], profile: ["list", "create", "switch", "remove"], @@ -213,6 +214,11 @@ const lintPrTextShape = { linkedIssue: z.number().int().positive().optional(), }; +const validateConfigShape = { + content: z.string().max(256 * 1024), + source: z.enum(["repo_file", "api_record", "none"]).optional(), +}; + const checkSlopRiskShape = { changedFiles: z .array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() })) @@ -431,6 +437,16 @@ server.registerTool( async (input) => toolResult("Gittensory PR-text lint.", await apiPost("/v1/lint/pr-text", input)), ); +server.registerTool( + "gittensory_validate_config", + { + description: + "Parse and validate a .gittensory.yml manifest string using the same focus-manifest parser as the server. Returns normalized config fields, parse warnings, and an ok/warn/error status. Metadata-only, no GitHub writes.", + inputSchema: validateConfigShape, + }, + async (input) => toolResult("Gittensory manifest validation.", await apiPost("/v1/validate/focus-manifest", input)), +); + server.registerTool( "gittensory_check_slop_risk", { @@ -1456,6 +1472,7 @@ async function runCli(args) { if (command === "doctor") return doctor(options); if (command === "init-client") return initClient(options); if (command === "lint-pr-text") return lintPrTextCli(args.slice(1)); + if (command === "validate-config") return validateConfigCli(args.slice(1)); if (command === "slop-risk") return slopRiskCli(args.slice(1)); if (command === "issue-slop") return issueSlopCli(args.slice(1)); if (command === "decision-pack") return decisionPackCli(options); @@ -1615,6 +1632,41 @@ async function lintPrTextCli(args) { for (const fix of payload.fixes ?? []) process.stdout.write(`- ${fix}\n`); } +function printValidateConfigHelp() { + process.stdout.write( + [ + "Usage: gittensory-mcp validate-config --file [--source repo_file|api_record|none] [--json]", + "", + "Validate a .gittensory.yml manifest before pushing.", + "Mirrors the gittensory_validate_config MCP tool and POST /v1/validate/focus-manifest. No source upload.", + "", + "Pass --json for machine-readable output.", + ].join("\n") + "\n", + ); +} + +async function validateConfigCli(args) { + if (!args.length || args[0] === "--help" || args[0] === "help") return printValidateConfigHelp(); + const options = parseOptions(args); + if (!options.file) throw new Error("Pass --file to the manifest to validate."); + const content = readCliTextFile(options.file, "Manifest"); + const source = options.source; + if (source !== undefined && !["repo_file", "api_record", "none"].includes(String(source))) { + throw new Error("--source must be one of: repo_file, api_record, none"); + } + const payload = await apiPost("/v1/validate/focus-manifest", { + content, + ...(source !== undefined ? { source } : {}), + }); + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + process.stdout.write(`Manifest validation: ${payload.status}\n`); + process.stdout.write(`present=${payload.present}\n`); + for (const warning of payload.warnings ?? []) process.stdout.write(`- ${warning}\n`); +} + function printSlopRiskHelp() { process.stdout.write( [ @@ -2096,6 +2148,7 @@ function printHelp() { gittensory-mcp preflight --login [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--validation "passed|npm test|summary"] [--json] gittensory-mcp review-pr --login [--repo owner/repo] [--base origin/main] [--commit ]... [--body ] [--body-file ] [--linked-issue ] [--json] gittensory-mcp lint-pr-text [--commit ]... [--body ] [--body-file ] [--linked-issue ] [--json] + gittensory-mcp validate-config --file [--source repo_file|api_record|none] [--json] gittensory-mcp slop-risk [--description ] [--description-file ] [--changed-file ]... [--test ]... [--test-file ]... [--json] gittensory-mcp issue-slop [--title ] [--body ] [--body-file ] [--json] gittensory-mcp agent plan --login [--repo owner/repo] [--json] diff --git a/src/api/routes.ts b/src/api/routes.ts index 6d6c4abc1f..299e96601f 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -247,6 +247,7 @@ import { buildPullRequestReviewability, type PullRequestReviewability } from ".. import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch"; import { buildSlopAssessment, buildIssueSlopAssessment, SLOP_RUBRIC_MARKDOWN, ISSUE_SLOP_RUBRIC_MARKDOWN } from "../signals/slop"; import { buildPredictedGateVerdict } from "../rules/predicted-gate"; +import { buildFocusManifestValidation } from "../services/focus-manifest-validation"; import { buildMaintainerActivationPreview, recommendedAdvisoryActivationSettings } from "../services/maintainer-activation"; import { buildRepoOutcomeCalibration } from "../services/outcome-calibration"; import { loadGatePrecisionReport } from "../services/gate-precision"; @@ -452,6 +453,11 @@ const lintPrTextSchema = z.object({ linkedIssue: z.number().int().positive().optional(), }); +const validateFocusManifestSchema = z.object({ + content: z.string().max(256 * 1024), + source: z.enum(["repo_file", "api_record", "none"]).optional(), +}); + // Pure local-metadata slop self-checks (no repo data, no secrets) — mirror the gittensory_check_slop_risk / // gittensory_check_issue_slop MCP tools so the npm package can offer the same agent-native self-check. const slopRiskSchema = z.object({ @@ -2788,6 +2794,13 @@ export function createApp() { return c.json(buildPrTextLint(parsed.data)); }); + app.post("/v1/validate/focus-manifest", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = validateFocusManifestSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_validate_focus_manifest_request", issues: parsed.error.issues }, 400); + return c.json(buildFocusManifestValidation(parsed.data)); + }); + // Agent-native slop self-checks (#530/#533): pure local-metadata, mirroring the MCP tools of the same name. app.post("/v1/lint/slop-risk", async (c) => { const body = await c.req.json().catch(() => null); @@ -5187,6 +5200,7 @@ const EXTENSION_PULL_CONTEXT_PATH = "/v1/extension/pull-context"; const EXTENSION_PULL_CONTEXT_SCOPE = "extension:pull_context"; const OPPORTUNITIES_FIND_PATH = "/v1/opportunities/find"; const LINT_PR_TEXT_PATH = "/v1/lint/pr-text"; +const VALIDATE_FOCUS_MANIFEST_PATH = "/v1/validate/focus-manifest"; const LINT_SLOP_RISK_PATH = "/v1/lint/slop-risk"; const LINT_ISSUE_SLOP_PATH = "/v1/lint/issue-slop"; // Contributor (miner) side of the extension (#556). Minted for NON-maintainer sign-ins; strictly @@ -5253,7 +5267,7 @@ function canSessionAccessPath(env: Env, identity: Extract/*`; the handler's // requireContributorAccess then enforces actor === login (self-only). diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f38e756c5a..c4653c3444 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -127,6 +127,7 @@ import { } from "./local-write-tools"; import { TEST_FRAMEWORKS } from "../signals/test-evidence"; import { applyStepResult, buildPlanDag, nextReadySteps, planProgress, validatePlanDag, type PlanDag } from "../services/plan-dag"; +import { buildFocusManifestValidation } from "../services/focus-manifest-validation"; import { isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadiness } from "../settings/agent-execution"; import { AGENT_ACTION_CLASSES, isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy"; import { resolveRepositorySettings } from "../settings/repository-settings"; @@ -237,6 +238,11 @@ const lintPrTextShape = { linkedIssue: z.number().int().positive().optional(), }; +const validateConfigShape = { + content: z.string().max(256 * 1024), + source: z.enum(["repo_file", "api_record", "none"]).optional(), +}; + const preflightShape = { repoFullName: z.string().min(3).max(PREFLIGHT_LIMITS.repoFullNameChars), contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(), @@ -1011,6 +1017,13 @@ const lintPrTextOutputSchema = { summary: z.string().optional(), generatedAt: z.string().optional(), }; + +const validateConfigOutputSchema = { + present: z.boolean().optional(), + warnings: z.array(z.string()).optional(), + normalized: z.record(z.string(), z.unknown()).optional(), + status: z.enum(["ok", "warn", "error"]).optional(), +}; // #550: output schemas for the remaining tools (preflight/score/local-branch/agent), so MCP clients can // machine-validate their results. Same lenient style as the schemas above — documented top-level keys, // all optional, complex values as z.unknown(). No behavior change; these mirror the existing payloads. @@ -1502,6 +1515,17 @@ export class GittensoryMcp { async (input) => this.toolResult(this.lintPrText(input)), ); + server.registerTool( + "gittensory_validate_config", + { + description: + "Parse and validate a .gittensory.yml manifest string using the same focus-manifest parser as the server. Returns normalized config fields, parse warnings, and an ok/warn/error status. Metadata-only, no GitHub writes.", + inputSchema: validateConfigShape, + outputSchema: validateConfigOutputSchema, + }, + async (input) => this.toolResult(this.validateConfig(input)), + ); + server.registerTool( "gittensory_preflight_local_diff", { @@ -2260,6 +2284,14 @@ export class GittensoryMcp { }; } + private validateConfig(input: { content: string; source?: "repo_file" | "api_record" | "none" | undefined }): ToolPayload { + const report = buildFocusManifestValidation(input); + return { + summary: `Gittensory manifest validation: ${report.status}.`, + data: report as unknown as Record, + }; + } + private async canAccessRepo(fullName: string): Promise { if (this.identity.kind === "session") return canLoginAccessRepo(this.env, this.identity.actor, fullName); // The static `mcp` identity is a shared, end-user-obtainable CLI credential — scope it to the operator's diff --git a/src/services/focus-manifest-validation.ts b/src/services/focus-manifest-validation.ts new file mode 100644 index 0000000000..035c078bc3 --- /dev/null +++ b/src/services/focus-manifest-validation.ts @@ -0,0 +1,74 @@ +import { + contentLaneConfigToJson, + featuresConfigToJson, + gateConfigToJson, + parseFocusManifestContent, + repoDocGenerationConfigToJson, + reviewConfigToJson, + reviewRecapConfigToJson, + settingsOverrideToJson, + type FocusManifest, + type FocusManifestSource, +} from "../signals/focus-manifest"; + +export type FocusManifestValidationStatus = "ok" | "warn" | "error"; + +export type FocusManifestValidationResult = { + present: boolean; + warnings: string[]; + normalized: Record; + status: FocusManifestValidationStatus; +}; + +const PARSE_FAILURE_PATTERN = /not valid (JSON|YAML)|must be a mapping|exceeded \d+ bytes/i; + +export function buildFocusManifestValidation(input: { + content: string; + source?: FocusManifestSource | undefined; +}): FocusManifestValidationResult { + const manifest = parseFocusManifestContent(input.content, input.source ?? "repo_file"); + const warnings = [...manifest.warnings]; + const normalized = focusManifestToNormalizedJson(manifest); + return { + present: manifest.present, + warnings, + normalized, + status: resolveValidationStatus(manifest, warnings), + }; +} + +function resolveValidationStatus(manifest: FocusManifest, warnings: string[]): FocusManifestValidationStatus { + if (warnings.some((warning) => PARSE_FAILURE_PATTERN.test(warning))) return "error"; + if (!manifest.present || warnings.length > 0) return "warn"; + return "ok"; +} + +function focusManifestToNormalizedJson(manifest: FocusManifest): Record { + const normalized: Record = { + present: manifest.present, + source: manifest.source, + }; + if (manifest.wantedPaths.length > 0) normalized.wantedPaths = manifest.wantedPaths; + if (manifest.preferredLabels.length > 0) normalized.preferredLabels = manifest.preferredLabels; + if (manifest.linkedIssuePolicy !== "optional") normalized.linkedIssuePolicy = manifest.linkedIssuePolicy; + if (manifest.testExpectations.length > 0) normalized.testExpectations = manifest.testExpectations; + if (manifest.issueDiscoveryPolicy !== "neutral") normalized.issueDiscoveryPolicy = manifest.issueDiscoveryPolicy; + if (manifest.publicNotes.length > 0) normalized.publicNotes = manifest.publicNotes; + + const gate = gateConfigToJson(manifest.gate); + if (gate !== null) normalized.gate = gate; + const settings = settingsOverrideToJson(manifest.settings); + if (settings !== null) normalized.settings = settings; + const review = reviewConfigToJson(manifest.review); + if (review !== null) normalized.review = review; + const features = featuresConfigToJson(manifest.features); + if (features !== null) normalized.features = features; + const contentLane = contentLaneConfigToJson(manifest.contentLane); + if (contentLane !== null) normalized.contentLane = contentLane; + const repoDocGeneration = repoDocGenerationConfigToJson(manifest.repoDocGeneration); + if (repoDocGeneration !== null) normalized.repoDocGeneration = repoDocGeneration; + const reviewRecap = reviewRecapConfigToJson(manifest.reviewRecap); + if (reviewRecap !== null) normalized.reviewRecap = reviewRecap; + + return normalized; +} diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 82540d7802..17f16a5422 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1216,6 +1216,21 @@ describe("api routes", () => { const invalidLintPrText = await app.request("/v1/lint/pr-text", { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ linkedIssue: -1 }) }, env); expect(invalidLintPrText.status).toBe(400); + const validateManifest = await app.request( + "/v1/validate/focus-manifest", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ content: "wantedPaths:\n - src/\n" }) }, + env, + ); + expect(validateManifest.status).toBe(200); + await expect(validateManifest.json()).resolves.toMatchObject({ status: "ok", present: true, warnings: [] }); + + const invalidValidateManifest = await app.request( + "/v1/validate/focus-manifest", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ content: 123 }) }, + env, + ); + expect(invalidValidateManifest.status).toBe(400); + const invalidFindOpportunities = await app.request( "/v1/opportunities/find", { method: "POST", headers: apiHeaders(env), body: JSON.stringify({}) }, diff --git a/test/unit/focus-manifest-validation.test.ts b/test/unit/focus-manifest-validation.test.ts new file mode 100644 index 0000000000..ec5ac2fe5e --- /dev/null +++ b/test/unit/focus-manifest-validation.test.ts @@ -0,0 +1,79 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { buildFocusManifestValidation } from "../../src/services/focus-manifest-validation"; +import { MAX_FOCUS_MANIFEST_BYTES } from "../../src/signals/focus-manifest"; + +const exampleManifest = readFileSync(join(process.cwd(), "config/examples/global.gittensory.yml"), "utf8"); + +describe("buildFocusManifestValidation (#2057)", () => { + it("returns ok for a valid manifest with recognized fields", () => { + const result = buildFocusManifestValidation({ content: "wantedPaths:\n - src/\n" }); + expect(result.status).toBe("ok"); + expect(result.present).toBe(true); + expect(result.warnings).toEqual([]); + expect(result.normalized).toMatchObject({ present: true, wantedPaths: ["src/"] }); + expect(JSON.stringify(result)).not.toMatch(/wallet|hotkey|reward estimate/i); + }); + + it("returns warn for a manifest with parser warnings but parseable content", () => { + const result = buildFocusManifestValidation({ content: "gate:\n pack: not-real\n enabled: true\n" }); + expect(result.status).toBe("warn"); + expect(result.warnings.join(" ")).toMatch(/gate\.pack/i); + expect(result.normalized.gate).toMatchObject({ enabled: true }); + }); + + it("returns error for malformed YAML", () => { + const result = buildFocusManifestValidation({ content: "wantedPaths: [\n" }); + expect(result.status).toBe("error"); + expect(result.present).toBe(false); + expect(result.warnings.join(" ")).toMatch(/not valid YAML/i); + }); + + it("returns error for malformed JSON", () => { + const result = buildFocusManifestValidation({ content: "{ not: valid json" }); + expect(result.status).toBe("error"); + expect(result.warnings.join(" ")).toMatch(/not valid JSON/i); + }); + + it("returns warn for an empty manifest with no recognized fields", () => { + const result = buildFocusManifestValidation({ content: " \n" }); + expect(result.status).toBe("warn"); + expect(result.present).toBe(false); + expect(result.normalized).toMatchObject({ present: false }); + }); + + it("returns error when content exceeds the manifest byte cap", () => { + const oversized = "x".repeat(MAX_FOCUS_MANIFEST_BYTES + 1); + const result = buildFocusManifestValidation({ content: oversized }); + expect(result.status).toBe("error"); + expect(result.warnings.join(" ")).toMatch(/exceeded/i); + }); + + it("omits private maintainerNotes from normalized output", () => { + const result = buildFocusManifestValidation({ + content: "maintainerNotes:\n - secret maintainer-only context\npublicNotes:\n - keep reviews focused\n", + }); + expect(result.normalized).not.toHaveProperty("maintainerNotes"); + expect(result.normalized.publicNotes).toEqual(["keep reviews focused"]); + expect(JSON.stringify(result)).not.toContain("secret maintainer-only context"); + }); + + it("normalizes a real example manifest without echoing private maintainer notes", () => { + const result = buildFocusManifestValidation({ content: exampleManifest }); + expect(result.status).toBe("ok"); + expect(result.present).toBe(true); + expect(result.normalized.gate).toMatchObject({ enabled: true }); + expect(result.normalized).not.toHaveProperty("maintainerNotes"); + }); + + it("keeps clean text byte-identical through normalization for a simple manifest", () => { + const content = "linkedIssuePolicy: required\ntestExpectations:\n - npm test\n"; + const result = buildFocusManifestValidation({ content }); + expect(result.status).toBe("ok"); + expect(result.normalized).toMatchObject({ + linkedIssuePolicy: "required", + testExpectations: ["npm test"], + }); + }); +}); diff --git a/test/unit/mcp-cli-validate-config.test.ts b/test/unit/mcp-cli-validate-config.test.ts new file mode 100644 index 0000000000..4e091ec5e2 --- /dev/null +++ b/test/unit/mcp-cli-validate-config.test.ts @@ -0,0 +1,48 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { closeFixtureServer, run, runAsync, startFixtureServer } from "./support/mcp-cli-harness"; + +describe("gittensory-mcp CLI — validate-config", () => { + let tempDir: string | null = null; + + afterEach(async () => { + await closeFixtureServer(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + }); + + async function env() { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer(); + return { GITTENSORY_API_URL: url, GITTENSORY_TOKEN: "session-token", GITTENSORY_CONFIG_DIR: tempDir, GITTENSORY_API_TIMEOUT_MS: "1000" }; + } + + it("validates a manifest file via the API and prints plain or json output", async () => { + const e = await env(); + const manifestPath = join(tempDir!, "manifest.yml"); + writeFileSync(manifestPath, "wantedPaths:\n - src/\n", "utf8"); + + const plain = await runAsync(["validate-config", "--file", manifestPath], e); + expect(plain).toMatch(/Manifest validation: ok/); + expect(plain).toMatch(/present=true/); + + const json = JSON.parse(await runAsync(["validate-config", "--file", manifestPath, "--json"], e)) as { + status: string; + present: boolean; + normalized: { wantedPaths: string[] }; + }; + expect(json).toMatchObject({ status: "ok", present: true, normalized: { wantedPaths: ["src/"] } }); + }); + + it("rejects missing --file and prints help", async () => { + const e = await env(); + const help = run(["validate-config", "--help"]); + expect(help).toMatch(/Usage: gittensory-mcp validate-config/); + expect(help).toMatch(/gittensory_validate_config/); + + const manifestPath = join(tempDir!, "missing.yml"); + await expect(runAsync(["validate-config", "--file", manifestPath], e)).rejects.toThrow(/Manifest file not found/); + }); +}); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index ccb925f0c4..d16b88b6ba 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -27,6 +27,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [ "gittensory_check_before_start", "gittensory_find_opportunities", "gittensory_lint_pr_text", + "gittensory_validate_config", "gittensory_get_registry_changes", "gittensory_get_upstream_drift", "gittensory_local_status", @@ -462,6 +463,32 @@ describe("MCP tool calls return schema-valid structured content", () => { expect((strong.structuredContent as Record).verdict).toBe("strong"); }); + it("gittensory_validate_config returns normalized manifest fields and status arms", async () => { + const { client } = await connectTestClient(); + const ok = await client.callTool({ + name: "gittensory_validate_config", + arguments: { content: "wantedPaths:\n - src/\n" }, + }); + expect(ok.isError).toBeFalsy(); + expect(ok.structuredContent).toMatchObject({ status: "ok", present: true, warnings: [] }); + expect((ok.structuredContent as Record).normalized).toMatchObject({ wantedPaths: ["src/"] }); + + const warn = await client.callTool({ + name: "gittensory_validate_config", + arguments: { content: "gate:\n pack: not-real\n enabled: true\n" }, + }); + expect(warn.isError).toBeFalsy(); + expect((warn.structuredContent as Record).status).toBe("warn"); + + const error = await client.callTool({ + name: "gittensory_validate_config", + arguments: { content: "{ not: valid json" }, + }); + expect(error.isError).toBeFalsy(); + expect((error.structuredContent as Record).status).toBe("error"); + expect(JSON.stringify(error.structuredContent)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i); + }); + it("gittensory_get_repo_outcome_patterns reports not-found, computed, and cached outcomes", async () => { const env = createTestEnv(); await upsertRepositoryFromGitHub(env, { name: "computed", full_name: "owner/computed", private: false, owner: { login: "owner" }, default_branch: "main" }); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index ee80fc435e..d7cc5b2728 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -242,6 +242,19 @@ export async function startFixtureServer( response.end(JSON.stringify(lintPrTextFixture(body))); return; } + if (request.url === "/v1/validate/focus-manifest" && request.method === "POST") { + const body = (await readJsonRequest(request)) as { content?: string }; + const content = body.content ?? ""; + const malformed = content.includes("not: valid json"); + response.end( + JSON.stringify( + malformed + ? { present: false, status: "error", warnings: ["Manifest content was not valid JSON; ignoring it and falling back to deterministic signals."], normalized: { present: false, source: "repo_file" } } + : { present: true, status: "ok", warnings: [], normalized: { present: true, source: "repo_file", wantedPaths: ["src/"] } }, + ), + ); + return; + } if (request.url === "/v1/lint/slop-risk" && request.method === "POST") { if (options.slopRiskStatus && options.slopRiskStatus >= 400) { await readJsonRequest(request); From 70e7d98e8677e269e7b7e552b75ed0d3300d9c5d Mon Sep 17 00:00:00 2001 From: realDiligent Date: Tue, 7 Jul 2026 22:43:09 +0800 Subject: [PATCH 2/2] test(mcp): cover all focus-manifest validation normalization branches Raise patch coverage for focus-manifest-validation.ts to satisfy codecov/patch. Co-authored-by: Cursor --- test/unit/focus-manifest-validation.test.ts | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/unit/focus-manifest-validation.test.ts b/test/unit/focus-manifest-validation.test.ts index ec5ac2fe5e..431dc1534f 100644 --- a/test/unit/focus-manifest-validation.test.ts +++ b/test/unit/focus-manifest-validation.test.ts @@ -76,4 +76,47 @@ describe("buildFocusManifestValidation (#2057)", () => { testExpectations: ["npm test"], }); }); + + it("normalizes every optional manifest section and honors a custom source", () => { + const result = buildFocusManifestValidation({ + source: "api_record", + content: ` +preferredLabels: [help wanted] +issueDiscoveryPolicy: encouraged +settings: + commentMode: all_prs +review: + profile: chill +features: + rag: true +contentLane: + entryFileGlob: data/*.json + collectionField: records +repoDocGeneration: + enabled: true + scope: [agents] +reviewRecap: + enabled: true + cadenceDays: 14 +`, + }); + expect(result.status).toBe("ok"); + expect(result.normalized).toMatchObject({ + source: "api_record", + preferredLabels: ["help wanted"], + issueDiscoveryPolicy: "encouraged", + settings: { commentMode: "all_prs" }, + review: { profile: "chill" }, + features: { rag: true }, + contentLane: { entryFileGlob: "data/*.json", collectionField: "records" }, + repoDocGeneration: { enabled: true, scope: ["agents"] }, + reviewRecap: { enabled: true, cadenceDays: 14 }, + }); + }); + + it("returns error when manifest content is not a mapping", () => { + const result = buildFocusManifestValidation({ content: "[1, 2, 3]" }); + expect(result.status).toBe("error"); + expect(result.warnings.join(" ")).toMatch(/must be a mapping/i); + }); });