ktg-plugin-marketplace/plugins/config-audit/scanners/collision-scanner.mjs
Kjell Tore Guttormsen cd25c1e934 feat(config-audit): cross-plugin collision scanner COL (v5 N6) [skip-docs]
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>
2026-05-01 07:46:15 +02:00

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);
}