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): { fs: FsAdapter; store: Map; writes: string[]; } { const store = new Map(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): 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
x
\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 }); } }); });