Files
suaveplan/packages/devtools/tooling/linter/src/markdown-fixer/markdown-fixer.test.ts
2026-05-27 23:29:53 +01:00

428 lines
14 KiB
TypeScript

import { describe, expect, it } from "@suaveplan/testing/runner";
import { LinterError } from "../error-codes/error-codes.js";
import type { FsAdapter, GlobAdapter } from "../types/linter.types.js";
import { fixMarkdown, processMarkdownFiles } from "./markdown-fixer.js";
function makeFs(seed: Record<string, string>): {
fs: FsAdapter;
store: Map<string, string>;
writes: string[];
} {
const store = new Map<string, string>(Object.entries(seed));
const writes: string[] = [];
const fs: FsAdapter = {
async readFile(path) {
const v = store.get(path);
if (v === undefined) {
throw new Error(`ENOENT: ${path}`);
}
return v;
},
async writeFile(path, data) {
writes.push(path);
store.set(path, data);
},
async exists(path) {
return store.has(path);
},
};
return { fs, store, writes };
}
function makeGlob(files: ReadonlyArray<string>): GlobAdapter {
return {
async find() {
return files;
},
};
}
describe("fixMarkdown", () => {
it("collapses multiple H1s into H2s after the first", {
timeout: 5000,
}, async () => {
const { fs, store } = makeFs({
"a.md": "# A\n\n# B\n\n# C\n",
});
const report = await fixMarkdown("a.md", { fs });
const out = store.get("a.md") ?? "";
expect(out).toContain("# A");
expect(out).toContain("## B");
expect(out).toContain("## C");
expect(report.changed).toBe(true);
expect(report.rulesApplied).toContain("MD025");
});
it("rewrites step-style strong paragraph to H3", {
timeout: 5000,
}, async () => {
const { fs, store } = makeFs({
"a.md": "**Step 1: PLAN**\n\nbody\n",
});
const report = await fixMarkdown("a.md", { fs });
const out = store.get("a.md") ?? "";
expect(out).toContain("### Step 1: PLAN");
expect(out).not.toContain("**Step 1: PLAN**");
expect(report.rulesApplied).toContain("MD036");
});
it("infers bash language for code blocks", { timeout: 5000 }, async () => {
const { fs, store } = makeFs({
"a.md": "```\nnpm install\n```\n",
});
const report = await fixMarkdown("a.md", { fs });
expect(store.get("a.md") ?? "").toContain("```bash");
expect(report.rulesApplied).toContain("MD040");
});
it("infers typescript language for code blocks", {
timeout: 5000,
}, async () => {
const { fs, store } = makeFs({
"a.md": "```\ninterface User { name: string }\n```\n",
});
await fixMarkdown("a.md", { fs });
expect(store.get("a.md") ?? "").toContain("```typescript");
});
it("infers javascript language for code blocks", {
timeout: 5000,
}, async () => {
const { fs, store } = makeFs({
"a.md": "```\nconst x = 1\nfunction y() {}\n```\n",
});
await fixMarkdown("a.md", { fs });
expect(store.get("a.md") ?? "").toContain("```javascript");
});
it("infers python language for code blocks", {
timeout: 5000,
}, async () => {
const { fs, store } = makeFs({
"a.md": "```\ndef hi():\n return 1\n```\n",
});
await fixMarkdown("a.md", { fs });
expect(store.get("a.md") ?? "").toContain("```python");
});
it("infers markdown language for code blocks", {
timeout: 5000,
}, async () => {
const { fs, store } = makeFs({
"a.md": "```\n# Title\n## Sub\n```\n",
});
await fixMarkdown("a.md", { fs });
expect(store.get("a.md") ?? "").toContain("```markdown");
});
it("infers html language for code blocks", { timeout: 5000 }, async () => {
const { fs, store } = makeFs({
"a.md": "```\n<div>x</div>\n```\n",
});
await fixMarkdown("a.md", { fs });
expect(store.get("a.md") ?? "").toContain("```html");
});
it("infers json language for code blocks", { timeout: 5000 }, async () => {
const { fs, store } = makeFs({
"a.md": '```\n{ "a": 1 }\n```\n',
});
await fixMarkdown("a.md", { fs });
expect(store.get("a.md") ?? "").toContain("```json");
});
it("defaults to text language for unrecognised content", {
timeout: 5000,
}, async () => {
const { fs, store } = makeFs({
"a.md": "```\nzzz qqq aaa\n```\n",
});
await fixMarkdown("a.md", { fs });
expect(store.get("a.md") ?? "").toContain("```text");
});
it("infers bash for git commands", { timeout: 5000 }, async () => {
const { fs, store } = makeFs({
"a.md": "```\ngit status\n```\n",
});
await fixMarkdown("a.md", { fs });
expect(store.get("a.md") ?? "").toContain("```bash");
});
it("collapses multiple blank lines", { timeout: 5000 }, async () => {
const { fs, store } = makeFs({
"a.md": "para a\n\n\n\n\npara b\n",
});
const report = await fixMarkdown("a.md", { fs });
const out = store.get("a.md") ?? "";
expect(out).not.toMatch(/\n\n\n/);
expect(report.rulesApplied).toContain("MD012");
});
it("capitalises proper names", { timeout: 5000 }, async () => {
const { fs, store } = makeFs({
"a.md": "This is a json api over http.\n",
});
const report = await fixMarkdown("a.md", { fs });
const out = store.get("a.md") ?? "";
expect(out).toContain("JSON");
expect(out).toContain("API");
expect(out).toContain("HTTP");
expect(report.rulesApplied).toContain("MD044");
});
it("ensures trailing single newline", { timeout: 5000 }, async () => {
const { fs, store } = makeFs({
"a.md": "para a\n\n\n",
});
await fixMarkdown("a.md", { fs });
const out = store.get("a.md") ?? "";
expect(out.endsWith("\n")).toBe(true);
expect(out.endsWith("\n\n")).toBe(false);
});
it("appends trailing newline when missing", { timeout: 5000 }, async () => {
// remark-stringify on an EMPTY document yields the empty string,
// which is the only path that exercises the "append" branch of
// postProcess.
const { fs, store } = makeFs({ "a.md": "" });
await fixMarkdown("a.md", { fs });
const out = store.get("a.md") ?? "";
expect(out).toBe("\n");
});
it("rewrites bold heading-style line with backticks", {
timeout: 5000,
}, async () => {
const { fs, store } = makeFs({
"a.md": "**Plain**\n\nbody\n",
});
const report = await fixMarkdown("a.md", { fs });
const out = store.get("a.md") ?? "";
expect(out).toContain("**`Plain`**");
expect(report.rulesApplied).toContain("MD036");
});
it("returns changed=false when content is already canonical", {
timeout: 5000,
}, async () => {
// First normalise — second run should be idempotent.
const { fs, store, writes } = makeFs({
"a.md": "# A\n",
});
await fixMarkdown("a.md", { fs });
const writesAfterFirst = writes.length;
const after = store.get("a.md") ?? "";
// Re-seed with the canonical output and check changed=false.
store.set("a.md", after);
const report = await fixMarkdown("a.md", { fs });
expect(report.changed).toBe(false);
expect(writes.length).toBe(writesAfterFirst);
});
it("raises FILE_NOT_FOUND when the path does not exist", {
timeout: 5000,
}, async () => {
const { fs } = makeFs({});
await expect(fixMarkdown("missing.md", { fs })).rejects.toThrow(
LinterError
);
await expect(fixMarkdown("missing.md", { fs })).rejects.toMatchObject({
code: "LINTER.FILE_NOT_FOUND",
});
});
it("raises READ_FAILED when the FsAdapter throws on read", {
timeout: 5000,
}, async () => {
const fs: FsAdapter = {
async readFile() {
throw new Error("EPERM");
},
async writeFile() {},
async exists() {
return true;
},
};
await expect(fixMarkdown("a.md", { fs })).rejects.toMatchObject({
code: "LINTER.READ_FAILED",
});
});
it("raises WRITE_FAILED when the FsAdapter throws on write", {
timeout: 5000,
}, async () => {
const fs: FsAdapter = {
async readFile() {
return "# A\n# B\n";
},
async writeFile() {
throw new Error("EROFS");
},
async exists() {
return true;
},
};
await expect(fixMarkdown("a.md", { fs })).rejects.toMatchObject({
code: "LINTER.WRITE_FAILED",
});
});
it("raises MARKDOWN_PARSE_FAILED when readFile returns a non-string", {
timeout: 5000,
}, async () => {
const fs: FsAdapter = {
async readFile() {
// Return an object that explodes in remark's
// process pipeline — `toString` throws so the
// VFile constructor cannot stringify.
const poisoned = {
toString() {
throw new TypeError("unstringifiable");
},
};
return poisoned as unknown as string;
},
async writeFile() {},
async exists() {
return true;
},
};
await expect(fixMarkdown("a.md", { fs })).rejects.toMatchObject({
code: "LINTER.MARKDOWN_PARSE_FAILED",
});
});
it("uses nodeFsAdapter when no fs is supplied", {
timeout: 5000,
}, async () => {
await expect(
fixMarkdown("/definitely-not-here-12345.md")
).rejects.toMatchObject({ code: "LINTER.FILE_NOT_FOUND" });
});
it("nodeFsAdapter exercises readFile / writeFile / exists with a temp file", {
timeout: 5000,
}, async () => {
const { nodeFsAdapter } = await import("./markdown-fixer.js");
const tmp = await import("node:fs/promises");
const path = await import("node:path");
const dir = await tmp.mkdtemp(path.join(process.cwd(), ".linter-tmp-"));
const file = path.join(dir, "a.md");
await tmp.writeFile(file, "# A\n# B\n", "utf-8");
try {
expect(await nodeFsAdapter.exists(file)).toBe(true);
expect(await nodeFsAdapter.exists(`${file}.missing`)).toBe(false);
expect(await nodeFsAdapter.readFile(file)).toBe("# A\n# B\n");
await nodeFsAdapter.writeFile(file, "# X\n");
expect(await nodeFsAdapter.readFile(file)).toBe("# X\n");
const report = await fixMarkdown(file);
expect(report.file).toBe(file);
} finally {
await tmp.rm(dir, { recursive: true });
}
});
});
describe("processMarkdownFiles", () => {
it("walks a glob and returns per-file reports", {
timeout: 5000,
}, async () => {
const { fs } = makeFs({
"a.md": "# A\n# A2\n",
"b.md": "# B\n",
});
const glob = makeGlob(["a.md", "b.md"]);
// Pre-normalise b.md so it lands as unchanged
await fixMarkdown("b.md", { fs });
const reports = await processMarkdownFiles({
patterns: ["**/*.md"],
fs,
glob,
});
expect(reports).toHaveLength(2);
expect(reports[0]?.file).toBe("a.md");
expect(reports[1]?.file).toBe("b.md");
expect(reports[0]?.changed).toBe(true);
expect(reports[1]?.changed).toBe(false);
});
it("defaults to **/*.md when patterns is empty", {
timeout: 5000,
}, async () => {
const { fs } = makeFs({ "a.md": "# A\n" });
let captured = "";
const glob: GlobAdapter = {
async find(pattern) {
captured = pattern;
return ["a.md"];
},
};
await processMarkdownFiles({ patterns: [], fs, glob });
expect(captured).toBe("**/*.md");
});
it("raises NO_TARGETS_FOUND when nothing matches", {
timeout: 5000,
}, async () => {
const { fs } = makeFs({});
const glob = makeGlob([]);
await expect(
processMarkdownFiles({
patterns: ["nope/*.md"],
fs,
glob,
})
).rejects.toMatchObject({ code: "LINTER.NO_TARGETS_FOUND" });
});
it("passes patterns through when supplied without glob override", {
timeout: 5000,
}, async () => {
// Exercise the `patterns:` spread branch (non-empty patterns).
const { fs } = makeFs({ "a.md": "# A\n" });
const glob = makeGlob(["a.md"]);
const reports = await processMarkdownFiles({
patterns: ["**/*.md"],
fs,
glob,
});
expect(reports).toHaveLength(1);
});
it("uses default cwd when not supplied", { timeout: 5000 }, async () => {
const { fs } = makeFs({ "a.md": "# A\n" });
let capturedCwd = "";
const glob: GlobAdapter = {
async find(_pattern, opts) {
capturedCwd = opts.cwd;
return ["a.md"];
},
};
await processMarkdownFiles({ fs, glob });
expect(capturedCwd).toBe(".");
});
it("uses default options object when called with no args", {
timeout: 5000,
}, async () => {
// Run from a temp dir with one .md file so all defaults
// (fs / glob / cwd / patterns) take their fallback branches.
const tmp = await import("node:fs/promises");
const path = await import("node:path");
const dir = await tmp.mkdtemp(
path.join(process.cwd(), ".linter-default-")
);
const before = process.cwd();
try {
process.chdir(dir);
await tmp.writeFile(path.join(dir, "a.md"), "# A\n", "utf-8");
const reports = await processMarkdownFiles();
expect(reports.length).toBeGreaterThanOrEqual(1);
} finally {
process.chdir(before);
await tmp.rm(dir, { recursive: true });
}
});
});