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
21 changes: 17 additions & 4 deletions scripts/loopover-config-lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -43,14 +46,24 @@ 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);
if (args.includes("--help") || args.includes("-h")) {
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);
Expand All @@ -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);
}

Expand Down
75 changes: 74 additions & 1 deletion test/unit/loopover-config-lint-script.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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([]);
});
});
});