feat(rules-injector): add GitHub Copilot instructions format support (#403)

* feat(rules-injector): add GitHub Copilot instructions format support

- Add .github/instructions/ directory to rule discovery paths
- Add applyTo as alias for globs field in frontmatter parser
- Support .github/copilot-instructions.md single-file format (always-apply)
- Filter .github/instructions/ to only accept *.instructions.md files
- Add comprehensive tests for parser and finder

Closes #397

* fix(rules-injector): use cross-platform path separator in calculateDistance

path.relative() returns OS-native separators (backslashes on Windows),
but the code was splitting by forward slash only, causing incorrect
distance calculations on Windows.

Use regex /[/\]/ to handle both separator types.

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
This commit is contained in:
Sisyphus
2026-01-02 10:27:33 +09:00
committed by GitHub
parent f088f008cc
commit bebe6607d4
7 changed files with 682 additions and 22 deletions
+44 -18
View File
@@ -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[] = [];