test(mods): check that every tool name a plugin guard watches exists in the registry

Adds scripts/check-guard-tool-names.mjs, an allowlist for tools other servers
serve, node:test tests, and a step in the all-plugins-smoke workflow. Fails
loud when nothing is extracted or the registry is empty.

Co-Authored-By: RuFlo <ruv@ruv.net>
This commit is contained in:
ruv committed 2026-10-04 19:02:02 -04:00
1 parent cd010ee76c
commit 00eed549d4
4 files changed
+248

No files matched your search

+16
View File
@@ -18,12 +18,20 @@ on:
- 'plugins/**'
- 'scripts/smoke-all-plugins.mjs'
- 'scripts/audit-plugin-mcp-prefix.mjs'
- 'scripts/check-guard-tool-names.mjs'
- 'scripts/guard-tool-names.allow.json'
- 'v3/@claude-flow/cli/src/mcp-tools/**'
- 'v3/@claude-flow/cli/src/ruvector/coverage-tools.ts'
- '.github/workflows/all-plugins-smoke.yml'
pull_request:
paths:
- 'plugins/**'
- 'scripts/smoke-all-plugins.mjs'
- 'scripts/audit-plugin-mcp-prefix.mjs'
- 'scripts/check-guard-tool-names.mjs'
- 'scripts/guard-tool-names.allow.json'
- 'v3/@claude-flow/cli/src/mcp-tools/**'
- 'v3/@claude-flow/cli/src/ruvector/coverage-tools.ts'
- '.github/workflows/all-plugins-smoke.yml'
workflow_dispatch:
@@ -47,6 +55,14 @@ jobs:
# smoke while letting the metaharness fleet finish.
run: node scripts/smoke-all-plugins.mjs --timeout 300
- name: Guard tool names exist in the registry
run: |
node --test tests/check-guard-tool-names.test.mjs
node scripts/check-guard-tool-names.mjs
# A renamed MCP tool leaves a plugin guard watching a name that no
# longer exists, so it silently stops guarding. Fails loud if zero
# names are extracted or the registry is empty.
- name: Fleet-wide exit-bypass antipattern lint (iter-75 bug class)
run: node scripts/audit-exit-bypass-antipattern.mjs
# Static analyzer: scans every plugins/*/scripts/*.mjs for the
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env node
// check-guard-tool-names — every MCP tool name a plugin mod guard watches by string must
// exist in the real tool registry, or the guard silently stops guarding after a rename.
//
// node scripts/check-guard-tool-names.mjs [--verbose] [--format json]
//
// Watched names: quoted tool-shaped strings in plugins/*/hooks/*.ts whose first segment is a
// known tool family (a family seen in the registry or the allowlist), so event names such as
// `in_progress` or `text_delta` are not mistaken for tools.
// Registry: `name: '...'` in v3/@claude-flow/cli/src/mcp-tools/*.ts, plus the tools a plugin's
// own server declares (plugins with a .mcp.json: `.tool('x'` / `registerTool('x'` in src/).
// Allowlist: scripts/guard-tool-names.allow.json, name -> reason (tools other servers serve).
//
// Exit: 0 clean · 1 unmatched watched names · 3 nothing extracted / empty registry (fail loud).
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const SHAPE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*_[a-z0-9]+(?:[_-][a-z0-9]+)*$/;
const QUOTED = /(['"`])([a-z][a-z0-9_-]*)\1/g;
const family = (name) => name.split('_')[0];
/** Quoted strings in `source` that have the tool-name shape. */
export function extractCandidates(source) {
const found = new Set();
for (const m of source.matchAll(QUOTED)) if (SHAPE.test(m[2])) found.add(m[2]);
return found;
}
/** Tool names a core tool module declares: `name: '...'` with the tool shape. */
export function extractRegistryNames(source) {
const found = new Set();
for (const m of source.matchAll(/\bname:\s*(['"`])([a-z][a-z0-9_-]*)\1/g)) if (SHAPE.test(m[2])) found.add(m[2]);
return found;
}
/** Tool names a plugin's own MCP server registers. Their server-local names have no family prefix rule. */
export function extractServerNames(source) {
const found = new Set();
for (const m of source.matchAll(/(?:\.tool|registerTool)\(\s*(['"`])([a-z][a-z0-9_-]*)\1/g)) found.add(m[2]);
return found;
}
/**
* Split the candidates into those to check and those ignored (family unknown), then report
* the unmatched. `registry` and `allow` are Set / plain object.
*/
export function check(watched, registry, allow) {
const families = new Set([...registry, ...Object.keys(allow)].map(family));
const checked = [];
const ignored = [];
for (const name of [...watched].sort()) (families.has(family(name)) ? checked : ignored).push(name);
const unmatched = checked.filter((n) => !registry.has(n) && !(n in allow));
return { checked, ignored, unmatched, allowed: checked.filter((n) => !registry.has(n) && n in allow) };
}
const tsFiles = (dir) => (existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.ts') && !/\.(test|spec)\.ts$/.test(f)).map((f) => join(dir, f)) : []);
function walk(dir, out = []) {
if (!existsSync(dir)) return out;
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.name === 'node_modules') continue;
const p = join(dir, e.name);
if (e.isDirectory()) walk(p, out);
else if (/\.(mjs|cjs|js|ts)$/.test(e.name)) out.push(p);
}
return out;
}
/** Gather watched names (name -> plugins) and the registry from the repo on disk. */
export function gather(root = ROOT) {
const watched = new Map();
const registry = new Set();
const plugins = join(root, 'plugins');
for (const p of readdirSync(plugins)) {
for (const f of tsFiles(join(plugins, p, 'hooks'))) {
for (const n of extractCandidates(readFileSync(f, 'utf8'))) watched.set(n, [...(watched.get(n) ?? []), p]);
}
if (existsSync(join(plugins, p, '.mcp.json'))) {
for (const f of walk(join(plugins, p, 'src'))) for (const n of extractServerNames(readFileSync(f, 'utf8'))) registry.add(n);
}
}
// mcp-tools/ holds most tools; ruvector/coverage-tools.ts is the one tool module outside it.
const cli = join(root, 'v3/@claude-flow/cli/src');
const toolFiles = [...tsFiles(join(cli, 'mcp-tools')), join(cli, 'ruvector/coverage-tools.ts')].filter(existsSync);
for (const f of toolFiles) for (const n of extractRegistryNames(readFileSync(f, 'utf8'))) registry.add(n);
const allowPath = join(root, 'scripts/guard-tool-names.allow.json');
const allow = existsSync(allowPath) ? JSON.parse(readFileSync(allowPath, 'utf8')) : {};
return { watched, registry, allow };
}
function main() {
const verbose = process.argv.includes('--verbose');
const json = process.argv.includes('--format') && process.argv[process.argv.indexOf('--format') + 1] === 'json';
const { watched, registry, allow } = gather();
if (watched.size === 0 || registry.size === 0) {
console.error(`check-guard-tool-names: extracted ${watched.size} watched names and ${registry.size} registry names; one is zero, so the check is broken (repo layout drift?). Failing.`);
process.exit(3);
}
const r = check(new Set(watched.keys()), registry, allow);
if (r.checked.length === 0) {
console.error('check-guard-tool-names: no watched name belongs to a known tool family; the check is broken. Failing.');
process.exit(3);
}
const stale = Object.keys(allow).filter((n) => registry.has(n));
if (json) console.log(JSON.stringify({ watched: watched.size, checked: r.checked.length, registry: registry.size, allowed: r.allowed, unmatched: r.unmatched, staleAllow: stale }));
else {
console.log(`check-guard-tool-names: ${r.checked.length} watched tool names checked (${r.ignored.length} non-tool strings ignored) against ${registry.size} registry names; ${r.allowed.length} allowlisted.`);
if (verbose) console.log(`allowlisted: ${r.allowed.join(', ')}\nignored: ${r.ignored.join(', ')}`);
for (const n of stale) console.log(`note: allowlist entry "${n}" is now in the registry; remove it.`);
for (const n of r.unmatched) console.error(`UNMATCHED ${n} (watched by ${[...new Set(watched.get(n))].join(', ')}): no such tool in the registry. Renamed or removed? Fix the guard or add it to scripts/guard-tool-names.allow.json with a reason.`);
}
process.exit(r.unmatched.length ? 1 : 0);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) main();
+8
View File
@@ -0,0 +1,8 @@
{
"hooks_recall": "ruvector MCP server (npx ruvector), not the ruflo CLI registry",
"hooks_remember": "ruvector MCP server (npx ruvector), not the ruflo CLI registry",
"hooks_learn": "ruvector MCP server (npx ruvector), not the ruflo CLI registry",
"hooks_compress_store": "ruvector MCP server (npx ruvector), not the ruflo CLI registry",
"agent_message": "a Claude Code stream-event type the console renders, not an MCP tool",
"mcp_tool_call": "a Codex stream-event type the console renders, not an MCP tool"
}
+106
View File
@@ -0,0 +1,106 @@
// node --test tests/check-guard-tool-names.test.mjs
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { check, extractCandidates, extractRegistryNames, extractServerNames, gather } from '../scripts/check-guard-tool-names.mjs';
const SCRIPT = fileURLToPath(new URL('../scripts/check-guard-tool-names.mjs', import.meta.url));
test('extractCandidates keeps quoted tool-shaped names only', () => {
const src = "const W = new Set(['agentdb_hierarchical-store', `memory_store`, \"x_federation_publish\", 'plain', 'Not_Shaped', 'a b_c'])";
assert.deepEqual([...extractCandidates(src)].sort(), ['agentdb_hierarchical-store', 'memory_store', 'x_federation_publish']);
});
test('extractRegistryNames reads name: fields', () => {
assert.deepEqual([...extractRegistryNames("{ name: 'memory_store', description: 'x' }, { name: \"hooks_pre-edit\" }, { name: 'plain' }")].sort(), ['hooks_pre-edit', 'memory_store']);
});
test('extractServerNames reads .tool( and registerTool( calls', () => {
assert.deepEqual([...extractServerNames("mcp.tool('claims_issue', 'd', {}); server.registerTool(\"channel_list\", {})")].sort(), ['channel_list', 'claims_issue']);
});
test('check reports a watched name that has no registry match', () => {
const r = check(new Set(['memory_store', 'memory_stor']), new Set(['memory_store']), {});
assert.deepEqual(r.unmatched, ['memory_stor']);
});
test('check ignores strings whose family is unknown, but not those in a known family', () => {
const r = check(new Set(['in_progress', 'text_delta', 'memory_gone']), new Set(['memory_store']), {});
assert.deepEqual(r.ignored, ['in_progress', 'text_delta']);
assert.deepEqual(r.unmatched, ['memory_gone']);
});
test('check honours the allowlist and reports it as allowed', () => {
const r = check(new Set(['hooks_recall']), new Set(['hooks_route']), { hooks_recall: 'ruvector' });
assert.deepEqual(r.unmatched, []);
assert.deepEqual(r.allowed, ['hooks_recall']);
});
function fixture(files) {
const root = mkdtempSync(join(tmpdir(), 'guard-names-'));
for (const [path, body] of Object.entries(files)) {
mkdirSync(join(root, path, '..'), { recursive: true });
writeFileSync(join(root, path), body);
}
return root;
}
test('gather skips test files, reads plugin server names, and merges the allowlist', () => {
const root = fixture({
'plugins/p/hooks/guard.ts': "const W = ['memory_store', 'channel_list']",
'plugins/p/hooks/guard.test.ts': "const W = ['memory_ghost']",
'plugins/p/.mcp.json': '{}',
'plugins/p/src/server.mjs': "mcp.tool('channel_list', 'd')",
'v3/@claude-flow/cli/src/mcp-tools/memory-tools.ts': "{ name: 'memory_store' }",
'scripts/guard-tool-names.allow.json': '{"x_y":"r"}',
});
try {
const g = gather(root);
assert.deepEqual([...g.watched.keys()].sort(), ['channel_list', 'memory_store']);
assert.deepEqual([...g.registry].sort(), ['channel_list', 'memory_store']);
assert.deepEqual(g.allow, { x_y: 'r' });
} finally {
rmSync(root, { recursive: true, force: true });
}
});
function runIn(files) {
const root = fixture(files);
mkdirSync(join(root, 'scripts'), { recursive: true });
cpSync(SCRIPT, join(root, 'scripts/check-guard-tool-names.mjs'));
try {
return spawnSync(process.execPath, [join(root, 'scripts/check-guard-tool-names.mjs')], { encoding: 'utf8' });
} finally {
rmSync(root, { recursive: true, force: true });
}
}
test('CLI fails loud (exit 3) when nothing was extracted', () => {
const r = runIn({ 'plugins/p/hooks/guard.ts': 'export {}', 'v3/@claude-flow/cli/src/mcp-tools/a.ts': "{ name: 'memory_store' }" });
assert.equal(r.status, 3);
assert.match(r.stderr, /check is broken/);
});
test('CLI fails loud (exit 3) when the registry is empty', () => {
const r = runIn({ 'plugins/p/hooks/guard.ts': "['memory_store']", 'v3/@claude-flow/cli/src/mcp-tools/a.ts': 'export {}' });
assert.equal(r.status, 3);
});
test('CLI exits 1 naming the plugin for a renamed tool, 0 once it matches', () => {
const base = { 'plugins/p/hooks/guard.ts': "['memory_store', 'memory_renamed']" };
const bad = runIn({ ...base, 'v3/@claude-flow/cli/src/mcp-tools/a.ts': "{ name: 'memory_store' }" });
assert.equal(bad.status, 1);
assert.match(bad.stderr, /UNMATCHED memory_renamed \(watched by p\)/);
const ok = runIn({ ...base, 'v3/@claude-flow/cli/src/mcp-tools/a.ts': "{ name: 'memory_store' }, { name: 'memory_renamed' }" });
assert.equal(ok.status, 0);
});
test('the real repo passes', () => {
const r = spawnSync(process.execPath, [SCRIPT], { encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
});