import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { lintSkillDir } from "./lint.js"; const VALID_SKILL = `--- name: Test Skill description: A skill for testing the linter version: 1.0.0 category: review platforms: [claude-code] permissions: [] --- ## Instructions This skill does something useful. \`\`\`bash echo "hello" \`\`\` `; function makeTmpDir(): string { const dir = join(tmpdir(), `skill-linter-test-${Date.now()}`); mkdirSync(dir, { recursive: true }); return dir; } describe("lintSkillDir", () => { let dir: string; beforeEach(() => { dir = makeTmpDir(); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); it("passes a valid skill with no violations", () => { writeFileSync(join(dir, "SKILL.md"), VALID_SKILL, "utf-8"); const result = lintSkillDir(dir); expect(result.filesChecked).toBe(1); const errors = result.violations.filter((v) => v.severity === "error"); expect(errors).toHaveLength(0); }); it("errors when SKILL.md has no frontmatter", () => { writeFileSync(join(dir, "SKILL.md"), "No frontmatter here.\n", "utf-8"); const result = lintSkillDir(dir); const errors = result.violations.filter((v) => v.severity === "error"); expect(errors.length).toBeGreaterThan(0); expect(errors[0].rule).toBe("frontmatter"); }); it("errors when version is not semver", () => { const content = VALID_SKILL.replace("version: 1.0.0", "version: v1.0"); writeFileSync(join(dir, "SKILL.md"), content, "utf-8"); const result = lintSkillDir(dir); const semverErr = result.violations.find((v) => v.rule === "semver"); expect(semverErr).toBeDefined(); expect(semverErr?.severity).toBe("error"); }); it("errors when category is not in allowed list", () => { const content = VALID_SKILL.replace( "category: review", "category: made-up-category", ); writeFileSync(join(dir, "SKILL.md"), content, "utf-8"); const result = lintSkillDir(dir); // parseSkillMd validates the category and surfaces it as a frontmatter error const catErr = result.violations.find( (v) => v.rule === "frontmatter" && v.message.includes("category"), ); expect(catErr).toBeDefined(); expect(catErr?.severity).toBe("error"); }); it("warns on em/en dash and fixes it when --fix is passed", () => { const content = VALID_SKILL.replace( "something useful", "something useful — yes", ); writeFileSync(join(dir, "SKILL.md"), content, "utf-8"); const beforeFix = lintSkillDir(dir); const dashWarn = beforeFix.violations.find( (v) => v.rule === "no-fancy-dash", ); expect(dashWarn).toBeDefined(); expect(dashWarn?.severity).toBe("warning"); expect(dashWarn?.fixable).toBe(true); const afterFix = lintSkillDir(dir, { fix: true }); expect(afterFix.fixed).toBeGreaterThan(0); const afterResult = lintSkillDir(dir); const stillDash = afterResult.violations.find( (v) => v.rule === "no-fancy-dash", ); expect(stillDash).toBeUndefined(); }); it("errors when file exceeds 200 lines", () => { const extra = "\n".repeat(202); writeFileSync(join(dir, "SKILL.md"), VALID_SKILL + extra, "utf-8"); const result = lintSkillDir(dir); const lineErr = result.violations.find((v) => v.rule === "line-limit"); expect(lineErr).toBeDefined(); expect(lineErr?.severity).toBe("error"); }); it("warns on missing code fence language and does not fix", () => { const content = VALID_SKILL.replace("```bash", "```"); writeFileSync(join(dir, "SKILL.md"), content, "utf-8"); const result = lintSkillDir(dir); const fenceWarn = result.violations.find( (v) => v.rule === "code-fence-lang", ); expect(fenceWarn).toBeDefined(); expect(fenceWarn?.severity).toBe("warning"); expect(fenceWarn?.fixable).toBe(false); }); it("warns on trailing whitespace and fixes it when --fix is passed", () => { const content = VALID_SKILL.replace( "something useful.", "something useful. ", ); writeFileSync(join(dir, "SKILL.md"), content, "utf-8"); const before = lintSkillDir(dir); const wsWarn = before.violations.find( (v) => v.rule === "no-trailing-whitespace", ); expect(wsWarn).toBeDefined(); lintSkillDir(dir, { fix: true }); const after = lintSkillDir(dir); const stillWs = after.violations.find( (v) => v.rule === "no-trailing-whitespace", ); expect(stillWs).toBeUndefined(); }); it("warns when file does not end with a single newline", () => { writeFileSync(join(dir, "SKILL.md"), VALID_SKILL.trimEnd(), "utf-8"); const result = lintSkillDir(dir); const nlWarn = result.violations.find((v) => v.rule === "trailing-newline"); expect(nlWarn).toBeDefined(); expect(nlWarn?.fixable).toBe(true); }); it("fixes missing trailing newline when --fix is passed", () => { writeFileSync(join(dir, "SKILL.md"), VALID_SKILL.trimEnd(), "utf-8"); lintSkillDir(dir, { fix: true }); const after = lintSkillDir(dir); const nlWarn = after.violations.find((v) => v.rule === "trailing-newline"); expect(nlWarn).toBeUndefined(); }); it("throws when path does not exist", () => { expect(() => lintSkillDir("/nonexistent/path/xyz")).toThrow( "does not exist", ); }); it("throws when no .md files are found", () => { expect(() => lintSkillDir(dir)).toThrow("No .md files found"); }); it("lints multiple .md files in directory", () => { writeFileSync(join(dir, "SKILL.md"), VALID_SKILL, "utf-8"); writeFileSync( join(dir, "MEMORY.md"), "# Memory\n\nSome memory content.\n", "utf-8", ); const result = lintSkillDir(dir); expect(result.filesChecked).toBe(2); }); });