diff --git a/.changeset/hip-ducks-act.md b/.changeset/hip-ducks-act.md new file mode 100644 index 0000000..762be6d --- /dev/null +++ b/.changeset/hip-ducks-act.md @@ -0,0 +1,5 @@ +--- +'@e18e/mcp': patch +--- + +feat: add `lookup-replacement` tool diff --git a/package.json b/package.json index ec4a854..83f1a5d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "changeset:version": "changeset version && pnpm --filter @e18e/mcp run update:version && git add --all", "lint": "eslint . && prettier . --check", "lint:fix": "eslint . --fix && prettier . --write", + "format": "prettier . --write", "release": "changeset publish", "test": "vitest" }, diff --git a/packages/stdio/package.json b/packages/stdio/package.json index 03155c1..40a79d3 100644 --- a/packages/stdio/package.json +++ b/packages/stdio/package.json @@ -30,7 +30,7 @@ } }, "scripts": { - "build": "pnpm generate:icons && tsgo && pnpm generate:docs && publint", + "build": "pnpm generate:icons && pnpm -C ../.. format && tsgo && pnpm generate:docs && publint", "generate:docs:dev": "node ./scripts/fetch-docs.ts dev", "generate:docs": "node ./scripts/fetch-docs.ts", "generate:icons": "node ./scripts/generate-icons.ts", @@ -48,7 +48,7 @@ "@tmcp/adapter-valibot": "catalog:mcp", "@tmcp/transport-stdio": "catalog:mcp", "es-module-lexer": "catalog:tooling", - "module-replacements": "^2.10.1", + "module-replacements": "catalog:e18e", "tmcp": "catalog:mcp", "valibot": "catalog:tooling" }, diff --git a/packages/stdio/src/index.test.ts b/packages/stdio/src/index.test.ts index bfae860..896829a 100644 --- a/packages/stdio/src/index.test.ts +++ b/packages/stdio/src/index.test.ts @@ -106,7 +106,7 @@ describe('npm-i-checker', () => { expect(tool.structuredContent).toEqual({ suggestions: [ - "Don't use `arr-diff` instead Use a.filter((item) => !b.includes(item))", + "Don't use `arr-diff` instead `const difference = (a, b) => a.filter((item) => !b.includes(item))`", ], }); }); @@ -118,7 +118,7 @@ describe('npm-i-checker', () => { expect(tool.structuredContent).toEqual({ suggestions: [ - "Don't use `arr-diff` instead Use a.filter((item) => !b.includes(item))", + "Don't use `arr-diff` instead `const difference = (a, b) => a.filter((item) => !b.includes(item))`", expect.stringContaining( "Don't use `chalk` instead read the following document:", ), @@ -178,7 +178,7 @@ console.log(diff(a, b));`, expect(tool.structuredContent).toEqual({ suggestions: [ - "Don't use `arr-diff` instead Use a.filter((item) => !b.includes(item))", + "Don't use `arr-diff` instead `const difference = (a, b) => a.filter((item) => !b.includes(item))`", ], }); }); @@ -203,8 +203,80 @@ onMount(()=>{ expect.stringContaining( "Don't use `chalk` instead read the following document:", ), - "Don't use `arr-diff` instead Use a.filter((item) => !b.includes(item))", + "Don't use `arr-diff` instead `const difference = (a, b) => a.filter((item) => !b.includes(item))`", ], }); }); }); + +describe('lookup-replacement', () => { + it('is an available tool', async () => { + const tools = await session.listTools(); + expect(tools.tools).toContainEqual( + expect.objectContaining({ + name: 'lookup-replacement', + }), + ); + }); + + it("doesn't return anything for empty queries", async () => { + const tool = await session.callTool('lookup-replacement', { + query: ' ', + }); + + expect(tool.structuredContent).toEqual({ + results: [], + }); + }); + + it('finds preferred package replacements by package name', async () => { + const tool = await session.callTool('lookup-replacement', { + query: 'chalk', + }); + + expect(tool.structuredContent).toEqual({ + results: expect.arrayContaining([ + expect.objectContaining({ + source: 'preferred', + module_name: 'chalk', + type: 'documented', + documentation: expect.stringContaining('chalk'), + }), + ]), + }); + }); + + it('finds micro utility replacements by replacement text', async () => { + const tool = await session.callTool('lookup-replacement', { + query: 'includes', + }); + + expect(tool.structuredContent).toEqual({ + results: expect.arrayContaining([ + expect.objectContaining({ + source: 'micro-utility', + module_name: 'arr-diff', + replacement: + '`const difference = (a, b) => a.filter((item) => !b.includes(item))`', + }), + ]), + }); + }); + + it('finds native replacements by package name', async () => { + const tool = await session.callTool('lookup-replacement', { + query: 'array-includes', + }); + + expect(tool.structuredContent).toEqual({ + results: expect.arrayContaining([ + expect.objectContaining({ + source: 'native', + module_name: 'array-includes', + replacement: 'Array.prototype.includes', + url: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes', + }), + ]), + }); + }); +}); diff --git a/packages/stdio/src/tools/index.ts b/packages/stdio/src/tools/index.ts index 7da2834..88b75e1 100644 --- a/packages/stdio/src/tools/index.ts +++ b/packages/stdio/src/tools/index.ts @@ -1,2 +1,3 @@ export { npm_i_checker } from './npm-i-checker/index.js'; export { code_checker } from './code-checker/index.js'; +export { lookup_replacement } from './lookup-replacement/index.js'; diff --git a/packages/stdio/src/tools/lookup-replacement/index.ts b/packages/stdio/src/tools/lookup-replacement/index.ts new file mode 100644 index 0000000..78d38de --- /dev/null +++ b/packages/stdio/src/tools/lookup-replacement/index.ts @@ -0,0 +1,42 @@ +import { tool } from 'tmcp/utils'; +import * as v from 'valibot'; +import { icons } from '../../icons/index.js'; +import type { E18EMcpServer } from '../../index.js'; +import { lookup_replacements } from '../utils.js'; + +export function lookup_replacement(server: E18EMcpServer) { + server.tool( + { + name: 'lookup-replacement', + description: + 'Check if a package has a more performant, maintained or efficient replacement by package name, replacement text, or topic. Returns a list of suggestions with descriptions and documentation links.', + icons, + schema: v.object({ + query: v.pipe( + v.string(), + v.description( + 'The package name, replacement text, or topic to search for, e.g. `chalk`, `filter`, or `Array.prototype.map`.', + ), + ), + }), + outputSchema: v.object({ + results: v.array( + v.object({ + source: v.picklist(['native', 'micro-utility', 'preferred']), + module_name: v.string(), + type: v.string(), + description: v.optional(v.string()), + documentation: v.optional(v.string()), + replacement: v.optional(v.string()), + url: v.optional(v.string()), + }), + ), + }), + }, + async ({ query }) => { + return tool.structured({ + results: lookup_replacements(query), + }); + }, + ); +} diff --git a/packages/stdio/src/tools/utils.ts b/packages/stdio/src/tools/utils.ts index 84d3e5a..e835b6e 100644 --- a/packages/stdio/src/tools/utils.ts +++ b/packages/stdio/src/tools/utils.ts @@ -1,31 +1,180 @@ -import microUtilsReplacements from 'module-replacements/manifests/micro-utilities.json' with { type: 'json' }; -import preferredReplacements from 'module-replacements/manifests/preferred.json' with { type: 'json' }; -import type { ModuleReplacement } from 'module-replacements'; +import type { + KnownUrl, + ManifestModule, + ModuleReplacement, + NativeModuleReplacement, + ModuleReplacementMapping, + SimpleModuleReplacement, +} from 'module-replacements'; +import { + microUtilsReplacements, + nativeReplacements, + preferredReplacements, + resolveDocUrl, +} from 'module-replacements'; import { get_docs } from '../docs/index.js'; const docs = await get_docs(); +function get_mapping_replacements( + manifest: ManifestModule, + mapping: ModuleReplacementMapping, +) { + return mapping.replacements + .map((replacement_id) => manifest.replacements[replacement_id]) + .filter((replacement) => replacement !== undefined); +} + +function get_e18e_doc_path(url?: KnownUrl) { + return typeof url === 'object' && url.type === 'e18e' + ? `${url.id}.md` + : undefined; +} + +function describe_replacement(replacement: ModuleReplacement) { + if (replacement.type === 'documented') return replacement.replacementModule; + if (replacement.type === 'native') + return replacement.description ?? replacement.id; + if (replacement.type === 'simple' && replacement.example) + return `\`${replacement.example}\``; + return replacement.description; +} + +function format_replacements( + mapping: ModuleReplacementMapping, + replacements: ModuleReplacement[], +) { + return replacements.length > 0 + ? replacements.map(describe_replacement).join('\n\n') + : `use ${mapping.replacements.join(', ')}`; +} + +export type LookupResult = { + source: 'native' | 'micro-utility' | 'preferred'; + module_name: string; + type: string; + description?: string; + documentation?: string; + replacement?: string; + url?: string; +}; + export function get_suggestions_for_package(pkg: string) { - let replacement = ( - microUtilsReplacements.moduleReplacements as ModuleReplacement[] - ).find((replacement) => replacement.moduleName === pkg); - replacement = - replacement ?? - (preferredReplacements.moduleReplacements as ModuleReplacement[]).find( - (replacement) => replacement.moduleName === pkg, - ); - if ( - replacement && - replacement.type !== 'native' && - replacement.type !== 'none' - ) { - if (replacement.type === 'documented') { - if (!docs[replacement.docPath + '.md']) return; - return `Don't use \`${pkg}\` instead read the following document:\n\n${ - docs[replacement.docPath + '.md'] - }`; + const micro_utils_mapping = microUtilsReplacements.mappings[pkg]; + if (micro_utils_mapping) { + return `Don't use \`${pkg}\` instead ${format_replacements( + micro_utils_mapping, + get_mapping_replacements(microUtilsReplacements, micro_utils_mapping), + )}`; + } + + const preferred_mapping = preferredReplacements.mappings[pkg]; + if (preferred_mapping) { + const doc_path = get_e18e_doc_path(preferred_mapping.url); + const documentation = doc_path ? docs[doc_path] : undefined; + if (documentation) { + return `Don't use \`${pkg}\` instead read the following document:\n\n${documentation}`; } - return `Don't use \`${pkg}\` instead ${replacement.replacement}`; + + return `Don't use \`${pkg}\` instead ${format_replacements( + preferred_mapping, + get_mapping_replacements(preferredReplacements, preferred_mapping), + )}`; } + return; } + +export function lookup_replacements(query: string) { + const normalized_query = query.toLowerCase().trim(); + if (!normalized_query) return []; + + const results: LookupResult[] = []; + + for (const mapping of Object.values(nativeReplacements.mappings)) { + const replacements = get_mapping_replacements(nativeReplacements, mapping); + if ( + !mapping.moduleName.toLowerCase().includes(normalized_query) && + !mapping.replacements.some((replacement) => + replacement.toLowerCase().includes(normalized_query), + ) + ) { + continue; + } + const native_replacement = replacements.find( + (replacement): replacement is NativeModuleReplacement => + replacement.type === 'native', + ); + const node_engine = native_replacement?.engines?.find( + (engine) => engine.engine === 'nodejs', + ); + const url = resolveDocUrl(native_replacement?.url ?? mapping.url); + + results.push({ + source: 'native', + module_name: mapping.moduleName, + type: native_replacement?.type ?? mapping.type, + description: node_engine?.minVersion + ? `Native replacement available in Node.js ${node_engine.minVersion} and newer. Use the built-in API instead.` + : 'Native replacement available. Use the built-in API instead.', + replacement: mapping.replacements.join(', '), + ...(url ? { url } : {}), + }); + } + + for (const mapping of Object.values(microUtilsReplacements.mappings)) { + const replacements = get_mapping_replacements( + microUtilsReplacements, + mapping, + ); + const simple_replacements = replacements.filter( + (replacement): replacement is SimpleModuleReplacement => + replacement.type === 'simple', + ); + if ( + !mapping.moduleName.toLowerCase().includes(normalized_query) && + !simple_replacements.some((replacement) => + `${replacement.description}\n${replacement.example ?? ''}` + .toLowerCase() + .includes(normalized_query), + ) + ) { + continue; + } + + results.push({ + source: 'micro-utility', + module_name: mapping.moduleName, + type: simple_replacements[0]?.type ?? mapping.type, + description: format_replacements(mapping, simple_replacements), + replacement: format_replacements(mapping, simple_replacements), + }); + } + + for (const mapping of Object.values(preferredReplacements.mappings)) { + const doc_path = get_e18e_doc_path(mapping.url); + const documentation = doc_path ? docs[doc_path] : undefined; + const url = resolveDocUrl(mapping.url); + if ( + !mapping.moduleName.toLowerCase().includes(normalized_query) && + !mapping.replacements.some((replacement) => + replacement.toLowerCase().includes(normalized_query), + ) && + !(doc_path ?? '').toLowerCase().includes(normalized_query) && + !documentation?.toLowerCase().includes(normalized_query) + ) { + continue; + } + + results.push({ + source: 'preferred', + module_name: mapping.moduleName, + type: documentation ? 'documented' : mapping.type, + documentation, + replacement: mapping.replacements.join(', '), + ...(url ? { url } : {}), + }); + } + + return results; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eaa1b6e..fa62f9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,10 @@ catalogs: wrangler: specifier: ^4.107.1 version: 4.107.1 + e18e: + module-replacements: + specifier: ^3.0.0 + version: 3.0.0 mcp: '@modelcontextprotocol/inspector': specifier: ^0.18.0 @@ -129,8 +133,8 @@ importers: specifier: catalog:tooling version: 1.7.0 module-replacements: - specifier: ^2.10.1 - version: 2.10.1 + specifier: catalog:e18e + version: 3.0.0 tmcp: specifier: catalog:mcp version: 1.19.4(typescript@5.9.3) @@ -2336,8 +2340,8 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} - module-replacements@2.10.1: - resolution: {integrity: sha512-qkKuLpMHDqRSM676OPL7HUpCiiP3NSxgf8NNR1ga2h/iJLNKTsOSjMEwrcT85DMSti2vmOqxknOVBGWj6H6etQ==} + module-replacements@3.0.0: + resolution: {integrity: sha512-tHIZqde+RlyNRobAIjfcH5UIgIrEbZbDGRL6J/x+HERX/g8O9mrm0p6knJbsTXmQtDvZ+eFP+xfOP3/9jHk6YA==} mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} @@ -5102,7 +5106,7 @@ snapshots: dependencies: brace-expansion: 2.0.2 - module-replacements@2.10.1: {} + module-replacements@3.0.0: {} mri@1.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 92cfabf..8edee9e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,8 @@ catalogs: cloudflare: '@cloudflare/vitest-pool-workers': ^0.12.21 wrangler: ^4.107.1 + e18e: + module-replacements: ^3.0.0 mcp: '@modelcontextprotocol/inspector': ^0.18.0 '@tmcp/adapter-valibot': ^0.1.6