428 lines
14 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|