diff --git a/scripts/loopover-config-lint.ts b/scripts/loopover-config-lint.ts index 6c999390bc..6993788871 100644 --- a/scripts/loopover-config-lint.ts +++ b/scripts/loopover-config-lint.ts @@ -8,14 +8,17 @@ import { lintManifestText, type SelfHostConfigLintResult } from "../src/selfhost import { MAX_FOCUS_MANIFEST_BYTES } from "../src/signals/focus-manifest"; function usage(): string { - return `Usage: npm run selfhost:config-lint -- [path] + return `Usage: npm run selfhost:config-lint -- [path] [--json] Validates a LoopOver focus manifest (.loopover.yml, a per-repo/global self-host private-config file, or any equivalent YAML/JSON file with the same shape) and reports unrecognized top-level fields and parser warnings, without echoing any of the file's values. Options: - path Manifest file to lint. Defaults to ".loopover.yml" in the current directory.`; + path Manifest file to lint. Defaults to ".loopover.yml" in the current directory. + --json Print the lint result as JSON ({ path, ok, warnings, recognizedFields, summary }) + instead of the human-readable report, for CI/pre-deploy checks that consume it + programmatically. The exit code is unchanged (1 when the manifest fails validation).`; } export function readManifestTextForLint(path: string): string { @@ -43,6 +46,14 @@ export function formatLintReport(path: string, result: SelfHostConfigLintResult) return lines.join("\n"); } +// #5931: machine-readable equivalent of formatLintReport for CI/pre-deploy consumers. The result is already a +// fully JSON-serializable SelfHostConfigLintResult; this just prefixes the linted path (like the text report's +// leading `${path}:`) and pretty-prints it. Kept a pure export alongside formatLintReport so it is directly +// unit-tested rather than only exercised through main()'s CLI I/O glue. +export function formatLintJson(path: string, result: SelfHostConfigLintResult): string { + return JSON.stringify({ path, ...result }, null, 2); +} + /* v8 ignore start -- CLI entrypoint (file I/O + process.exit); formatLintReport above carries the tested logic. */ function main(): void { const args = process.argv.slice(2); @@ -50,7 +61,9 @@ function main(): void { console.log(usage()); return; } - const path = args[0] ?? ".loopover.yml"; + const jsonMode = args.includes("--json"); + // First non-flag argument is the path, so `--json` may appear before or after it. Defaults unchanged. + const path = args.find((arg) => !arg.startsWith("-")) ?? ".loopover.yml"; let text; try { text = readManifestTextForLint(path); @@ -60,7 +73,7 @@ function main(): void { process.exit(1); } const result = lintManifestText(text); - console.log(formatLintReport(path, result)); + console.log(jsonMode ? formatLintJson(path, result) : formatLintReport(path, result)); if (!result.ok) process.exit(1); } diff --git a/test/unit/loopover-config-lint-script.test.ts b/test/unit/loopover-config-lint-script.test.ts index fa44651747..422375237e 100644 --- a/test/unit/loopover-config-lint-script.test.ts +++ b/test/unit/loopover-config-lint-script.test.ts @@ -1,8 +1,9 @@ +import { execFileSync } from "node:child_process"; import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { formatLintReport, readManifestTextForLint } from "../../scripts/loopover-config-lint"; +import { formatLintJson, formatLintReport, readManifestTextForLint } from "../../scripts/loopover-config-lint"; import { lintManifestText } from "../../src/selfhost/config-lint"; import { MAX_FOCUS_MANIFEST_BYTES } from "../../src/signals/focus-manifest"; @@ -95,3 +96,75 @@ describe("readManifestTextForLint (#2923 regression)", () => { }); }); }); + +describe("formatLintJson (#5931)", () => { + it("serializes a clean manifest to JSON carrying path + the full SelfHostConfigLintResult", () => { + const result = lintManifestText("wantedPaths:\n - src/\n"); + const parsed = JSON.parse(formatLintJson(".loopover.yml", result)); + expect(parsed).toEqual({ + path: ".loopover.yml", + ok: true, + warnings: [], + recognizedFields: ["wantedPaths"], + summary: "Manifest parsed 1 recognized field.", + }); + }); + + it("serializes a manifest with an unknown top-level field, exposing warnings without echoing the raw value", () => { + const result = lintManifestText("unknownSecretKey: super-secret-value\n"); + const json = formatLintJson("private-config.yml", result); + const parsed = JSON.parse(json); + expect(parsed.path).toBe("private-config.yml"); + expect(parsed.ok).toBe(false); + expect(parsed.recognizedFields).toEqual([]); + expect(parsed.warnings).toContain("Manifest contains unknown top-level field: unknownSecretKey."); + expect(typeof parsed.summary).toBe("string"); + // Same secret-redaction contract as the text report (#2906): the raw value never appears in the output. + expect(json).not.toContain("super-secret-value"); + }); +}); + +// #5931: a real CLI-invocation test so a future edit can't silently break `--json` main() wiring (the flag/path +// parsing + text-vs-json switch live in main()'s v8-ignored I/O block, so only a subprocess exercises them). +describe("selfhost:config-lint --json CLI (#5931)", () => { + const TSX_BIN = join(process.cwd(), "node_modules", ".bin", "tsx"); + function runJson(manifestPath: string): { code: number; parsed: { path: string; ok: boolean; warnings: string[]; recognizedFields: string[]; summary: string } } { + try { + const out = execFileSync(TSX_BIN, ["scripts/loopover-config-lint.ts", manifestPath, "--json"], { encoding: "utf8" }); + return { code: 0, parsed: JSON.parse(out) }; + } catch (error) { + // A failing manifest exits 1; execFileSync throws but still captures the JSON it printed to stdout. + const e = error as { status?: number; stdout?: string }; + return { code: e.status ?? 1, parsed: JSON.parse(e.stdout ?? "{}") }; + } + } + function withTempManifest(contents: string, run: (path: string) => void): void { + const dir = mkdtempSync(join(tmpdir(), "loopover-config-lint-json-")); + try { + const path = join(dir, "manifest.yml"); + writeFileSync(path, contents); + run(path); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + it("prints valid JSON with ok/warnings/recognizedFields/summary and exits 0 for a clean manifest", () => { + withTempManifest("wantedPaths:\n - src/\n", (path) => { + const { code, parsed } = runJson(path); + expect(code).toBe(0); + expect(parsed).toMatchObject({ path, ok: true, warnings: [], recognizedFields: ["wantedPaths"] }); + expect(typeof parsed.summary).toBe("string"); + }); + }); + + it("prints valid JSON with warnings and exits 1 for a manifest with an unknown top-level field", () => { + withTempManifest("unknownSecretKey: super-secret-value\n", (path) => { + const { code, parsed } = runJson(path); + expect(code).toBe(1); + expect(parsed.ok).toBe(false); + expect(parsed.warnings).toContain("Manifest contains unknown top-level field: unknownSecretKey."); + expect(parsed.recognizedFields).toEqual([]); + }); + }); +});