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
30 changes: 26 additions & 4 deletions scripts/gittensory-config-lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
// 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 { lstatSync, readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { lintManifestText, type SelfHostConfigLintResult } from "../src/selfhost/config-lint";
import { MAX_FOCUS_MANIFEST_BYTES } from "../src/signals/focus-manifest";

function usage(): string {
return `Usage: npm run selfhost:config-lint -- [path]
Expand All @@ -17,6 +18,24 @@ Options:
path Manifest file to lint. Defaults to ".gittensory.yml" in the current directory.`;
}

export function readManifestTextForLint(path: string): string {
let stat;
try {
stat = lstatSync(path);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
throw new Error(`no such file: ${path}`);
}
throw error;
}
if (stat.isSymbolicLink()) throw new Error(`refusing to read symlink: ${path}`);
if (!stat.isFile()) throw new Error(`not a regular file: ${path}`);
if (stat.size > MAX_FOCUS_MANIFEST_BYTES) {
throw new Error(`file exceeds ${MAX_FOCUS_MANIFEST_BYTES} bytes: ${path}`);
}
return readFileSync(path, "utf8");
}

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(", ")}`);
Expand All @@ -32,11 +51,14 @@ function main(): void {
return;
}
const path = args[0] ?? ".gittensory.yml";
if (!existsSync(path)) {
console.error(`gittensory-config-lint: no such file: ${path}\n\n${usage()}`);
let text;
try {
text = readManifestTextForLint(path);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`gittensory-config-lint: ${message}\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);
Expand Down
63 changes: 62 additions & 1 deletion test/unit/gittensory-config-lint-script.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
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 } from "../../scripts/gittensory-config-lint";
import { formatLintReport, readManifestTextForLint } from "../../scripts/gittensory-config-lint";
import { lintManifestText } from "../../src/selfhost/config-lint";
import { MAX_FOCUS_MANIFEST_BYTES } from "../../src/signals/focus-manifest";

describe("formatLintReport (#2906)", () => {
it("reports a valid manifest's summary and recognized fields, no warnings", () => {
Expand Down Expand Up @@ -34,3 +38,60 @@ describe("formatLintReport (#2906)", () => {
);
});
});

describe("readManifestTextForLint (#2923 regression)", () => {
function withTempDir(run: (dir: string) => void): void {
const dir = mkdtempSync(join(tmpdir(), "gittensory-config-lint-"));
try {
run(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

it("reads a regular manifest file at or below the parser byte limit", () => {
withTempDir((dir) => {
const path = join(dir, "manifest.yml");
writeFileSync(path, "wantedPaths:\n - src/\n");

expect(readManifestTextForLint(path)).toBe("wantedPaths:\n - src/\n");
});
});

it("rejects missing paths before attempting to read", () => {
withTempDir((dir) => {
const path = join(dir, "missing.yml");

expect(() => readManifestTextForLint(path)).toThrow(`no such file: ${path}`);
});
});

it("rejects symlinks so repository-controlled manifests cannot target special files", () => {
withTempDir((dir) => {
const target = join(dir, "target.yml");
const link = join(dir, "manifest.yml");
writeFileSync(target, "wantedPaths:\n - src/\n");
symlinkSync(target, link);

expect(() => readManifestTextForLint(link)).toThrow(`refusing to read symlink: ${link}`);
});
});

it("rejects non-regular files before reading", () => {
withTempDir((dir) => {
const manifestDir = join(dir, "manifest.yml");
mkdirSync(manifestDir);

expect(() => readManifestTextForLint(manifestDir)).toThrow(`not a regular file: ${manifestDir}`);
});
});

it("rejects oversized regular files before loading their contents", () => {
withTempDir((dir) => {
const path = join(dir, "manifest.yml");
writeFileSync(path, "a".repeat(MAX_FOCUS_MANIFEST_BYTES + 1));

expect(() => readManifestTextForLint(path)).toThrow(`file exceeds ${MAX_FOCUS_MANIFEST_BYTES} bytes: ${path}`);
});
});
});