From 962ca1a07a89b99d3430f846cf24fbe029cb087e Mon Sep 17 00:00:00 2001 From: luojiyin1987 Date: Mon, 13 Jul 2026 19:07:52 +0800 Subject: [PATCH 1/4] feat: surface core executionErrors and exit(1) (#96) Adapt @lint-md/core 2.1.5 (core #185), which returns rule execution errors as a structured list (RuleExecutionError[]) instead of throwing or logging implicitly. The CLI previously dropped them, so a crashing rule produced a silent exit(0) and let CI pass. - types.ts: BatchLintItem.executionErrors?: RuleExecutionError[] - lint-worker.ts: pass result.executionErrors through to the caller - batch-lint.ts: keepLintItem now also retains items that carry executionErrors, so the warning + exit(1) have a target - report-execution-errors.ts (new): getExecutionErrorWarnings() emits one stderr line per error (not deduped across rounds/phases) and hasExecutionErrors() for the exit gate. Path / ruleName / nodeType / message are all sanitized. nodeType is omitted when absent. - lint-md.ts: surface executionErrors on every path (stdin lint/fix, file lint/fix) via the dedicated stderr channel, after the report / fixes are written. Exit 1 is taken regardless of --suppress-warnings, since a rule failure is a hard error, not a document-level lint finding. Built on the #98 { allResults, actionableResults } result shape; the helper mirrors report-incomplete-fixes.ts. Tests: lint-worker mock passthrough, keepLintItem retention, and report-execution-errors behaviour (multi-error, missing nodeType, no dedupe, sanitization). --- __tests__/keep-lint-item.spec.ts | 22 ++++ __tests__/lint-worker.spec.ts | 79 +++++++++++++++ __tests__/report-execution-errors.spec.ts | 117 ++++++++++++++++++++++ src/lint-md.ts | 55 ++++++++-- src/types.ts | 5 + src/utils/batch-lint.ts | 9 +- src/utils/lint-worker.ts | 1 + src/utils/report-execution-errors.ts | 40 ++++++++ 8 files changed, 316 insertions(+), 12 deletions(-) create mode 100644 __tests__/lint-worker.spec.ts create mode 100644 __tests__/report-execution-errors.spec.ts create mode 100644 src/utils/report-execution-errors.ts diff --git a/__tests__/keep-lint-item.spec.ts b/__tests__/keep-lint-item.spec.ts index 0a5aa8d..5f07efa 100644 --- a/__tests__/keep-lint-item.spec.ts +++ b/__tests__/keep-lint-item.spec.ts @@ -88,4 +88,26 @@ describe("keepLintItem", () => { test("treats pre-#182 cores (no convergence field) like the old behaviour", () => { expect(keepLintItem(baseItem({}))).toBe(false); }); + + test("keeps items with only execution errors so #96 warning has a target", () => { + expect( + keepLintItem( + baseItem({ + executionErrors: [ + { + ruleName: "r", + message: "boom", + round: 1, + phase: "fix", + }, + ], + }) + ) + ).toBe(true); + }); + + test("drops items without execution errors when nothing else is set", () => { + expect(keepLintItem(baseItem({ executionErrors: [] }))).toBe(false); + expect(keepLintItem(baseItem({ executionErrors: undefined }))).toBe(false); + }); }); diff --git a/__tests__/lint-worker.spec.ts b/__tests__/lint-worker.spec.ts new file mode 100644 index 0000000..d3601bd --- /dev/null +++ b/__tests__/lint-worker.spec.ts @@ -0,0 +1,79 @@ +import { jest } from "@jest/globals"; + +jest.mock("@lint-md/core", () => ({ + lintMarkdown: jest.fn(), +})); + +import { lintMarkdown } from "@lint-md/core"; +import lintWorker from "../src/utils/lint-worker"; +import { writeFile, mkdtemp, rm } from "fs/promises"; +import { tmpdir } from "os"; +import * as path from "path"; + +const mockedLintMarkdown = lintMarkdown as jest.MockedFunction< + typeof lintMarkdown +>; + +describe("lintWorker executionErrors passthrough", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), "lint-worker-exec-")); + mockedLintMarkdown.mockReset(); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + test("passes executionErrors from core through to the result", async () => { + const file = path.join(tmpDir, "doc.md"); + await writeFile(file, "# Title\n", "utf8"); + + const executionErrors = [ + { + ruleName: "no-empty-list", + message: "rule threw", + round: 2, + phase: "fix" as const, + nodeType: "listItem", + }, + ]; + + mockedLintMarkdown.mockReturnValue({ + lintResult: [], + fixedResult: null, + fixableErrorCount: 0, + fixableWarningCount: 0, + executionErrors, + } as any); + + const result = await lintWorker({ + filePath: file, + rules: {}, + isFixMode: false, + }); + + expect(result.executionErrors).toBe(executionErrors); + }); + + test("keeps executionErrors undefined when core returns none", async () => { + const file = path.join(tmpDir, "clean.md"); + await writeFile(file, "# Clean\n", "utf8"); + + mockedLintMarkdown.mockReturnValue({ + lintResult: [], + fixedResult: null, + fixableErrorCount: 0, + fixableWarningCount: 0, + } as any); + + const result = await lintWorker({ + filePath: file, + rules: {}, + isFixMode: false, + }); + + expect(result.executionErrors).toBeUndefined(); + }); +}); diff --git a/__tests__/report-execution-errors.spec.ts b/__tests__/report-execution-errors.spec.ts new file mode 100644 index 0000000..a7dc078 --- /dev/null +++ b/__tests__/report-execution-errors.spec.ts @@ -0,0 +1,117 @@ +import { + getExecutionErrorWarnings, + hasExecutionErrors, +} from "../src/utils/report-execution-errors"; +import type { RuleExecutionError } from "@lint-md/core"; +import type { BatchLintItem } from "../src/types"; + +const makeItem = ( + path: string, + executionErrors?: RuleExecutionError[] +): BatchLintItem => ({ + path, + lintResult: [], + ...(executionErrors ? { executionErrors } : {}), +}); + +describe("report-execution-errors", () => { + describe("hasExecutionErrors", () => { + test("returns true when any item has execution errors", () => { + expect( + hasExecutionErrors([ + makeItem("a.md"), + makeItem("b.md", [ + { ruleName: "r", message: "x", round: 1, phase: "fix" }, + ]), + ]) + ).toBe(true); + }); + + test("returns false when no item has execution errors", () => { + expect(hasExecutionErrors([makeItem("a.md"), makeItem("b.md")])).toBe( + false + ); + }); + + test("returns false for empty input", () => { + expect(hasExecutionErrors([])).toBe(false); + }); + }); + + describe("getExecutionErrorWarnings", () => { + test("emits one line per error, in input order", () => { + const items: BatchLintItem[] = [ + makeItem("a.md", [ + { + ruleName: "rule-a", + message: "boom", + round: 1, + phase: "fix", + nodeType: "listItem", + }, + ]), + makeItem("b.md", [ + { + ruleName: "rule-b", + message: "crash", + round: 3, + phase: "selector", + }, + ]), + ]; + + expect(getExecutionErrorWarnings(items)).toEqual([ + "[lint-md] a.md: rule-a failed in fix (round 1, node listItem): boom", + "[lint-md] b.md: rule-b failed in selector (round 3): crash", + ]); + }); + + test("omits the node segment when nodeType is missing", () => { + const [line] = getExecutionErrorWarnings([ + makeItem("c.md", [ + { ruleName: "r", message: "x", round: 2, phase: "create" }, + ]), + ]); + + expect(line).toBe("[lint-md] c.md: r failed in create (round 2): x"); + expect(line).not.toContain("node undefined"); + expect(line).not.toMatch(/node\s*$/u); + }); + + test("does not deduplicate errors from the same rule across rounds", () => { + const [first, second] = getExecutionErrorWarnings([ + makeItem("d.md", [ + { ruleName: "r", message: "x", round: 1, phase: "fix" }, + { ruleName: "r", message: "x", round: 2, phase: "fix" }, + ]), + ]); + + expect(first).toBe("[lint-md] d.md: r failed in fix (round 1): x"); + expect(second).toBe("[lint-md] d.md: r failed in fix (round 2): x"); + }); + + test("sanitizes path, ruleName, nodeType and message", () => { + const [line] = getExecutionErrorWarnings([ + makeItem("evil\n::error::spoof.md", [ + { + ruleName: "evil-rule\r", + message: "bad\0text", + round: 1, + phase: "fix", + nodeType: "bad\nnode", + }, + ]), + ]); + + expect(line).not.toContain("\n::error::"); + expect(line).toContain("evil"); + expect(line).toContain("spoof.md"); + expect(line).not.toContain("\r"); + expect(line).not.toContain("\0"); + }); + + test("returns no warnings when no item has execution errors", () => { + expect(getExecutionErrorWarnings([makeItem("a.md")])).toEqual([]); + }); + }); +}); diff --git a/src/lint-md.ts b/src/lint-md.ts index 954fa2f..6230179 100644 --- a/src/lint-md.ts +++ b/src/lint-md.ts @@ -27,6 +27,10 @@ import { getFixDevMetrics, getIncompleteFixWarnings, } from "./utils/report-incomplete-fixes"; +import { + getExecutionErrorWarnings, + hasExecutionErrors, +} from "./utils/report-execution-errors"; import { formatCoreError } from "./utils/format-core-error"; program @@ -103,15 +107,22 @@ program fixedResult: result.fixedResult, fixableErrorCount: result.fixableErrorCount, fixableWarningCount: result.fixableWarningCount, + executionErrors: result.executionErrors, }; for (const warning of getIncompleteFixWarnings([stdinItem])) { console.error(warning); } + for (const warning of getExecutionErrorWarnings([stdinItem])) { + console.error(warning); + } if (isDev) { for (const line of getFixDevMetrics([stdinItem])) { console.error(line); } } + if (hasExecutionErrors([stdinItem])) { + process.exit(1); + } return; } catch (e) { const formatted = formatCoreError(e); @@ -127,18 +138,28 @@ program try { const result = lintMarkdown(content, rules, false); + const stdinItem = { + path: "(stdin)", + lintResult: result.lintResult, + fixableErrorCount: result.fixableErrorCount, + fixableWarningCount: result.fixableWarningCount, + executionErrors: result.executionErrors, + }; const { consoleMessage, errorCount, warningCount } = getReportData([ - { - path: "(stdin)", - lintResult: result.lintResult, - fixableErrorCount: result.fixableErrorCount, - fixableWarningCount: result.fixableWarningCount, - }, + stdinItem, ]); console.log(consoleMessage); - if (errorCount > 0 || (!suppressWarnings && warningCount !== 0)) { + for (const warning of getExecutionErrorWarnings([stdinItem])) { + console.error(warning); + } + + if ( + errorCount > 0 || + (!suppressWarnings && warningCount !== 0) || + hasExecutionErrors([stdinItem]) + ) { process.exit(1); } } catch (e) { @@ -201,7 +222,15 @@ program console.log(consoleMessage); - if (errorCount > 0 || (!suppressWarnings && warningCount !== 0)) { + for (const warning of getExecutionErrorWarnings(actionableResults)) { + console.error(warning); + } + + if ( + errorCount > 0 || + (!suppressWarnings && warningCount !== 0) || + hasExecutionErrors(actionableResults) + ) { process.exit(1); } } else { @@ -222,12 +251,22 @@ program for (const warning of getUnappliedFixesWarnings(actionableResults)) { console.error(warning); } + for (const warning of getExecutionErrorWarnings(actionableResults)) { + console.error(warning); + } if (isDev) { for (const line of getFixDevMetrics(allResults)) { console.log(line); } } + + // Rule execution errors (core #185) are hard failures: surface them + // after the fixes are written and fail the CI run regardless of + // --suppress-warnings. + if (hasExecutionErrors(actionableResults)) { + process.exit(1); + } } } catch (e) { const formatted = formatCoreError(e); diff --git a/src/types.ts b/src/types.ts index 22e9526..b982514 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,7 @@ import type { LintMdRulesConfig, LintReportItem, FixedResult, + RuleExecutionError, } from "@lint-md/core"; export type ThreadCount = number | "auto"; @@ -49,4 +50,8 @@ export interface BatchLintItem { fixedResult?: FixedResult | null; fixableErrorCount?: number; fixableWarningCount?: number; + // Per-round, per-phase rule execution errors from @lint-md/core 2.1.5 + // (core #185). CLI surfaces these as stderr warnings and exits 1 + // regardless of --suppress-warnings. + executionErrors?: RuleExecutionError[]; } diff --git a/src/utils/batch-lint.ts b/src/utils/batch-lint.ts index 0827463..f9de7e7 100644 --- a/src/utils/batch-lint.ts +++ b/src/utils/batch-lint.ts @@ -95,13 +95,14 @@ export const resolveAdaptiveConcurrency = async ( // occur without a lint report, so we must not drop it (see #86 / P1-6 // "partially-unfixed is observable", which the #89 stderr warning surfaces). // Also retain items whose fix pass did not fully converge (cycle / max) so -// the #98 stderr warning has a target. Older cores that predate the -// `convergence` field leave it undefined, which is treated as stable and -// filtered as before. +// the #98 stderr warning has a target, and items that carry rule execution +// errors so the #96 stderr warning + exit(1) have a target. Older cores +// that predate these fields leave them undefined and are filtered as before. export const keepLintItem = (item: BatchLintItem): boolean => item.lintResult.length > 0 || Boolean(item.fixedResult?.notAppliedFixes?.length) || - isIncompleteFix(item); + isIncompleteFix(item) || + (item.executionErrors?.length ?? 0) > 0; export interface BatchLintResult { /** Every worker result, including clean files. Used for dev metrics. */ diff --git a/src/utils/lint-worker.ts b/src/utils/lint-worker.ts index c71b350..d214e3f 100644 --- a/src/utils/lint-worker.ts +++ b/src/utils/lint-worker.ts @@ -14,6 +14,7 @@ const lintWorker = async (options: LintWorkerOptions) => { fixedResult: result.fixedResult, fixableErrorCount: result.fixableErrorCount, fixableWarningCount: result.fixableWarningCount, + executionErrors: result.executionErrors, }; }; diff --git a/src/utils/report-execution-errors.ts b/src/utils/report-execution-errors.ts new file mode 100644 index 0000000..3506bcd --- /dev/null +++ b/src/utils/report-execution-errors.ts @@ -0,0 +1,40 @@ +import type { BatchLintItem } from "../types"; +import { sanitizeTerminalText } from "./sanitize-terminal"; + +// @lint-md/core 2.1.5 (core #185) returns rule execution errors as a +// structured list instead of throwing or logging implicitly. CLI surfaces +// them on a dedicated stderr channel (not mixed into getReportData's +// lint/warning counts) and exits 1 regardless of --suppress-warnings, since +// a rule crashing is a hard failure, not a document-level lint finding. + +export const hasExecutionErrors = (items: BatchLintItem[]): boolean => + items.some((item) => (item.executionErrors?.length ?? 0) > 0); + +export const getExecutionErrorWarnings = (items: BatchLintItem[]): string[] => { + const warnings: string[] = []; + + for (const item of items) { + const errors = item.executionErrors; + if (!errors || errors.length === 0) { + continue; + } + + for (const error of errors) { + const { ruleName, message, phase, round, nodeType } = error; + const pathText = sanitizeTerminalText(item.path); + const ruleText = sanitizeTerminalText(ruleName); + const messageText = sanitizeTerminalText(message); + const nodeText = nodeType ? sanitizeTerminalText(nodeType) : undefined; + + const location = nodeText + ? ` (round ${round}, node ${nodeText})` + : ` (round ${round})`; + + warnings.push( + `[lint-md] ${pathText}: ${ruleText} failed in ${phase}${location}: ${messageText}` + ); + } + } + + return warnings; +}; From cda11ded311f934dae99cbd58c2f1c91f9bda1f4 Mon Sep 17 00:00:00 2001 From: luojiyin1987 Date: Mon, 13 Jul 2026 19:31:09 +0800 Subject: [PATCH 2/4] fix(execution-errors): use exitCode instead of process.exit to avoid truncated pipe output P1: the four execution-error exit points previously called process.exit(1) right after writing stdout (stdin --fix) / stderr diagnostics. On POSIX pipes, process.exit() can terminate before stdout/stderr flush, truncating the fixed markdown in and dropping the error diagnostics. Switch to process.exitCode = 1 so the process exits naturally and all buffered I/O completes (Node docs: set process.exitCode rather than calling process.exit()). P2: extract the shared exit-decision into emitExecutionErrorsAndSetExitCode() (diagnostics -> stderr, sets process.exitCode = 1, returns whether errors existed) and route all four entry points (stdin lint/fix, file lint/fix) through it. Add direct unit tests for the contract: writes diagnostics, sets exitCode = 1, no-op when empty, idempotent at 1. The helper keeps the --suppress-warnings bypass and per-round/phase, no-dedupe, sanitized output from reportExecutionErrors(). Verified: npm run build / npm test (152 pass, +6) / npm run lint all green. --- __tests__/report-execution-errors.spec.ts | 113 ++++++++++++++++++++++ src/lint-md.ts | 39 +++----- src/utils/report-execution-errors.ts | 39 ++++++++ 3 files changed, 167 insertions(+), 24 deletions(-) diff --git a/__tests__/report-execution-errors.spec.ts b/__tests__/report-execution-errors.spec.ts index a7dc078..c8d7b99 100644 --- a/__tests__/report-execution-errors.spec.ts +++ b/__tests__/report-execution-errors.spec.ts @@ -1,6 +1,8 @@ import { + emitExecutionErrorsAndSetExitCode, getExecutionErrorWarnings, hasExecutionErrors, + reportExecutionErrors, } from "../src/utils/report-execution-errors"; import type { RuleExecutionError } from "@lint-md/core"; import type { BatchLintItem } from "../src/types"; @@ -114,4 +116,115 @@ describe("report-execution-errors", () => { expect(getExecutionErrorWarnings([makeItem("a.md")])).toEqual([]); }); }); + + describe("reportExecutionErrors", () => { + const makeStream = () => { + const chunks: string[] = []; + const stream = { + write: (chunk: string) => { + chunks.push(chunk); + return true; + }, + } as unknown as NodeJS.WritableStream; + return { stream, chunks }; + }; + + test("writes one line per error to the given stream and returns true", () => { + const { stream, chunks } = makeStream(); + const ok = reportExecutionErrors( + [ + makeItem("a.md", [ + { ruleName: "r", message: "boom", round: 1, phase: "fix" }, + ]), + ], + stream + ); + + expect(ok).toBe(true); + expect(chunks).toEqual([ + "[lint-md] a.md: r failed in fix (round 1): boom\n", + ]); + }); + + test("returns false and writes nothing when there are no errors", () => { + const { stream, chunks } = makeStream(); + const ok = reportExecutionErrors([makeItem("a.md")], stream); + + expect(ok).toBe(false); + expect(chunks).toEqual([]); + }); + + test("does not deduplicate errors from the same rule across rounds", () => { + const { stream, chunks } = makeStream(); + reportExecutionErrors( + [ + makeItem("d.md", [ + { ruleName: "r", message: "x", round: 1, phase: "fix" }, + { ruleName: "r", message: "x", round: 2, phase: "fix" }, + ]), + ], + stream + ); + + expect(chunks).toEqual([ + "[lint-md] d.md: r failed in fix (round 1): x\n", + "[lint-md] d.md: r failed in fix (round 2): x\n", + ]); + }); + }); + + describe("emitExecutionErrorsAndSetExitCode", () => { + const baseError = { + ruleName: "boom", + message: "rule threw", + round: 1, + phase: "fix" as const, + }; + + let exitCodeBefore: number | undefined; + let stderrSpy: jest.SpyInstance; + + beforeEach(() => { + exitCodeBefore = process.exitCode as number | undefined; + process.exitCode = undefined; + stderrSpy = jest + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + process.exitCode = exitCodeBefore; + }); + + test("writes diagnostics to stderr and sets process.exitCode = 1", () => { + const reported = emitExecutionErrorsAndSetExitCode([ + makeItem("a.md", [baseError]), + ]); + + expect(reported).toBe(true); + expect(process.exitCode).toBe(1); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining("[lint-md] a.md: boom failed in fix") + ); + }); + + test("does not set exitCode and returns false when there are no errors", () => { + const reported = emitExecutionErrorsAndSetExitCode([makeItem("a.md")]); + + expect(reported).toBe(false); + expect(process.exitCode).toBeUndefined(); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + test("sets exitCode = 1 even when it is already 1 (idempotent)", () => { + process.exitCode = 1; + const reported = emitExecutionErrorsAndSetExitCode([ + makeItem("a.md", [baseError]), + ]); + + expect(reported).toBe(true); + expect(process.exitCode).toBe(1); + }); + }); }); diff --git a/src/lint-md.ts b/src/lint-md.ts index 6230179..c236ffd 100644 --- a/src/lint-md.ts +++ b/src/lint-md.ts @@ -1,6 +1,10 @@ #!/usr/bin/env node import * as process from "process"; + +const setExitCode = (code: number): void => { + (globalThis as { process?: NodeJS.Process }).process!.exitCode = code; +}; import { readFileSync } from "fs"; import { availableParallelism } from "os"; import { program } from "commander"; @@ -28,7 +32,7 @@ import { getIncompleteFixWarnings, } from "./utils/report-incomplete-fixes"; import { - getExecutionErrorWarnings, + emitExecutionErrorsAndSetExitCode, hasExecutionErrors, } from "./utils/report-execution-errors"; import { formatCoreError } from "./utils/format-core-error"; @@ -112,17 +116,12 @@ program for (const warning of getIncompleteFixWarnings([stdinItem])) { console.error(warning); } - for (const warning of getExecutionErrorWarnings([stdinItem])) { - console.error(warning); - } + emitExecutionErrorsAndSetExitCode([stdinItem]); if (isDev) { for (const line of getFixDevMetrics([stdinItem])) { console.error(line); } } - if (hasExecutionErrors([stdinItem])) { - process.exit(1); - } return; } catch (e) { const formatted = formatCoreError(e); @@ -151,16 +150,14 @@ program console.log(consoleMessage); - for (const warning of getExecutionErrorWarnings([stdinItem])) { - console.error(warning); - } + emitExecutionErrorsAndSetExitCode([stdinItem]); if ( errorCount > 0 || (!suppressWarnings && warningCount !== 0) || hasExecutionErrors([stdinItem]) ) { - process.exit(1); + setExitCode(1); } } catch (e) { const formatted = formatCoreError(e); @@ -222,16 +219,14 @@ program console.log(consoleMessage); - for (const warning of getExecutionErrorWarnings(actionableResults)) { - console.error(warning); - } + emitExecutionErrorsAndSetExitCode(actionableResults); if ( errorCount > 0 || (!suppressWarnings && warningCount !== 0) || hasExecutionErrors(actionableResults) ) { - process.exit(1); + setExitCode(1); } } else { await runTasksWithLimit( @@ -251,9 +246,7 @@ program for (const warning of getUnappliedFixesWarnings(actionableResults)) { console.error(warning); } - for (const warning of getExecutionErrorWarnings(actionableResults)) { - console.error(warning); - } + emitExecutionErrorsAndSetExitCode(actionableResults); if (isDev) { for (const line of getFixDevMetrics(allResults)) { @@ -261,12 +254,10 @@ program } } - // Rule execution errors (core #185) are hard failures: surface them - // after the fixes are written and fail the CI run regardless of - // --suppress-warnings. - if (hasExecutionErrors(actionableResults)) { - process.exit(1); - } + // Rule execution errors (core #185) are hard failures: emitted above + // (after the fixes are written) and failed the CI run regardless of + // --suppress-warnings. emitExecutionErrorsAndSetExitCode already set + // process.exitCode = 1 so the written files and diagnostics flush. } } catch (e) { const formatted = formatCoreError(e); diff --git a/src/utils/report-execution-errors.ts b/src/utils/report-execution-errors.ts index 3506bcd..5b17d0c 100644 --- a/src/utils/report-execution-errors.ts +++ b/src/utils/report-execution-errors.ts @@ -1,3 +1,4 @@ +import process from "process"; import type { BatchLintItem } from "../types"; import { sanitizeTerminalText } from "./sanitize-terminal"; @@ -38,3 +39,41 @@ export const getExecutionErrorWarnings = (items: BatchLintItem[]): string[] => { return warnings; }; + +/** + * Writes one diagnostic line per execution error to the given stream and + * returns true if any errors were reported. Callers should set + * `process.exitCode = 1` (NOT call `process.exit(1)`) so stdout/stderr + * finish flushing on pipe-based stdin flows. + */ +export const reportExecutionErrors = ( + items: BatchLintItem[], + stream: NodeJS.WritableStream = process.stderr +): boolean => { + let wrote = false; + for (const line of getExecutionErrorWarnings(items)) { + stream.write(`${line}\n`); + wrote = true; + } + return wrote; +}; + +/** + * Surfaces execution errors on the default stderr channel and sets + * `process.exitCode = 1` when any are present. Setting the exit code (not + * calling `process.exit(1)`) lets stdout/stderr finish flushing, which + * matters for pipe-based stdin flows such as + * `cat README.md | lint-md --stdin --fix > README.fixed.md`. + * + * Returns true when errors were present so the four CLI entry points can + * also fold this into their existing exit decision without a second pass. + */ +export const emitExecutionErrorsAndSetExitCode = ( + items: BatchLintItem[] +): boolean => { + const reported = reportExecutionErrors(items); + if (reported) { + process.exitCode = 1; + } + return reported; +}; From eb913e61af8cb5330813bf2a89d24958df4c5771 Mon Sep 17 00:00:00 2001 From: luojiyin1987 Date: Mon, 13 Jul 2026 20:09:26 +0800 Subject: [PATCH 3/4] refactor(execution-errors): drop redundant hasExecutionErrors scan and early-return on lint failure Store emitExecutionErrorsAndSetExitCode()'s return value as hasRuleFailures and reuse it in the existing exit decision, instead of re-scanning items with hasExecutionErrors() and re-assigning process.exitCode. Add an early return once the exit code is set so stdin lint and file lint no longer emit the trailing 'Done in ...' timing line after a failure, matching the previous process.exit(1) behaviour. --- src/lint-md.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/lint-md.ts b/src/lint-md.ts index c236ffd..d999500 100644 --- a/src/lint-md.ts +++ b/src/lint-md.ts @@ -31,10 +31,7 @@ import { getFixDevMetrics, getIncompleteFixWarnings, } from "./utils/report-incomplete-fixes"; -import { - emitExecutionErrorsAndSetExitCode, - hasExecutionErrors, -} from "./utils/report-execution-errors"; +import { emitExecutionErrorsAndSetExitCode } from "./utils/report-execution-errors"; import { formatCoreError } from "./utils/format-core-error"; program @@ -150,14 +147,15 @@ program console.log(consoleMessage); - emitExecutionErrorsAndSetExitCode([stdinItem]); + const hasRuleFailures = emitExecutionErrorsAndSetExitCode([stdinItem]); if ( errorCount > 0 || (!suppressWarnings && warningCount !== 0) || - hasExecutionErrors([stdinItem]) + hasRuleFailures ) { setExitCode(1); + return; } } catch (e) { const formatted = formatCoreError(e); @@ -219,14 +217,16 @@ program console.log(consoleMessage); - emitExecutionErrorsAndSetExitCode(actionableResults); + const hasRuleFailures = + emitExecutionErrorsAndSetExitCode(actionableResults); if ( errorCount > 0 || (!suppressWarnings && warningCount !== 0) || - hasExecutionErrors(actionableResults) + hasRuleFailures ) { setExitCode(1); + return; } } else { await runTasksWithLimit( From dec2b7c310baf9e061b398bd66b17e85dc907d0f Mon Sep 17 00:00:00 2001 From: luojiyin1987 Date: Mon, 13 Jul 2026 20:49:40 +0800 Subject: [PATCH 4/4] refactor(execution-errors): early-return on file --fix failure to skip trailing Done line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save emitExecutionErrorsAndSetExitCode()'s return value and return after the dev metrics block when a rule failure is present, so file --fix no longer prints the trailing 'Done in …' timing line on failure — matching the previous process.exit(1) behaviour. Writes, stderr diagnostics, exit code and IO flushing are unaffected. --- src/lint-md.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/lint-md.ts b/src/lint-md.ts index d999500..897cd29 100644 --- a/src/lint-md.ts +++ b/src/lint-md.ts @@ -246,7 +246,8 @@ program for (const warning of getUnappliedFixesWarnings(actionableResults)) { console.error(warning); } - emitExecutionErrorsAndSetExitCode(actionableResults); + const hasRuleFailures = + emitExecutionErrorsAndSetExitCode(actionableResults); if (isDev) { for (const line of getFixDevMetrics(allResults)) { @@ -258,6 +259,11 @@ program // (after the fixes are written) and failed the CI run regardless of // --suppress-warnings. emitExecutionErrorsAndSetExitCode already set // process.exitCode = 1 so the written files and diagnostics flush. + // Early-return so we don't print a trailing "Done in …" on failure, + // matching the previous process.exit(1) behaviour. + if (hasRuleFailures) { + return; + } } } catch (e) { const formatted = formatCoreError(e);