New COL scanner detects skill-name collisions across plugins and
between user-level skills (~/.claude/skills/) and plugin-bundled
skills. Skill identity is the directory basename — matches how
enumerateSkills resolves names.
Detection rules (per docs/v5-namespace-research.md, confidence: medium):
- Plugin-vs-plugin same skill name → severity low (CA-COL-001)
- User-vs-plugin same skill name → severity medium (CA-COL-001)
- Plugin-vs-built-in collisions: out of scope for v5.0.0 (insufficient
verification — recorded for v5.0.1 follow-up).
Findings carry details.namespaces array with {source, name, path} for
every conflicting source — supports per-collision reporting downstream.
output.mjs: finding() helper now passes through optional `details`
field (scanner-specific structured payload).
scoring.mjs: COL → "Plugin Hygiene" (new area, 10 total). Posture test
updated from 9 → 10 area scores.
.gitignore: docs/v5-namespace-research.md is local-only (Step 22a
research output, gitignored per plan).
Fixture collision-plugins/fake-home/ has user skill `review` colliding
with plugin-a + plugin-b's `review` (medium severity), plus plugin-c's
unique `summarize` (no collision).
[skip-docs] reason: v5 plan fences off README/CLAUDE.md badge updates
to Session 5; Forgejo pre-commit-docs-gate hook requires this tag.
Tests: 617 → 625 (+8).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
125 lines
4.8 KiB
JavaScript
125 lines
4.8 KiB
JavaScript
/**
|
|
* 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<name, Array<skill>>.
|
|
*/
|
|
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);
|
|
}
|