diff --git a/src/selfhost/config-lint.ts b/src/selfhost/config-lint.ts new file mode 100644 index 0000000000..7c49f7ba44 --- /dev/null +++ b/src/selfhost/config-lint.ts @@ -0,0 +1,126 @@ +import { parse as parseYaml } from "yaml"; +import { MAX_FOCUS_MANIFEST_BYTES, parseFocusManifestContent } from "../signals/focus-manifest"; + +const TOP_LEVEL_FIELDS = [ + "source", + "wantedPaths", + "blockedPaths", + "preferredLabels", + "linkedIssuePolicy", + "testExpectations", + "issueDiscoveryPolicy", + "maintainerNotes", + "publicNotes", + "gate", + "settings", + "review", + "features", + "contentLane", +] 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), + ); +} + +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 unknown = Object.keys(parsed) + .filter((key) => !TOP_LEVEL_FIELD_SET.has(key)) + .map(formatFieldName); + return 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/test/unit/selfhost-config-lint.test.ts b/test/unit/selfhost-config-lint.test.ts new file mode 100644 index 0000000000..e8f0d3f9a7 --- /dev/null +++ b/test/unit/selfhost-config-lint.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vitest"; +import { lintManifestText } from "../../src/selfhost/config-lint"; +import { MAX_FOCUS_MANIFEST_BYTES } from "../../src/signals/focus-manifest"; + +describe("lintManifestText (#2079)", () => { + it("passes a valid manifest with one recognized field", () => { + expect(lintManifestText("wantedPaths:\n - src/\n")).toEqual({ + ok: true, + warnings: [], + recognizedFields: ["wantedPaths"], + summary: "Manifest parsed 1 recognized field.", + }); + }); + + it("reports every recognized focus field without echoing values", () => { + const result = lintManifestText(` +wantedPaths: [src/private-policy/] +blockedPaths: [dist/] +preferredLabels: [operator-only] +linkedIssuePolicy: required +testExpectations: [unit coverage] +issueDiscoveryPolicy: discouraged +maintainerNotes: [private maintainer note] +publicNotes: [keep reviews focused] +gate: + enabled: false +settings: + commentMode: all_prs +review: + profile: chill +features: + rag: true +contentLane: + entryFileGlob: data/*.json + collectionField: records +`); + + expect(result.ok).toBe(true); + expect(result.warnings).toEqual([]); + expect(result.summary).toBe("Manifest parsed 13 recognized fields."); + expect(result.recognizedFields).toEqual([ + "wantedPaths", + "blockedPaths", + "preferredLabels", + "linkedIssuePolicy", + "testExpectations", + "issueDiscoveryPolicy", + "maintainerNotes", + "publicNotes", + "gate", + "settings", + "review", + "features", + "contentLane", + ]); + expect(JSON.stringify(result)).not.toContain("private maintainer note"); + expect(JSON.stringify(result)).not.toContain("operator-only"); + }); + + it("flags empty or fieldless manifests as not ok", () => { + expect(lintManifestText(undefined)).toEqual({ + ok: false, + warnings: ["Manifest did not define any recognized focus fields."], + recognizedFields: [], + summary: "Manifest has 1 warning.", + }); + + const fieldless = lintManifestText("{}"); + expect(fieldless.ok).toBe(false); + expect(fieldless.recognizedFields).toEqual([]); + expect(fieldless.warnings).toEqual(["Manifest contained no recognized focus fields; falling back to deterministic signals."]); + + const scalar = lintManifestText("null"); + expect(scalar.ok).toBe(false); + expect(scalar.recognizedFields).toEqual([]); + expect(scalar.warnings).toEqual([ + "Manifest must be a mapping of fields; ignoring malformed manifest and falling back to deterministic signals.", + ]); + }); + + it("reports explicit default-valued policy fields as recognized", () => { + expect(lintManifestText("linkedIssuePolicy: optional\n")).toEqual({ + ok: true, + warnings: [], + recognizedFields: ["linkedIssuePolicy"], + summary: "Manifest parsed 1 recognized field.", + }); + + expect(lintManifestText("issueDiscoveryPolicy: neutral\n")).toEqual({ + ok: true, + warnings: [], + recognizedFields: ["issueDiscoveryPolicy"], + summary: "Manifest parsed 1 recognized field.", + }); + }); + + it("accepts source metadata without reporting it as a focus field", () => { + expect(lintManifestText("source: repo_file\n")).toEqual({ + ok: false, + warnings: ["Manifest contained no recognized focus fields; falling back to deterministic signals."], + recognizedFields: [], + summary: "Manifest has 1 warning.", + }); + }); + + it("surfaces malformed YAML without adding synthetic unknown-key warnings", () => { + const result = lintManifestText("wantedPaths: [unterminated"); + + expect(result.ok).toBe(false); + expect(result.recognizedFields).toEqual([]); + expect(result.warnings).toEqual(["Manifest content was not valid YAML; ignoring it and falling back to deterministic signals."]); + expect(result.summary).toBe("Manifest has 1 warning."); + }); + + it("warns on unknown top-level fields by name only", () => { + const result = lintManifestText(` +unknownSecretKey: super-secret-value +"": blank-field-name +"private path": /tmp/private +`); + + expect(result.ok).toBe(false); + expect(result.recognizedFields).toEqual([]); + expect(result.summary).toBe("Manifest has 2 warnings."); + expect(result.warnings).toEqual([ + "Manifest contained no recognized focus fields; falling back to deterministic signals.", + "Manifest contains unknown top-level fields: unknownSecretKey, , private_path.", + ]); + expect(JSON.stringify(result)).not.toContain("super-secret-value"); + expect(JSON.stringify(result)).not.toContain("/tmp/private"); + }); + + it("uses singular wording for one unknown top-level field", () => { + const result = lintManifestText("wantedPaths: [src/]\nunknownSecretKey: super-secret-value\n"); + + expect(result.ok).toBe(false); + expect(result.recognizedFields).toEqual(["wantedPaths"]); + expect(result.warnings).toEqual(["Manifest contains unknown top-level field: unknownSecretKey."]); + expect(JSON.stringify(result)).not.toContain("super-secret-value"); + }); + + it("checks unknown fields in YAML flow mappings that start like JSON", () => { + const result = lintManifestText("{wantedPaths: [src/], unknownSecretKey: secret}"); + + expect(result.ok).toBe(false); + expect(result.recognizedFields).toEqual([]); + expect(result.warnings).toEqual([ + "Manifest content was not valid JSON; ignoring it and falling back to deterministic signals.", + "Manifest contains unknown top-level field: unknownSecretKey.", + ]); + expect(JSON.stringify(result)).not.toContain("secret"); + }); + + it("keeps known JSON manifests quiet and non-object JSON invalid", () => { + expect(lintManifestText(JSON.stringify({ gate: { enabled: true } }))).toMatchObject({ + ok: true, + warnings: [], + recognizedFields: ["gate"], + }); + + const array = lintManifestText("[]"); + expect(array.ok).toBe(false); + expect(array.warnings).toEqual(["Manifest must be a mapping of fields; ignoring malformed manifest and falling back to deterministic signals."]); + }); + + it("redacts supplied config values from parser warnings", () => { + const result = lintManifestText(` +linkedIssuePolicy: sometimes +gate: + enabled: true + aiReview: + provider: secret-provider +review: + profile: secret-profile +`); + + expect(result.ok).toBe(false); + expect(result.recognizedFields).toEqual(["linkedIssuePolicy", "gate", "review"]); + expect(result.warnings.join("\n")).toContain("falling back to the default"); + expect(result.warnings.join("\n")).toContain("ignoring the supplied value"); + expect(JSON.stringify(result)).not.toContain("sometimes"); + expect(JSON.stringify(result)).not.toContain("secret-provider"); + expect(JSON.stringify(result)).not.toContain("secret-profile"); + }); + + it("degrades oversize content without reparsing keys", () => { + const asciiOversize = lintManifestText("a".repeat(MAX_FOCUS_MANIFEST_BYTES + 1)); + expect(asciiOversize.ok).toBe(false); + expect(asciiOversize.warnings).toEqual([ + `Manifest content exceeded ${MAX_FOCUS_MANIFEST_BYTES} bytes; ignoring it and falling back to deterministic signals.`, + ]); + + const utf8Oversize = lintManifestText("é".repeat(Math.floor(MAX_FOCUS_MANIFEST_BYTES / 2) + 1)); + expect(utf8Oversize.ok).toBe(false); + expect(utf8Oversize.warnings).toEqual([ + `Manifest content exceeded ${MAX_FOCUS_MANIFEST_BYTES} bytes; ignoring it and falling back to deterministic signals.`, + ]); + }); + + it("does not reparse padded oversize content after trimming (regression)", () => { + const result = lintManifestText( + `${" ".repeat(MAX_FOCUS_MANIFEST_BYTES + 1)}unknownSecretKey: super-secret-value\n`, + ); + + expect(result.ok).toBe(false); + expect(result.recognizedFields).toEqual([]); + expect(result.warnings).toEqual([ + `Manifest content exceeded ${MAX_FOCUS_MANIFEST_BYTES} bytes; ignoring it and falling back to deterministic signals.`, + ]); + expect(JSON.stringify(result)).not.toContain("unknownSecretKey"); + expect(JSON.stringify(result)).not.toContain("super-secret-value"); + }); +});