fix(skill-loader): deterministic collision handling for skill names
- Separate directory and file entries, process directories first
- Use Map to deduplicate skills by name (first-wins)
- Directory skills (SKILL.md, {dir}.md) take precedence over file skills (*.md)
- Add test for collision scenario
Addresses Oracle P2 review feedback from PR #1254
This commit is contained in:
parent
dee8cf1720
commit
f79f164cd5
@ -516,5 +516,42 @@ Nested content.
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("prefers directory skill (SKILL.md) over file skill (*.md) on name collision", async () => {
|
||||
// #given - both foo.md file AND foo/SKILL.md directory exist
|
||||
// Directory skill should win (deterministic precedence: SKILL.md > {dir}.md > *.md)
|
||||
const dirSkillDir = join(SKILLS_DIR, "collision-test")
|
||||
mkdirSync(dirSkillDir, { recursive: true })
|
||||
writeFileSync(join(dirSkillDir, "SKILL.md"), `---
|
||||
name: collision-test
|
||||
description: Directory-based skill (should win)
|
||||
---
|
||||
I am the directory skill.
|
||||
`)
|
||||
|
||||
// Also create a file with same base name at parent level
|
||||
writeFileSync(join(SKILLS_DIR, "collision-test.md"), `---
|
||||
name: collision-test
|
||||
description: File-based skill (should lose)
|
||||
---
|
||||
I am the file skill.
|
||||
`)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
|
||||
// #then - only one skill should exist, and it should be the directory-based one
|
||||
const matchingSkills = skills.filter(s => s.name === "collision-test")
|
||||
expect(matchingSkills).toHaveLength(1)
|
||||
expect(matchingSkills[0]?.definition.description).toContain("Directory-based skill")
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -140,53 +140,59 @@ async function loadSkillsFromDir(
|
||||
maxDepth: number = 2
|
||||
): Promise<LoadedSkill[]> {
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => [])
|
||||
const skills: LoadedSkill[] = []
|
||||
const skillMap = new Map<string, LoadedSkill>()
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
const directories = entries.filter(e => !e.name.startsWith(".") && (e.isDirectory() || e.isSymbolicLink()))
|
||||
const files = entries.filter(e => !e.name.startsWith(".") && !e.isDirectory() && !e.isSymbolicLink() && isMarkdownFile(e))
|
||||
|
||||
for (const entry of directories) {
|
||||
const entryPath = join(skillsDir, entry.name)
|
||||
const resolvedPath = await resolveSymlinkAsync(entryPath)
|
||||
const dirName = entry.name
|
||||
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
const resolvedPath = await resolveSymlinkAsync(entryPath)
|
||||
const dirName = entry.name
|
||||
|
||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||
try {
|
||||
await fs.access(skillMdPath)
|
||||
const skill = await loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope, namePrefix)
|
||||
if (skill) skills.push(skill)
|
||||
continue
|
||||
} catch {
|
||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||
try {
|
||||
await fs.access(skillMdPath)
|
||||
const skill = await loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope, namePrefix)
|
||||
if (skill && !skillMap.has(skill.name)) {
|
||||
skillMap.set(skill.name, skill)
|
||||
}
|
||||
|
||||
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
|
||||
try {
|
||||
await fs.access(namedSkillMdPath)
|
||||
const skill = await loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope, namePrefix)
|
||||
if (skill) skills.push(skill)
|
||||
continue
|
||||
} catch {
|
||||
}
|
||||
|
||||
// Recurse into subdirectories if no skill found and within depth limit
|
||||
if (depth < maxDepth) {
|
||||
const newPrefix = namePrefix ? `${namePrefix}/${dirName}` : dirName
|
||||
const nestedSkills = await loadSkillsFromDir(resolvedPath, scope, newPrefix, depth + 1, maxDepth)
|
||||
skills.push(...nestedSkills)
|
||||
}
|
||||
|
||||
continue
|
||||
} catch {
|
||||
}
|
||||
|
||||
if (isMarkdownFile(entry)) {
|
||||
const baseName = basename(entry.name, ".md")
|
||||
const skill = await loadSkillFromPath(entryPath, skillsDir, baseName, scope, namePrefix)
|
||||
if (skill) skills.push(skill)
|
||||
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
|
||||
try {
|
||||
await fs.access(namedSkillMdPath)
|
||||
const skill = await loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope, namePrefix)
|
||||
if (skill && !skillMap.has(skill.name)) {
|
||||
skillMap.set(skill.name, skill)
|
||||
}
|
||||
continue
|
||||
} catch {
|
||||
}
|
||||
|
||||
if (depth < maxDepth) {
|
||||
const newPrefix = namePrefix ? `${namePrefix}/${dirName}` : dirName
|
||||
const nestedSkills = await loadSkillsFromDir(resolvedPath, scope, newPrefix, depth + 1, maxDepth)
|
||||
for (const nestedSkill of nestedSkills) {
|
||||
if (!skillMap.has(nestedSkill.name)) {
|
||||
skillMap.set(nestedSkill.name, nestedSkill)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return skills
|
||||
for (const entry of files) {
|
||||
const entryPath = join(skillsDir, entry.name)
|
||||
const baseName = basename(entry.name, ".md")
|
||||
const skill = await loadSkillFromPath(entryPath, skillsDir, baseName, scope, namePrefix)
|
||||
if (skill && !skillMap.has(skill.name)) {
|
||||
skillMap.set(skill.name, skill)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(skillMap.values())
|
||||
}
|
||||
|
||||
function skillsToRecord(skills: LoadedSkill[]): Record<string, CommandDefinition> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user