Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/loopover-engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
145 changes: 145 additions & 0 deletions packages/loopover-engine/src/config-lint.ts
Original file line number Diff line number Diff line change
@@ -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<string>(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<string, string> = {
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | null {
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: 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 || "<blank>";
}

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");
}
93 changes: 93 additions & 0 deletions packages/loopover-engine/src/focus-manifest-validation.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown> {
const normalized: Record<string, unknown> = {
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;
}
8 changes: 8 additions & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading