Skip to content

Commit 78df1a1

Browse files
authored
feat(selfhost): add a --json output mode to loopover-config-lint (#5931) (#5979)
scripts/loopover-config-lint.ts was the only config-validation entrypoint in the repo without a machine-readable output mode, even though the SelfHostConfigLintResult it already computes (ok/warnings/recognizedFields/summary) is trivially JSON-serializable — and every sibling CLI (loopover-mcp validate-config/doctor/status) already supports --json. Add a --json flag: main() prints `{ path, ...result }` as pretty JSON instead of the text report, keeping the same exit code (1 when the manifest fails). The serialization is a pure formatLintJson export (directly unit-tested alongside formatLintReport), and a real subprocess CLI test invokes the script with --json for a clean and an unknown-field manifest so the main() wiring can't silently regress. Default text output and the file-read error paths are unchanged.
1 parent ce126e6 commit 78df1a1

2 files changed

Lines changed: 91 additions & 5 deletions

File tree

scripts/loopover-config-lint.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,17 @@ import { lintManifestText, type SelfHostConfigLintResult } from "../src/selfhost
88
import { MAX_FOCUS_MANIFEST_BYTES } from "../src/signals/focus-manifest";
99

1010
function usage(): string {
11-
return `Usage: npm run selfhost:config-lint -- [path]
11+
return `Usage: npm run selfhost:config-lint -- [path] [--json]
1212
1313
Validates a LoopOver focus manifest (.loopover.yml, a per-repo/global self-host
1414
private-config file, or any equivalent YAML/JSON file with the same shape) and reports
1515
unrecognized top-level fields and parser warnings, without echoing any of the file's values.
1616
1717
Options:
18-
path Manifest file to lint. Defaults to ".loopover.yml" in the current directory.`;
18+
path Manifest file to lint. Defaults to ".loopover.yml" in the current directory.
19+
--json Print the lint result as JSON ({ path, ok, warnings, recognizedFields, summary })
20+
instead of the human-readable report, for CI/pre-deploy checks that consume it
21+
programmatically. The exit code is unchanged (1 when the manifest fails validation).`;
1922
}
2023

2124
export function readManifestTextForLint(path: string): string {
@@ -43,14 +46,24 @@ export function formatLintReport(path: string, result: SelfHostConfigLintResult)
4346
return lines.join("\n");
4447
}
4548

49+
// #5931: machine-readable equivalent of formatLintReport for CI/pre-deploy consumers. The result is already a
50+
// fully JSON-serializable SelfHostConfigLintResult; this just prefixes the linted path (like the text report's
51+
// leading `${path}:`) and pretty-prints it. Kept a pure export alongside formatLintReport so it is directly
52+
// unit-tested rather than only exercised through main()'s CLI I/O glue.
53+
export function formatLintJson(path: string, result: SelfHostConfigLintResult): string {
54+
return JSON.stringify({ path, ...result }, null, 2);
55+
}
56+
4657
/* v8 ignore start -- CLI entrypoint (file I/O + process.exit); formatLintReport above carries the tested logic. */
4758
function main(): void {
4859
const args = process.argv.slice(2);
4960
if (args.includes("--help") || args.includes("-h")) {
5061
console.log(usage());
5162
return;
5263
}
53-
const path = args[0] ?? ".loopover.yml";
64+
const jsonMode = args.includes("--json");
65+
// First non-flag argument is the path, so `--json` may appear before or after it. Defaults unchanged.
66+
const path = args.find((arg) => !arg.startsWith("-")) ?? ".loopover.yml";
5467
let text;
5568
try {
5669
text = readManifestTextForLint(path);
@@ -60,7 +73,7 @@ function main(): void {
6073
process.exit(1);
6174
}
6275
const result = lintManifestText(text);
63-
console.log(formatLintReport(path, result));
76+
console.log(jsonMode ? formatLintJson(path, result) : formatLintReport(path, result));
6477
if (!result.ok) process.exit(1);
6578
}
6679

test/unit/loopover-config-lint-script.test.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import { execFileSync } from "node:child_process";
12
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
23
import { tmpdir } from "node:os";
34
import { join } from "node:path";
45
import { describe, expect, it } from "vitest";
5-
import { formatLintReport, readManifestTextForLint } from "../../scripts/loopover-config-lint";
6+
import { formatLintJson, formatLintReport, readManifestTextForLint } from "../../scripts/loopover-config-lint";
67
import { lintManifestText } from "../../src/selfhost/config-lint";
78
import { MAX_FOCUS_MANIFEST_BYTES } from "../../src/signals/focus-manifest";
89

@@ -95,3 +96,75 @@ describe("readManifestTextForLint (#2923 regression)", () => {
9596
});
9697
});
9798
});
99+
100+
describe("formatLintJson (#5931)", () => {
101+
it("serializes a clean manifest to JSON carrying path + the full SelfHostConfigLintResult", () => {
102+
const result = lintManifestText("wantedPaths:\n - src/\n");
103+
const parsed = JSON.parse(formatLintJson(".loopover.yml", result));
104+
expect(parsed).toEqual({
105+
path: ".loopover.yml",
106+
ok: true,
107+
warnings: [],
108+
recognizedFields: ["wantedPaths"],
109+
summary: "Manifest parsed 1 recognized field.",
110+
});
111+
});
112+
113+
it("serializes a manifest with an unknown top-level field, exposing warnings without echoing the raw value", () => {
114+
const result = lintManifestText("unknownSecretKey: super-secret-value\n");
115+
const json = formatLintJson("private-config.yml", result);
116+
const parsed = JSON.parse(json);
117+
expect(parsed.path).toBe("private-config.yml");
118+
expect(parsed.ok).toBe(false);
119+
expect(parsed.recognizedFields).toEqual([]);
120+
expect(parsed.warnings).toContain("Manifest contains unknown top-level field: unknownSecretKey.");
121+
expect(typeof parsed.summary).toBe("string");
122+
// Same secret-redaction contract as the text report (#2906): the raw value never appears in the output.
123+
expect(json).not.toContain("super-secret-value");
124+
});
125+
});
126+
127+
// #5931: a real CLI-invocation test so a future edit can't silently break `--json` main() wiring (the flag/path
128+
// parsing + text-vs-json switch live in main()'s v8-ignored I/O block, so only a subprocess exercises them).
129+
describe("selfhost:config-lint --json CLI (#5931)", () => {
130+
const TSX_BIN = join(process.cwd(), "node_modules", ".bin", "tsx");
131+
function runJson(manifestPath: string): { code: number; parsed: { path: string; ok: boolean; warnings: string[]; recognizedFields: string[]; summary: string } } {
132+
try {
133+
const out = execFileSync(TSX_BIN, ["scripts/loopover-config-lint.ts", manifestPath, "--json"], { encoding: "utf8" });
134+
return { code: 0, parsed: JSON.parse(out) };
135+
} catch (error) {
136+
// A failing manifest exits 1; execFileSync throws but still captures the JSON it printed to stdout.
137+
const e = error as { status?: number; stdout?: string };
138+
return { code: e.status ?? 1, parsed: JSON.parse(e.stdout ?? "{}") };
139+
}
140+
}
141+
function withTempManifest(contents: string, run: (path: string) => void): void {
142+
const dir = mkdtempSync(join(tmpdir(), "loopover-config-lint-json-"));
143+
try {
144+
const path = join(dir, "manifest.yml");
145+
writeFileSync(path, contents);
146+
run(path);
147+
} finally {
148+
rmSync(dir, { recursive: true, force: true });
149+
}
150+
}
151+
152+
it("prints valid JSON with ok/warnings/recognizedFields/summary and exits 0 for a clean manifest", () => {
153+
withTempManifest("wantedPaths:\n - src/\n", (path) => {
154+
const { code, parsed } = runJson(path);
155+
expect(code).toBe(0);
156+
expect(parsed).toMatchObject({ path, ok: true, warnings: [], recognizedFields: ["wantedPaths"] });
157+
expect(typeof parsed.summary).toBe("string");
158+
});
159+
});
160+
161+
it("prints valid JSON with warnings and exits 1 for a manifest with an unknown top-level field", () => {
162+
withTempManifest("unknownSecretKey: super-secret-value\n", (path) => {
163+
const { code, parsed } = runJson(path);
164+
expect(code).toBe(1);
165+
expect(parsed.ok).toBe(false);
166+
expect(parsed.warnings).toContain("Manifest contains unknown top-level field: unknownSecretKey.");
167+
expect(parsed.recognizedFields).toEqual([]);
168+
});
169+
});
170+
});

0 commit comments

Comments
 (0)