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
126 changes: 126 additions & 0 deletions src/selfhost/config-lint.ts
Original file line number Diff line number Diff line change
@@ -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<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),
);
}

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<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");
}
213 changes: 213 additions & 0 deletions test/unit/selfhost-config-lint.test.ts
Original file line number Diff line number Diff line change
@@ -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, <blank>, 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");
});
});
Loading