diff --git a/package.json b/package.json index 8d5c49b206..6e84b405ad 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "selfhost:env-reference": "node scripts/gen-selfhost-env-reference.mjs", "selfhost:env-reference:check": "node scripts/gen-selfhost-env-reference.mjs --check", "selfhost:validate-observability": "node scripts/validate-observability-configs.mjs", + "selfhost:config-lint": "tsx scripts/gittensory-config-lint.ts", "cf-typegen": "wrangler types && perl -pi -e 's/[[:blank:]]+$//' worker-configuration.d.ts", "cf-typegen:check": "wrangler types --check", "db:migrate:local": "wrangler d1 migrations apply gittensory --local", diff --git a/scripts/gittensory-config-lint.ts b/scripts/gittensory-config-lint.ts new file mode 100644 index 0000000000..1a708a2df2 --- /dev/null +++ b/scripts/gittensory-config-lint.ts @@ -0,0 +1,46 @@ +#!/usr/bin/env tsx +// Wires up the previously-unwired src/selfhost/config-lint.ts validator (#2906): a self-hoster (or the +// maintainer, dogfooding on JSONbored/gittensory) can now actually run it against a real .gittensory.yml or +// private-config file and get actionable feedback, instead of the validator existing only in its own test suite. +import { existsSync, readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { lintManifestText, type SelfHostConfigLintResult } from "../src/selfhost/config-lint"; + +function usage(): string { + return `Usage: npm run selfhost:config-lint -- [path] + +Validates a Gittensory focus manifest (.gittensory.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 ".gittensory.yml" in the current directory.`; +} + +export function formatLintReport(path: string, result: SelfHostConfigLintResult): string { + const lines = [`${path}: ${result.summary}`]; + if (result.recognizedFields.length > 0) lines.push(` recognized fields: ${result.recognizedFields.join(", ")}`); + for (const warning of result.warnings) lines.push(` - ${warning}`); + return lines.join("\n"); +} + +/* 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] ?? ".gittensory.yml"; + if (!existsSync(path)) { + console.error(`gittensory-config-lint: no such file: ${path}\n\n${usage()}`); + process.exit(1); + } + const text = readFileSync(path, "utf8"); + const result = lintManifestText(text); + console.log(formatLintReport(path, result)); + if (!result.ok) process.exit(1); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); +/* v8 ignore stop */ diff --git a/test/unit/gittensory-config-lint-script.test.ts b/test/unit/gittensory-config-lint-script.test.ts new file mode 100644 index 0000000000..366065284b --- /dev/null +++ b/test/unit/gittensory-config-lint-script.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { formatLintReport } from "../../scripts/gittensory-config-lint"; +import { lintManifestText } from "../../src/selfhost/config-lint"; + +describe("formatLintReport (#2906)", () => { + it("reports a valid manifest's summary and recognized fields, no warnings", () => { + const result = lintManifestText("wantedPaths:\n - src/\n"); + expect(formatLintReport(".gittensory.yml", result)).toBe( + ".gittensory.yml: Manifest parsed 1 recognized field.\n recognized fields: wantedPaths", + ); + }); + + it("reports warnings without a recognized-fields line when none are recognized", () => { + const result = lintManifestText("unknownSecretKey: super-secret-value\n"); + expect(formatLintReport(".gittensory.yml", result)).toBe( + [ + ".gittensory.yml: Manifest has 2 warnings.", + " - Manifest contained no recognized focus fields; falling back to deterministic signals.", + " - Manifest contains unknown top-level field: unknownSecretKey.", + ].join("\n"), + ); + // Never echoes the raw supplied value into the report (#2906 dogfoods config-lint's own secret-redaction). + expect(formatLintReport(".gittensory.yml", result)).not.toContain("super-secret-value"); + }); + + it("reports both recognized fields and warnings together for a partially-valid manifest", () => { + const result = lintManifestText("wantedPaths: [src/]\nunknownSecretKey: super-secret-value\n"); + expect(formatLintReport("private-config.yml", result)).toBe( + [ + "private-config.yml: Manifest has 1 warning.", + " recognized fields: wantedPaths", + " - Manifest contains unknown top-level field: unknownSecretKey.", + ].join("\n"), + ); + }); +});