import { describe, it, expect, beforeAll, afterAll, beforeEach } from "bun:test"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { generateSkillsForProject, analyzeProject, analyzeManifest, analyzeGuard, analyzeStack, listGeneratedSkills, resolveSkillsOutDir, SkillsPathEscapeError, } from "../index"; let root: string; beforeAll(() => { root = mkdtempSync(join(tmpdir(), "skills-gen-test-")); // A fake Mandu project writeFileSync( join(root, "package.json"), JSON.stringify( { name: "my-proj", version: "0.0.1", dependencies: { "@mandujs/core": "0.22.1", react: "19.0.0", "react-dom": "19.0.0", tailwindcss: "4.0.0", }, devDependencies: { "@playwright/test": "1.40.0", }, engines: { bun: ">=1.3.12" }, }, null, 2, ), ); // Manifest with routes mkdirSync(join(root, ".mandu"), { recursive: true }); writeFileSync( join(root, ".mandu", "manifest.json"), JSON.stringify( { version: 1, routes: [ { id: "/api/users", pattern: "/api/users", kind: "api", methods: ["GET", "POST"], module: "app/api/users/route.ts" }, { id: "/users", pattern: "/users", kind: "page", module: "app/users/page.tsx", componentModule: "app/users/page.tsx" }, { id: "/", pattern: "/", kind: "page", module: "app/page.tsx", componentModule: "app/page.tsx" }, ], }, null, 2, ), ); // Resources mkdirSync(join(root, "shared", "resources"), { recursive: true }); writeFileSync(join(root, "shared", "resources", "user.resource.ts"), "// user"); writeFileSync(join(root, "shared", "resources", "post.resource.ts"), "// post"); // Guard config writeFileSync( join(root, "guard.config.ts"), `export default { preset: "mandu" };`, ); // Guard report with violations writeFileSync( join(root, ".mandu", "guard-report.json"), JSON.stringify( { violations: [ { ruleId: "LAYER_VIOLATION", file: "a.ts", message: "bad layer" }, { ruleId: "LAYER_VIOLATION", file: "b.ts", message: "bad layer" }, { ruleId: "SLOT_NOT_FOUND", file: "c.ts", message: "missing slot" }, ], }, null, 2, ), ); }); afterAll(() => { rmSync(root, { recursive: true, force: true }); }); describe("analyzers", () => { it("analyzeManifest counts routes and samples", () => { const m = analyzeManifest(root); expect(m.present).toBe(true); expect(m.totalRoutes).toBe(3); expect(m.apiRoutes).toBe(1); expect(m.pageRoutes).toBe(2); expect(m.resources).toContain("user"); expect(m.resources).toContain("post"); expect(m.sampleRoutes.length).toBe(3); }); it("analyzeGuard detects preset and top rules", () => { const g = analyzeGuard(root); expect(g.preset).toBe("mandu"); expect(g.reportPresent).toBe(true); expect(g.violationCount).toBe(3); expect(g.topRules?.[0].ruleId).toBe("LAYER_VIOLATION"); expect(g.topRules?.[0].count).toBe(2); }); it("analyzeStack detects stack + Bun runtime", () => { const s = analyzeStack(root); expect(s.manduCore).toBe("0.22.1"); expect(s.hasReact).toBe(true); expect(s.hasTailwind).toBe(true); expect(s.hasPlaywright).toBe(true); expect(s.bunRuntime).toBe(true); }); it("analyzers are safe on an empty dir", () => { const empty = mkdtempSync(join(tmpdir(), "skills-gen-empty-")); try { const m = analyzeManifest(empty); expect(m.present).toBe(false); expect(m.totalRoutes).toBe(0); const g = analyzeGuard(empty); expect(g.reportPresent).toBe(false); const s = analyzeStack(empty); expect(s.hasReact).toBe(false); expect(s.bunRuntime).toBe(false); } finally { rmSync(empty, { recursive: true, force: true }); } }); }); describe("generateSkillsForProject", () => { it("throws when repoRoot is missing", () => { // @ts-expect-error intentional expect(() => generateSkillsForProject({})).toThrow(); }); it("produces glossary / conventions / workflow by default", () => { const result = generateSkillsForProject({ repoRoot: root, regenerate: true }); const ids = result.files.map((f) => f.id); expect(ids).toContain("my-proj-domain-glossary"); expect(ids).toContain("my-proj-conventions"); expect(ids).toContain("my-proj-workflow"); expect(result.files.every((f) => f.written || f.skipped)).toBe(true); }); it("glossary includes manifest resources and sample routes", () => { const result = generateSkillsForProject({ repoRoot: root, regenerate: true }); const glossary = result.files.find((f) => f.id === "my-proj-domain-glossary")!; expect(glossary.content).toContain("user"); expect(glossary.content).toContain("post"); expect(glossary.content).toContain("/api/users"); expect(glossary.content).toContain("Total: 3 routes"); }); it("conventions reflect guard violations", () => { const result = generateSkillsForProject({ repoRoot: root, regenerate: true }); const conv = result.files.find((f) => f.id === "my-proj-conventions")!; expect(conv.content).toContain("Guard preset: **mandu**"); expect(conv.content).toContain("LAYER_VIOLATION"); expect(conv.content).toContain("| 2 |"); }); it("workflow hints at installed Playwright + Tailwind", () => { const result = generateSkillsForProject({ repoRoot: root, regenerate: true }); const wf = result.files.find((f) => f.id === "my-proj-workflow")!; expect(wf.content).toContain("Tailwind"); expect(wf.content).toContain("playwright"); }); it("dry-run writes no files", () => { const result = generateSkillsForProject({ repoRoot: root, regenerate: true, dryRun: true, }); for (const f of result.files) { expect(f.written).toBe(false); expect(f.skipped).toBe(false); } expect(result.dryRun).toBe(true); }); it("skips existing files by default (no regenerate)", () => { // First write generateSkillsForProject({ repoRoot: root, regenerate: true }); // Second without regenerate const second = generateSkillsForProject({ repoRoot: root }); expect(second.files.every((f) => f.skipped)).toBe(true); expect(second.files.every((f) => !f.written)).toBe(true); }); it("regenerate overwrites existing files", () => { generateSkillsForProject({ repoRoot: root, regenerate: true }); // Spec-compliant layout: `//SKILL.md` (Closes #197). const target = join( root, ".claude", "skills", "my-proj-domain-glossary", "SKILL.md", ); writeFileSync(target, "USER_CHANGED"); const res = generateSkillsForProject({ repoRoot: root, regenerate: true }); const glossary = res.files.find((f) => f.id === "my-proj-domain-glossary")!; expect(glossary.written).toBe(true); const content = readFileSync(target, "utf8"); expect(content).not.toContain("USER_CHANGED"); expect(content).toContain("Domain Glossary"); }); it("honors custom outDir", () => { const customRoot = mkdtempSync(join(tmpdir(), "skills-gen-custom-")); try { writeFileSync( join(customRoot, "package.json"), JSON.stringify({ name: "cust" }), ); const custom = join(customRoot, "out"); generateSkillsForProject({ repoRoot: customRoot, outDir: custom, regenerate: true, }); // Spec-compliant layout: `//SKILL.md` (#197). expect( existsSync(join(custom, "cust-domain-glossary", "SKILL.md")), ).toBe(true); } finally { rmSync(customRoot, { recursive: true, force: true }); } }); it("honors kinds filter", () => { const res = generateSkillsForProject({ repoRoot: root, regenerate: true, kinds: ["glossary"], }); expect(res.files.length).toBe(1); expect(res.files[0].id).toBe("my-proj-domain-glossary"); }); it("analyzeProject ties all analyzers together", () => { const p = analyzeProject(root); expect(p.projectName).toBe("my-proj"); expect(p.manifest.totalRoutes).toBe(3); expect(p.guard.preset).toBe("mandu"); expect(p.stack.bunRuntime).toBe(true); }); it("listGeneratedSkills returns files after generation", () => { generateSkillsForProject({ repoRoot: root, regenerate: true }); const paths = listGeneratedSkills(root); expect(paths.length).toBeGreaterThan(0); // Spec-compliant path: `//SKILL.md` (#197). const suffix = join("my-proj-conventions", "SKILL.md"); expect(paths.some((p) => p.endsWith(suffix))).toBe(true); }); it("strips scope from scoped project name", () => { const scoped = mkdtempSync(join(tmpdir(), "skills-gen-scoped-")); try { writeFileSync( join(scoped, "package.json"), JSON.stringify({ name: "@myorg/my-feature" }), ); const res = generateSkillsForProject({ repoRoot: scoped, regenerate: true }); expect(res.analysis.projectName).toBe("my-feature"); expect(res.files.some((f) => f.id === "my-feature-domain-glossary")).toBe(true); } finally { rmSync(scoped, { recursive: true, force: true }); } }); }); describe("skills out-dir containment (Wave R3 L-02)", () => { it("resolveSkillsOutDir accepts a relative subdir", () => { const proj = mkdtempSync(join(tmpdir(), "skills-contain-ok-")); try { const resolved = resolveSkillsOutDir(proj, ".claude/skills"); expect(resolved.startsWith(proj)).toBe(true); } finally { rmSync(proj, { recursive: true, force: true }); } }); it("resolveSkillsOutDir accepts .mandu/skills (relative)", () => { const proj = mkdtempSync(join(tmpdir(), "skills-contain-mandu-")); try { const resolved = resolveSkillsOutDir(proj, ".mandu/skills"); expect(resolved.startsWith(proj)).toBe(true); } finally { rmSync(proj, { recursive: true, force: true }); } }); it("resolveSkillsOutDir rejects ../../etc traversal", () => { const proj = mkdtempSync(join(tmpdir(), "skills-contain-traverse-")); try { expect(() => resolveSkillsOutDir(proj, "../../etc")).toThrow(SkillsPathEscapeError); } finally { rmSync(proj, { recursive: true, force: true }); } }); it("resolveSkillsOutDir rejects a sibling-directory absolute path", () => { const base = mkdtempSync(join(tmpdir(), "skills-contain-sib-")); const sibling = mkdtempSync(join(tmpdir(), "skills-contain-other-")); try { expect(() => resolveSkillsOutDir(base, sibling)).toThrow(SkillsPathEscapeError); } finally { rmSync(base, { recursive: true, force: true }); rmSync(sibling, { recursive: true, force: true }); } }); it("generateSkillsForProject propagates SkillsPathEscapeError for ../../etc", () => { const proj = mkdtempSync(join(tmpdir(), "skills-gen-escape-")); try { writeFileSync(join(proj, "package.json"), JSON.stringify({ name: "x" })); expect(() => generateSkillsForProject({ repoRoot: proj, outDir: "../../etc" }), ).toThrow(SkillsPathEscapeError); } finally { rmSync(proj, { recursive: true, force: true }); } }); it("generateSkillsForProject accepts .mandu/skills and writes there", () => { const proj = mkdtempSync(join(tmpdir(), "skills-gen-relmandu-")); try { writeFileSync(join(proj, "package.json"), JSON.stringify({ name: "relproj" })); const res = generateSkillsForProject({ repoRoot: proj, outDir: ".mandu/skills", regenerate: true, }); const firstPath = res.files[0]?.path; expect(firstPath).toBeDefined(); expect(firstPath!.includes(join(".mandu", "skills"))).toBe(true); expect(firstPath!.startsWith(proj)).toBe(true); } finally { rmSync(proj, { recursive: true, force: true }); } }); });