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
22 changes: 22 additions & 0 deletions __tests__/keep-lint-item.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
79 changes: 79 additions & 0 deletions __tests__/lint-worker.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
230 changes: 230 additions & 0 deletions __tests__/report-execution-errors.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import {
emitExecutionErrorsAndSetExitCode,
getExecutionErrorWarnings,
hasExecutionErrors,
reportExecutionErrors,
} 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([]);
});
});

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);
});
});
});
Loading