From 787d1cdf63d780820aadf34d5aabed85cbd68863 Mon Sep 17 00:00:00 2001 From: davion-knight <298846663+davion-knight@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:32:17 -0500 Subject: [PATCH] feat(mcp): validate .loopover.yml offline by extracting buildFocusManifestValidation into @loopover/engine loopover_validate_config's result builder, buildFocusManifestValidation, lived app-only in src/services/focus-manifest-validation.ts, so the local stdio server had to POST to /v1/validate/focus-manifest to validate a manifest -- unusable offline. Its parsing core (parseFocusManifestContent) was already engine-side, but the result builder and its unknownTopLevelWarnings dependency (src/selfhost/config-lint.ts) were not. Extract both into @loopover/engine (packages/loopover-engine/src/{focus-manifest-validation,config-lint}.ts), leaving re-export shims at the original src/ locations so the remote server (src/mcp/server.ts) and API (src/api/routes.ts) keep working unchanged. Add the two exports-map entries and barrel re-exports. Rewire the local loopover_validate_config handler to call buildFocusManifestValidation in-process instead of apiPost, so a user validates a .loopover.yml fully offline. All existing focus-manifest-validation and config-lint tests pass against the new location; adds a stdio test that drives loopover_validate_config with an unreachable API URL to prove offline behavior. Closes #6269 --- packages/loopover-engine/package.json | 8 + packages/loopover-engine/src/config-lint.ts | 145 ++++++++++++++++ .../src/focus-manifest-validation.ts | 93 +++++++++++ packages/loopover-engine/src/index.ts | 8 + packages/loopover-mcp/bin/loopover-mcp.js | 8 +- src/selfhost/config-lint.ts | 156 ++---------------- src/services/focus-manifest-validation.ts | 104 ++---------- .../mcp-cli-validate-config-offline.test.ts | 82 +++++++++ 8 files changed, 364 insertions(+), 240 deletions(-) create mode 100644 packages/loopover-engine/src/config-lint.ts create mode 100644 packages/loopover-engine/src/focus-manifest-validation.ts create mode 100644 test/unit/mcp-cli-validate-config-offline.test.ts diff --git a/packages/loopover-engine/package.json b/packages/loopover-engine/package.json index 97ebc54b67..1f5bbc73ea 100644 --- a/packages/loopover-engine/package.json +++ b/packages/loopover-engine/package.json @@ -51,6 +51,14 @@ "types": "./dist/signals/check-summary.d.ts", "default": "./dist/signals/check-summary.js" }, + "./config-lint": { + "types": "./dist/config-lint.d.ts", + "default": "./dist/config-lint.js" + }, + "./focus-manifest-validation": { + "types": "./dist/focus-manifest-validation.d.ts", + "default": "./dist/focus-manifest-validation.js" + }, "./signals/path-matchers": { "types": "./dist/signals/path-matchers.d.ts", "default": "./dist/signals/path-matchers.js" diff --git a/packages/loopover-engine/src/config-lint.ts b/packages/loopover-engine/src/config-lint.ts new file mode 100644 index 0000000000..1794e2da7b --- /dev/null +++ b/packages/loopover-engine/src/config-lint.ts @@ -0,0 +1,145 @@ +import { parse as parseYaml } from "yaml"; +import { MAX_FOCUS_MANIFEST_BYTES, parseFocusManifestContent } from "./focus-manifest.js"; + +const TOP_LEVEL_FIELDS = [ + "source", + "wantedPaths", + "preferredLabels", + "linkedIssuePolicy", + "testExpectations", + "issueDiscoveryPolicy", + "maintainerNotes", + "publicNotes", + "gate", + "settings", + "review", + "features", + "experimental", + "contentLane", + "repoDocGeneration", + "reviewRecap", + "maintainerRecap", + "ops", + "publicStats", + "draftFlow", + "upstreamDriftIssues", +] as const; + +const TOP_LEVEL_FIELD_SET = new Set(TOP_LEVEL_FIELDS); +const NO_RECOGNIZED_FOCUS_FIELDS_WARNING = + "Manifest contained no recognized focus fields; falling back to deterministic signals."; + +export type SelfHostConfigLintResult = { + ok: boolean; + warnings: string[]; + recognizedFields: string[]; + summary: string; +}; + +export function lintManifestText(text: string | null | undefined): SelfHostConfigLintResult { + const manifest = parseFocusManifestContent(text, "repo_file"); + const recognizedFields = recognizedFieldsFor(text); + const warnings = [ + ...manifest.warnings + .map(redactManifestWarning) + .filter((warning) => recognizedFields.length === 0 || warning !== NO_RECOGNIZED_FOCUS_FIELDS_WARNING), + ...unknownTopLevelWarnings(text), + ]; + if (warnings.length === 0 && recognizedFields.length === 0) { + warnings.push("Manifest did not define any recognized focus fields."); + } + const ok = warnings.length === 0 && recognizedFields.length > 0; + return { + ok, + warnings, + recognizedFields, + summary: ok + ? `Manifest parsed ${recognizedFields.length} recognized field${recognizedFields.length === 1 ? "" : "s"}.` + : `Manifest has ${warnings.length} warning${warnings.length === 1 ? "" : "s"}.`, + }; +} + +function recognizedFieldsFor(text: string | null | undefined): string[] { + const parsed = parseCanonicalTopLevelObject(text); + if (parsed === null) return []; + return TOP_LEVEL_FIELDS.filter( + (field) => field !== "source" && Object.prototype.hasOwnProperty.call(parsed, field), + ); +} + +// Fields retired from TOP_LEVEL_FIELDS that still warrant a migration-specific warning (rather than the +// generic "unknown field" message) pointing operators at their replacement mechanism. +const RETIRED_FIELD_MIGRATION_WARNINGS: Record = { + blockedPaths: "blockedPaths is retired; use settings.hardGuardrailGlobs for path holds.", +}; + +export function unknownTopLevelWarnings(text: string | null | undefined): string[] { + const raw = text ?? ""; + const trimmed = raw.trim(); + if (!trimmed || isOversize(raw)) return []; + const parsed = parseTopLevelObject(trimmed); + if (parsed === null) return []; + const keys = Object.keys(parsed).filter((key) => !TOP_LEVEL_FIELD_SET.has(key)); + // `hasOwnProperty.call`, NOT `key in`: a manifest field named like an Object.prototype member + // (`constructor`, `toString`, `hasOwnProperty`, ...) would otherwise test true for the inherited + // property and resolve to the prototype's function instead of a real retired-field warning string, + // corrupting the string[] result and suppressing the genuine unknown-field warning. + const isRetired = (key: string): boolean => Object.prototype.hasOwnProperty.call(RETIRED_FIELD_MIGRATION_WARNINGS, key); + const retiredWarnings = keys.filter(isRetired).map((key) => RETIRED_FIELD_MIGRATION_WARNINGS[key]!); + const unknown = keys.filter((key) => !isRetired(key)).map(formatFieldName); + return [ + ...retiredWarnings, + ...(unknown.length > 0 ? [`Manifest contains unknown top-level field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}.`] : []), + ]; +} + +function parseCanonicalTopLevelObject(text: string | null | undefined): Record | null { + const raw = text ?? ""; + const trimmed = raw.trim(); + if (!trimmed || isOversize(raw)) return null; + const looksLikeJson = trimmed.startsWith("{") || trimmed.startsWith("["); + try { + return topLevelObjectOrNull(looksLikeJson ? JSON.parse(trimmed) : parseYaml(trimmed)); + } catch { + return null; + } +} + +function parseTopLevelObject(text: string): Record | null { + const looksLikeJson = text.startsWith("{") || text.startsWith("["); + if (looksLikeJson) { + try { + const parsed = JSON.parse(text); + return topLevelObjectOrNull(parsed); + } catch { + // YAML flow mappings can start with "{" or "[" while still being valid manifest syntax. + } + } + try { + return topLevelObjectOrNull(parseYaml(text)); + } catch { + return null; + } +} + +function topLevelObjectOrNull(parsed: unknown): Record | null { + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; +} + +function isOversize(text: string): boolean { + return text.length > MAX_FOCUS_MANIFEST_BYTES || new TextEncoder().encode(text).byteLength > MAX_FOCUS_MANIFEST_BYTES; +} + +function formatFieldName(name: string): string { + const trimmed = name.replace(/[^\w.-]/g, "_").slice(0, 80); + return trimmed || ""; +} + +function redactManifestWarning(warning: string): string { + return warning + .replace(/; ignoring "[^"]*"\./g, "; ignoring the supplied value.") + .replace(/; ignoring "[^"]*"/g, "; ignoring the supplied value") + .replace(/falling back to "[^"]*"/g, "falling back to the default"); +} diff --git a/packages/loopover-engine/src/focus-manifest-validation.ts b/packages/loopover-engine/src/focus-manifest-validation.ts new file mode 100644 index 0000000000..78ab592d7a --- /dev/null +++ b/packages/loopover-engine/src/focus-manifest-validation.ts @@ -0,0 +1,93 @@ +import { + contentLaneConfigToJson, + featuresConfigToJson, + gateConfigToJson, + parseFocusManifestContent, + repoDocGenerationConfigToJson, + reviewConfigToJson, + reviewRecapConfigToJson, + maintainerRecapConfigToJson, + opsConfigToJson, + publicStatsConfigToJson, + draftFlowConfigToJson, + upstreamDriftIssuesConfigToJson, + settingsOverrideToJson, + type FocusManifest, + type FocusManifestSource, +} from "./focus-manifest.js"; +import { unknownTopLevelWarnings } from "./config-lint.js"; + +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"); + // Warn on unrecognized top-level fields (e.g. a typo'd `gates:` instead of `gate:`), matching the + // selfhost config-lint validator — parseFocusManifestContent reads only known fields, so a mistyped + // block is otherwise silently dropped with no warning (#5929). + const warnings = [...manifest.warnings, ...unknownTopLevelWarnings(input.content)]; + 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; + const maintainerRecap = maintainerRecapConfigToJson(manifest.maintainerRecap); + if (maintainerRecap !== null) normalized.maintainerRecap = maintainerRecap; + const ops = opsConfigToJson(manifest.ops); + if (ops !== null) normalized.ops = ops; + const publicStats = publicStatsConfigToJson(manifest.publicStats); + if (publicStats !== null) normalized.publicStats = publicStats; + const draftFlow = draftFlowConfigToJson(manifest.draftFlow); + if (draftFlow !== null) normalized.draftFlow = draftFlow; + const upstreamDriftIssues = upstreamDriftIssuesConfigToJson(manifest.upstreamDriftIssues); + if (upstreamDriftIssues !== null) normalized.upstreamDriftIssues = upstreamDriftIssues; + + return normalized; +} diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index fa2d760187..d6aebabd80 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -811,6 +811,14 @@ export { type VisualRoutesConfig, type VisualTheme, } from "./focus-manifest.js"; +// Focus-manifest linting + validation (#6269), extracted so the local (`@loopover/mcp`) MCP server can lint +// and validate a `.loopover.yml` offline/in-process instead of round-tripping to the remote API. +export { lintManifestText, unknownTopLevelWarnings, type SelfHostConfigLintResult } from "./config-lint.js"; +export { + buildFocusManifestValidation, + type FocusManifestValidationResult, + type FocusManifestValidationStatus, +} from "./focus-manifest-validation.js"; // Reward/risk reasoning signals (#2281). The four builders depend on the still-in-`src` maintainer signal // stack, so they take an injected `RewardRiskEngineDeps` (the `src/signals/reward-risk.ts` shim binds it). export { diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index c541545153..07bad41d8b 100755 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -17,6 +17,9 @@ import { buildOpenPrSpec, buildPostEligibilityCommentSpec, buildTestGenSpec, + // #6269: the same manifest-validation builder the remote server uses, so `loopover_validate_config` + // can validate a `.loopover.yml` in-process instead of round-tripping to the API. + buildFocusManifestValidation, } from "@loopover/engine"; import { buildSlopAssessment, SLOP_RUBRIC_MARKDOWN } from "@loopover/engine/signals/slop"; import { z } from "zod"; @@ -628,7 +631,7 @@ const STDIO_TOOL_DESCRIPTORS = [ { name: "loopover_validate_config", category: "utility", - description: "Parse and validate a .loopover.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.", + description: "Parse and validate a .loopover.yml manifest string using the same focus-manifest parser as the server. Returns normalized config fields, parse warnings, and an ok/warn/error status. Computed in-process; no source upload and no API round-trip. Metadata-only, no GitHub writes.", }, { name: "loopover_check_slop_risk", @@ -1086,7 +1089,8 @@ registerStdioTool( description: stdioToolDescription("loopover_validate_config"), inputSchema: validateConfigShape, }, - async (input) => toolResult("LoopOver manifest validation.", await apiPost("/v1/validate/focus-manifest", input)), + // #6269: computed in-process via the extracted engine builder -- no API round-trip, works fully offline. + (input) => toolResult("LoopOver manifest validation.", buildFocusManifestValidation(input)), ); registerStdioTool( diff --git a/src/selfhost/config-lint.ts b/src/selfhost/config-lint.ts index ca7fc6db94..8f4acd77b5 100644 --- a/src/selfhost/config-lint.ts +++ b/src/selfhost/config-lint.ts @@ -1,145 +1,11 @@ -import { parse as parseYaml } from "yaml"; -import { MAX_FOCUS_MANIFEST_BYTES, parseFocusManifestContent } from "../signals/focus-manifest"; - -const TOP_LEVEL_FIELDS = [ - "source", - "wantedPaths", - "preferredLabels", - "linkedIssuePolicy", - "testExpectations", - "issueDiscoveryPolicy", - "maintainerNotes", - "publicNotes", - "gate", - "settings", - "review", - "features", - "experimental", - "contentLane", - "repoDocGeneration", - "reviewRecap", - "maintainerRecap", - "ops", - "publicStats", - "draftFlow", - "upstreamDriftIssues", -] as const; - -const TOP_LEVEL_FIELD_SET = new Set(TOP_LEVEL_FIELDS); -const NO_RECOGNIZED_FOCUS_FIELDS_WARNING = - "Manifest contained no recognized focus fields; falling back to deterministic signals."; - -export type SelfHostConfigLintResult = { - ok: boolean; - warnings: string[]; - recognizedFields: string[]; - summary: string; -}; - -export function lintManifestText(text: string | null | undefined): SelfHostConfigLintResult { - const manifest = parseFocusManifestContent(text, "repo_file"); - const recognizedFields = recognizedFieldsFor(text); - const warnings = [ - ...manifest.warnings - .map(redactManifestWarning) - .filter((warning) => recognizedFields.length === 0 || warning !== NO_RECOGNIZED_FOCUS_FIELDS_WARNING), - ...unknownTopLevelWarnings(text), - ]; - if (warnings.length === 0 && recognizedFields.length === 0) { - warnings.push("Manifest did not define any recognized focus fields."); - } - const ok = warnings.length === 0 && recognizedFields.length > 0; - return { - ok, - warnings, - recognizedFields, - summary: ok - ? `Manifest parsed ${recognizedFields.length} recognized field${recognizedFields.length === 1 ? "" : "s"}.` - : `Manifest has ${warnings.length} warning${warnings.length === 1 ? "" : "s"}.`, - }; -} - -function recognizedFieldsFor(text: string | null | undefined): string[] { - const parsed = parseCanonicalTopLevelObject(text); - if (parsed === null) return []; - return TOP_LEVEL_FIELDS.filter( - (field) => field !== "source" && Object.prototype.hasOwnProperty.call(parsed, field), - ); -} - -// Fields retired from TOP_LEVEL_FIELDS that still warrant a migration-specific warning (rather than the -// generic "unknown field" message) pointing operators at their replacement mechanism. -const RETIRED_FIELD_MIGRATION_WARNINGS: Record = { - blockedPaths: "blockedPaths is retired; use settings.hardGuardrailGlobs for path holds.", -}; - -export function unknownTopLevelWarnings(text: string | null | undefined): string[] { - const raw = text ?? ""; - const trimmed = raw.trim(); - if (!trimmed || isOversize(raw)) return []; - const parsed = parseTopLevelObject(trimmed); - if (parsed === null) return []; - const keys = Object.keys(parsed).filter((key) => !TOP_LEVEL_FIELD_SET.has(key)); - // `hasOwnProperty.call`, NOT `key in`: a manifest field named like an Object.prototype member - // (`constructor`, `toString`, `hasOwnProperty`, ...) would otherwise test true for the inherited - // property and resolve to the prototype's function instead of a real retired-field warning string, - // corrupting the string[] result and suppressing the genuine unknown-field warning. - const isRetired = (key: string): boolean => Object.prototype.hasOwnProperty.call(RETIRED_FIELD_MIGRATION_WARNINGS, key); - const retiredWarnings = keys.filter(isRetired).map((key) => RETIRED_FIELD_MIGRATION_WARNINGS[key]!); - const unknown = keys.filter((key) => !isRetired(key)).map(formatFieldName); - return [ - ...retiredWarnings, - ...(unknown.length > 0 ? [`Manifest contains unknown top-level field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}.`] : []), - ]; -} - -function parseCanonicalTopLevelObject(text: string | null | undefined): Record | null { - const raw = text ?? ""; - const trimmed = raw.trim(); - if (!trimmed || isOversize(raw)) return null; - const looksLikeJson = trimmed.startsWith("{") || trimmed.startsWith("["); - try { - return topLevelObjectOrNull(looksLikeJson ? JSON.parse(trimmed) : parseYaml(trimmed)); - } catch { - return null; - } -} - -function parseTopLevelObject(text: string): Record | null { - const looksLikeJson = text.startsWith("{") || text.startsWith("["); - if (looksLikeJson) { - try { - const parsed = JSON.parse(text); - return topLevelObjectOrNull(parsed); - } catch { - // YAML flow mappings can start with "{" or "[" while still being valid manifest syntax. - } - } - try { - return topLevelObjectOrNull(parseYaml(text)); - } catch { - return null; - } -} - -function topLevelObjectOrNull(parsed: unknown): Record | null { - return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : null; -} - -function isOversize(text: string): boolean { - return text.length > MAX_FOCUS_MANIFEST_BYTES || new TextEncoder().encode(text).byteLength > MAX_FOCUS_MANIFEST_BYTES; -} - -function formatFieldName(name: string): string { - const trimmed = name.replace(/[^\w.-]/g, "_").slice(0, 80); - return trimmed || ""; -} - -function redactManifestWarning(warning: string): string { - return warning - .replace(/; ignoring "[^"]*"\./g, "; ignoring the supplied value.") - .replace(/; ignoring "[^"]*"/g, "; ignoring the supplied value") - .replace(/falling back to "[^"]*"/g, "falling back to the default"); -} +/** + * Config-lint shim (#6269). The manifest-linting core now lives in `@loopover/engine` + * (`packages/loopover-engine/src/config-lint.ts`) so the local (`@loopover/mcp`) MCP server can lint a + * `.loopover.yml` offline, in-process. This file re-exports the engine surface for the existing `src/` + * callers unchanged. + */ +export { + lintManifestText, + unknownTopLevelWarnings, + type SelfHostConfigLintResult, +} from "../../packages/loopover-engine/src/config-lint.js"; diff --git a/src/services/focus-manifest-validation.ts b/src/services/focus-manifest-validation.ts index 4383775b1f..fdc0f149c3 100644 --- a/src/services/focus-manifest-validation.ts +++ b/src/services/focus-manifest-validation.ts @@ -1,93 +1,11 @@ -import { - contentLaneConfigToJson, - featuresConfigToJson, - gateConfigToJson, - parseFocusManifestContent, - repoDocGenerationConfigToJson, - reviewConfigToJson, - reviewRecapConfigToJson, - maintainerRecapConfigToJson, - opsConfigToJson, - publicStatsConfigToJson, - draftFlowConfigToJson, - upstreamDriftIssuesConfigToJson, - settingsOverrideToJson, - type FocusManifest, - type FocusManifestSource, -} from "../signals/focus-manifest"; -import { unknownTopLevelWarnings } from "../selfhost/config-lint"; - -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"); - // Warn on unrecognized top-level fields (e.g. a typo'd `gates:` instead of `gate:`), matching the - // selfhost config-lint validator — parseFocusManifestContent reads only known fields, so a mistyped - // block is otherwise silently dropped with no warning (#5929). - const warnings = [...manifest.warnings, ...unknownTopLevelWarnings(input.content)]; - 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; - const maintainerRecap = maintainerRecapConfigToJson(manifest.maintainerRecap); - if (maintainerRecap !== null) normalized.maintainerRecap = maintainerRecap; - const ops = opsConfigToJson(manifest.ops); - if (ops !== null) normalized.ops = ops; - const publicStats = publicStatsConfigToJson(manifest.publicStats); - if (publicStats !== null) normalized.publicStats = publicStats; - const draftFlow = draftFlowConfigToJson(manifest.draftFlow); - if (draftFlow !== null) normalized.draftFlow = draftFlow; - const upstreamDriftIssues = upstreamDriftIssuesConfigToJson(manifest.upstreamDriftIssues); - if (upstreamDriftIssues !== null) normalized.upstreamDriftIssues = upstreamDriftIssues; - - return normalized; -} +/** + * Focus-manifest validation shim (#6269). The validation result builder now lives in `@loopover/engine` + * (`packages/loopover-engine/src/focus-manifest-validation.ts`) so the local (`@loopover/mcp`) MCP server's + * `loopover_validate_config` can compute the result in-process/offline. This file re-exports the engine + * surface for the existing `src/` callers (`src/api/routes.ts`, `src/mcp/server.ts`) unchanged. + */ +export { + buildFocusManifestValidation, + type FocusManifestValidationResult, + type FocusManifestValidationStatus, +} from "../../packages/loopover-engine/src/focus-manifest-validation.js"; diff --git a/test/unit/mcp-cli-validate-config-offline.test.ts b/test/unit/mcp-cli-validate-config-offline.test.ts new file mode 100644 index 0000000000..0c7b21b6b6 --- /dev/null +++ b/test/unit/mcp-cli-validate-config-offline.test.ts @@ -0,0 +1,82 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +// #6269: loopover_validate_config now computes its result in-process via the extracted @loopover/engine +// builder (buildFocusManifestValidation) instead of POSTing to /v1/validate/focus-manifest. These tests drive +// the real local stdio server with a DELIBERATELY UNREACHABLE API URL to prove the tool validates fully offline +// -- if it still round-tripped to the API, every call here would fail/time out. +const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); + +let client: Client; +let transport: StdioClientTransport; +let configDir: string; + +beforeEach(async () => { + configDir = mkdtempSync(join(tmpdir(), "loopover-validate-offline-")); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { + ...process.env, + LOOPOVER_CONFIG_DIR: configDir, + LOOPOVER_TOKEN: "session-token", + // Point the API at a black-holed port so any accidental round-trip would fail, not silently pass. + LOOPOVER_API_URL: "http://127.0.0.1:1", + LOOPOVER_API_TIMEOUT_MS: "1000", + }, + }); + client = new Client({ name: "validate-config-offline-test", version: "0.0.1" }); + await client.connect(transport); +}); + +afterEach(async () => { + await client.close().catch(() => undefined); + if (configDir) rmSync(configDir, { recursive: true, force: true }); +}); + +function result(raw: unknown): { status: string; present: boolean; warnings: string[]; normalized: Record } { + return (raw as { structuredContent?: unknown }).structuredContent as { + status: string; + present: boolean; + warnings: string[]; + normalized: Record; + }; +} + +describe("loopover_validate_config offline (#6269)", () => { + it("validates a well-formed manifest in-process with no API round-trip", async () => { + const raw = await client.callTool({ + name: "loopover_validate_config", + arguments: { content: "wantedPaths:\n - src/\n" }, + }); + expect(raw.isError).toBeFalsy(); + const r = result(raw); + expect(r.status).toBe("ok"); + expect(r.present).toBe(true); + expect(r.normalized).toMatchObject({ wantedPaths: ["src/"] }); + }); + + it("warns on an unknown top-level field (the extracted config-lint path runs locally)", async () => { + const raw = await client.callTool({ + name: "loopover_validate_config", + arguments: { content: "gates:\n linkedIssue: block\n" }, + }); + expect(raw.isError).toBeFalsy(); + const r = result(raw); + expect(r.status).toBe("warn"); + expect(r.warnings.join("\n")).toMatch(/unknown top-level field/i); + }); + + it("reports error status for unparseable manifest content", async () => { + const raw = await client.callTool({ + name: "loopover_validate_config", + arguments: { content: "wantedPaths: [unterminated\n" }, + }); + expect(raw.isError).toBeFalsy(); + expect(result(raw).status).toBe("error"); + }); +});