feat(brain): C1 L1-watcher alias mechanism — strict-ready
Closes the 9 pre-existing name@source drifts that prevented strict mode: settings.json lists each marketplace plugin by 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" — single row #39 for 8 sub-plugins). Mechanism: - tools/.l1-watcher-aliases.txt — settings_name=tooling_substring map. - detectDrift(settings, tooling, aliases): direct match first, then alias-substring fallback. Settings name considered formalized if Tooling text includes either the name itself or aliases[name]. - parseAliases(raw) exported — line-based KV parser with #-comments and split-on-first-= semantics (values may contain "="). TDD: 6 new tests (3 detectDrift + 4 parseAliases). 12/12 GREEN. Smoke: node tools/l1-watcher.mjs -> exit 0, "OK — 0 drift". Known cosmetic baseline issue (pre-existing, not introduced here): the missingInSettings WARN list is noisy — regex /#\d+\s+([\w-]+(?:@[\w-]+)?)/g captures the first \w+ after "#NN" even when it is a plain word (e.g. "#1 PostgreSQL MCP" -> "PostgreSQL"), producing ~190 WARN entries. WARN is non-blocking, so strict mode flip in Phase 3 is unaffected; a follow-up filter on names containing "@" would silence this without behavioural change. Refs: ADR-011 brain governance §6.1 (C1 L1-watcher detector for the "plugin in settings.json without Tooling formalization" L1 pattern). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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: <settings_name>=<substring to find in Tooling text>
|
||||
# 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
|
||||
+28
-5
@@ -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) {
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user