mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-10-23 03:47:00 +09:00
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:
1 parent
cd010ee76c
commit
00eed549d4
4 files changed
+248
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user