From ebca0d29d0e49e4e35b4f867c7fe2f73fad22113 Mon Sep 17 00:00:00 2001 From: "shinji.sakaguchi" Date: Wed, 2 Jul 2025 15:30:21 +0900 Subject: [PATCH 1/2] feat: update how to generate .mdc files from rulesync/*.md --- src/core/parser.ts | 4 +- src/generators/rules/cursor.test.ts | 221 +++++++++++++++++++++------- src/generators/rules/cursor.ts | 83 +++++++++-- src/parsers/cursor.test.ts | 8 +- src/parsers/cursor.ts | 45 +++++- 5 files changed, 282 insertions(+), 79 deletions(-) diff --git a/src/core/parser.ts b/src/core/parser.ts index b65a35e1f..38501bfcc 100644 --- a/src/core/parser.ts +++ b/src/core/parser.ts @@ -114,9 +114,9 @@ function validateFrontmatter(data: unknown, filepath: string): void { `Missing required field "description" in ${filepath}: must be a descriptive string`, ); } - if (!obj.description || typeof obj.description !== "string") { + if (typeof obj.description !== "string") { throw new Error( - `Invalid "description" field in ${filepath}: must be a non-empty string, got ${typeof obj.description}`, + `Invalid "description" field in ${filepath}: must be a string, got ${typeof obj.description}`, ); } diff --git a/src/generators/rules/cursor.test.ts b/src/generators/rules/cursor.test.ts index 8c7d7db10..ca305e109 100644 --- a/src/generators/rules/cursor.test.ts +++ b/src/generators/rules/cursor.test.ts @@ -13,83 +13,198 @@ describe("generateCursorConfig", () => { }); const mockConfig: Config = { - projectName: "test-project", - rulesDir: ".rulesync", + aiRulesDir: ".rulesync", outputPaths: { copilot: ".github/instructions", cursor: ".cursor/rules", cline: ".clinerules", claudecode: "", + claude: "", roo: ".roo/rules", geminicli: "", }, + watchEnabled: false, + defaultTargets: ["cursor"], }; - const mockRule: ParsedRule = { - frontmatter: { - root: true, - targets: ["cursor"], - description: "Test rule", - globs: ["**/*.ts"], - }, - content: "Test rule content", - filename: "test-rule", - filepath: ".rulesync/test-rule.md", - }; - - it("should generate cursor config files", async () => { - vi.mocked(loadIgnorePatterns).mockResolvedValue({ patterns: [] }); - - const outputs = await generateCursorConfig([mockRule], mockConfig); - - expect(outputs).toHaveLength(1); - expect(outputs[0]).toEqual({ - tool: "cursor", - filepath: ".cursor/rules/test-rule.mdc", - content: expect.stringContaining("description: Test rule"), + describe("rule type generation based on 4 type of .mdc", () => { + it("should generate 'always' type for globs: ['**/*']", async () => { + vi.mocked(loadIgnorePatterns).mockResolvedValue({ patterns: [] }); + + const alwaysRule: ParsedRule = { + frontmatter: { + root: false, + targets: ["*"], + description: "API development rule", + globs: ["**/*"], + }, + content: "# Always Applied Rule\n\nThis rule applies to all files.", + filename: "always-rule", + filepath: ".rulesync/always-rule.md", + }; + + const outputs = await generateCursorConfig([alwaysRule], mockConfig); + + expect(outputs).toHaveLength(1); + expect(outputs[0].content).toContain("description:"); + expect(outputs[0].content).toContain("globs:"); + expect(outputs[0].content).toContain("alwaysApply: true"); + expect(outputs[0].content).toContain("# Always Applied Rule"); }); - }); - it("should generate .cursorignore when .rulesyncignore exists", async () => { - vi.mocked(loadIgnorePatterns).mockResolvedValue({ - patterns: ["*.test.md", "temp/**/*"], + it("should generate 'manual' type for empty description and empty globs", async () => { + vi.mocked(loadIgnorePatterns).mockResolvedValue({ patterns: [] }); + + const manualRule: ParsedRule = { + frontmatter: { + root: false, + targets: ["*"], + description: "", + globs: [], + }, + content: "# Manual Rule\n\nThis rule requires manual application.", + filename: "manual-rule", + filepath: ".rulesync/manual-rule.md", + }; + + const outputs = await generateCursorConfig([manualRule], mockConfig); + + expect(outputs).toHaveLength(1); + expect(outputs[0].content).toContain("description:"); + expect(outputs[0].content).toContain("globs:"); + expect(outputs[0].content).toContain("alwaysApply: false"); + expect(outputs[0].content).toContain("# Manual Rule"); }); - const outputs = await generateCursorConfig([mockRule], mockConfig); - - expect(outputs).toHaveLength(2); + it("should generate 'autoattached' type for empty description and non-empty globs", async () => { + vi.mocked(loadIgnorePatterns).mockResolvedValue({ patterns: [] }); + + const autoAttachedRule: ParsedRule = { + frontmatter: { + root: false, + targets: ["*"], + description: "", + globs: ["**/*.json", "**/*.ts", "**/*.js"], + }, + content: "# Auto Attached Rule\n\nThis rule auto-attaches to specific files.", + filename: "auto-attached-rule", + filepath: ".rulesync/auto-attached-rule.md", + }; + + const outputs = await generateCursorConfig([autoAttachedRule], mockConfig); + + expect(outputs).toHaveLength(1); + expect(outputs[0].content).toContain("description:"); + expect(outputs[0].content).toContain("globs: **/*.json,**/*.ts,**/*.js"); + expect(outputs[0].content).toContain("alwaysApply: false"); + expect(outputs[0].content).toContain("# Auto Attached Rule"); + }); - // Check rule file - expect(outputs[0].filepath).toBe(".cursor/rules/test-rule.mdc"); + it("should generate 'agentrequested' type for non-empty description and empty globs", async () => { + vi.mocked(loadIgnorePatterns).mockResolvedValue({ patterns: [] }); + + const agentRequestedRule: ParsedRule = { + frontmatter: { + root: false, + targets: ["*"], + description: "API development rule", + globs: [], + }, + content: "# Agent Requested Rule\n\nThis rule is applied when requested by agent.", + filename: "agent-requested-rule", + filepath: ".rulesync/agent-requested-rule.md", + }; + + const outputs = await generateCursorConfig([agentRequestedRule], mockConfig); + + expect(outputs).toHaveLength(1); + expect(outputs[0].content).toContain("description: API development rule"); + expect(outputs[0].content).toContain("globs:"); + expect(outputs[0].content).toContain("alwaysApply: false"); + expect(outputs[0].content).toContain("# Agent Requested Rule"); + }); - // Check .cursorignore file - expect(outputs[1]).toEqual({ - tool: "cursor", - filepath: ".cursorignore", - content: expect.stringContaining("# Generated by rulesync from .rulesyncignore"), + it("should handle edge case: non-empty description and non-empty globs (should be agentrequested)", async () => { + vi.mocked(loadIgnorePatterns).mockResolvedValue({ patterns: [] }); + + const edgeCaseRule: ParsedRule = { + frontmatter: { + root: false, + targets: ["*"], + description: "API development rule", + globs: ["**/*.ts"], + }, + content: "# Edge Case Rule\n\nThis has both description and globs.", + filename: "edge-case-rule", + filepath: ".rulesync/edge-case-rule.md", + }; + + const outputs = await generateCursorConfig([edgeCaseRule], mockConfig); + + expect(outputs).toHaveLength(1); + // According to the specification order, this should be 'agentrequested' + // because it doesn't match 'always' (globs != ["**/*"]) or 'manual' (description not empty) + // or 'autoattached' (description not empty), so falls to 'agentrequested' + expect(outputs[0].content).toContain("description: API development rule"); + expect(outputs[0].content).toContain("globs:"); + expect(outputs[0].content).toContain("alwaysApply: false"); }); - expect(outputs[1].content).toContain("*.test.md"); - expect(outputs[1].content).toContain("temp/**/*"); }); - it("should not generate .cursorignore when no ignore patterns exist", async () => { - vi.mocked(loadIgnorePatterns).mockResolvedValue({ patterns: [] }); + describe("ignore file generation", () => { + const testRule: ParsedRule = { + frontmatter: { + root: false, + targets: ["*"], + description: "Test rule", + globs: ["**/*.ts"], + }, + content: "Test rule content", + filename: "test-rule", + filepath: ".rulesync/test-rule.md", + }; + + it("should generate .cursorignore when .rulesyncignore exists", async () => { + vi.mocked(loadIgnorePatterns).mockResolvedValue({ + patterns: ["*.test.md", "temp/**/*"], + }); + + const outputs = await generateCursorConfig([testRule], mockConfig); + + expect(outputs).toHaveLength(2); + + // Check rule file + expect(outputs[0].filepath).toBe(".cursor/rules/test-rule.mdc"); + + // Check .cursorignore file + expect(outputs[1]).toEqual({ + tool: "cursor", + filepath: ".cursorignore", + content: expect.stringContaining("# Generated by rulesync from .rulesyncignore"), + }); + expect(outputs[1].content).toContain("*.test.md"); + expect(outputs[1].content).toContain("temp/**/*"); + }); - const outputs = await generateCursorConfig([mockRule], mockConfig); + it("should not generate .cursorignore when no ignore patterns exist", async () => { + vi.mocked(loadIgnorePatterns).mockResolvedValue({ patterns: [] }); - expect(outputs).toHaveLength(1); - expect(outputs.every((o) => o.filepath !== ".cursorignore")).toBe(true); - }); + const outputs = await generateCursorConfig([testRule], mockConfig); - it("should respect baseDir parameter", async () => { - vi.mocked(loadIgnorePatterns).mockResolvedValue({ - patterns: ["*.test.md"], + expect(outputs).toHaveLength(1); + expect(outputs.every((o) => o.filepath !== ".cursorignore")).toBe(true); }); - const outputs = await generateCursorConfig([mockRule], mockConfig, "/custom/base"); + it("should respect baseDir parameter", async () => { + vi.mocked(loadIgnorePatterns).mockResolvedValue({ + patterns: ["*.test.md"], + }); + + const outputs = await generateCursorConfig([testRule], mockConfig, "/custom/base"); - expect(outputs).toHaveLength(2); - expect(outputs[0].filepath).toBe("/custom/base/.cursor/rules/test-rule.mdc"); - expect(outputs[1].filepath).toBe("/custom/base/.cursorignore"); + expect(outputs).toHaveLength(2); + expect(outputs[0].filepath).toBe("/custom/base/.cursor/rules/test-rule.mdc"); + expect(outputs[1].filepath).toBe("/custom/base/.cursorignore"); + }); }); }); diff --git a/src/generators/rules/cursor.ts b/src/generators/rules/cursor.ts index a2adac29b..8ab209c49 100644 --- a/src/generators/rules/cursor.ts +++ b/src/generators/rules/cursor.ts @@ -44,31 +44,86 @@ export async function generateCursorConfig( function generateCursorMarkdown(rule: ParsedRule): string { const lines: string[] = []; + // Determine rule type based on four kinds of .mdc files + const ruleType = determineCursorRuleType(rule.frontmatter); + // Add MDC header for Cursor lines.push("---"); - lines.push(`description: ${rule.frontmatter.description}`); - if (rule.frontmatter.globs.length > 0) { - lines.push(`globs: ${rule.frontmatter.globs.join(",")}`); - } - // Determine ruletype based on root and globs - let ruletype: string; - if (rule.frontmatter.root === true) { - ruletype = "always"; - } else if (rule.frontmatter.root === false && rule.frontmatter.globs.length === 0) { - ruletype = "agentrequested"; - } else { - ruletype = "autoattached"; + switch (ruleType) { + case "always": + // 1. always: description and globs are empty, alwaysApply: true + lines.push("description:"); + lines.push("globs:"); + lines.push("alwaysApply: true"); + break; + + case "manual": + // 2. manual: keep original empty values, alwaysApply: false + lines.push("description:"); + lines.push("globs:"); + lines.push("alwaysApply: false"); + break; + + case "autoattached": + // 3. auto attached: empty description, globs from original (comma-separated), alwaysApply: false + lines.push("description:"); + lines.push(`globs: ${rule.frontmatter.globs.join(",")}`); + lines.push("alwaysApply: false"); + break; + + case "agentrequested": + // 4. agent_request: description from original, empty globs, alwaysApply: false + lines.push(`description: ${rule.frontmatter.description}`); + lines.push("globs:"); + lines.push("alwaysApply: false"); + break; } - lines.push(`ruletype: ${ruletype}`); lines.push("---"); - + lines.push(""); lines.push(rule.content); return lines.join("\n"); } +/** + * Determine Cursor rule type based on four kinds of .mdc specification + * Order of checking: 1. always → 2. manual → 3. auto attached → 4. agent_request + */ +function determineCursorRuleType( + frontmatter: import("../../types/index.js").RuleFrontmatter, +): string { + const isDescriptionEmpty = !frontmatter.description || frontmatter.description.trim() === ""; + const isGlobsEmpty = frontmatter.globs.length === 0; + const isGlobsExactlyAllFiles = frontmatter.globs.length === 1 && frontmatter.globs[0] === "**/*"; + + // 1. always: globs is exactly ["**/*"] + if (isGlobsExactlyAllFiles) { + return "always"; + } + + // 2. manual: description is empty/undefined AND globs is empty/undefined + if (isDescriptionEmpty && isGlobsEmpty) { + return "manual"; + } + + // 3. auto attached: description is empty/undefined AND globs is non-empty (but not ["**/*"]) + if (isDescriptionEmpty && !isGlobsEmpty) { + return "autoattached"; + } + + // 4. agent request: description is non-empty AND globs is empty/undefined + if (!isDescriptionEmpty && isGlobsEmpty) { + return "agentrequested"; + } + + // Edge case: description is non-empty AND globs is non-empty (but not ["**/*"]) + // According to specification order, this should be treated as "agentrequested" + // because it doesn't match 1, 2, or 3, so it falls to 4 + return "agentrequested"; +} + function generateCursorIgnore(patterns: string[]): string { const lines: string[] = [ "# Generated by rulesync from .rulesyncignore", diff --git a/src/parsers/cursor.test.ts b/src/parsers/cursor.test.ts index 653f9ff54..154ad4a3c 100644 --- a/src/parsers/cursor.test.ts +++ b/src/parsers/cursor.test.ts @@ -45,8 +45,8 @@ This is a test cursor rule content. const rule = result.rules[0]; expect(rule.frontmatter.targets).toEqual(["cursor"]); - expect(rule.frontmatter.description).toBe("Cursor rule: test-rule"); - expect(rule.frontmatter.globs).toEqual(["**/*"]); + expect(rule.frontmatter.description).toBe("Test cursor rule"); + expect(rule.frontmatter.globs).toEqual(["**/*.ts"]); expect(rule.content.trim()).toBe("# Test Cursor Rule\n\nThis is a test cursor rule content."); expect(rule.filename).toBe("cursor-test-rule"); }); @@ -77,7 +77,7 @@ This rule applies to all files using the asterisk wildcard without quotes. const rule = result.rules[0]; expect(rule.frontmatter.targets).toEqual(["cursor"]); - expect(rule.frontmatter.description).toBe("Cursor rule: docs-maintenance"); + expect(rule.frontmatter.description).toBe("Guidelines for maintaining project documentation"); expect(rule.content.trim()).toBe( "# Documentation Maintenance\n\nThis rule applies to all files using the asterisk wildcard without quotes.", ); @@ -153,7 +153,7 @@ This is a legacy .cursorrules file. const rule = result.rules[0]; expect(rule.frontmatter.targets).toEqual(["cursor"]); - expect(rule.frontmatter.description).toBe("Cursor IDE configuration rules"); + expect(rule.frontmatter.description).toBe("Legacy cursor rules"); expect(rule.frontmatter.globs).toEqual(["**/*"]); expect(rule.content.trim()).toBe( "# Legacy Cursor Rules\n\nThis is a legacy .cursorrules file.", diff --git a/src/parsers/cursor.ts b/src/parsers/cursor.ts index 136209cd4..84cbbf592 100644 --- a/src/parsers/cursor.ts +++ b/src/parsers/cursor.ts @@ -56,11 +56,28 @@ export async function parseCursorConfiguration( const content = parsed.content.trim(); if (content) { + // Convert Cursor frontmatter format to rulesync format + const cursorFrontmatter = parsed.data as any; + + // Map Cursor's alwaysApply to rulesync's root (only if explicitly set to true) + const root = cursorFrontmatter && cursorFrontmatter.alwaysApply === true; + + // Use existing values or defaults + let globs = (cursorFrontmatter && (cursorFrontmatter.globs || cursorFrontmatter.glob)) || [ + "**/*", + ]; + // Ensure globs is always an array + if (typeof globs === "string") { + globs = [globs]; + } + const frontmatter: RuleFrontmatter = { - root: false, + root: root, targets: ["cursor"], - description: "Cursor IDE configuration rules", - globs: ["**/*"], + description: + (cursorFrontmatter && cursorFrontmatter.description) || + "Cursor IDE configuration rules", + globs: globs, }; rules.push({ @@ -93,11 +110,27 @@ export async function parseCursorConfiguration( if (content) { const filename = basename(file, ".mdc"); + // Convert Cursor frontmatter format to rulesync format + const cursorFrontmatter = parsed.data as any; + + // Map Cursor's alwaysApply to rulesync's root (only if explicitly set to true) + const root = cursorFrontmatter && cursorFrontmatter.alwaysApply === true; + + // Use existing values or defaults + let globs = (cursorFrontmatter && + (cursorFrontmatter.globs || cursorFrontmatter.glob)) || ["**/*"]; + // Ensure globs is always an array + if (typeof globs === "string") { + globs = [globs]; + } + const frontmatter: RuleFrontmatter = { - root: false, + root: root, targets: ["cursor"], - description: `Cursor rule: ${filename}`, - globs: ["**/*"], + description: + (cursorFrontmatter && cursorFrontmatter.description) || + `Cursor rule: ${filename}`, + globs: globs, }; rules.push({ From 979aa437e9e1712c57f59189f3062428a1e8fff9 Mon Sep 17 00:00:00 2001 From: "shinji.sakaguchi" Date: Wed, 2 Jul 2025 20:02:06 +0900 Subject: [PATCH 2/2] feat: update "import" command which copes cursor .mdc files --- src/parsers/cursor.test.ts | 247 ++++++++++++++++++++++++++++++++++++- src/parsers/cursor.ts | 150 ++++++++++++++++++---- 2 files changed, 369 insertions(+), 28 deletions(-) diff --git a/src/parsers/cursor.test.ts b/src/parsers/cursor.test.ts index 154ad4a3c..b83452400 100644 --- a/src/parsers/cursor.test.ts +++ b/src/parsers/cursor.test.ts @@ -44,9 +44,9 @@ This is a test cursor rule content. expect(result.rules).toHaveLength(1); const rule = result.rules[0]; - expect(rule.frontmatter.targets).toEqual(["cursor"]); + expect(rule.frontmatter.targets).toEqual(["*"]); expect(rule.frontmatter.description).toBe("Test cursor rule"); - expect(rule.frontmatter.globs).toEqual(["**/*.ts"]); + expect(rule.frontmatter.globs).toEqual([]); expect(rule.content.trim()).toBe("# Test Cursor Rule\n\nThis is a test cursor rule content."); expect(rule.filename).toBe("cursor-test-rule"); }); @@ -76,7 +76,7 @@ This rule applies to all files using the asterisk wildcard without quotes. expect(result.rules).toHaveLength(1); const rule = result.rules[0]; - expect(rule.frontmatter.targets).toEqual(["cursor"]); + expect(rule.frontmatter.targets).toEqual(["*"]); expect(rule.frontmatter.description).toBe("Guidelines for maintaining project documentation"); expect(rule.content.trim()).toBe( "# Documentation Maintenance\n\nThis rule applies to all files using the asterisk wildcard without quotes.", @@ -244,4 +244,245 @@ ruletype: always expect(result.errors[1]).toContain("No Cursor configuration files found"); }); }); + + describe("four kinds of .mdc pattern specification compliance", () => { + it("should handle 'always' pattern (alwaysApply: true)", async () => { + const cursorRulesDir = join(testDir, ".cursor", "rules"); + mkdirSync(cursorRulesDir, { recursive: true }); + + const mdcContent = `--- +description: "Any description" +globs: ["**/*.ts", "**/*.js"] +alwaysApply: true +--- + +# Always Applied Rule + +This rule is always applied. +`; + + writeFileSync(join(cursorRulesDir, "always-rule.mdc"), mdcContent); + + const result = await parseCursorConfiguration(testDir); + + expect(result.errors).toEqual([]); + expect(result.rules).toHaveLength(1); + + const rule = result.rules[0]; + expect(rule.frontmatter.root).toBe(false); + expect(rule.frontmatter.targets).toEqual(["*"]); + expect(rule.frontmatter.description).toBe(""); + expect(rule.frontmatter.globs).toEqual(["**/*"]); + expect(rule.filename).toBe("cursor-always-rule"); + }); + + it("should handle 'manual' pattern (empty description and empty globs)", async () => { + const cursorRulesDir = join(testDir, ".cursor", "rules"); + mkdirSync(cursorRulesDir, { recursive: true }); + + const mdcContent = `--- +description: +globs: +alwaysApply: false +--- + +# Manual Rule + +This is a manual rule with no file patterns. +`; + + writeFileSync(join(cursorRulesDir, "manual-rule.mdc"), mdcContent); + + const result = await parseCursorConfiguration(testDir); + + expect(result.errors).toEqual([]); + expect(result.rules).toHaveLength(1); + + const rule = result.rules[0]; + expect(rule.frontmatter.root).toBe(false); + expect(rule.frontmatter.targets).toEqual(["*"]); + expect(rule.frontmatter.description).toBe(""); + expect(rule.frontmatter.globs).toEqual([]); + expect(rule.filename).toBe("cursor-manual-rule"); + }); + + it("should handle 'auto attached' pattern (empty description and non-empty globs)", async () => { + const cursorRulesDir = join(testDir, ".cursor", "rules"); + mkdirSync(cursorRulesDir, { recursive: true }); + + const mdcContent = `--- +description: +globs: **/*.py,**/*.pyc +alwaysApply: false +--- + +# Auto Attached Rule + +This rule is automatically attached to Python files. +`; + + writeFileSync(join(cursorRulesDir, "auto-attached.mdc"), mdcContent); + + const result = await parseCursorConfiguration(testDir); + + expect(result.errors).toEqual([]); + expect(result.rules).toHaveLength(1); + + const rule = result.rules[0]; + expect(rule.frontmatter.root).toBe(false); + expect(rule.frontmatter.targets).toEqual(["*"]); + expect(rule.frontmatter.description).toBe("Cursor rule: auto-attached"); + expect(rule.frontmatter.globs).toEqual(["**/*.py", "**/*.pyc"]); + expect(rule.filename).toBe("cursor-auto-attached"); + }); + + it("should handle 'auto attached' pattern with single glob", async () => { + const cursorRulesDir = join(testDir, ".cursor", "rules"); + mkdirSync(cursorRulesDir, { recursive: true }); + + const mdcContent = `--- +description: +globs: **/*.ts +alwaysApply: false +--- + +# Single Glob Rule + +This rule applies to TypeScript files only. +`; + + writeFileSync(join(cursorRulesDir, "single-glob.mdc"), mdcContent); + + const result = await parseCursorConfiguration(testDir); + + expect(result.errors).toEqual([]); + expect(result.rules).toHaveLength(1); + + const rule = result.rules[0]; + expect(rule.frontmatter.root).toBe(false); + expect(rule.frontmatter.targets).toEqual(["*"]); + expect(rule.frontmatter.description).toBe("Cursor rule: single-glob"); + expect(rule.frontmatter.globs).toEqual(["**/*.ts"]); + expect(rule.filename).toBe("cursor-single-glob"); + }); + + it("should handle 'agent_request' pattern (non-empty description)", async () => { + const cursorRulesDir = join(testDir, ".cursor", "rules"); + mkdirSync(cursorRulesDir, { recursive: true }); + + const mdcContent = `--- +description: "Pythonのコードを書く場合" +globs: +alwaysApply: false +--- + +# Agent Request Rule + +This rule is triggered by agent requests. +`; + + writeFileSync(join(cursorRulesDir, "agent-request.mdc"), mdcContent); + + const result = await parseCursorConfiguration(testDir); + + expect(result.errors).toEqual([]); + expect(result.rules).toHaveLength(1); + + const rule = result.rules[0]; + expect(rule.frontmatter.root).toBe(false); + expect(rule.frontmatter.targets).toEqual(["*"]); + expect(rule.frontmatter.description).toBe("Pythonのコードを書く場合"); + expect(rule.frontmatter.globs).toEqual([]); + expect(rule.filename).toBe("cursor-agent-request"); + }); + + it("should handle edge case: non-empty description and non-empty globs (should be agent_request)", async () => { + const cursorRulesDir = join(testDir, ".cursor", "rules"); + mkdirSync(cursorRulesDir, { recursive: true }); + + const mdcContent = `--- +description: "TypeScript development rules" +globs: **/*.ts +alwaysApply: false +--- + +# Edge Case Rule + +This has both description and globs, but should be treated as agent_request. +`; + + writeFileSync(join(cursorRulesDir, "edge-case.mdc"), mdcContent); + + const result = await parseCursorConfiguration(testDir); + + expect(result.errors).toEqual([]); + expect(result.rules).toHaveLength(1); + + const rule = result.rules[0]; + expect(rule.frontmatter.root).toBe(false); + expect(rule.frontmatter.targets).toEqual(["*"]); + expect(rule.frontmatter.description).toBe("TypeScript development rules"); + expect(rule.frontmatter.globs).toEqual([]); + expect(rule.filename).toBe("cursor-edge-case"); + }); + + it("should handle undefined alwaysApply (should default to false)", async () => { + const cursorRulesDir = join(testDir, ".cursor", "rules"); + mkdirSync(cursorRulesDir, { recursive: true }); + + const mdcContent = `--- +description: +globs: +--- + +# Default AlwaysApply Rule + +This rule has no alwaysApply field. +`; + + writeFileSync(join(cursorRulesDir, "default-always.mdc"), mdcContent); + + const result = await parseCursorConfiguration(testDir); + + expect(result.errors).toEqual([]); + expect(result.rules).toHaveLength(1); + + const rule = result.rules[0]; + expect(rule.frontmatter.root).toBe(false); + expect(rule.frontmatter.targets).toEqual(["*"]); + expect(rule.frontmatter.description).toBe(""); + expect(rule.frontmatter.globs).toEqual([]); + expect(rule.filename).toBe("cursor-default-always"); + }); + + it("should handle empty array globs", async () => { + const cursorRulesDir = join(testDir, ".cursor", "rules"); + mkdirSync(cursorRulesDir, { recursive: true }); + + const mdcContent = `--- +description: +globs: [] +alwaysApply: false +--- + +# Empty Array Globs Rule + +This rule has empty array globs. +`; + + writeFileSync(join(cursorRulesDir, "empty-array.mdc"), mdcContent); + + const result = await parseCursorConfiguration(testDir); + + expect(result.errors).toEqual([]); + expect(result.rules).toHaveLength(1); + + const rule = result.rules[0]; + expect(rule.frontmatter.root).toBe(false); + expect(rule.frontmatter.targets).toEqual(["*"]); + expect(rule.frontmatter.description).toBe(""); + expect(rule.frontmatter.globs).toEqual([]); + expect(rule.filename).toBe("cursor-empty-array"); + }); + }); }); diff --git a/src/parsers/cursor.ts b/src/parsers/cursor.ts index 84cbbf592..862704347 100644 --- a/src/parsers/cursor.ts +++ b/src/parsers/cursor.ts @@ -21,9 +21,18 @@ const customMatterOptions = { yaml: { parse: (str: string): object => { try { - // Preprocess to handle "globs: *" (Cursor's valid format) by adding quotes - // This converts "globs: *" to "globs: \"*\"" for proper YAML parsing - const preprocessed = str.replace(/^(\s*globs:\s*)\*\s*$/gm, '$1"*"'); + // Preprocess to handle Cursor's valid formats: + // 1. "globs: *" (bare asterisk) -> "globs: \"*\"" + // 2. "globs: **/*.ts" (glob patterns without quotes) -> "globs: \"**/*.ts\"" + // 3. "globs: **/*.py,**/*.pyc" (comma-separated patterns) -> "globs: \"**/*.py,**/*.pyc\"" + // Note: Don't process array literals like [] or ["item"] + let preprocessed = str + // Handle bare asterisk + .replace(/^(\s*globs:\s*)\*\s*$/gm, '$1"*"') + // Handle glob patterns without quotes (single or comma-separated) + // But exclude array literals (starting with [ or already quoted strings) + .replace(/^(\s*globs:\s*)([^\s"'[\n][^"'[\n]*?)(\s*)$/gm, '$1"$2"$3'); + return load(preprocessed, { schema: DEFAULT_SCHEMA }) as object; } catch (error) { // If that fails, try with FAILSAFE_SCHEMA as a fallback @@ -39,6 +48,117 @@ const customMatterOptions = { }, }; +/** + * convert from .mdc file to rulesync format according to four kinds of .mdc file format + */ +function convertCursorMdcFrontmatter(cursorFrontmatter: any, filename: string): RuleFrontmatter { + // 用語の定義に従って値を正規化 + const description = normalizeValue(cursorFrontmatter?.description); + const globs = normalizeGlobsValue(cursorFrontmatter?.globs); + const alwaysApply = cursorFrontmatter?.alwaysApply === true; + + // 1. always: alwaysApply: true がある場合 + if (alwaysApply) { + return { + root: false, + targets: ["*"], + description: "", + globs: ["**/*"], + }; + } + + // 2. manual: description空 + globs空 + alwaysApply: false + if (isEmpty(description) && isEmpty(globs)) { + return { + root: false, + targets: ["*"], + description: "", + globs: [], + }; + } + + // 3. auto attached: description空 + globs非空 + alwaysApply: false + if (isEmpty(description) && !isEmpty(globs)) { + return { + root: false, + targets: ["*"], + description: `Cursor rule: ${filename}`, + globs: convertGlobsToArray(globs), + }; + } + + // 4. agent_request: description非空 + alwaysApply: false + if (!isEmpty(description)) { + return { + root: false, + targets: ["*"], + description: description!, + globs: [], + }; + } + + // デフォルト: manual として扱う + return { + root: false, + targets: ["*"], + description: "", + globs: [], + }; +} + +/** + * 値を正規化する(空文字列、未記載、未定義を統一的に扱う) + */ +function normalizeValue(value: any): string | undefined { + if (value === undefined || value === null || value === "") { + return undefined; + } + return String(value); +} + +/** + * globs値を正規化する + */ +function normalizeGlobsValue(value: any): string | string[] | undefined { + if (value === undefined || value === null || value === "") { + return undefined; + } + if (Array.isArray(value)) { + return value.length === 0 ? undefined : value; + } + return String(value); +} + +/** + * 値が空かどうかを判定する + */ +function isEmpty(value: any): boolean { + return value === undefined || value === null || value === ""; +} + +/** + * globs値を配列に変換する + */ +function convertGlobsToArray(globs: string | string[] | undefined): string[] { + if (!globs) { + return []; + } + + if (Array.isArray(globs)) { + return globs; + } + + // カンマ区切りの文字列を配列に変換 + if (typeof globs === "string") { + return globs + .split(",") + .map((g) => g.trim()) + .filter((g) => g.length > 0); + } + + return []; +} + export async function parseCursorConfiguration( baseDir: string = process.cwd(), ): Promise { @@ -110,28 +230,8 @@ export async function parseCursorConfiguration( if (content) { const filename = basename(file, ".mdc"); - // Convert Cursor frontmatter format to rulesync format - const cursorFrontmatter = parsed.data as any; - - // Map Cursor's alwaysApply to rulesync's root (only if explicitly set to true) - const root = cursorFrontmatter && cursorFrontmatter.alwaysApply === true; - - // Use existing values or defaults - let globs = (cursorFrontmatter && - (cursorFrontmatter.globs || cursorFrontmatter.glob)) || ["**/*"]; - // Ensure globs is always an array - if (typeof globs === "string") { - globs = [globs]; - } - - const frontmatter: RuleFrontmatter = { - root: root, - targets: ["cursor"], - description: - (cursorFrontmatter && cursorFrontmatter.description) || - `Cursor rule: ${filename}`, - globs: globs, - }; + // Convert according to four kinds of mdc file format + const frontmatter = convertCursorMdcFrontmatter(parsed.data, filename); rules.push({ frontmatter,