diff --git a/src/hooks/rules-injector/constants.ts b/src/hooks/rules-injector/constants.ts index 12e0467..bd66102 100644 --- a/src/hooks/rules-injector/constants.ts +++ b/src/hooks/rules-injector/constants.ts @@ -14,10 +14,17 @@ export const PROJECT_MARKERS = [ ]; export const PROJECT_RULE_SUBDIRS: [string, string][] = [ + [".github", "instructions"], [".cursor", "rules"], [".claude", "rules"], ]; +export const PROJECT_RULE_FILES: string[] = [ + ".github/copilot-instructions.md", +]; + +export const GITHUB_INSTRUCTIONS_PATTERN = /\.instructions\.md$/; + export const USER_RULE_DIR = ".claude/rules"; export const RULE_EXTENSIONS = [".md", ".mdc"]; diff --git a/src/hooks/rules-injector/finder.test.ts b/src/hooks/rules-injector/finder.test.ts new file mode 100644 index 0000000..0841fad --- /dev/null +++ b/src/hooks/rules-injector/finder.test.ts @@ -0,0 +1,381 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { findProjectRoot, findRuleFiles } from "./finder"; + +describe("findRuleFiles", () => { + const TEST_DIR = join(tmpdir(), `rules-injector-test-${Date.now()}`); + const homeDir = join(TEST_DIR, "home"); + + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + mkdirSync(homeDir, { recursive: true }); + mkdirSync(join(TEST_DIR, ".git"), { recursive: true }); + }); + + afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } + }); + + describe(".github/instructions/ discovery", () => { + it("should discover .github/instructions/*.instructions.md files", () => { + // #given .github/instructions/ with valid files + const instructionsDir = join(TEST_DIR, ".github", "instructions"); + mkdirSync(instructionsDir, { recursive: true }); + writeFileSync( + join(instructionsDir, "typescript.instructions.md"), + "TS rules" + ); + writeFileSync( + join(instructionsDir, "python.instructions.md"), + "PY rules" + ); + + const srcDir = join(TEST_DIR, "src"); + mkdirSync(srcDir, { recursive: true }); + const currentFile = join(srcDir, "index.ts"); + writeFileSync(currentFile, "code"); + + // #when finding rules for a file + const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile); + + // #then should find both instruction files + const paths = candidates.map((c) => c.path); + expect( + paths.some((p) => p.includes("typescript.instructions.md")) + ).toBe(true); + expect(paths.some((p) => p.includes("python.instructions.md"))).toBe( + true + ); + }); + + it("should ignore non-.instructions.md files in .github/instructions/", () => { + // #given .github/instructions/ with invalid files + const instructionsDir = join(TEST_DIR, ".github", "instructions"); + mkdirSync(instructionsDir, { recursive: true }); + writeFileSync( + join(instructionsDir, "valid.instructions.md"), + "valid" + ); + writeFileSync(join(instructionsDir, "invalid.md"), "invalid"); + writeFileSync(join(instructionsDir, "readme.txt"), "readme"); + + const currentFile = join(TEST_DIR, "index.ts"); + writeFileSync(currentFile, "code"); + + // #when finding rules + const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile); + + // #then should only find .instructions.md file + const paths = candidates.map((c) => c.path); + expect(paths.some((p) => p.includes("valid.instructions.md"))).toBe( + true + ); + expect(paths.some((p) => p.endsWith("invalid.md"))).toBe(false); + expect(paths.some((p) => p.includes("readme.txt"))).toBe(false); + }); + + it("should discover nested .instructions.md files in subdirectories", () => { + // #given nested .github/instructions/ structure + const instructionsDir = join(TEST_DIR, ".github", "instructions"); + const frontendDir = join(instructionsDir, "frontend"); + mkdirSync(frontendDir, { recursive: true }); + writeFileSync( + join(frontendDir, "react.instructions.md"), + "React rules" + ); + + const currentFile = join(TEST_DIR, "app.tsx"); + writeFileSync(currentFile, "code"); + + // #when finding rules + const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile); + + // #then should find nested instruction file + const paths = candidates.map((c) => c.path); + expect(paths.some((p) => p.includes("react.instructions.md"))).toBe( + true + ); + }); + }); + + describe(".github/copilot-instructions.md (single file)", () => { + it("should discover copilot-instructions.md at project root", () => { + // #given .github/copilot-instructions.md at root + const githubDir = join(TEST_DIR, ".github"); + mkdirSync(githubDir, { recursive: true }); + writeFileSync( + join(githubDir, "copilot-instructions.md"), + "Global instructions" + ); + + const currentFile = join(TEST_DIR, "index.ts"); + writeFileSync(currentFile, "code"); + + // #when finding rules + const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile); + + // #then should find the single file rule + const singleFile = candidates.find((c) => + c.path.includes("copilot-instructions.md") + ); + expect(singleFile).toBeDefined(); + expect(singleFile?.isSingleFile).toBe(true); + }); + + it("should mark single file rules with isSingleFile: true", () => { + // #given copilot-instructions.md + const githubDir = join(TEST_DIR, ".github"); + mkdirSync(githubDir, { recursive: true }); + writeFileSync( + join(githubDir, "copilot-instructions.md"), + "Instructions" + ); + + const currentFile = join(TEST_DIR, "file.ts"); + writeFileSync(currentFile, "code"); + + // #when finding rules + const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile); + + // #then isSingleFile should be true + const copilotFile = candidates.find((c) => c.isSingleFile); + expect(copilotFile).toBeDefined(); + expect(copilotFile?.path).toContain("copilot-instructions.md"); + }); + + it("should set distance to 0 for single file rules", () => { + // #given copilot-instructions.md at project root + const githubDir = join(TEST_DIR, ".github"); + mkdirSync(githubDir, { recursive: true }); + writeFileSync( + join(githubDir, "copilot-instructions.md"), + "Instructions" + ); + + const srcDir = join(TEST_DIR, "src", "deep", "nested"); + mkdirSync(srcDir, { recursive: true }); + const currentFile = join(srcDir, "file.ts"); + writeFileSync(currentFile, "code"); + + // #when finding rules from deeply nested file + const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile); + + // #then single file should have distance 0 + const copilotFile = candidates.find((c) => c.isSingleFile); + expect(copilotFile?.distance).toBe(0); + }); + }); + + describe("backward compatibility", () => { + it("should still discover .claude/rules/ files", () => { + // #given .claude/rules/ directory + const rulesDir = join(TEST_DIR, ".claude", "rules"); + mkdirSync(rulesDir, { recursive: true }); + writeFileSync(join(rulesDir, "typescript.md"), "TS rules"); + + const currentFile = join(TEST_DIR, "index.ts"); + writeFileSync(currentFile, "code"); + + // #when finding rules + const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile); + + // #then should find claude rules + const paths = candidates.map((c) => c.path); + expect(paths.some((p) => p.includes(".claude/rules/"))).toBe(true); + }); + + it("should still discover .cursor/rules/ files", () => { + // #given .cursor/rules/ directory + const rulesDir = join(TEST_DIR, ".cursor", "rules"); + mkdirSync(rulesDir, { recursive: true }); + writeFileSync(join(rulesDir, "python.md"), "PY rules"); + + const currentFile = join(TEST_DIR, "main.py"); + writeFileSync(currentFile, "code"); + + // #when finding rules + const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile); + + // #then should find cursor rules + const paths = candidates.map((c) => c.path); + expect(paths.some((p) => p.includes(".cursor/rules/"))).toBe(true); + }); + + it("should discover .mdc files in rule directories", () => { + // #given .mdc file in .claude/rules/ + const rulesDir = join(TEST_DIR, ".claude", "rules"); + mkdirSync(rulesDir, { recursive: true }); + writeFileSync(join(rulesDir, "advanced.mdc"), "MDC rules"); + + const currentFile = join(TEST_DIR, "app.ts"); + writeFileSync(currentFile, "code"); + + // #when finding rules + const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile); + + // #then should find .mdc file + const paths = candidates.map((c) => c.path); + expect(paths.some((p) => p.endsWith("advanced.mdc"))).toBe(true); + }); + }); + + describe("mixed sources", () => { + it("should discover rules from all sources", () => { + // #given rules in multiple directories + const claudeRules = join(TEST_DIR, ".claude", "rules"); + const cursorRules = join(TEST_DIR, ".cursor", "rules"); + const githubInstructions = join(TEST_DIR, ".github", "instructions"); + const githubDir = join(TEST_DIR, ".github"); + + mkdirSync(claudeRules, { recursive: true }); + mkdirSync(cursorRules, { recursive: true }); + mkdirSync(githubInstructions, { recursive: true }); + + writeFileSync(join(claudeRules, "claude.md"), "claude"); + writeFileSync(join(cursorRules, "cursor.md"), "cursor"); + writeFileSync( + join(githubInstructions, "copilot.instructions.md"), + "copilot" + ); + writeFileSync(join(githubDir, "copilot-instructions.md"), "global"); + + const currentFile = join(TEST_DIR, "index.ts"); + writeFileSync(currentFile, "code"); + + // #when finding rules + const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile); + + // #then should find all rules + expect(candidates.length).toBeGreaterThanOrEqual(4); + const paths = candidates.map((c) => c.path); + expect(paths.some((p) => p.includes(".claude/rules/"))).toBe(true); + expect(paths.some((p) => p.includes(".cursor/rules/"))).toBe(true); + expect(paths.some((p) => p.includes(".github/instructions/"))).toBe( + true + ); + expect(paths.some((p) => p.includes("copilot-instructions.md"))).toBe( + true + ); + }); + + it("should not duplicate single file rules", () => { + // #given copilot-instructions.md + const githubDir = join(TEST_DIR, ".github"); + mkdirSync(githubDir, { recursive: true }); + writeFileSync( + join(githubDir, "copilot-instructions.md"), + "Instructions" + ); + + const currentFile = join(TEST_DIR, "file.ts"); + writeFileSync(currentFile, "code"); + + // #when finding rules + const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile); + + // #then should only have one copilot-instructions.md entry + const copilotFiles = candidates.filter((c) => + c.path.includes("copilot-instructions.md") + ); + expect(copilotFiles.length).toBe(1); + }); + }); + + describe("user-level rules", () => { + it("should discover user-level .claude/rules/ files", () => { + // #given user-level rules + const userRulesDir = join(homeDir, ".claude", "rules"); + mkdirSync(userRulesDir, { recursive: true }); + writeFileSync(join(userRulesDir, "global.md"), "Global user rules"); + + const currentFile = join(TEST_DIR, "app.ts"); + writeFileSync(currentFile, "code"); + + // #when finding rules + const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile); + + // #then should find user-level rules + const userRule = candidates.find((c) => c.isGlobal); + expect(userRule).toBeDefined(); + expect(userRule?.path).toContain("global.md"); + }); + + it("should mark user-level rules as isGlobal: true", () => { + // #given user-level rules + const userRulesDir = join(homeDir, ".claude", "rules"); + mkdirSync(userRulesDir, { recursive: true }); + writeFileSync(join(userRulesDir, "user.md"), "User rules"); + + const currentFile = join(TEST_DIR, "app.ts"); + writeFileSync(currentFile, "code"); + + // #when finding rules + const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile); + + // #then isGlobal should be true + const userRule = candidates.find((c) => c.path.includes("user.md")); + expect(userRule?.isGlobal).toBe(true); + expect(userRule?.distance).toBe(9999); + }); + }); +}); + +describe("findProjectRoot", () => { + const TEST_DIR = join(tmpdir(), `project-root-test-${Date.now()}`); + + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } + }); + + it("should find project root with .git directory", () => { + // #given directory with .git + mkdirSync(join(TEST_DIR, ".git"), { recursive: true }); + const nestedFile = join(TEST_DIR, "src", "components", "Button.tsx"); + mkdirSync(join(TEST_DIR, "src", "components"), { recursive: true }); + writeFileSync(nestedFile, "code"); + + // #when finding project root from nested file + const root = findProjectRoot(nestedFile); + + // #then should return the directory with .git + expect(root).toBe(TEST_DIR); + }); + + it("should find project root with package.json", () => { + // #given directory with package.json + writeFileSync(join(TEST_DIR, "package.json"), "{}"); + const nestedFile = join(TEST_DIR, "lib", "index.js"); + mkdirSync(join(TEST_DIR, "lib"), { recursive: true }); + writeFileSync(nestedFile, "code"); + + // #when finding project root + const root = findProjectRoot(nestedFile); + + // #then should find the package.json directory + expect(root).toBe(TEST_DIR); + }); + + it("should return null when no project markers found", () => { + // #given directory without any project markers + const isolatedDir = join(TEST_DIR, "isolated"); + mkdirSync(isolatedDir, { recursive: true }); + const file = join(isolatedDir, "file.txt"); + writeFileSync(file, "content"); + + // #when finding project root + const root = findProjectRoot(file); + + // #then should return null + expect(root).toBeNull(); + }); +}); diff --git a/src/hooks/rules-injector/finder.ts b/src/hooks/rules-injector/finder.ts index 6dd1237..3bf2939 100644 --- a/src/hooks/rules-injector/finder.ts +++ b/src/hooks/rules-injector/finder.ts @@ -6,24 +6,24 @@ import { } from "node:fs"; import { dirname, join, relative } from "node:path"; import { + GITHUB_INSTRUCTIONS_PATTERN, PROJECT_MARKERS, + PROJECT_RULE_FILES, PROJECT_RULE_SUBDIRS, RULE_EXTENSIONS, USER_RULE_DIR, } from "./constants"; +import type { RuleFileCandidate } from "./types"; -/** - * Candidate rule file with metadata for filtering and sorting - */ -export interface RuleFileCandidate { - /** Absolute path to the rule file */ - path: string; - /** Real path after symlink resolution (for duplicate detection) */ - realPath: string; - /** Whether this is a global/user-level rule */ - isGlobal: boolean; - /** Directory distance from current file (9999 for global rules) */ - distance: number; +function isGitHubInstructionsDir(dir: string): boolean { + return dir.includes(".github/instructions") || dir.endsWith(".github/instructions"); +} + +function isValidRuleFile(fileName: string, dir: string): boolean { + if (isGitHubInstructionsDir(dir)) { + return GITHUB_INSTRUCTIONS_PATTERN.test(fileName); + } + return RULE_EXTENSIONS.some((ext) => fileName.endsWith(ext)); } /** @@ -76,10 +76,7 @@ function findRuleFilesRecursive(dir: string, results: string[]): void { if (entry.isDirectory()) { findRuleFilesRecursive(fullPath, results); } else if (entry.isFile()) { - const isRuleFile = RULE_EXTENSIONS.some((ext) => - entry.name.endsWith(ext), - ); - if (isRuleFile) { + if (isValidRuleFile(entry.name, dir)) { results.push(fullPath); } } @@ -133,8 +130,10 @@ export function calculateDistance( return 9999; } - const ruleParts = ruleRel ? ruleRel.split("/") : []; - const currentParts = currentRel ? currentRel.split("/") : []; + // Split by both forward and back slashes for cross-platform compatibility + // path.relative() returns OS-native separators (backslashes on Windows) + const ruleParts = ruleRel ? ruleRel.split(/[/\\]/) : []; + const currentParts = currentRel ? currentRel.split(/[/\\]/) : []; // Find common prefix length let common = 0; @@ -207,6 +206,33 @@ export function findRuleFiles( distance++; } + // Check for single-file rules at project root (e.g., .github/copilot-instructions.md) + if (projectRoot) { + for (const ruleFile of PROJECT_RULE_FILES) { + const filePath = join(projectRoot, ruleFile); + if (existsSync(filePath)) { + try { + const stat = statSync(filePath); + if (stat.isFile()) { + const realPath = safeRealpathSync(filePath); + if (!seenRealPaths.has(realPath)) { + seenRealPaths.add(realPath); + candidates.push({ + path: filePath, + realPath, + isGlobal: false, + distance: 0, + isSingleFile: true, + }); + } + } + } catch { + // Skip if file can't be read + } + } + } + } + // Search user-level rule directory (~/.claude/rules) const userRuleDir = join(homeDir, USER_RULE_DIR); const userFiles: string[] = []; diff --git a/src/hooks/rules-injector/index.ts b/src/hooks/rules-injector/index.ts index 4a7c5c0..949a5f7 100644 --- a/src/hooks/rules-injector/index.ts +++ b/src/hooks/rules-injector/index.ts @@ -100,8 +100,14 @@ export function createRulesInjectorHook(ctx: PluginInput) { const rawContent = readFileSync(candidate.path, "utf-8"); const { metadata, body } = parseRuleFrontmatter(rawContent); - const matchResult = shouldApplyRule(metadata, resolved, projectRoot); - if (!matchResult.applies) continue; + let matchReason: string; + if (candidate.isSingleFile) { + matchReason = "copilot-instructions (always apply)"; + } else { + const matchResult = shouldApplyRule(metadata, resolved, projectRoot); + if (!matchResult.applies) continue; + matchReason = matchResult.reason ?? "matched"; + } const contentHash = createContentHash(body); if (isDuplicateByContentHash(contentHash, cache.contentHashes)) continue; @@ -112,7 +118,7 @@ export function createRulesInjectorHook(ctx: PluginInput) { toInject.push({ relativePath, - matchReason: matchResult.reason ?? "matched", + matchReason, content: body, distance: candidate.distance, }); diff --git a/src/hooks/rules-injector/parser.test.ts b/src/hooks/rules-injector/parser.test.ts new file mode 100644 index 0000000..15b6f6b --- /dev/null +++ b/src/hooks/rules-injector/parser.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "bun:test"; +import { parseRuleFrontmatter } from "./parser"; + +describe("parseRuleFrontmatter", () => { + describe("applyTo field (GitHub Copilot format)", () => { + it("should parse applyTo as single string", () => { + // #given frontmatter with applyTo as single string + const content = `--- +applyTo: "*.ts" +--- +Rule content here`; + + // #when parsing + const result = parseRuleFrontmatter(content); + + // #then globs should contain the pattern + expect(result.metadata.globs).toBe("*.ts"); + expect(result.body).toBe("Rule content here"); + }); + + it("should parse applyTo as inline array", () => { + // #given frontmatter with applyTo as inline array + const content = `--- +applyTo: ["*.ts", "*.tsx"] +--- +Rule content`; + + // #when parsing + const result = parseRuleFrontmatter(content); + + // #then globs should be array + expect(result.metadata.globs).toEqual(["*.ts", "*.tsx"]); + }); + + it("should parse applyTo as multi-line array", () => { + // #given frontmatter with applyTo as multi-line array + const content = `--- +applyTo: + - "*.ts" + - "src/**/*.js" +--- +Content`; + + // #when parsing + const result = parseRuleFrontmatter(content); + + // #then globs should be array + expect(result.metadata.globs).toEqual(["*.ts", "src/**/*.js"]); + }); + + it("should parse applyTo as comma-separated string", () => { + // #given frontmatter with comma-separated applyTo + const content = `--- +applyTo: "*.ts, *.js" +--- +Content`; + + // #when parsing + const result = parseRuleFrontmatter(content); + + // #then globs should be array + expect(result.metadata.globs).toEqual(["*.ts", "*.js"]); + }); + + it("should merge applyTo and globs when both present", () => { + // #given frontmatter with both applyTo and globs + const content = `--- +globs: "*.md" +applyTo: "*.ts" +--- +Content`; + + // #when parsing + const result = parseRuleFrontmatter(content); + + // #then should merge both into globs array + expect(result.metadata.globs).toEqual(["*.md", "*.ts"]); + }); + + it("should parse applyTo without quotes", () => { + // #given frontmatter with unquoted applyTo + const content = `--- +applyTo: **/*.py +--- +Python rules`; + + // #when parsing + const result = parseRuleFrontmatter(content); + + // #then should parse correctly + expect(result.metadata.globs).toBe("**/*.py"); + }); + + it("should parse applyTo with description", () => { + // #given frontmatter with applyTo and description (GitHub Copilot style) + const content = `--- +applyTo: "**/*.ts,**/*.tsx" +description: "TypeScript coding standards" +--- +# TypeScript Guidelines`; + + // #when parsing + const result = parseRuleFrontmatter(content); + + // #then should parse both fields + expect(result.metadata.globs).toEqual(["**/*.ts", "**/*.tsx"]); + expect(result.metadata.description).toBe("TypeScript coding standards"); + }); + }); + + describe("existing globs/paths parsing (backward compatibility)", () => { + it("should still parse globs field correctly", () => { + // #given existing globs format + const content = `--- +globs: ["*.py", "**/*.ts"] +--- +Python/TypeScript rules`; + + // #when parsing + const result = parseRuleFrontmatter(content); + + // #then should work as before + expect(result.metadata.globs).toEqual(["*.py", "**/*.ts"]); + }); + + it("should still parse paths field as alias", () => { + // #given paths field (Claude Code style) + const content = `--- +paths: ["src/**"] +--- +Source rules`; + + // #when parsing + const result = parseRuleFrontmatter(content); + + // #then should map to globs + expect(result.metadata.globs).toEqual(["src/**"]); + }); + + it("should parse alwaysApply correctly", () => { + // #given frontmatter with alwaysApply + const content = `--- +alwaysApply: true +--- +Always apply this rule`; + + // #when parsing + const result = parseRuleFrontmatter(content); + + // #then should recognize alwaysApply + expect(result.metadata.alwaysApply).toBe(true); + }); + }); + + describe("no frontmatter", () => { + it("should return empty metadata and full body for plain markdown", () => { + // #given markdown without frontmatter + const content = `# Instructions +This is a plain rule file without frontmatter.`; + + // #when parsing + const result = parseRuleFrontmatter(content); + + // #then should have empty metadata + expect(result.metadata).toEqual({}); + expect(result.body).toBe(content); + }); + + it("should handle empty content", () => { + // #given empty content + const content = ""; + + // #when parsing + const result = parseRuleFrontmatter(content); + + // #then should return empty metadata and body + expect(result.metadata).toEqual({}); + expect(result.body).toBe(""); + }); + }); + + describe("edge cases", () => { + it("should handle frontmatter with only applyTo", () => { + // #given minimal GitHub Copilot format + const content = `--- +applyTo: "**" +--- +Apply to all files`; + + // #when parsing + const result = parseRuleFrontmatter(content); + + // #then should parse correctly + expect(result.metadata.globs).toBe("**"); + expect(result.body).toBe("Apply to all files"); + }); + + it("should handle mixed array formats", () => { + // #given globs as multi-line and applyTo as inline + const content = `--- +globs: + - "*.md" +applyTo: ["*.ts", "*.js"] +--- +Mixed format`; + + // #when parsing + const result = parseRuleFrontmatter(content); + + // #then should merge both + expect(result.metadata.globs).toEqual(["*.md", "*.ts", "*.js"]); + }); + + it("should handle Windows-style line endings", () => { + // #given content with CRLF + const content = "---\r\napplyTo: \"*.ts\"\r\n---\r\nWindows content"; + + // #when parsing + const result = parseRuleFrontmatter(content); + + // #then should parse correctly + expect(result.metadata.globs).toBe("*.ts"); + expect(result.body).toBe("Windows content"); + }); + }); +}); diff --git a/src/hooks/rules-injector/parser.ts b/src/hooks/rules-injector/parser.ts index 2a96675..12d41ed 100644 --- a/src/hooks/rules-injector/parser.ts +++ b/src/hooks/rules-injector/parser.ts @@ -60,7 +60,7 @@ function parseYamlContent(yamlContent: string): RuleMetadata { metadata.description = parseStringValue(rawValue); } else if (key === "alwaysApply") { metadata.alwaysApply = rawValue === "true"; - } else if (key === "globs" || key === "paths") { + } else if (key === "globs" || key === "paths" || key === "applyTo") { const { value, consumed } = parseArrayOrStringValue(rawValue, lines, i); // Merge paths into globs (Claude Code compatibility) if (key === "paths") { diff --git a/src/hooks/rules-injector/types.ts b/src/hooks/rules-injector/types.ts index f065fa7..63bf9f6 100644 --- a/src/hooks/rules-injector/types.ts +++ b/src/hooks/rules-injector/types.ts @@ -1,6 +1,8 @@ /** * Rule file metadata (Claude Code style frontmatter) + * Supports both Claude Code format (globs, paths) and GitHub Copilot format (applyTo) * @see https://docs.anthropic.com/en/docs/claude-code/settings#rule-files + * @see https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot */ export interface RuleMetadata { description?: string; @@ -30,6 +32,18 @@ export interface RuleInfo { realPath: string; } +/** + * Rule file candidate with discovery context + */ +export interface RuleFileCandidate { + path: string; + realPath: string; + isGlobal: boolean; + distance: number; + /** Single-file rules (e.g., .github/copilot-instructions.md) always apply without frontmatter */ + isSingleFile?: boolean; +} + /** * Session storage for injected rules tracking */