diff --git a/tools/.l1-watcher-aliases.txt b/tools/.l1-watcher-aliases.txt new file mode 100644 index 00000000..8acaa4f2 --- /dev/null +++ b/tools/.l1-watcher-aliases.txt @@ -0,0 +1,24 @@ +# L1-watcher aliases — settings.json machine name → Tooling Прил. Н search substring. +# +# Purpose: settings.json lists plugins under their marketplace machine name +# (e.g. "frontend-design@claude-plugins-official"), while Tooling Прил. Н +# describes them under a human/group name (e.g. "Frontend Design plugin", +# "Trail of Bits Skills"). Without this map the watcher false-positives. +# +# Format: = +# Lines starting with # and blank lines are ignored. First "=" splits key/value. +# +# When adding a new plugin to ~/.claude/settings.json: prefer formalizing it +# under its own #NN row in Tooling §3.3 (Pravila §16.4 — это требует Tooling- +# номер). Only add an alias here when the plugin is intentionally documented +# as part of a group (e.g. Trail of Bits Skills #39 = 8 sub-plugins). + +frontend-design@claude-plugins-official=Frontend Design plugin +differential-review@trailofbits=Trail of Bits Skills +audit-context-building@trailofbits=Trail of Bits Skills +supply-chain-risk-auditor@trailofbits=Trail of Bits Skills +insecure-defaults@trailofbits=Trail of Bits Skills +sharp-edges@trailofbits=Trail of Bits Skills +static-analysis@trailofbits=Trail of Bits Skills +variant-analysis@trailofbits=Trail of Bits Skills +agentic-actions-auditor@trailofbits=Trail of Bits Skills diff --git a/tools/l1-watcher.mjs b/tools/l1-watcher.mjs index 67ca8a6b..76891a4f 100644 --- a/tools/l1-watcher.mjs +++ b/tools/l1-watcher.mjs @@ -5,13 +5,33 @@ import { homedir } from 'os'; const PLUGIN_NAME_PATTERN = /[a-z][\w-]*(?:@[\w-]+)?/g; -export function detectDrift(settings, toolingText) { +export function parseAliases(raw) { + if (!raw) return {}; + const out = {}; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 1) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (key && value) out[key] = value; + } + return out; +} + +export function detectDrift(settings, toolingText, aliases = {}) { const enabled = Object.entries(settings.enabledPlugins || {}) .filter(([, v]) => v === true) .map(([k]) => k); const found = new Set(); for (const m of toolingText.matchAll(PLUGIN_NAME_PATTERN)) found.add(m[0]); - const missingInTooling = enabled.filter((p) => !found.has(p)); + const missingInTooling = enabled.filter((p) => { + if (found.has(p)) return false; + const alias = aliases[p]; + if (alias && toolingText.includes(alias)) return false; + return true; + }); const inToolingButNotSettings = []; const toolingNumbered = toolingText.match(/#\d+\s+([\w-]+(?:@[\w-]+)?)/g) || []; const toolingPluginNames = toolingNumbered.map((s) => s.split(/\s+/)[1]); @@ -36,16 +56,19 @@ export function loadInputs(projectRoot = process.cwd()) { enabledPlugins: { ...(userSettings.enabledPlugins || {}), ...(projectSettings.enabledPlugins || {}) }, }; const tooling = loadFileMaybe(join(projectRoot, 'docs', 'Tooling_v8_3.md')) || ''; - return { settings: merged, tooling }; + const aliasesRaw = loadFileMaybe(join(projectRoot, 'tools', '.l1-watcher-aliases.txt')); + const aliases = parseAliases(aliasesRaw); + return { settings: merged, tooling, aliases }; } if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/l1-watcher.mjs')) { - const { settings, tooling } = loadInputs(); - const drift = detectDrift(settings, tooling); + const { settings, tooling, aliases } = loadInputs(); + const drift = detectDrift(settings, tooling, aliases); if (drift.missingInTooling.length > 0) { console.error(`[l1-watcher] FAIL — plugins in settings but not formalized in Tooling Прил. Н:`); drift.missingInTooling.forEach((p) => console.error(` - ${p}`)); console.error(`Run /claude-md-management:claude-md-improver to formalize.`); + console.error(`If the plugin is referenced in Tooling under a group/human name, add an alias to tools/.l1-watcher-aliases.txt.`); process.exit(1); } if (drift.missingInSettings.length > 0) { diff --git a/tools/l1-watcher.test.mjs b/tools/l1-watcher.test.mjs index cb8eb0ea..9150bd89 100644 --- a/tools/l1-watcher.test.mjs +++ b/tools/l1-watcher.test.mjs @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { detectDrift } from './l1-watcher.mjs'; +import { detectDrift, parseAliases } from './l1-watcher.mjs'; describe('detectDrift', () => { it('finds plugins in settings but not in tooling', () => { @@ -31,4 +31,82 @@ describe('detectDrift', () => { const drift = detectDrift(settings, tooling); expect(drift.missingInTooling).toEqual([]); }); + + it('uses alias to match plugin under group/human name in tooling', () => { + const settings = { + enabledPlugins: { 'frontend-design@claude-plugins-official': true }, + }; + const tooling = '#30 Frontend Design plugin (paired stack)'; + const aliases = { + 'frontend-design@claude-plugins-official': 'Frontend Design plugin', + }; + const drift = detectDrift(settings, tooling, aliases); + expect(drift.missingInTooling).toEqual([]); + }); + + it('several plugins share one alias (Trail of Bits group)', () => { + const settings = { + enabledPlugins: { + 'differential-review@trailofbits': true, + 'sharp-edges@trailofbits': true, + }, + }; + const tooling = '#39 Trail of Bits Skills (субсет 8 audit-плагинов)'; + const aliases = { + 'differential-review@trailofbits': 'Trail of Bits Skills', + 'sharp-edges@trailofbits': 'Trail of Bits Skills', + }; + const drift = detectDrift(settings, tooling, aliases); + expect(drift.missingInTooling).toEqual([]); + }); + + it('falls back to direct match when no alias defined', () => { + const settings = { enabledPlugins: { 'foo@org': true } }; + const tooling = 'no foo here'; + const drift = detectDrift(settings, tooling, {}); + expect(drift.missingInTooling).toEqual(['foo@org']); + }); + + it('alias does NOT save a plugin when alias substring is also absent', () => { + const settings = { enabledPlugins: { 'foo@org': true } }; + const tooling = 'unrelated text'; + const aliases = { 'foo@org': 'Some Group Name' }; + const drift = detectDrift(settings, tooling, aliases); + expect(drift.missingInTooling).toEqual(['foo@org']); + }); +}); + +describe('parseAliases', () => { + it('parses key=value lines and ignores comments and blanks', () => { + const raw = [ + '# header comment', + '', + 'frontend-design@claude-plugins-official=Frontend Design plugin', + ' ', + '# another comment', + 'sharp-edges@trailofbits=Trail of Bits Skills', + '', + ].join('\n'); + const aliases = parseAliases(raw); + expect(aliases).toEqual({ + 'frontend-design@claude-plugins-official': 'Frontend Design plugin', + 'sharp-edges@trailofbits': 'Trail of Bits Skills', + }); + }); + + it('returns empty object on empty/null input', () => { + expect(parseAliases('')).toEqual({}); + expect(parseAliases(null)).toEqual({}); + expect(parseAliases(undefined)).toEqual({}); + }); + + it('trims whitespace around key and value', () => { + const raw = ' foo@org = Bar Name '; + expect(parseAliases(raw)).toEqual({ 'foo@org': 'Bar Name' }); + }); + + it('handles values that contain = sign (only splits on first =)', () => { + const raw = 'a@b=v1=v2'; + expect(parseAliases(raw)).toEqual({ 'a@b': 'v1=v2' }); + }); });