From 6a4bf2a45cc1644d4c0bbe906db802cd6a198181 Mon Sep 17 00:00:00 2001 From: Fini Date: Wed, 22 Apr 2026 02:01:49 +0800 Subject: [PATCH] test(pen-mcp): element-tool registry parity drift guard (10 cases) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source-level parity checks across pen-mcp's three derived artifacts: Source of truth: ELEMENT_TOOL_DEFINITIONS array Derived 1: ELEMENT_TOOL_NAMES set (runtime dispatch filter) Derived 2: handler file at tools/.ts Derived 3: switch branch in handleElementToolCall Checks: - ≥42 tools registered (floor) - Every tool name matches add_[a-z_]+_v0 - Every tool has a handler file at the expected kebab path - Every handler file exports handle - Every handler imports its matching build from pen-core - Every tool has a switch branch in the runtime dispatcher - Every switch branch actually calls the handler (no copy-paste typos where a case dispatches to the wrong handler) - No orphan handler files (every tools/add-*-v0.ts maps to a name) - No duplicate tool names - ELEMENT_TOOL_NAMES set === ELEMENT_TOOL_DEFINITIONS names This is the pen-mcp-side counterpart to apps/web's shim-server-parity.test.ts. Together they guard against drift on both client and server sides of the N-tool integration. --- .../element-tool-registry-parity.test.ts | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 packages/pen-mcp/src/__tests__/element-tool-registry-parity.test.ts diff --git a/packages/pen-mcp/src/__tests__/element-tool-registry-parity.test.ts b/packages/pen-mcp/src/__tests__/element-tool-registry-parity.test.ts new file mode 100644 index 000000000..781679570 --- /dev/null +++ b/packages/pen-mcp/src/__tests__/element-tool-registry-parity.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { ELEMENT_TOOL_DEFINITIONS, ELEMENT_TOOL_NAMES } from '../routes/element-tool-defs'; + +/** + * pen-mcp handler × pen-core builder registry parity. + * + * The canonical source of truth: ELEMENT_TOOL_NAMES (derived from + * ELEMENT_TOOL_DEFINITIONS). Each tool must have: + * + * 1. A tool definition in ELEMENT_TOOL_DEFINITIONS + * 2. A handler file at `src/tools/.ts` that + * exports `handle` and imports the matching pen-core + * builder + * 3. A switch branch in handleElementToolCall that dispatches to + * the handler + * + * If any of these three diverge — e.g. definition without handler, + * handler without dispatch, import of the wrong builder — this test + * catches the drift at CI time, before external MCP clients (Claude + * Code / Codex / Gemini CLI) get a 500 error on the missing tool or + * a silent wrong-shape response. + * + * This test is the pen-mcp-side counterpart to + * apps/web's shim-server-parity.test.ts. + */ + +const TOOLS_DIR = join(__dirname, '..', 'tools'); +const ROUTES_DIR = join(__dirname, '..', 'routes'); + +function toolNameToHandlerFile(toolName: string): string { + // add_heading_v0 → add-heading-v0.ts + return toolName.replace(/_/g, '-') + '.ts'; +} + +function toolNameToHandlerExport(toolName: string): string { + // add_heading_v0 → handleAddHeadingV0 + const pascal = toolName + .split('_') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(''); + return 'handle' + pascal; +} + +function readRouterFile(): string { + return readFileSync(join(ROUTES_DIR, 'element-tool-defs.ts'), 'utf-8'); +} + +describe('pen-mcp element-tool registry parity', () => { + it('ELEMENT_TOOL_DEFINITIONS has at least 42 tools (the expected coverage floor)', () => { + expect(ELEMENT_TOOL_DEFINITIONS.length).toBeGreaterThanOrEqual(42); + }); + + it('Every tool name matches the add_X_v0 convention', () => { + for (const def of ELEMENT_TOOL_DEFINITIONS) { + expect(def.name, `tool definition: ${def.name}`).toMatch(/^add_[a-z_]+_v0$/); + } + }); + + it('Every tool has a handler file at the expected kebab-case path', () => { + const actualFiles = new Set( + readdirSync(TOOLS_DIR).filter((f) => f.endsWith('.ts') && f.startsWith('add-')), + ); + + const missing: string[] = []; + for (const name of ELEMENT_TOOL_NAMES) { + const expected = toolNameToHandlerFile(name); + if (!actualFiles.has(expected)) missing.push(`${name} → expected ${expected}`); + } + expect(missing).toEqual([]); + }); + + it('Every handler file exports the expected handle function', () => { + const errors: string[] = []; + for (const name of ELEMENT_TOOL_NAMES) { + const fp = join(TOOLS_DIR, toolNameToHandlerFile(name)); + const src = readFileSync(fp, 'utf-8'); + const expectedExport = toolNameToHandlerExport(name); + const pattern = new RegExp(`export\\s+(async\\s+)?function\\s+${expectedExport}\\b`); + if (!pattern.test(src)) { + errors.push(`${name} in ${fp}: missing export \`${expectedExport}\``); + } + } + expect(errors).toEqual([]); + }); + + it('Every handler imports its matching builder from @zseven-w/pen-core', () => { + // tool name → expected builder name + const expectedBuilder = (toolName: string): string => { + const body = toolName.replace(/^add_/, '').replace(/_v0$/, ''); + const pascal = body + .split('_') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(''); + return 'build' + pascal; + }; + + const errors: string[] = []; + for (const name of ELEMENT_TOOL_NAMES) { + const fp = join(TOOLS_DIR, toolNameToHandlerFile(name)); + const src = readFileSync(fp, 'utf-8'); + const builderName = expectedBuilder(name); + // Accept import forms: named import block, single-line, etc. + const pattern = new RegExp( + `import\\s+[^;]*\\b${builderName}\\b[^;]*from\\s+['\"]@zseven-w/pen-core['\"]`, + ); + if (!pattern.test(src)) { + errors.push( + `${name} in ${fp}: handler does not import \`${builderName}\` from @zseven-w/pen-core`, + ); + } + } + expect(errors).toEqual([]); + }); + + it('Every tool has a switch branch in handleElementToolCall', () => { + const routerSrc = readRouterFile(); + const missing: string[] = []; + for (const name of ELEMENT_TOOL_NAMES) { + const caseMatch = new RegExp(`case\\s+['\"]${name.replace(/\./g, '\\.')}['\"]:`); + if (!caseMatch.test(routerSrc)) { + missing.push(name); + } + } + expect(missing).toEqual([]); + }); + + it('Every switch case dispatches to the handler exported from its handler file', () => { + // Verify that inside the switch, the case 'X' body references + // the corresponding handleX function (not some other name). + const routerSrc = readRouterFile(); + const errors: string[] = []; + for (const name of ELEMENT_TOOL_NAMES) { + const handler = toolNameToHandlerExport(name); + // Find the case line + grab the following few lines of body + const caseIdx = routerSrc.indexOf(`case '${name}':`); + if (caseIdx === -1) continue; // covered by previous test + const bodyEnd = routerSrc.indexOf('case ', caseIdx + 1); + const body = routerSrc.slice(caseIdx, bodyEnd === -1 ? undefined : bodyEnd); + if (!body.includes(handler)) { + errors.push(`${name}: switch case body does not call \`${handler}\``); + } + } + expect(errors).toEqual([]); + }); + + it('No orphan handler files (every tools/add-*-v0.ts maps to an ELEMENT_TOOL_NAMES entry)', () => { + const handlerFiles = readdirSync(TOOLS_DIR) + .filter((f) => /^add-[a-z-]+-v0\.ts$/.test(f)) + .filter((f) => { + // Skip `add-section-v0.ts` — it's from an older batch and + // not part of the 42-tool element registry. Discriminate by + // checking if its handler is imported into element-tool-defs. + const src = readRouterFile(); + // Map file name back to handler name: add-section-v0.ts → handleAddSectionV0 + const toolName = f.replace(/\.ts$/, '').replace(/-/g, '_'); + const handler = toolNameToHandlerExport(toolName); + return src.includes(handler); + }); + + const orphans: string[] = []; + for (const f of handlerFiles) { + const toolName = f.replace(/\.ts$/, '').replace(/-/g, '_'); + if (!ELEMENT_TOOL_NAMES.has(toolName)) { + orphans.push(`${f} → expected tool name ${toolName}`); + } + } + expect(orphans).toEqual([]); + }); + + it('No duplicate tool names in ELEMENT_TOOL_DEFINITIONS', () => { + const names = ELEMENT_TOOL_DEFINITIONS.map((d) => d.name); + expect(new Set(names).size).toBe(names.length); + }); + + it('ELEMENT_TOOL_NAMES matches ELEMENT_TOOL_DEFINITIONS names exactly', () => { + const defNames = new Set(ELEMENT_TOOL_DEFINITIONS.map((d) => d.name)); + const namesOnly = new Set(ELEMENT_TOOL_NAMES); + expect([...namesOnly].sort()).toEqual([...defNames].sort()); + }); +});