From 49af833129d1999a80a15a42d8b0f3e3805e2bce Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sat, 22 Aug 2026 16:16:24 +0800 Subject: [PATCH] fix(cli): reject file-only options with --stdin --- __tests__/cli.spec.ts | 80 ++++++++++++++++++++++++++++ __tests__/threads-validation.spec.ts | 35 +++++++----- src/lint-md.ts | 25 +++++++++ 3 files changed, 127 insertions(+), 13 deletions(-) diff --git a/__tests__/cli.spec.ts b/__tests__/cli.spec.ts index f7f84ac..0e67430 100644 --- a/__tests__/cli.spec.ts +++ b/__tests__/cli.spec.ts @@ -176,4 +176,84 @@ describe("cli tests", () => { expect(runFileLint).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); }); + + test("rejects --threads combined with --stdin before validating its value", async () => { + const runFileLint = jest.fn().mockResolvedValue({ exitCode: 0 }); + const runStdinLint = jest.fn().mockReturnValue({ exitCode: 0 }); + jest.doMock("../src/cli/run-lint", () => ({ + runFileLint, + runStdinLint, + })); + const mockError = jest.spyOn(console, "error").mockImplementation(); + const { runCli } = require("../src/lint-md"); + + process.exitCode = undefined; + runCli(["node", "lint-md", "--stdin", "--threads", "abc"]); + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining( + "[lint-md] --threads cannot be used with --stdin." + ) + ); + expect(mockError).not.toHaveBeenCalledWith( + expect.stringContaining("INVALID_THREADS") + ); + expect(runStdinLint).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + test("rejects --max-file-size combined with --stdin", async () => { + const runFileLint = jest.fn().mockResolvedValue({ exitCode: 0 }); + const runStdinLint = jest.fn().mockReturnValue({ exitCode: 0 }); + jest.doMock("../src/cli/run-lint", () => ({ + runFileLint, + runStdinLint, + })); + const mockError = jest.spyOn(console, "error").mockImplementation(); + const { runCli } = require("../src/lint-md"); + + process.exitCode = undefined; + runCli(["node", "lint-md", "--stdin", "--max-file-size", "5mb"]); + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining( + "[lint-md] --max-file-size cannot be used with --stdin." + ) + ); + expect(runStdinLint).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + test("aggregates all file-only options rejected with --stdin", async () => { + const runFileLint = jest.fn().mockResolvedValue({ exitCode: 0 }); + const runStdinLint = jest.fn().mockReturnValue({ exitCode: 0 }); + jest.doMock("../src/cli/run-lint", () => ({ + runFileLint, + runStdinLint, + })); + const mockError = jest.spyOn(console, "error").mockImplementation(); + const { runCli } = require("../src/lint-md"); + + process.exitCode = undefined; + runCli([ + "node", + "lint-md", + "--stdin", + "--threads", + "4", + "--max-file-size", + "5mb", + ]); + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining( + "[lint-md] The following options cannot be used with --stdin:\n--threads\n--max-file-size" + ) + ); + expect(runStdinLint).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); }); diff --git a/__tests__/threads-validation.spec.ts b/__tests__/threads-validation.spec.ts index 15edaff..457c265 100644 --- a/__tests__/threads-validation.spec.ts +++ b/__tests__/threads-validation.spec.ts @@ -7,7 +7,7 @@ const TSX = path.resolve(__dirname, "../node_modules/tsx/dist/cli.mjs"); const CLI = path.resolve(__dirname, "../src/lint-md.ts"); describe("--threads validation across CLI paths", () => { - test("stdin + --threads abc → exit 1 + stderr", () => { + test("stdin + --threads abc → rejected as a conflicting option, exit 1", () => { try { execFileSync( process.execPath, @@ -21,7 +21,9 @@ describe("--threads validation across CLI paths", () => { throw new Error("should have thrown"); } catch (e: any) { expect(e.status).toBe(1); - expect(e.stderr).toContain("--threads must be a positive integer"); + expect(e.stderr).toContain( + "[lint-md] --threads cannot be used with --stdin." + ); } }); @@ -42,17 +44,24 @@ describe("--threads validation across CLI paths", () => { } }); - test("stdin + --threads auto → does not exit 1 (numeric validation skipped)", () => { - const result = execFileSync( - process.execPath, - [TSX, CLI, "--stdin", "--threads", "auto"], - { - input: "# title\n", - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - } - ); - expect(result).toContain("Done in"); + test("stdin + --threads auto → rejected as a conflicting option, exit 1", () => { + try { + execFileSync( + process.execPath, + [TSX, CLI, "--stdin", "--threads", "auto"], + { + input: "# title\n", + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + } + ); + throw new Error("should have thrown"); + } catch (e: any) { + expect(e.status).toBe(1); + expect(e.stderr).toContain( + "[lint-md] --threads cannot be used with --stdin." + ); + } }); test("files + --threads auto → exit 0 on a small markdown file", () => { diff --git a/src/lint-md.ts b/src/lint-md.ts index ce7d896..a7f6555 100644 --- a/src/lint-md.ts +++ b/src/lint-md.ts @@ -92,6 +92,31 @@ export const createProgram = (): Command => { ); } + // --threads and --max-file-size only affect file linting. Reject them + // here instead of silently accepting options that would do nothing. + const conflictingOptions = [ + [threads !== undefined, "--threads"], + [maxFileSize !== undefined, "--max-file-size"], + ] + .filter(([present]) => present) + .map(([, name]) => name); + + if (stdin && conflictingOptions.length === 1) { + throw new CliError( + "CONFLICTING_INPUT", + `[lint-md] ${conflictingOptions[0]} cannot be used with --stdin.` + ); + } + + if (stdin && conflictingOptions.length > 1) { + throw new CliError( + "CONFLICTING_INPUT", + `[lint-md] The following options cannot be used with --stdin:\n${conflictingOptions.join( + "\n" + )}` + ); + } + if (isDev) { console.log(`dev -- version: ${version}, ${new Date().toString()}`); }