/** * COL Scanner — Cross-Plugin/User-vs-Plugin Skill Collision (v5 N6) * * Detects skill-name collisions across plugins and between user-level skills * (~/.claude/skills/) and plugin-bundled skills. Skill names come from the * directory layout (basename of dirname(SKILL.md)) — that matches how * enumerateSkills resolves them. * * Detection rules (from Step 22a research, confidence: medium): * - Two or more plugins exposing a skill with the same directory name: * severity `low` (CA-COL-001) — order ambiguity even when invocation is * namespaced via `/plugin:skill`. * - A user-level skill and a plugin skill with the same name: severity * `medium` (CA-COL-001) — bare invocation may resolve unpredictably. * - Plugin-vs-built-in collisions: out of scope for v5.0.0 (insufficient * verification — see docs/v5-namespace-research.md). * * Each finding's `details.namespaces` array carries `{ source, name }` for * every conflicting source so downstream tooling can render a per-collision * report. * * Zero external dependencies. */ import { finding, scannerResult } from './lib/output.mjs'; import { SEVERITY } from './lib/severity.mjs'; import { enumeratePlugins, enumerateSkills } from './lib/active-config-reader.mjs'; const SCANNER = 'COL'; /** * Group skills by name. Returns Map>. */ function groupSkillsByName(skills) { const grouped = new Map(); for (const s of skills) { if (!s || typeof s.name !== 'string') continue; if (!grouped.has(s.name)) grouped.set(s.name, []); grouped.get(s.name).push(s); } return grouped; } /** * Main scanner entry point. * * @param {string} targetPath unused (collision check is HOME-scoped) * @param {object} discovery unused (collision check ignores project discovery) */ export async function scan(_targetPath, _discovery) { const start = Date.now(); const findings = []; const plugins = await enumeratePlugins(); const allSkills = await enumerateSkills(plugins); const grouped = groupSkillsByName(allSkills); for (const [name, skills] of grouped) { if (skills.length < 2) continue; const userSkill = skills.find(s => s.source === 'user'); const pluginSkills = skills.filter(s => s.source === 'plugin'); if (userSkill && pluginSkills.length > 0) { // User-vs-plugin collision (severity medium per Step 22a) const namespaces = [ { source: 'user', name, path: userSkill.path }, ...pluginSkills.map(s => ({ source: `plugin:${s.pluginName}`, name, path: s.path, })), ]; findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: `Skill name "${name}" collides between user-level and plugin sources`, description: `A user-level skill at ${userSkill.path} shares its directory name "${name}" ` + `with ${pluginSkills.length} plugin-bundled skill` + `${pluginSkills.length === 1 ? '' : 's'}. Bare invocation may resolve ` + 'unpredictably; the user has to remember which definition is currently active.', file: userSkill.path, evidence: `name="${name}"; sources=` + [`user`, ...pluginSkills.map(s => `plugin:${s.pluginName}`)].join(','), recommendation: `Rename either the user skill (~/.claude/skills/${name}/) or one of the plugin ` + 'skills, or rely on namespaced invocation paths and remove the bare alias to ' + 'eliminate the ambiguity.', category: 'plugin-hygiene', details: { namespaces }, })); } else if (pluginSkills.length >= 2) { // Plugin-vs-plugin collision (severity low per Step 22a) const pluginNames = pluginSkills.map(s => s.pluginName); findings.push(finding({ scanner: SCANNER, severity: SEVERITY.low, title: `Skill name "${name}" used by multiple plugins`, description: `${pluginSkills.length} plugins (${pluginNames.join(', ')}) expose a skill ` + `named "${name}". Even when invocation is namespaced via /plugin:skill, ` + 'shared names create ambiguity in error messages, search results, and the ' + 'plugin-skills enumeration.', file: pluginSkills[0].path, evidence: `name="${name}"; plugins=${pluginNames.join(',')}`, recommendation: 'Coordinate naming across plugins, or rename one to clarify intent. The ' + 'shared name forces every reader to disambiguate by source.', category: 'plugin-hygiene', details: { namespaces: pluginSkills.map(s => ({ source: `plugin:${s.pluginName}`, name, path: s.path, })), }, })); } } return scannerResult(SCANNER, 'ok', findings, allSkills.length, Date.now() - start); }