# Directory Structure ``` ├── .cursor │ └── rules │ └── 000_general.mdc ├── .github │ └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .vscode │ └── settings.json ├── biome.json ├── CLAUDE.md ├── lefthook.yaml ├── LICENSE ├── package.json ├── packages │ └── sandbox │ ├── package.json │ ├── README.md │ ├── src │ │ ├── moduleA.ts │ │ ├── moduleB.ts │ │ └── utils.ts │ └── tsconfig.json ├── pnpm-lock.yaml ├── README.md ├── scripts │ └── mcp_launcher.js ├── src │ ├── errors │ │ └── timeout-error.ts │ ├── index.ts │ ├── mcp │ │ ├── config.ts │ │ ├── stdio.ts │ │ └── tools │ │ ├── integration.test.ts │ │ ├── register-find-references-tool.ts │ │ ├── register-move-symbol-to-file-tool.ts │ │ ├── register-remove-path-alias-tool.ts │ │ ├── register-rename-file-system-entry-tool.ts │ │ ├── register-rename-symbol-tool.ts │ │ └── ts-morph-tools.ts │ ├── ts-morph │ │ ├── _utils │ │ │ ├── calculate-relative-path.test.ts │ │ │ ├── calculate-relative-path.ts │ │ │ ├── find-declarations-to-update.test.ts │ │ │ ├── find-declarations-to-update.ts │ │ │ └── ts-morph-project.ts │ │ ├── find-references.test.ts │ │ ├── find-references.ts │ │ ├── move-symbol-to-file │ │ │ ├── classify-dependencies.test.ts │ │ │ ├── classify-dependencies.ts │ │ │ ├── collect-external-imports.test.ts │ │ │ ├── collect-external-imports.ts │ │ │ ├── create-source-file-if-not-exists.test.ts │ │ │ ├── create-source-file-if-not-exists.ts │ │ │ ├── ensure-exports-in-original-file.test.ts │ │ │ ├── ensure-exports-in-original-file.ts │ │ │ ├── find-declaration.test.ts │ │ │ ├── find-declaration.ts │ │ │ ├── generate-content │ │ │ │ ├── build-new-file-import-section.ts │ │ │ │ ├── generate-new-source-file-content.test.ts │ │ │ │ └── generate-new-source-file-content.ts │ │ │ ├── get-declaration-identifier.ts │ │ │ ├── internal-dependencies.test.ts │ │ │ ├── internal-dependencies.ts │ │ │ ├── move-symbol-to-file.dependencies.test.ts │ │ │ ├── move-symbol-to-file.test.ts │ │ │ ├── move-symbol-to-file.ts │ │ │ ├── remove-original-symbol.test.ts │ │ │ ├── remove-original-symbol.ts │ │ │ ├── update-imports-in-referencing-files.test.ts │ │ │ ├── update-imports-in-referencing-files.ts │ │ │ ├── update-target-file.test.ts │ │ │ └── update-target-file.ts │ │ ├── remove-path-alias │ │ │ ├── remove-path-alias.test.ts │ │ │ └── remove-path-alias.ts │ │ ├── rename-file-system │ │ │ ├── _utils │ │ │ │ ├── check-is-path-alias.ts │ │ │ │ ├── find-declarations-for-rename-operation.ts │ │ │ │ ├── find-referencing-declarations-for-identifier.ts │ │ │ │ ├── get-identifier-node-from-declaration.test.ts │ │ │ │ └── get-identifier-node-from-declaration.ts │ │ │ ├── move-file-system-entries.ts │ │ │ ├── prepare-renames.ts │ │ │ ├── rename-file-system-entry.base.test.ts │ │ │ ├── rename-file-system-entry.complex.test.ts │ │ │ ├── rename-file-system-entry.errors.test.ts │ │ │ ├── rename-file-system-entry.index.test.ts │ │ │ ├── rename-file-system-entry.special.test.ts │ │ │ ├── rename-file-system-entry.test.ts │ │ │ ├── rename-file-system-entry.ts │ │ │ └── update-module-specifiers.ts │ │ ├── rename-symbol │ │ │ ├── rename-symbol.test.ts │ │ │ └── rename-symbol.ts │ │ └── types.ts │ └── utils │ ├── logger-helpers.ts │ └── logger.ts ├── tsconfig.json └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional stylelint cache .stylelintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local .env.cloud # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next out # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # vuepress v2.x temp and cache directory .temp .cache # vitepress build output **/.vitepress/dist # vitepress cache directory **/.vitepress/cache # Docusaurus cache and generated files .docusaurus # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* # Taskfile cache .task ``` -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- ```json {} ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { runStdioServer } from "./mcp/stdio"; // サーバー起動 runStdioServer().catch((error: Error) => { process.stderr.write(JSON.stringify({ error: `Fatal error: ${error}` })); process.exit(1); }); ``` -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- ```json { "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "organizeImports": { "enabled": false }, "files": { "ignore": ["dist/**/*", "coverage/**/*"] }, "linter": { "enabled": true, "rules": { "recommended": true } } } ``` -------------------------------------------------------------------------------- /packages/sandbox/src/moduleA.ts: -------------------------------------------------------------------------------- ```typescript export const valueA = "Value from Module A"; export function funcA(): string { console.log("Function A executed"); return "Result from Func A"; } export interface InterfaceA { id: number; name: string; } // Add more complex scenarios later if needed ``` -------------------------------------------------------------------------------- /src/errors/timeout-error.ts: -------------------------------------------------------------------------------- ```typescript export class TimeoutError extends Error { constructor( message: string, public readonly durationSeconds: number, ) { super(message); this.name = "TimeoutError"; // Set the prototype explicitly. Object.setPrototypeOf(this, TimeoutError.prototype); } } ``` -------------------------------------------------------------------------------- /src/mcp/stdio.ts: -------------------------------------------------------------------------------- ```typescript import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { createMcpServer } from "./config"; export async function runStdioServer() { const mcpServer = createMcpServer(); const transport = new StdioServerTransport(); await mcpServer.connect(transport); } ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ``` -------------------------------------------------------------------------------- /packages/sandbox/tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ESNext", "module": "ESNext", "moduleResolution": "node", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "./dist", "rootDir": "./src", "baseUrl": ".", "paths": { "@/*": ["src/*"] } }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ``` -------------------------------------------------------------------------------- /lefthook.yaml: -------------------------------------------------------------------------------- ```yaml pre-commit: parallel: true commands: format: glob: "**/*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}" run: pnpm biome check --write --no-errors-on-unmatched --files-ignore-unknown=true {staged_files} && git update-index --again pre-push: parallel: true commands: format: glob: "**/*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}" run: pnpm biome check --no-errors-on-unmatched --files-ignore-unknown=true {push_files} test: run: pnpm test ``` -------------------------------------------------------------------------------- /packages/sandbox/src/moduleB.ts: -------------------------------------------------------------------------------- ```typescript import { valueA, funcA, type InterfaceA } from "./moduleA"; import { utilFunc1, internalUtil } from "@/utils"; // Use path alias export const valueB = `Value from Module B using ${valueA}`; function privateHelperB() { return `${internalUtil()} from B`; } export function funcB(): InterfaceA { console.log("Function B executed"); utilFunc1(); const resultA = funcA(); console.log("Result from funcA:", resultA); console.log(privateHelperB()); return { id: 1, name: valueB }; } console.log(valueB); ``` -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- ```yaml name: CI on: push: branches: [ main ] pull_request: branches: [ main ] jobs: build_and_test: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up pnpm uses: pnpm/action-setup@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: 'pnpm' - name: Install dependencies run: pnpm install - name: Run type check run: pnpm run check-types - name: Run lint run: pnpm run lint - name: Run tests run: pnpm run test ``` -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- ```typescript import { defineConfig } from "vitest/config"; // https://vitejs.dev/config/ export default defineConfig({ test: { env: { API_ADDRESS: "http://localhost:8080", }, restoreMocks: true, mockReset: true, clearMocks: true, coverage: { provider: "v8", reporter: ["text", "json", "html", "lcov"], exclude: [ "node_modules/**", "dist/**", "packages/sandbox/**", "**/*.test.ts", "**/*.spec.ts", "**/index.ts", "vitest.config.ts", "src/mcp/index.ts", "src/mcp/stdio.ts", "src/utils/logger.ts", "src/utils/logger-helpers.ts", "src/errors/**", ], thresholds: { lines: 70, functions: 70, branches: 65, statements: 70, }, clean: true, all: true, }, }, }); ``` -------------------------------------------------------------------------------- /src/mcp/tools/ts-morph-tools.ts: -------------------------------------------------------------------------------- ```typescript import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerRenameSymbolTool } from "./register-rename-symbol-tool"; import { registerRenameFileSystemEntryTool } from "./register-rename-file-system-entry-tool"; import { registerFindReferencesTool } from "./register-find-references-tool"; import { registerRemovePathAliasTool } from "./register-remove-path-alias-tool"; import { registerMoveSymbolToFileTool } from "./register-move-symbol-to-file-tool"; /** * ts-morph を利用したリファクタリングツール群を MCP サーバーに登録する */ export function registerTsMorphTools(server: McpServer): void { registerRenameSymbolTool(server); registerRenameFileSystemEntryTool(server); registerFindReferencesTool(server); registerRemovePathAliasTool(server); registerMoveSymbolToFileTool(server); } ``` -------------------------------------------------------------------------------- /src/ts-morph/rename-file-system/move-file-system-entries.ts: -------------------------------------------------------------------------------- ```typescript import logger from "../../utils/logger"; import type { RenameOperation } from "../types"; import { performance } from "node:perf_hooks"; export function moveFileSystemEntries( renameOperations: RenameOperation[], signal?: AbortSignal, ) { const startTime = performance.now(); signal?.throwIfAborted(); logger.debug( { count: renameOperations.length }, "Starting file system moves", ); for (const { sourceFile, newPath, oldPath } of renameOperations) { signal?.throwIfAborted(); logger.trace({ from: oldPath, to: newPath }, "Moving file"); try { sourceFile.move(newPath); } catch (err) { logger.error( { err, from: oldPath, to: newPath }, "Error during sourceFile.move()", ); throw err; } } const durationMs = (performance.now() - startTime).toFixed(2); logger.debug({ durationMs }, "Finished file system moves"); } ``` -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- ```typescript import pino from "pino"; import { configureTransport, parseEnvVariables, setupExitHandlers, } from "./logger-helpers"; const env = parseEnvVariables(); const isTestEnv = env.NODE_ENV === "test"; const pinoOptions: pino.LoggerOptions = { level: isTestEnv ? "silent" : env.LOG_LEVEL, base: { pid: process.pid }, timestamp: pino.stdTimeFunctions.isoTime, formatters: { level: (label) => ({ level: label.toUpperCase() }), }, }; const transport = !isTestEnv ? configureTransport(env.NODE_ENV, env.LOG_OUTPUT, env.LOG_FILE_PATH) : undefined; const baseLogger = transport ? pino(pinoOptions, pino.transport(transport)) : pino(pinoOptions); setupExitHandlers(baseLogger); // テスト環境では初期化ログを出力しない if (!isTestEnv) { baseLogger.info( { logLevel: env.LOG_LEVEL, logOutput: env.LOG_OUTPUT, logFilePath: env.LOG_OUTPUT === "file" ? env.LOG_FILE_PATH : undefined, nodeEnv: env.NODE_ENV, }, "ロガー初期化完了", ); } export default baseLogger; ``` -------------------------------------------------------------------------------- /src/ts-morph/rename-file-system/_utils/find-referencing-declarations-for-identifier.ts: -------------------------------------------------------------------------------- ```typescript import { type ExportDeclaration, type Identifier, type ImportDeclaration, SyntaxKind, } from "ts-morph"; import logger from "../../../utils/logger"; export function findReferencingDeclarationsForIdentifier( identifierNode: Identifier, signal?: AbortSignal, ): Set<ImportDeclaration | ExportDeclaration> { const referencingDeclarations = new Set< ImportDeclaration | ExportDeclaration >(); logger.trace( { identifierText: identifierNode.getText() }, "Finding references for identifier", ); const references = identifierNode.findReferencesAsNodes(); for (const referenceNode of references) { signal?.throwIfAborted(); const importOrExportDecl = referenceNode.getFirstAncestorByKind(SyntaxKind.ImportDeclaration) ?? referenceNode.getFirstAncestorByKind(SyntaxKind.ExportDeclaration); if (importOrExportDecl?.getModuleSpecifier()) { referencingDeclarations.add(importOrExportDecl); } } logger.trace( { identifierText: identifierNode.getText(), count: referencingDeclarations.size, }, "Found referencing declarations for identifier", ); return referencingDeclarations; } ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "@sirosuzume/mcp-tsmorph-refactor", "version": "0.2.9", "description": "ts-morph を利用した MCP リファクタリングサーバー", "main": "dist/index.js", "bin": { "mcp-tsmorph-refactor": "dist/index.js" }, "files": ["dist", "package.json", "README.md"], "publishConfig": { "access": "public" }, "repository": { "type": "git", "url": "git+https://github.com/SiroSuzume/mcp-ts-morph.git" }, "packageManager": "[email protected]", "scripts": { "preinstall": "npx only-allow pnpm", "clean": "shx rm -rf dist", "build": "pnpm run clean && tsc && shx chmod +x dist/index.js", "prepublishOnly": "pnpm run build", "inspector": "npx @modelcontextprotocol/inspector node build/index.js", "test": "vitest run --pool threads --poolOptions.threads.singleThread", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "check-types": "tsc --noEmit", "lint": "biome lint ./", "lint:fix": "biome lint --write ./", "format": "biome check --write ./" }, "keywords": ["mcp", "ts-morph", "refactoring"], "author": "SiroSuzume", "license": "MIT", "volta": { "node": "20.19.0" }, "devDependencies": { "@biomejs/biome": "^1.9.4", "@types/node": "^22.14.0", "@vitest/coverage-v8": "3.1.2", "lefthook": "^1.11.8", "pino-pretty": "^13.0.0", "shx": "^0.4.0", "tsx": "^4.19.3", "vitest": "^3.1.1" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.17.5", "pino": "^9.6.0", "ts-morph": "^25.0.1", "typescript": "^5.8.3", "zod": "^3.24.2" } } ``` -------------------------------------------------------------------------------- /src/ts-morph/rename-file-system/_utils/find-declarations-for-rename-operation.ts: -------------------------------------------------------------------------------- ```typescript import type { ExportDeclaration, ImportDeclaration } from "ts-morph"; import logger from "../../../utils/logger"; import type { RenameOperation } from "../../types"; import { findReferencingDeclarationsForIdentifier } from "./find-referencing-declarations-for-identifier"; import { getIdentifierNodeFromDeclaration } from "./get-identifier-node-from-declaration"; export function findDeclarationsForRenameOperation( renameOperation: RenameOperation, signal?: AbortSignal, ): Set<ImportDeclaration | ExportDeclaration> { const { sourceFile } = renameOperation; const declarationsForThisOperation = new Set< ImportDeclaration | ExportDeclaration >(); try { const exportSymbols = sourceFile.getExportSymbols(); logger.trace( { file: sourceFile.getFilePath(), count: exportSymbols.length }, "Found export symbols for rename operation", ); for (const symbol of exportSymbols) { signal?.throwIfAborted(); const symbolDeclarations = symbol.getDeclarations(); for (const symbolDeclaration of symbolDeclarations) { signal?.throwIfAborted(); const identifierNode = getIdentifierNodeFromDeclaration(symbolDeclaration); if (!identifierNode) { continue; } const foundDecls = findReferencingDeclarationsForIdentifier( identifierNode, signal, ); for (const decl of foundDecls) { declarationsForThisOperation.add(decl); } } } } catch (error) { logger.warn( { file: sourceFile.getFilePath(), err: error }, "Error processing rename operation symbols", ); } return declarationsForThisOperation; } ``` -------------------------------------------------------------------------------- /src/ts-morph/_utils/ts-morph-project.ts: -------------------------------------------------------------------------------- ```typescript import { Project, type SourceFile } from "ts-morph"; import * as path from "node:path"; import { NewLineKind } from "typescript"; import logger from "../../utils/logger"; export function initializeProject(tsconfigPath: string): Project { const absoluteTsconfigPath = path.resolve(tsconfigPath); return new Project({ tsConfigFilePath: absoluteTsconfigPath, manipulationSettings: { newLineKind: NewLineKind.LineFeed, }, }); } export function getChangedFiles(project: Project): SourceFile[] { return project.getSourceFiles().filter((sf) => !sf.isSaved()); } export async function saveProjectChanges( project: Project, signal?: AbortSignal, ): Promise<void> { signal?.throwIfAborted(); try { await project.save(); } catch (error) { if (error instanceof Error && error.name === "AbortError") { throw error; } const message = error instanceof Error ? error.message : String(error); throw new Error(`ファイル保存中にエラーが発生しました: ${message}`); } } export function getTsConfigPaths( project: Project, ): Record<string, string[]> | undefined { try { const options = project.compilerOptions.get(); if (!options.paths) { return undefined; } if (typeof options.paths !== "object") { logger.warn( { paths: options.paths }, "Compiler options 'paths' is not an object.", ); return undefined; } const validPaths: Record<string, string[]> = {}; for (const [key, value] of Object.entries(options.paths)) { if ( Array.isArray(value) && value.every((item) => typeof item === "string") ) { validPaths[key] = value; } else { logger.warn( { pathKey: key, pathValue: value }, "Invalid format for paths entry, skipping.", ); } } return validPaths; } catch (error) { logger.error({ err: error }, "Failed to get compiler options or paths"); return undefined; } } export function getTsConfigBaseUrl(project: Project): string | undefined { try { const options = project.compilerOptions.get(); return options.baseUrl; } catch (error) { logger.error({ err: error }, "Failed to get compiler options baseUrl"); return undefined; } } ``` -------------------------------------------------------------------------------- /src/ts-morph/move-symbol-to-file/update-target-file.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from "vitest"; import { Project } from "ts-morph"; import { updateTargetFile } from "./update-target-file"; import type { ImportMap } from "./generate-content/build-new-file-import-section"; describe("updateTargetFile", () => { it("既存ファイルに新しい宣言と、それに必要な新しい名前付きインポートを追加・マージできる", () => { const project = new Project({ useInMemoryFileSystem: true }); const targetFilePath = "/src/target.ts"; project.createSourceFile( "/utils.ts", "export const foo = 1; export const bar = 2; export const qux = 3;", ); const initialContent = `import { foo, bar } from "../utils"; console.log(foo); console.log(bar); `; const targetSourceFile = project.createSourceFile( targetFilePath, initialContent, ); const requiredImportMap: ImportMap = new Map([ [ "../utils", { namedImports: new Set(["qux"]), isNamespaceImport: false, }, ], ]); const declarationStrings: string[] = [ "export function baz() { return qux(); }", ]; const expectedContent = `import { bar, foo, qux } from "../utils"; console.log(foo); console.log(bar); export function baz() { return qux(); } `; updateTargetFile(targetSourceFile, requiredImportMap, declarationStrings); expect(targetSourceFile.getFullText().trim()).toBe(expectedContent.trim()); }); it("requiredImportMap に自己参照パスが含まれていても、自己参照インポートは追加しない", () => { const project = new Project({ useInMemoryFileSystem: true }); const targetFilePath = "/src/target.ts"; const initialContent = `export type ExistingType = number; console.log('hello'); `; const targetSourceFile = project.createSourceFile( targetFilePath, initialContent, ); const requiredImportMap: ImportMap = new Map([ [ ".", { namedImports: new Set(["ExistingType"]), isNamespaceImport: false, }, ], ]); const declarationStrings: string[] = []; const expectedContent = initialContent; updateTargetFile(targetSourceFile, requiredImportMap, declarationStrings); expect(targetSourceFile.getFullText().trim()).toBe(expectedContent.trim()); }); // TODO: Add more realistic test cases (e.g., default imports, different modules) }); ``` -------------------------------------------------------------------------------- /src/ts-morph/rename-file-system/prepare-renames.ts: -------------------------------------------------------------------------------- ```typescript import logger from "../../utils/logger"; import type { PathMapping, RenameOperation } from "../types"; import * as path from "node:path"; import { performance } from "node:perf_hooks"; import type { Project } from "ts-morph"; function checkDestinationExists( project: Project, pathToCheck: string, signal?: AbortSignal, ): void { signal?.throwIfAborted(); if (project.getSourceFile(pathToCheck)) { throw new Error(`リネーム先パスに既にファイルが存在します: ${pathToCheck}`); } if (project.getDirectory(pathToCheck)) { throw new Error( `リネーム先パスに既にディレクトリが存在します: ${pathToCheck}`, ); } } export function prepareRenames( project: Project, renames: PathMapping[], signal?: AbortSignal, ): RenameOperation[] { const startTime = performance.now(); signal?.throwIfAborted(); const renameOperations: RenameOperation[] = []; const uniqueNewPaths = new Set<string>(); logger.debug({ count: renames.length }, "Starting rename preparation"); for (const rename of renames) { signal?.throwIfAborted(); const logRename = { old: rename.oldPath, new: rename.newPath }; logger.trace({ rename: logRename }, "Processing rename request"); const absoluteOldPath = path.resolve(rename.oldPath); const absoluteNewPath = path.resolve(rename.newPath); if (uniqueNewPaths.has(absoluteNewPath)) { throw new Error(`リネーム先のパスが重複しています: ${absoluteNewPath}`); } uniqueNewPaths.add(absoluteNewPath); checkDestinationExists(project, absoluteNewPath, signal); signal?.throwIfAborted(); const sourceFile = project.getSourceFile(absoluteOldPath); const directory = project.getDirectory(absoluteOldPath); if (sourceFile) { logger.trace({ path: absoluteOldPath }, "Identified as file rename"); renameOperations.push({ sourceFile, oldPath: absoluteOldPath, newPath: absoluteNewPath, }); } else if (directory) { logger.trace({ path: absoluteOldPath }, "Identified as directory rename"); signal?.throwIfAborted(); const filesInDir = directory.getDescendantSourceFiles(); logger.trace( { path: absoluteOldPath, count: filesInDir.length }, "Found files in directory to rename", ); for (const sf of filesInDir) { const oldFilePath = sf.getFilePath(); const relative = path.relative(absoluteOldPath, oldFilePath); const newFilePath = path.resolve(absoluteNewPath, relative); logger.trace( { oldFile: oldFilePath, newFile: newFilePath }, "Adding directory file to rename operations", ); renameOperations.push({ sourceFile: sf, oldPath: oldFilePath, newPath: newFilePath, }); } } else { throw new Error(`リネーム対象が見つかりません: ${absoluteOldPath}`); } } const durationMs = (performance.now() - startTime).toFixed(2); logger.debug( { operationCount: renameOperations.length, durationMs }, "Finished rename preparation", ); return renameOperations; } ``` -------------------------------------------------------------------------------- /src/mcp/tools/register-remove-path-alias-tool.ts: -------------------------------------------------------------------------------- ```typescript import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { removePathAlias } from "../../ts-morph/remove-path-alias/remove-path-alias"; import { Project } from "ts-morph"; import * as path from "node:path"; // path モジュールが必要 import { performance } from "node:perf_hooks"; export function registerRemovePathAliasTool(server: McpServer): void { server.tool( "remove_path_alias_by_tsmorph", `[Uses ts-morph] Replaces path aliases (e.g., '@/') with relative paths in import/export statements within the specified target path. Analyzes the project based on \`tsconfig.json\` to resolve aliases and calculate relative paths. ## Usage Use this tool to convert alias paths like \`import Button from '@/components/Button'\` to relative paths like \`import Button from '../../components/Button'\`. This can be useful for improving portability or adhering to specific project conventions. 1. Specify the **absolute path** to the project\`tsconfig.json\`. 2. Specify the **absolute path** to the target file or directory where path aliases should be removed. 3. Optionally, run with \`dryRun: true\` to preview the changes without modifying files. ## Parameters - tsconfigPath (string, required): Absolute path to the project\`tsconfig.json\` file. **Must be an absolute path.** - targetPath (string, required): The absolute path to the file or directory to process. **Must be an absolute path.** - dryRun (boolean, optional): If true, only show intended changes without modifying files. Defaults to false. ## Result - On success: Returns a message containing the list of file paths modified (or scheduled to be modified if dryRun). - On failure: Returns a message indicating the error.`, { tsconfigPath: z .string() .describe("Absolute path to the project's tsconfig.json file."), targetPath: z .string() .describe("Absolute path to the target file or directory."), dryRun: z .boolean() .optional() .default(false) .describe( "If true, only show intended changes without modifying files.", ), }, async (args) => { const startTime = performance.now(); let message = ""; let isError = false; let duration = "0.00"; const project = new Project({ tsConfigFilePath: args.tsconfigPath, }); try { const { tsconfigPath, targetPath, dryRun } = args; const compilerOptions = project.compilerOptions.get(); const tsconfigDir = path.dirname(tsconfigPath); const baseUrl = path.resolve( tsconfigDir, compilerOptions.baseUrl ?? ".", ); const pathsOption = compilerOptions.paths ?? {}; const result = await removePathAlias({ project, targetPath, dryRun, baseUrl, paths: pathsOption, }); if (!dryRun) { await project.save(); } const changedFilesList = result.changedFiles.length > 0 ? result.changedFiles.join("\n - ") : "(No changes)"; const actionVerb = dryRun ? "scheduled for modification" : "modified"; message = `Path alias removal (${ dryRun ? "Dry run" : "Execute" }): Within the specified path '${targetPath}', the following files were ${actionVerb}:\n - ${changedFilesList}`; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); message = `Error during path alias removal process: ${errorMessage}`; isError = true; } finally { const endTime = performance.now(); duration = ((endTime - startTime) / 1000).toFixed(2); } const finalMessage = `${message}\nStatus: ${ isError ? "Failure" : "Success" }\nProcessing time: ${duration} seconds`; return { content: [{ type: "text", text: finalMessage }], isError: isError, }; }, ); } ``` -------------------------------------------------------------------------------- /src/mcp/tools/register-find-references-tool.ts: -------------------------------------------------------------------------------- ```typescript import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { findSymbolReferences } from "../../ts-morph/find-references"; // 新しい関数と型をインポート import { performance } from "node:perf_hooks"; export function registerFindReferencesTool(server: McpServer): void { server.tool( "find_references_by_tsmorph", `[Uses ts-morph] Finds the definition and all references to a symbol at a given position throughout the project. Analyzes the project based on \`tsconfig.json\` to locate the definition and all usages of the symbol (function, variable, class, etc.) specified by its position. ## Usage Use this tool before refactoring to understand the impact of changing a specific symbol. It helps identify where a function is called, where a variable is used, etc. 1. Specify the **absolute path** to the project's \`tsconfig.json\`. 2. Specify the **absolute path** to the file containing the symbol you want to investigate. 3. Specify the exact **position** (line and column) of the symbol within the file. ## Parameters - tsconfigPath (string, required): Absolute path to the project's root \`tsconfig.json\` file. Essential for ts-morph to parse the project. **Must be an absolute path.** - targetFilePath (string, required): The absolute path to the file containing the symbol to find references for. **Must be an absolute path.** - position (object, required): The exact position of the symbol to find references for. - line (number, required): 1-based line number. - column (number, required): 1-based column number. ## Result - On success: Returns a message containing the definition location (if found) and a list of reference locations (file path, line number, column number, and line text). - On failure: Returns a message indicating the error.`, { tsconfigPath: z .string() .describe("Absolute path to the project's tsconfig.json file."), targetFilePath: z .string() .describe("Absolute path to the file containing the symbol."), position: z .object({ line: z.number().describe("1-based line number."), column: z.number().describe("1-based column number."), }) .describe("The exact position of the symbol."), }, async (args) => { const startTime = performance.now(); let message = ""; let isError = false; let duration = "0.00"; // duration を外で宣言・初期化 try { const { tsconfigPath, targetFilePath, position } = args; const { references, definition } = await findSymbolReferences({ tsconfigPath: tsconfigPath, targetFilePath: targetFilePath, position, }); let resultText = ""; if (definition) { resultText += "Definition:\n"; resultText += `- ${definition.filePath}:${definition.line}:${definition.column}\n`; resultText += ` \`\`\`typescript\n ${definition.text}\n \`\`\`\n\n`; } else { resultText += "Definition not found.\n\n"; } if (references.length > 0) { resultText += `References (${references.length} found):\n`; const formattedReferences = references .map( (ref) => `- ${ref.filePath}:${ref.line}:${ref.column}\n \`\`\`typescript\n ${ref.text}\n \`\`\`\``, ) .join("\n\n"); resultText += formattedReferences; } else { resultText += "References not found."; } message = resultText.trim(); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); message = `Error during reference search: ${errorMessage}`; isError = true; } finally { const endTime = performance.now(); duration = ((endTime - startTime) / 1000).toFixed(2); // duration を更新 } // finally の外で return する const finalMessage = `${message}\nStatus: ${ isError ? "Failure" : "Success" }\nProcessing time: ${duration} seconds`; return { content: [{ type: "text", text: finalMessage }], isError: isError, }; }, ); } ``` -------------------------------------------------------------------------------- /src/mcp/tools/register-rename-symbol-tool.ts: -------------------------------------------------------------------------------- ```typescript import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { renameSymbol } from "../../ts-morph/rename-symbol/rename-symbol"; import { performance } from "node:perf_hooks"; export function registerRenameSymbolTool(server: McpServer): void { server.tool( "rename_symbol_by_tsmorph", // Note for developers: // The following English description is primarily intended for the LLM's understanding. // Please refer to the JSDoc comment above for the original Japanese description. `[Uses ts-morph] Renames TypeScript/JavaScript symbols across the project. Analyzes the AST (Abstract Syntax Tree) to track and update references throughout the project, not just the definition site. Useful for cross-file refactoring tasks during Vibe Coding. ## Usage Use this tool, for example, when you change a function name defined in one file and want to reflect that change in other files that import and use it. ts-morph parses the project based on \`tsconfig.json\` to resolve symbol references and perform the rename. 1. Specify the exact location (file path, line, column) of the symbol (function name, variable name, class name, etc.) you want to rename. This is necessary for ts-morph to identify the target Identifier node in the AST. 2. Specify the current symbol name and the new symbol name. 3. It\'s recommended to first run with \`dryRun: true\` to check which files ts-morph will modify. 4. If the preview looks correct, run with \`dryRun: false\` (or omit it) to actually save the changes to the file system. ## Parameters - tsconfigPath (string, required): Path to the project\'s root \`tsconfig.json\` file. Essential for ts-morph to correctly parse the project structure and file references. **Must be an absolute path (relative paths can be misinterpreted).** - targetFilePath (string, required): Path to the file where the symbol to be renamed is defined (or first appears). **Must be an absolute path (relative paths can be misinterpreted).** - position (object, required): The exact position on the symbol to be renamed. Serves as the starting point for ts-morph to locate the AST node. - line (number, required): 1-based line number, typically obtained from an editor. - column (number, required): 1-based column number (position of the first character of the symbol name), typically obtained from an editor. - symbolName (string, required): The current name of the symbol before renaming. Used to verify against the node name found at the specified position. - newName (string, required): The new name for the symbol after renaming. - dryRun (boolean, optional): If set to true, prevents ts-morph from making and saving file changes, returning only the list of files that would be affected. Useful for verification. Defaults to false. ## Result - On success: Returns a message containing the list of file paths modified (or scheduled to be modified if dryRun) by the rename. - On failure: Returns a message indicating the error.`, { tsconfigPath: z .string() .describe("Path to the project's tsconfig.json file."), targetFilePath: z .string() .describe("Path to the file containing the symbol to rename."), position: z .object({ line: z.number().describe("1-based line number."), column: z.number().describe("1-based column number."), }) .describe("The exact position of the symbol to rename."), symbolName: z.string().describe("The current name of the symbol."), newName: z.string().describe("The new name for the symbol."), dryRun: z .boolean() .optional() .default(false) .describe( "If true, only show intended changes without modifying files.", ), }, async (args) => { const startTime = performance.now(); let message = ""; let isError = false; let duration = "0.00"; try { const { tsconfigPath, targetFilePath, position, symbolName, newName, dryRun, } = args; const result = await renameSymbol({ tsconfigPath: tsconfigPath, targetFilePath: targetFilePath, position: position, symbolName: symbolName, newName: newName, dryRun: dryRun, }); const changedFilesList = result.changedFiles.length > 0 ? result.changedFiles.join("\n - ") : "(No changes)"; if (dryRun) { message = `Dry run complete: Renaming symbol '${symbolName}' to '${newName}' would modify the following files:\n - ${changedFilesList}`; } else { message = `Rename successful: Renamed symbol '${symbolName}' to '${newName}'. The following files were modified:\n - ${changedFilesList}`; } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); message = `Error during rename process: ${errorMessage}`; isError = true; } finally { const endTime = performance.now(); duration = ((endTime - startTime) / 1000).toFixed(2); } const finalMessage = `${message}\nStatus: ${ isError ? "Failure" : "Success" }\nProcessing time: ${duration} seconds`; return { content: [{ type: "text", text: finalMessage }], isError: isError, }; }, ); } ``` -------------------------------------------------------------------------------- /src/ts-morph/rename-file-system/rename-file-system-entry.complex.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from "vitest"; import * as path from "node:path"; import { Project } from "ts-morph"; import { renameFileSystemEntry } from "./rename-file-system-entry"; // --- Test Setup Helper --- const setupProject = () => { const project = new Project({ useInMemoryFileSystem: true, compilerOptions: { baseUrl: ".", paths: { "@/*": ["src/*"], }, esModuleInterop: true, allowJs: true, }, }); project.createDirectory("/src"); project.createDirectory("/src/utils"); project.createDirectory("/src/components"); project.createDirectory("/src/internal-feature"); return project; }; describe("renameFileSystemEntry Complex Cases", () => { it("内部参照を持つフォルダをリネームする", async () => { const project = setupProject(); const oldDirPath = "/src/internal-feature"; const newDirPath = "/src/cool-feature"; const file1Path = path.join(oldDirPath, "file1.ts"); const file2Path = path.join(oldDirPath, "file2.ts"); project.createSourceFile( file1Path, `import { value2 } from './file2'; export const value1 = value2 + 1;`, ); project.createSourceFile(file2Path, "export const value2 = 100;"); await renameFileSystemEntry({ project, renames: [{ oldPath: oldDirPath, newPath: newDirPath }], dryRun: false, }); expect(project.getDirectory(newDirPath)).toBeDefined(); const movedFile1 = project.getSourceFile(path.join(newDirPath, "file1.ts")); expect(movedFile1).toBeDefined(); expect(movedFile1?.getFullText()).toContain( "import { value2 } from './file2';", ); }); it("複数のファイルを同時にリネームし、それぞれの参照が正しく更新される", async () => { const project = setupProject(); const oldFile1 = "/src/utils/file1.ts"; const newFile1 = "/src/utils/renamed1.ts"; const oldFile2 = "/src/components/file2.ts"; const newFile2 = "/src/components/renamed2.ts"; const refFile = "/src/ref.ts"; project.createSourceFile(oldFile1, "export const val1 = 1;"); project.createSourceFile(oldFile2, "export const val2 = 2;"); project.createSourceFile( refFile, `import { val1 } from './utils/file1';\nimport { val2 } from './components/file2';`, ); await renameFileSystemEntry({ project, renames: [ { oldPath: oldFile1, newPath: newFile1 }, { oldPath: oldFile2, newPath: newFile2 }, ], dryRun: false, }); expect(project.getSourceFile(oldFile1)).toBeUndefined(); expect(project.getSourceFile(newFile1)).toBeDefined(); expect(project.getSourceFile(oldFile2)).toBeUndefined(); expect(project.getSourceFile(newFile2)).toBeDefined(); const updatedRef = project.getSourceFileOrThrow(refFile).getFullText(); expect(updatedRef).toContain("import { val1 } from './utils/renamed1';"); expect(updatedRef).toContain( "import { val2 } from './components/renamed2';", ); }); it("ファイルとディレクトリを同時にリネームし、それぞれの参照が正しく更新される", async () => { const project = setupProject(); const oldFile = "/src/utils/fileA.ts"; const newFile = "/src/utils/fileRenamed.ts"; const oldDir = "/src/components"; const newDir = "/src/widgets"; const compInDir = path.join(oldDir, "comp.ts"); const refFile = "/src/ref.ts"; project.createSourceFile(oldFile, "export const valA = 'A';"); project.createSourceFile(compInDir, "export const valComp = 'Comp';"); project.createSourceFile( refFile, `import { valA } from './utils/fileA';\nimport { valComp } from './components/comp';`, ); await renameFileSystemEntry({ project, renames: [ { oldPath: oldFile, newPath: newFile }, { oldPath: oldDir, newPath: newDir }, ], dryRun: false, }); expect(project.getSourceFile(oldFile)).toBeUndefined(); expect(project.getSourceFile(newFile)).toBeDefined(); expect(project.getDirectory(newDir)).toBeDefined(); expect(project.getSourceFile(path.join(newDir, "comp.ts"))).toBeDefined(); const updatedRef = project.getSourceFileOrThrow(refFile).getFullText(); expect(updatedRef).toContain("import { valA } from './utils/fileRenamed';"); expect(updatedRef).toContain("import { valComp } from './widgets/comp';"); }); it("ファイル名をスワップする(一時ファイル経由)", async () => { const project = setupProject(); const fileA = "/src/fileA.ts"; const fileB = "/src/fileB.ts"; const tempFile = "/src/temp.ts"; const refFile = "/src/ref.ts"; project.createSourceFile(fileA, "export const valA = 'A';"); project.createSourceFile(fileB, "export const valB = 'B';"); project.createSourceFile( refFile, `import { valA } from './fileA';\nimport { valB } from './fileB';`, ); await renameFileSystemEntry({ project, renames: [{ oldPath: fileA, newPath: tempFile }], dryRun: false, }); await renameFileSystemEntry({ project, renames: [{ oldPath: fileB, newPath: fileA }], dryRun: false, }); await renameFileSystemEntry({ project, renames: [{ oldPath: tempFile, newPath: fileB }], dryRun: false, }); expect(project.getSourceFile(tempFile)).toBeUndefined(); expect(project.getSourceFile(fileA)?.getFullText()).toContain( "export const valB = 'B';", ); expect(project.getSourceFile(fileB)?.getFullText()).toContain( "export const valA = 'A';", ); const updatedRef = project.getSourceFileOrThrow(refFile).getFullText(); expect(updatedRef).toContain("import { valA } from './fileB';"); expect(updatedRef).toContain("import { valB } from './fileA';"); }); }); ``` -------------------------------------------------------------------------------- /src/ts-morph/rename-file-system/rename-file-system-entry.base.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from "vitest"; import { Project } from "ts-morph"; import * as path from "node:path"; import { renameFileSystemEntry } from "./rename-file-system-entry"; // --- Test Setup Helper --- const setupProject = () => { const project = new Project({ useInMemoryFileSystem: true, compilerOptions: { baseUrl: ".", paths: { "@/*": ["src/*"], }, esModuleInterop: true, allowJs: true, }, }); // 共通のディレクトリ構造をメモリ上に作成 project.createDirectory("/src"); project.createDirectory("/src/utils"); project.createDirectory("/src/components"); project.createDirectory("/src/old-feature"); project.createDirectory("/src/myFeature"); project.createDirectory("/src/anotherFeature"); project.createDirectory("/src/dirA"); project.createDirectory("/src/dirB"); project.createDirectory("/src/dirC"); project.createDirectory("/src/core"); project.createDirectory("/src/widgets"); return project; }; describe("renameFileSystemEntry Base Cases", () => { it("ファイルリネーム時に相対パスとエイリアスパスのimport文を正しく更新する", async () => { const project = setupProject(); const oldUtilPath = "/src/utils/old-util.ts"; const newUtilPath = "/src/utils/new-util.ts"; const componentPath = "/src/components/MyComponent.ts"; const utilIndexPath = "/src/utils/index.ts"; project.createSourceFile( oldUtilPath, 'export const oldUtil = () => "old";', ); project.createSourceFile(utilIndexPath, 'export * from "./old-util";'); project.createSourceFile( componentPath, `import { oldUtil as relativeImport } from '../utils/old-util'; import { oldUtil as aliasImport } from '@/utils/old-util'; import { oldUtil as indexImport } from '../utils'; console.log(relativeImport(), aliasImport(), indexImport()); `, ); await renameFileSystemEntry({ project, renames: [{ oldPath: oldUtilPath, newPath: newUtilPath }], dryRun: false, }); const updatedComponentContent = project .getSourceFileOrThrow(componentPath) .getFullText(); expect(updatedComponentContent).toBe( `import { oldUtil as relativeImport } from '../utils/new-util'; import { oldUtil as aliasImport } from '../utils/new-util'; import { oldUtil as indexImport } from '../utils'; console.log(relativeImport(), aliasImport(), indexImport()); `, ); expect(project.getSourceFile(oldUtilPath)).toBeUndefined(); expect(project.getSourceFile(newUtilPath)).toBeDefined(); }); it("フォルダリネーム時に相対パスとエイリアスパスのimport文を正しく更新する", async () => { const project = setupProject(); const oldFeatureDir = "/src/old-feature"; const newFeatureDir = "/src/new-feature"; const featureFilePath = path.join(oldFeatureDir, "feature.ts"); const componentPath = "/src/components/AnotherComponent.ts"; const featureIndexPath = path.join(oldFeatureDir, "index.ts"); project.createSourceFile( featureFilePath, 'export const feature = () => "feature";', ); project.createSourceFile(featureIndexPath, 'export * from "./feature";'); project.createSourceFile( componentPath, `import { feature as relativeImport } from '../old-feature/feature'; import { feature as aliasImport } from '@/old-feature/feature'; import { feature as indexImport } from '../old-feature'; console.log(relativeImport(), aliasImport(), indexImport()); `, ); await renameFileSystemEntry({ project, renames: [{ oldPath: oldFeatureDir, newPath: newFeatureDir }], dryRun: false, }); const updatedComponentContent = project .getSourceFileOrThrow(componentPath) .getFullText(); expect( updatedComponentContent, ).toBe(`import { feature as relativeImport } from '../new-feature/feature'; import { feature as aliasImport } from '../new-feature/feature'; import { feature as indexImport } from '../new-feature/index'; console.log(relativeImport(), aliasImport(), indexImport()); `); expect(project.getDirectory(newFeatureDir)).toBeDefined(); expect( project.getSourceFile(path.join(newFeatureDir, "feature.ts")), ).toBeDefined(); expect( project.getSourceFile(path.join(newFeatureDir, "index.ts")), ).toBeDefined(); }); it("同階層(.)や親階層(..)への相対パスimport文を持つファイルをリネームした際に、参照元のパスが正しく更新される", async () => { const project = setupProject(); const dirA = "/src/dirA"; const dirB = "/src/dirB"; const fileA1Path = path.join(dirA, "fileA1.ts"); const fileA2Path = path.join(dirA, "fileA2.ts"); const fileBPath = path.join(dirB, "fileB.ts"); const fileA3Path = path.join(dirA, "fileA3.ts"); project.createSourceFile(fileA1Path, "export const valA1 = 1;"); project.createSourceFile(fileA2Path, "export const valA2 = 2;"); project.createSourceFile( fileBPath, ` import { valA2 } from '../dirA/fileA2'; import { valA1 } from '../dirA/fileA1'; console.log(valA2, valA1); `, ); project.createSourceFile( fileA3Path, ` import { valA2 } from './fileA2'; console.log(valA2); `, ); const newFileA2Path = path.join(dirA, "renamedA2.ts"); await renameFileSystemEntry({ project, renames: [{ oldPath: fileA2Path, newPath: newFileA2Path }], dryRun: false, }); const updatedFileBContent = project .getSourceFileOrThrow(fileBPath) .getFullText(); const updatedFileA3Content = project .getSourceFileOrThrow(fileA3Path) .getFullText(); expect(updatedFileBContent).toContain( "import { valA2 } from '../dirA/renamedA2';", ); expect(updatedFileBContent).toContain( "import { valA1 } from '../dirA/fileA1';", ); expect(updatedFileA3Content).toContain( "import { valA2 } from './renamedA2';", ); expect(project.getSourceFile(fileA2Path)).toBeUndefined(); expect(project.getSourceFile(newFileA2Path)).toBeDefined(); }); it("親階層(..)への相対パスimport文を持つファイルを、別のディレクトリに移動(リネーム)した際に、参照元のパスが正しく更新される", async () => { const project = setupProject(); const dirA = "/src/dirA"; const dirC = "/src/dirC"; const fileA1Path = path.join(dirA, "fileA1.ts"); const fileA2Path = path.join(dirA, "fileA2.ts"); project.createSourceFile(fileA1Path, "export const valA1 = 1;"); project.createSourceFile( fileA2Path, ` import { valA1 } from './fileA1'; console.log(valA1); `, ); const newFileA1Path = path.join(dirC, "movedA1.ts"); await renameFileSystemEntry({ project, renames: [{ oldPath: fileA1Path, newPath: newFileA1Path }], dryRun: false, }); const updatedFileA2Content = project .getSourceFileOrThrow(fileA2Path) .getFullText(); expect(updatedFileA2Content).toContain( "import { valA1 } from '../dirC/movedA1';", ); expect(project.getSourceFile(fileA1Path)).toBeUndefined(); expect(project.getSourceFile(newFileA1Path)).toBeDefined(); }); }); ``` -------------------------------------------------------------------------------- /src/ts-morph/move-symbol-to-file/generate-content/build-new-file-import-section.ts: -------------------------------------------------------------------------------- ```typescript import logger from "../../../utils/logger"; import { calculateRelativePath } from "../../_utils/calculate-relative-path"; import type { DependencyClassification, NeededExternalImports, } from "../../types"; type ExtendedImportInfo = { defaultName?: string; namedImports: Set<string>; isNamespaceImport: boolean; namespaceImportName?: string; }; export type ImportMap = Map<string, ExtendedImportInfo>; function aggregateImports( importMap: ImportMap, relativePath: string, importName: string, isDefault: boolean, ) { if (isDefault) { const actualDefaultName = importName; if (!importMap.has(relativePath)) { importMap.set(relativePath, { namedImports: new Set(), isNamespaceImport: false, }); } const entry = importMap.get(relativePath); if (!entry || entry.isNamespaceImport) { logger.warn( `Skipping default import aggregation for ${relativePath} due to existing namespace import or missing entry.`, ); return; } entry.defaultName = actualDefaultName; logger.debug( `Aggregated default import: ${actualDefaultName} for path: ${relativePath}`, ); return; } const nameToAdd = importName; if (!importMap.has(relativePath)) { importMap.set(relativePath, { namedImports: new Set(), isNamespaceImport: false, }); } const entry = importMap.get(relativePath); if (!entry || entry.isNamespaceImport) { logger.warn( `Skipping named import aggregation for ${relativePath} due to existing namespace import or missing entry.`, ); return; } entry.namedImports.add(nameToAdd); logger.debug( `Aggregated named import: ${nameToAdd} for path: ${relativePath}`, ); } function processExternalImports( importMap: ImportMap, neededExternalImports: NeededExternalImports, newFilePath: string, ): void { logger.debug("Processing external imports..."); for (const [ originalModuleSpecifier, { names, declaration, isNamespaceImport, namespaceImportName }, ] of neededExternalImports.entries()) { const moduleSourceFile = declaration?.getModuleSpecifierSourceFile(); let relativePath = ""; let isSelfReference = false; if ( moduleSourceFile && !moduleSourceFile.getFilePath().includes("/node_modules/") ) { const absoluteModulePath = moduleSourceFile.getFilePath(); if (absoluteModulePath === newFilePath) { isSelfReference = true; } else { relativePath = calculateRelativePath(newFilePath, absoluteModulePath); logger.debug( `Calculated relative path for NON-node_modules import: ${relativePath} (from ${absoluteModulePath})`, ); } } else { relativePath = originalModuleSpecifier; logger.debug( `Using original module specifier for node_modules or unresolved import: ${relativePath}`, ); } if (isSelfReference) { logger.debug(`Skipping self-reference import for path: ${newFilePath}`); continue; } if (isNamespaceImport && namespaceImportName) { if (!importMap.has(relativePath)) { importMap.set(relativePath, { namedImports: new Set(), isNamespaceImport: true, namespaceImportName: namespaceImportName, }); logger.debug( `Added namespace import: ${namespaceImportName} for path: ${relativePath}`, ); } else { logger.warn( `Namespace import for ${relativePath} conflicts with existing non-namespace imports. Skipping.`, ); } continue; } const defaultImportNode = declaration?.getDefaultImport(); const actualDefaultName = defaultImportNode?.getText(); for (const name of names) { const isDefaultFlag = name === "default" && !!actualDefaultName; if (isDefaultFlag) { if (!actualDefaultName) { logger.warn( `Default import name was expected but not found for ${relativePath}. Skipping default import.`, ); continue; } aggregateImports(importMap, relativePath, actualDefaultName, true); } else { aggregateImports(importMap, relativePath, name, false); } } } } function processInternalDependencies( importMap: ImportMap, classifiedDependencies: DependencyClassification[], newFilePath: string, originalFilePath: string, ): void { logger.debug("Processing internal dependencies for import map..."); if (newFilePath === originalFilePath) { logger.debug( "Skipping internal dependency processing as source and target files are the same.", ); return; } const dependenciesToImportNames = new Set<string>(); for (const dep of classifiedDependencies) { if (dep.type === "importFromOriginal" || dep.type === "addExport") { logger.debug(`Internal dependency to import from original: ${dep.name}`); dependenciesToImportNames.add(dep.name); } } if (dependenciesToImportNames.size === 0) { logger.debug("No internal dependencies need importing from original file."); return; } const internalImportPath = calculateRelativePath( newFilePath, originalFilePath, ); logger.debug( `Calculated relative path for internal import: ${internalImportPath}`, ); if (internalImportPath !== "." && internalImportPath !== "./") { for (const name of dependenciesToImportNames) { aggregateImports(importMap, internalImportPath, name, false); } } else { logger.debug("Skipping aggregation for self-referencing internal path."); } } function buildImportStatementString( defaultImportName: string | undefined, namedImportSpecifiers: string, relativePath: string, isNamespaceImport: boolean, namespaceImportName?: string, ): string { const fromPart = `from "${relativePath}";`; if (isNamespaceImport && namespaceImportName) { return `import * as ${namespaceImportName} ${fromPart}`; } if (!defaultImportName && !namedImportSpecifiers) { logger.debug(`Building side-effect import for ${relativePath}`); return `import ${fromPart}`; } const defaultPart = defaultImportName ? `${defaultImportName}` : ""; const namedPart = namedImportSpecifiers ? `{ ${namedImportSpecifiers} }` : ""; const separator = defaultPart && namedPart ? ", " : ""; return `import ${defaultPart}${separator}${namedPart} ${fromPart}`; } export function calculateRequiredImportMap( neededExternalImports: NeededExternalImports, classifiedDependencies: DependencyClassification[], newFilePath: string, originalFilePath: string, ): ImportMap { const importMap: ImportMap = new Map(); processExternalImports(importMap, neededExternalImports, newFilePath); processInternalDependencies( importMap, classifiedDependencies, newFilePath, originalFilePath, ); return importMap; } export function buildImportSectionStringFromMap(importMap: ImportMap): string { logger.debug("Generating import section string..."); let importSection = ""; const sortedPaths = [...importMap.keys()].sort(); for (const path of sortedPaths) { const importData = importMap.get(path); if (!importData) { logger.warn(`Import data not found for path ${path} during generation.`); continue; } const { defaultName, namedImports, isNamespaceImport, namespaceImportName, } = importData; const sortedNamedImports = [...namedImports].sort().join(", "); const importStatement = buildImportStatementString( defaultName, sortedNamedImports, path, isNamespaceImport, namespaceImportName, ); if (importStatement) { importSection += `${importStatement}\n`; } } if (importSection) { importSection += "\n"; } logger.debug(`Generated Import Section String: ${importSection}`); return importSection; } ``` -------------------------------------------------------------------------------- /src/ts-morph/move-symbol-to-file/update-imports-in-referencing-files.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from "vitest"; import { Project, IndentationText, QuoteKind } from "ts-morph"; import { updateImportsInReferencingFiles } from "./update-imports-in-referencing-files"; describe("updateImportsInReferencingFiles", () => { const oldDirPath = "/src/moduleA"; const oldFilePath = `${oldDirPath}/old-location.ts`; const moduleAIndexPath = `${oldDirPath}/index.ts`; const newFilePath = "/src/moduleC/new-location.ts"; // --- Setup Helper Function --- const setupTestProject = () => { const project = new Project({ manipulationSettings: { indentationText: IndentationText.TwoSpaces, quoteKind: QuoteKind.Single, }, useInMemoryFileSystem: true, compilerOptions: { baseUrl: ".", paths: { "@/*": ["src/*"], }, typeRoots: [], }, }); project.createDirectory("/src"); project.createDirectory(oldDirPath); project.createDirectory("/src/moduleB"); project.createDirectory("/src/moduleC"); project.createDirectory("/src/moduleD"); project.createDirectory("/src/moduleE"); project.createDirectory("/src/moduleF"); project.createDirectory("/src/moduleG"); // Use literal strings for symbols in setup project.createSourceFile( oldFilePath, `export const exportedSymbol = 123; export const anotherSymbol = 456; export type MyType = { id: number }; `, ); project.createSourceFile( moduleAIndexPath, `export { exportedSymbol, anotherSymbol } from './old-location'; export type { MyType } from './old-location'; `, ); const importerRel = project.createSourceFile( "/src/moduleB/importer-relative.ts", `import { exportedSymbol } from '../moduleA/old-location';\nconsole.log(exportedSymbol);`, ); const importerAlias = project.createSourceFile( "/src/moduleD/importer-alias.ts", `import { anotherSymbol } from '@/moduleA/old-location';\nconsole.log(anotherSymbol);`, ); const importerIndex = project.createSourceFile( "/src/moduleE/importer-index.ts", `import { exportedSymbol } from '../moduleA';\nconsole.log(exportedSymbol);`, ); const importerMulti = project.createSourceFile( "/src/moduleF/importer-multi.ts", `import { exportedSymbol, anotherSymbol } from '../moduleA/old-location';\nconsole.log(exportedSymbol, anotherSymbol);`, ); const importerType = project.createSourceFile( "/src/moduleG/importer-type.ts", `import type { MyType } from '../moduleA/old-location';\nlet val: MyType;`, ); const noRefFile = project.createSourceFile( "/src/no-ref.ts", 'console.log("hello");', ); return { project, importerRelPath: "/src/moduleB/importer-relative.ts", importerAliasPath: "/src/moduleD/importer-alias.ts", importerIndexPath: "/src/moduleE/importer-index.ts", importerMultiPath: "/src/moduleF/importer-multi.ts", importerTypePath: "/src/moduleG/importer-type.ts", noRefFilePath: "/src/no-ref.ts", oldFilePath, newFilePath, }; }; it("相対パスでインポートしているファイルのパスを正しく更新する", async () => { const { project, oldFilePath, newFilePath, importerRelPath } = setupTestProject(); await updateImportsInReferencingFiles( project, oldFilePath, newFilePath, "exportedSymbol", ); const expected = `import { exportedSymbol } from '../moduleC/new-location'; console.log(exportedSymbol);`; expect(project.getSourceFile(importerRelPath)?.getText()).toBe(expected); }); it("エイリアスパスでインポートしているファイルのパスを正しく更新する (相対パスになる)", async () => { const { project, oldFilePath, newFilePath, importerAliasPath } = setupTestProject(); await updateImportsInReferencingFiles( project, oldFilePath, newFilePath, "anotherSymbol", ); const expected = `import { anotherSymbol } from '../moduleC/new-location'; console.log(anotherSymbol);`; expect(project.getSourceFile(importerAliasPath)?.getText()).toBe(expected); }); it("複数のファイルから参照されている場合、指定したシンボルのパスのみ更新する", async () => { const { project, oldFilePath, newFilePath, importerRelPath, importerAliasPath, } = setupTestProject(); await updateImportsInReferencingFiles( project, oldFilePath, newFilePath, "exportedSymbol", ); const expectedRel = `import { exportedSymbol } from '../moduleC/new-location'; console.log(exportedSymbol);`; expect(project.getSourceFile(importerRelPath)?.getText()).toBe(expectedRel); const expectedAlias = `import { anotherSymbol } from '@/moduleA/old-location'; console.log(anotherSymbol);`; expect(project.getSourceFile(importerAliasPath)?.getText()).toBe( expectedAlias, ); }); it("複数の名前付きインポートを持つファイルのパスを、指定したシンボルのみ更新する", async () => { const { project, oldFilePath, newFilePath, importerMultiPath } = setupTestProject(); const symbolToMove = "exportedSymbol"; await updateImportsInReferencingFiles( project, oldFilePath, newFilePath, symbolToMove, ); const expected = `import { anotherSymbol } from '../moduleA/old-location'; import { exportedSymbol } from '../moduleC/new-location'; console.log(exportedSymbol, anotherSymbol);`; expect(project.getSourceFile(importerMultiPath)?.getText()).toBe(expected); }); it("Typeインポートを持つファイルのパスを正しく更新する", async () => { const { project, oldFilePath, newFilePath, importerTypePath } = setupTestProject(); await updateImportsInReferencingFiles( project, oldFilePath, newFilePath, "MyType", ); const expected = `import type { MyType } from '../moduleC/new-location'; let val: MyType;`; expect(project.getSourceFile(importerTypePath)?.getText()).toBe(expected); }); it("移動元ファイルへの参照がない場合、エラーなく完了し、他のファイルは変更されない", async () => { const { project, oldFilePath, newFilePath, noRefFilePath } = setupTestProject(); const originalContent = project.getSourceFile(noRefFilePath)?.getText() ?? ""; await expect( updateImportsInReferencingFiles( project, oldFilePath, newFilePath, "exportedSymbol", ), ).resolves.toBeUndefined(); expect(project.getSourceFile(noRefFilePath)?.getText()).toBe( originalContent, ); }); it("移動先ファイルが元々移動元シンボルをインポートしていた場合、そのインポート指定子/宣言を削除する", async () => { const project = new Project({ useInMemoryFileSystem: true }); const oldPath = "/src/old.ts"; const newPath = "/src/new.ts"; project.createSourceFile( oldPath, "export const symbolToMove = 1; export const keepSymbol = 2;", ); const referencingFile = project.createSourceFile( newPath, `import { symbolToMove, keepSymbol } from './old'; console.log(symbolToMove, keepSymbol);`, ); await updateImportsInReferencingFiles( project, oldPath, newPath, "symbolToMove", ); const expected = `import { keepSymbol } from './old'; console.log(symbolToMove, keepSymbol);`; expect(referencingFile.getText()).toBe(expected); // --- ケース2: 移動対象シンボルのみインポートしていた場合 --- const project2 = new Project({ useInMemoryFileSystem: true }); project2.createSourceFile(oldPath, "export const symbolToMove = 1;"); const referencingFile2 = project2.createSourceFile( newPath, `import { symbolToMove } from './old'; console.log(symbolToMove);`, ); await updateImportsInReferencingFiles( project2, oldPath, newPath, "symbolToMove", ); expect(referencingFile2.getText()).toBe("console.log(symbolToMove);"); }); // --- 【制限事項確認】将来的に対応したいケース --- it.skip("【制限事項】バレルファイル経由でインポートしているファイルのパスは更新される", async () => { const { project, oldFilePath, newFilePath, importerIndexPath } = setupTestProject(); await updateImportsInReferencingFiles( project, oldFilePath, newFilePath, "exportedSymbol", ); const updatedContent = project.getSourceFile(importerIndexPath)?.getText() ?? ""; const expectedImportPath = "../../moduleC/new-location"; const expected = `import { exportedSymbol } from '${expectedImportPath}'; console.log(exportedSymbol);`; expect(updatedContent).toBe(expected); }); }); ``` -------------------------------------------------------------------------------- /src/ts-morph/_utils/find-declarations-to-update.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from "vitest"; import { Project, IndentationText, QuoteKind } from "ts-morph"; import { findDeclarationsReferencingFile } from "./find-declarations-to-update"; // --- Setup Helper Function --- const setupTestProject = () => { const project = new Project({ manipulationSettings: { indentationText: IndentationText.TwoSpaces, quoteKind: QuoteKind.Single, }, useInMemoryFileSystem: true, compilerOptions: { baseUrl: ".", paths: { "@/*": ["src/*"], "@utils/*": ["src/utils/*"], }, // typeRoots: [], // Avoids errors on potentially missing node types if not installed }, }); // Target file const targetFilePath = "/src/target.ts"; const targetFile = project.createSourceFile( targetFilePath, `export const targetSymbol = 'target'; export type TargetType = number;`, ); // File importing with relative path const importerRelPath = "/src/importer-relative.ts"; project.createSourceFile( importerRelPath, `import { targetSymbol } from './target'; import type { TargetType } from './target'; console.log(targetSymbol);`, ); // File importing with alias path const importerAliasPath = "/src/importer-alias.ts"; project.createSourceFile( importerAliasPath, `import { targetSymbol } from '@/target'; console.log(targetSymbol);`, ); // Barrel file re-exporting from target const barrelFilePath = "/src/index.ts"; project.createSourceFile( barrelFilePath, `export { targetSymbol } from './target'; // 値を再エクスポート export type { TargetType } from './target'; // 型を再エクスポート`, ); // File importing from barrel file const importerBarrelPath = "/src/importer-barrel.ts"; project.createSourceFile( importerBarrelPath, `import { targetSymbol } from './index'; // バレルファイルからインポート console.log(targetSymbol);`, ); // File with no reference const noRefFilePath = "/src/no-ref.ts"; project.createSourceFile(noRefFilePath, "const unrelated = 1;"); return { project, targetFile, targetFilePath, importerRelPath, importerAliasPath, barrelFilePath, importerBarrelPath, noRefFilePath, }; }; describe("findDeclarationsReferencingFile", () => { it("target.ts を直接参照している全ての宣言 (Import/Export) を見つける", async () => { const { project, targetFile, targetFilePath, importerRelPath, importerAliasPath, barrelFilePath, } = setupTestProject(); const results = await findDeclarationsReferencingFile(targetFile); // 期待値: 5つの宣言 (相対パスインポートx2, エイリアスパスインポートx1, バレルエクスポートx2) expect(results).toHaveLength(5); // --- 相対パスインポートの検証 --- const relativeImports = results.filter( (r) => r.referencingFilePath === importerRelPath && r.declaration.getKindName() === "ImportDeclaration", ); expect(relativeImports).toHaveLength(2); const valueRelImport = relativeImports.find((r) => r.declaration.getText().includes("targetSymbol"), ); expect(valueRelImport?.originalSpecifierText).toBe("./target"); const typeRelImport = relativeImports.find((r) => r.declaration.getText().includes("TargetType"), ); expect(typeRelImport?.originalSpecifierText).toBe("./target"); // --- エイリアスパスインポートの検証 --- const aliasImports = results.filter( (r) => r.referencingFilePath === importerAliasPath && r.declaration.getKindName() === "ImportDeclaration", ); expect(aliasImports).toHaveLength(1); expect(aliasImports[0].originalSpecifierText).toBe("@/target"); expect(aliasImports[0].wasPathAlias).toBe(true); // --- バレルエクスポートの検証 --- const barrelExports = results.filter( (r) => r.referencingFilePath === barrelFilePath && r.declaration.getKindName() === "ExportDeclaration", ); expect(barrelExports).toHaveLength(2); const valueBarrelExport = barrelExports.find((r) => r.declaration.getText().includes("targetSymbol"), ); expect(valueBarrelExport?.originalSpecifierText).toBe("./target"); const typeBarrelExport = barrelExports.find((r) => r.declaration.getText().includes("TargetType"), ); expect(typeBarrelExport?.originalSpecifierText).toBe("./target"); }); it("エイリアスパスでインポートしている ImportDeclaration を見つけ、wasPathAlias が true になる", async () => { const { project, targetFile, targetFilePath, importerAliasPath } = setupTestProject(); const results = await findDeclarationsReferencingFile(targetFile); // エイリアスパスによるインポートを特定する const aliasImports = results.filter( (r) => r.referencingFilePath === importerAliasPath, ); expect(aliasImports).toHaveLength(1); const aliasImport = aliasImports[0]; expect(aliasImport).toBeDefined(); expect(aliasImport.referencingFilePath).toBe(importerAliasPath); expect(aliasImport.resolvedPath).toBe(targetFilePath); expect(aliasImport.originalSpecifierText).toBe("@/target"); expect(aliasImport.declaration.getKindName()).toBe("ImportDeclaration"); expect(aliasImport.wasPathAlias).toBe(true); // エイリアスが検出されるべき }); it("バレルファイルで再エクスポートしている ExportDeclaration を見つける", async () => { const { project, targetFile, targetFilePath, barrelFilePath } = setupTestProject(); const results = await findDeclarationsReferencingFile(targetFile); // バレルファイルからのエクスポートを特定する const exportDeclarations = results.filter( (r) => r.referencingFilePath === barrelFilePath, ); expect(exportDeclarations).toHaveLength(2); const valueExport = exportDeclarations.find((r) => r.declaration.getText().includes("targetSymbol"), ); expect(valueExport).toBeDefined(); expect(valueExport?.referencingFilePath).toBe(barrelFilePath); expect(valueExport?.resolvedPath).toBe(targetFilePath); expect(valueExport?.originalSpecifierText).toBe("./target"); expect(valueExport?.declaration.getKindName()).toBe("ExportDeclaration"); expect(valueExport?.wasPathAlias).toBe(false); const typeExport = exportDeclarations.find((r) => r.declaration.getText().includes("TargetType"), ); expect(typeExport).toBeDefined(); expect(typeExport?.referencingFilePath).toBe(barrelFilePath); expect(typeExport?.resolvedPath).toBe(targetFilePath); expect(typeExport?.originalSpecifierText).toBe("./target"); expect(typeExport?.declaration.getKindName()).toBe("ExportDeclaration"); expect(typeExport?.wasPathAlias).toBe(false); }); // findDeclarationsReferencingFile は getReferencingSourceFiles を使うため、 // バレルファイルを経由した参照は見つけられない (これは想定される動作) it("バレルファイル経由のインポートは見つけられない (getReferencingSourceFiles の仕様)", async () => { const { project, targetFile, importerBarrelPath } = setupTestProject(); const results = await findDeclarationsReferencingFile(targetFile); // 結果に importerBarrelPath からのインポートが含まれないことを確認 const barrelImport = results.find( (r) => r.referencingFilePath === importerBarrelPath, ); expect(barrelImport).toBeUndefined(); }); it("対象ファイルへの参照がない場合は空の配列を返す", async () => { const { project } = setupTestProject(); // 参照されていないファイルを作成 const unreferencedFile = project.createSourceFile( "/src/unreferenced.ts", "export const x = 1;", ); const results = await findDeclarationsReferencingFile(unreferencedFile); expect(results).toHaveLength(0); }); it("Import と Export が混在する場合、両方を見つけられる", async () => { const { project, targetFile, targetFilePath } = setupTestProject(); // target からインポートとエクスポートの両方を行う別のファイルを追加 const mixedRefPath = "/src/mixed-ref.ts"; project.createSourceFile( mixedRefPath, ` import { targetSymbol } from './target'; export { TargetType } from './target'; console.log(targetSymbol); `, ); const results = await findDeclarationsReferencingFile(targetFile); // mixedRefPath からの2つの宣言 + セットアップからの他の宣言を期待 const mixedRefs = results.filter( (r) => r.referencingFilePath === mixedRefPath, ); expect(mixedRefs).toHaveLength(2); const importDecl = mixedRefs.find( (d) => d.declaration.getKindName() === "ImportDeclaration", ); const exportDecl = mixedRefs.find( (d) => d.declaration.getKindName() === "ExportDeclaration", ); expect(importDecl).toBeDefined(); expect(exportDecl).toBeDefined(); }); }); ``` -------------------------------------------------------------------------------- /src/ts-morph/remove-path-alias/remove-path-alias.test.ts: -------------------------------------------------------------------------------- ```typescript import { Project } from "ts-morph"; import { describe, it, expect } from "vitest"; import * as path from "node:path"; import { removePathAlias } from "./remove-path-alias"; const TEST_TSCONFIG_PATH = "/tsconfig.json"; const TEST_BASE_URL = "/src"; const TEST_PATHS = { "@/*": ["*"], "@components/*": ["components/*"], "@utils/helpers": ["utils/helpers.ts"], }; const setupProject = () => { const project = new Project({ useInMemoryFileSystem: true, compilerOptions: { baseUrl: path.relative(path.dirname(TEST_TSCONFIG_PATH), TEST_BASE_URL), paths: TEST_PATHS, allowJs: true, }, }); project.createSourceFile( TEST_TSCONFIG_PATH, JSON.stringify({ compilerOptions: { baseUrl: "./src", paths: TEST_PATHS }, }), ); return project; }; describe("removePathAlias", () => { it("単純なワイルドカードエイリアス (@/*) を相対パスに変換できること", async () => { const project = setupProject(); const importerPath = "/src/features/featureA/index.ts"; const componentPath = "/src/components/Button.ts"; project.createSourceFile(componentPath, "export const Button = {};"); const importerContent = `import { Button } from '@/components/Button';`; project.createSourceFile(importerPath, importerContent); const result = await removePathAlias({ project, targetPath: importerPath, baseUrl: TEST_BASE_URL, paths: TEST_PATHS, dryRun: false, }); const sourceFile = project.getSourceFileOrThrow(importerPath); const importDeclaration = sourceFile.getImportDeclarations()[0]; expect(importDeclaration?.getModuleSpecifierValue()).toBe( "../../components/Button", ); expect(result.changedFiles).toEqual([importerPath]); }); it("特定のパスエイリアス (@components/*) を相対パスに変換できること", async () => { const project = setupProject(); const importerPath = "/src/index.ts"; const componentPath = "/src/components/Input/index.ts"; project.createSourceFile(componentPath, "export const Input = {};"); const importerContent = `import { Input } from '@components/Input';`; project.createSourceFile(importerPath, importerContent); const result = await removePathAlias({ project, targetPath: importerPath, baseUrl: TEST_BASE_URL, paths: TEST_PATHS, dryRun: false, }); const sourceFile = project.getSourceFileOrThrow(importerPath); expect( sourceFile.getImportDeclarations()[0]?.getModuleSpecifierValue(), ).toBe("./components/Input/index"); expect(result.changedFiles).toEqual([importerPath]); }); it("ファイルへの直接エイリアス (@utils/helpers) を相対パスに変換できること", async () => { const project = setupProject(); const importerPath = "/src/features/featureB/utils.ts"; const helperPath = "/src/utils/helpers.ts"; project.createSourceFile(helperPath, "export const helperFunc = () => {};"); const importerContent = `import { helperFunc } from '@utils/helpers';`; project.createSourceFile(importerPath, importerContent); const result = await removePathAlias({ project, targetPath: importerPath, baseUrl: TEST_BASE_URL, paths: TEST_PATHS, dryRun: false, }); const sourceFile = project.getSourceFileOrThrow(importerPath); expect( sourceFile.getImportDeclarations()[0]?.getModuleSpecifierValue(), ).toBe("../../utils/helpers"); expect(result.changedFiles).toEqual([importerPath]); }); it("エイリアスでない通常の相対パスは変更しないこと", async () => { const project = setupProject(); const importerPath = "/src/features/featureA/index.ts"; const servicePath = "/src/features/featureA/service.ts"; project.createSourceFile(servicePath, "export class Service {}"); const importerContent = `import { Service } from './service';`; const sourceFile = project.createSourceFile(importerPath, importerContent); const originalContent = sourceFile.getFullText(); const result = await removePathAlias({ project, targetPath: importerPath, baseUrl: TEST_BASE_URL, paths: TEST_PATHS, dryRun: false, }); expect(sourceFile.getFullText()).toBe(originalContent); expect(result.changedFiles).toEqual([]); }); it("エイリアスでない node_modules パスは変更しないこと", async () => { const project = setupProject(); const importerPath = "/src/index.ts"; const importerContent = `import * as fs from 'fs';`; const sourceFile = project.createSourceFile(importerPath, importerContent); const originalContent = sourceFile.getFullText(); const result = await removePathAlias({ project, targetPath: importerPath, baseUrl: TEST_BASE_URL, paths: TEST_PATHS, dryRun: false, }); expect(sourceFile.getFullText()).toBe(originalContent); expect(result.changedFiles).toEqual([]); }); it("dryRun モードではファイルを変更せず、変更予定リストを返すこと", async () => { const project = setupProject(); const importerPath = "/src/features/featureA/index.ts"; const componentPath = "/src/components/Button.ts"; project.createSourceFile(componentPath, "export const Button = {};"); const importerContent = `import { Button } from '@/components/Button';`; const sourceFile = project.createSourceFile(importerPath, importerContent); const originalContent = sourceFile.getFullText(); const result = await removePathAlias({ project, targetPath: importerPath, baseUrl: TEST_BASE_URL, paths: TEST_PATHS, dryRun: true, }); expect(sourceFile.getFullText()).toBe(originalContent); expect(result.changedFiles).toEqual([importerPath]); }); it("ディレクトリを対象とした場合に、内部の複数ファイルのエイリアスを変換できること", async () => { const project = setupProject(); const dirPath = "/src/features/multi"; const file1Path = path.join(dirPath, "file1.ts"); const file2Path = path.join(dirPath, "sub/file2.ts"); const buttonPath = "/src/components/Button.ts"; const inputPath = "/src/components/Input.ts"; project.createSourceFile(buttonPath, "export const Button = {};"); project.createSourceFile(inputPath, "export const Input = {};"); project.createSourceFile( file1Path, "import { Button } from '@/components/Button';", ); project.createSourceFile( file2Path, "import { Input } from '@components/Input';", ); const result = await removePathAlias({ project, targetPath: dirPath, baseUrl: TEST_BASE_URL, paths: TEST_PATHS, dryRun: false, }); const file1 = project.getSourceFileOrThrow(file1Path); const file2 = project.getSourceFileOrThrow(file2Path); expect(file1.getImportDeclarations()[0]?.getModuleSpecifierValue()).toBe( "../../components/Button", ); expect(file2.getImportDeclarations()[0]?.getModuleSpecifierValue()).toBe( "../../../components/Input", ); expect(result.changedFiles.sort()).toEqual([file1Path, file2Path].sort()); }); it("解決できないエイリアスパスを変更しないこと", async () => { const project = setupProject(); const importerPath = "/src/index.ts"; const importerContent = `import { Something } from '@unknown/package';`; const sourceFile = project.createSourceFile(importerPath, importerContent); const originalContent = sourceFile.getFullText(); const result = await removePathAlias({ project, targetPath: importerPath, baseUrl: TEST_BASE_URL, paths: TEST_PATHS, dryRun: false, }); expect(sourceFile.getFullText()).toBe(originalContent); expect(result.changedFiles).toEqual([]); }); it("エイリアスが index.ts を指す場合、結果は /index で終わる (省略されない)", async () => { const project = setupProject(); const importerPath = "/src/features/featureA/component.ts"; const indexPath = "/src/components/index.ts"; project.createSourceFile(indexPath, "export const CompIndex = 1;"); project.createSourceFile( importerPath, "import { CompIndex } from '@/components';", ); const result = await removePathAlias({ project, targetPath: importerPath, baseUrl: "/", paths: { "@/*": ["src/*"] }, dryRun: false, }); const sourceFile = project.getSourceFileOrThrow(importerPath); expect( sourceFile.getImportDeclarations()[0]?.getModuleSpecifierValue(), ).toBe("../../components/index"); expect(result.changedFiles).toEqual([importerPath]); }); it("エイリアスが .js ファイルを指す場合、結果から拡張子は削除される", async () => { const project = setupProject(); const importerPath = "/src/app.ts"; const jsPath = "/src/utils/legacy.js"; project.createSourceFile(jsPath, "export const legacyFunc = () => {};"); project.createSourceFile( importerPath, "import { legacyFunc } from '@/utils/legacy.js';", ); const result = await removePathAlias({ project, targetPath: importerPath, baseUrl: "/", paths: { "@/*": ["src/*"] }, dryRun: false, }); const sourceFile = project.getSourceFileOrThrow(importerPath); expect( sourceFile.getImportDeclarations()[0]?.getModuleSpecifierValue(), ).toBe("./utils/legacy"); expect(result.changedFiles).toEqual([importerPath]); }); }); ``` -------------------------------------------------------------------------------- /src/ts-morph/move-symbol-to-file/internal-dependencies.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from "vitest"; import { type FunctionDeclaration, Project, SyntaxKind, type VariableStatement, } from "ts-morph"; import { findTopLevelDeclarationByName } from "./find-declaration"; import { getInternalDependencies } from "./internal-dependencies"; // --- Test Setup Helper --- const setupProject = () => { const project = new Project({ useInMemoryFileSystem: true, compilerOptions: { target: 99, module: 99 }, }); project.createDirectory("/src"); return project; }; describe("getInternalDependencies", () => { it("関数宣言が依存する内部関数と内部変数を特定できる", () => { const project = setupProject(); const filePath = "/src/internal-deps-advanced.ts"; const sourceFile = project.createSourceFile( filePath, ` const configValue = 10; const calculatedValue = configValue * 2; function helperFunc(n: number): number { return n + calculatedValue; } export function mainFunc(x: number): void { const result = helperFunc(x); console.log(result); } `, ); const mainFuncDecl = findTopLevelDeclarationByName( sourceFile, "mainFunc", SyntaxKind.FunctionDeclaration, ) as FunctionDeclaration; const helperFuncDecl = findTopLevelDeclarationByName( sourceFile, "helperFunc", SyntaxKind.FunctionDeclaration, ) as FunctionDeclaration; const calculatedValueStmt = findTopLevelDeclarationByName( sourceFile, "calculatedValue", SyntaxKind.VariableStatement, ) as VariableStatement; const configValueStmt = findTopLevelDeclarationByName( sourceFile, "configValue", SyntaxKind.VariableStatement, ) as VariableStatement; expect(mainFuncDecl).toBeDefined(); expect(helperFuncDecl).toBeDefined(); expect(calculatedValueStmt).toBeDefined(); expect(configValueStmt).toBeDefined(); const dependencies = getInternalDependencies(mainFuncDecl); expect(dependencies).toBeInstanceOf(Array); expect(dependencies).toHaveLength(3); // helperFunc, calculatedValue, configValue expect(dependencies).toEqual( expect.arrayContaining([ helperFuncDecl, calculatedValueStmt, configValueStmt, ]), ); }); it("関数宣言が依存する内部変数を特定できる (間接依存)", () => { const project = setupProject(); const filePath = "/src/internal-deps-advanced.ts"; const sourceFile = project.createSourceFile( filePath, ` const configValue = 10; // <- さらに依存 const calculatedValue = configValue * 2; // <- 依存先 function helperFunc(n: number): number { return n + calculatedValue; } // <- これを対象 `, ); const helperFuncDecl = findTopLevelDeclarationByName( sourceFile, "helperFunc", SyntaxKind.FunctionDeclaration, ) as FunctionDeclaration; const calculatedValueStmt = findTopLevelDeclarationByName( sourceFile, "calculatedValue", SyntaxKind.VariableStatement, ) as VariableStatement; const configValueStmt = findTopLevelDeclarationByName( sourceFile, "configValue", SyntaxKind.VariableStatement, ) as VariableStatement; expect(helperFuncDecl).toBeDefined(); expect(calculatedValueStmt).toBeDefined(); expect(configValueStmt).toBeDefined(); const dependencies = getInternalDependencies(helperFuncDecl); expect(dependencies).toBeInstanceOf(Array); expect(dependencies).toHaveLength(2); // calculatedValue, configValue expect(dependencies).toEqual( expect.arrayContaining([calculatedValueStmt, configValueStmt]), ); }); it("変数宣言が依存する内部変数を特定できる", () => { const project = setupProject(); const filePath = "/src/internal-deps-advanced.ts"; const sourceFile = project.createSourceFile( filePath, ` const configValue = 10; // <- さらに依存 const calculatedValue = configValue * 2; // <- 依存先 export const derivedConst = calculatedValue + 5; // <- これを対象 `, ); const derivedConstStmt = findTopLevelDeclarationByName( sourceFile, "derivedConst", SyntaxKind.VariableStatement, ) as VariableStatement; const calculatedValueStmt = findTopLevelDeclarationByName( sourceFile, "calculatedValue", SyntaxKind.VariableStatement, ) as VariableStatement; const configValueStmt = findTopLevelDeclarationByName( sourceFile, "configValue", SyntaxKind.VariableStatement, ) as VariableStatement; expect(derivedConstStmt).toBeDefined(); expect(calculatedValueStmt).toBeDefined(); expect(configValueStmt).toBeDefined(); const dependencies = getInternalDependencies(derivedConstStmt); expect(dependencies).toBeInstanceOf(Array); expect(dependencies).toHaveLength(2); // calculatedValue, configValue expect(dependencies).toEqual( expect.arrayContaining([calculatedValueStmt, configValueStmt]), ); }); it("変数宣言が依存する内部変数を特定できる (直接依存)", () => { const project = setupProject(); const filePath = "/src/internal-deps-advanced.ts"; const sourceFile = project.createSourceFile( filePath, ` const configValue = 10; // <- 依存先 const calculatedValue = configValue * 2; // <- これを対象 `, ); const calculatedValueStmt = findTopLevelDeclarationByName( sourceFile, "calculatedValue", SyntaxKind.VariableStatement, ) as VariableStatement; const configValueStmt = findTopLevelDeclarationByName( sourceFile, "configValue", SyntaxKind.VariableStatement, ) as VariableStatement; expect( calculatedValueStmt, "Test setup failed: calculatedValue not found", ).toBeDefined(); expect( configValueStmt, "Test setup failed: configValue not found", ).toBeDefined(); const dependencies = getInternalDependencies(calculatedValueStmt); expect(dependencies).toBeInstanceOf(Array); expect(dependencies).toHaveLength(1); expect(dependencies[0]).toBe(configValueStmt); }); it("依存関係がない場合は空配列を返す", () => { const project = setupProject(); const filePath = "/src/internal-deps-advanced.ts"; const sourceFile = project.createSourceFile( filePath, ` const configValue = 10; function unusedFunc() {} `, ); const configValueStmt = findTopLevelDeclarationByName( sourceFile, "configValue", SyntaxKind.VariableStatement, ) as VariableStatement; const unusedFuncDecl = findTopLevelDeclarationByName( sourceFile, "unusedFunc", SyntaxKind.FunctionDeclaration, ) as FunctionDeclaration; expect( configValueStmt, "Test setup failed: configValue not found", ).toBeDefined(); expect( unusedFuncDecl, "Test setup failed: unusedFunc not found", ).toBeDefined(); const configDeps = getInternalDependencies(configValueStmt); const unusedDeps = getInternalDependencies(unusedFuncDecl); expect(configDeps).toEqual([]); expect(unusedDeps).toEqual([]); }); it("関数宣言が依存する非エクスポートのアロー関数を特定できる", () => { const project = setupProject(); const filePath = "/src/arrow-func-dep.ts"; const sourceFile = project.createSourceFile( filePath, ` const arrowHelper = (n: number): number => n * n; export function mainFunc(x: number): number { return arrowHelper(x); } `, ); const mainFuncDecl = findTopLevelDeclarationByName( sourceFile, "mainFunc", SyntaxKind.FunctionDeclaration, ) as FunctionDeclaration; const arrowHelperStmt = findTopLevelDeclarationByName( sourceFile, "arrowHelper", SyntaxKind.VariableStatement, ) as VariableStatement; expect(mainFuncDecl).toBeDefined(); expect(arrowHelperStmt).toBeDefined(); const dependencies = getInternalDependencies(mainFuncDecl); expect(dependencies.length).toBe(1); expect(dependencies[0]).toBe(arrowHelperStmt); }); it("複数の間接的な内部依存関係を再帰的に特定できる", () => { const project = setupProject(); const filePath = "/src/recursive-deps.ts"; const sourceFile = project.createSourceFile( filePath, ` const d = 4; const c = () => d; const b = () => c(); export const a = () => b(); // a -> b -> c -> d const e = () => d; // d は a 以外からも参照されるが、ここでは a の依存のみ見る `, ); const aStmt = findTopLevelDeclarationByName( sourceFile, "a", SyntaxKind.VariableStatement, ) as VariableStatement; const bStmt = findTopLevelDeclarationByName( sourceFile, "b", SyntaxKind.VariableStatement, ) as VariableStatement; const cStmt = findTopLevelDeclarationByName( sourceFile, "c", SyntaxKind.VariableStatement, ) as VariableStatement; const dStmt = findTopLevelDeclarationByName( sourceFile, "d", SyntaxKind.VariableStatement, ) as VariableStatement; expect(aStmt).toBeDefined(); expect(bStmt).toBeDefined(); expect(cStmt).toBeDefined(); expect(dStmt).toBeDefined(); const dependencies = getInternalDependencies(aStmt); expect(dependencies).toBeInstanceOf(Array); expect(dependencies).toHaveLength(3); // b, c, d が含まれるはず expect(dependencies).toEqual(expect.arrayContaining([bStmt, cStmt, dStmt])); }); }); ``` -------------------------------------------------------------------------------- /src/mcp/tools/register-rename-file-system-entry-tool.ts: -------------------------------------------------------------------------------- ```typescript import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { renameFileSystemEntry } from "../../ts-morph/rename-file-system/rename-file-system-entry"; import { initializeProject } from "../../ts-morph/_utils/ts-morph-project"; import * as path from "node:path"; import { performance } from "node:perf_hooks"; import { TimeoutError } from "../../errors/timeout-error"; import logger from "../../utils/logger"; const renameSchema = z.object({ tsconfigPath: z .string() .describe("Absolute path to the project's tsconfig.json file."), renames: z .array( z.object({ oldPath: z .string() .describe( "The current absolute path of the file or folder to rename.", ), newPath: z .string() .describe("The new desired absolute path for the file or folder."), }), ) .nonempty() .describe("An array of rename operations, each with oldPath and newPath."), dryRun: z .boolean() .optional() .default(false) .describe("If true, only show intended changes without modifying files."), timeoutSeconds: z .number() .int() .positive() .optional() .default(120) .describe( "Maximum time in seconds allowed for the operation before it times out. Defaults to 120.", ), }); type RenameArgs = z.infer<typeof renameSchema>; export function registerRenameFileSystemEntryTool(server: McpServer): void { server.tool( "rename_filesystem_entry_by_tsmorph", `[Uses ts-morph] Renames **one or more** TypeScript/JavaScript files **and/or folders** and updates all import/export paths referencing them throughout the project. Analyzes the project based on \`tsconfig.json\` to find all references to the items being renamed and automatically corrects their paths. **Handles various path types, including relative paths, path aliases (e.g., @/), and imports referencing a directory\'s index.ts (\`from \'.\'\` or \`from \'..\'\`).** Checks for conflicts before applying changes. ## Usage Use this tool when you want to rename/move multiple files or folders simultaneously (e.g., renaming \`util.ts\` to \`helper.ts\` and moving \`src/data\` to \`src/coreData\` in one operation) and need all the \`import\`/\`export\` statements referencing them to be updated automatically. 1. Specify the path to the project's \`tsconfig.json\` file. **Must be an absolute path.** 2. Provide an array of rename operations. Each object in the array must contain: - \`oldPath\`: The current **absolute path** of the file or folder to rename. - \`newPath\`: The new desired **absolute path** for the file or folder. 3. It\'s recommended to first run with \`dryRun: true\` to check which files will be affected. 4. If the preview looks correct, run with \`dryRun: false\` (or omit it) to actually save the changes to the file system. ## Parameters - tsconfigPath (string, required): Absolute path to the project's root \`tsconfig.json\` file. **Must be an absolute path.** - renames (array of objects, required): An array where each object specifies a rename operation with: - oldPath (string, required): The current absolute path of the file or folder. **Must be an absolute path.** - newPath (string, required): The new desired absolute path for the file or folder. **Must be an absolute path.** - dryRun (boolean, optional): If set to true, prevents making and saving file changes, returning only the list of files that would be affected. Defaults to false. - timeoutSeconds (number, optional): Maximum time in seconds allowed for the operation before it times out. Defaults to 120 seconds. ## Result - On success: Returns a message listing the file paths modified or scheduled to be modified. - On failure: Returns a message indicating the error (e.g., path conflict, file not found, timeout). ## Remarks - **Symbol-based Reference Finding:** This tool now primarily uses symbol analysis (identifying exported functions, classes, variables, etc.) to find references across the project, rather than solely relying on path matching. - **Path Alias Handling:** Path aliases (e.g., \`@/\`) in import/export statements *are* updated, but they will be **converted to relative paths**. If preserving path aliases is crucial, consider using the \`remove_path_alias_by_tsmorph\` tool *before* renaming to convert them to relative paths preemptively. - **Index File Imports:** Imports referencing a directory's \`index.ts\` or \`index.tsx\` (e.g., \`import Component from '../components'\`) will be updated to reference the specific index file directly (e.g., \`import Component from '../components/index.tsx'\`). - **Known Limitation (Default Exports):** Currently, this tool may not correctly update references for default exports declared using an identifier (e.g., \`export default MyIdentifier;\`). Default exports using function or class declarations (e.g., \`export default function myFunction() {}\`) are generally handled. - **Performance:** Renaming numerous files/folders or operating in a very large project can take significant time due to the detailed symbol analysis and reference updates. - **Conflicts:** The tool checks for conflicts (e.g., renaming to an existing path, duplicate targets) before applying changes. - **Timeout:** Operations exceeding the specified \`timeoutSeconds\` will be canceled.`, renameSchema.shape, async (args: RenameArgs) => { const startTime = performance.now(); let message = ""; let isError = false; let changedFilesCount = 0; const { tsconfigPath, renames, dryRun, timeoutSeconds } = args; const TIMEOUT_MS = timeoutSeconds * 1000; let resultPayload: { content: { type: "text"; text: string }[]; isError: boolean; } = { content: [{ type: "text", text: "An unexpected error occurred." }], isError: true, }; const controller = new AbortController(); let timeoutId: NodeJS.Timeout | undefined = undefined; const logArgs = { tsconfigPath, renames: renames.map((r) => ({ old: path.basename(r.oldPath), new: path.basename(r.newPath), })), dryRun, timeoutSeconds, }; try { timeoutId = setTimeout(() => { const errorMessage = `Operation timed out after ${timeoutSeconds} seconds`; logger.error( { toolArgs: logArgs, durationSeconds: timeoutSeconds }, errorMessage, ); controller.abort(new TimeoutError(errorMessage, timeoutSeconds)); }, TIMEOUT_MS); const project = initializeProject(tsconfigPath); const result = await renameFileSystemEntry({ project, renames, dryRun, signal: controller.signal, }); changedFilesCount = result.changedFiles.length; const changedFilesList = result.changedFiles.length > 0 ? result.changedFiles.join("\n - ") : "(No changes)"; const renameSummary = renames .map( (r) => `'${path.basename(r.oldPath)}' -> '${path.basename(r.newPath)}'`, ) .join(", "); if (dryRun) { message = `Dry run complete: Renaming [${renameSummary}] would modify the following files:\n - ${changedFilesList}`; } else { message = `Rename successful: Renamed [${renameSummary}]. The following files were modified:\n - ${changedFilesList}`; } isError = false; } catch (error) { logger.error( { err: error, toolArgs: logArgs }, "Error executing rename_filesystem_entry_by_tsmorph", ); if (error instanceof TimeoutError) { message = `処理が ${error.durationSeconds} 秒以内に完了しなかったため、タイムアウトしました。操作はキャンセルされました.\nプロジェクトの規模が大きいか、変更箇所が多い可能性があります.`; } else if (error instanceof Error && error.name === "AbortError") { message = `操作がキャンセルされました: ${error.message}`; } else { const errorMessage = error instanceof Error ? error.message : String(error); message = `Error during rename process: ${errorMessage}`; } isError = true; } finally { if (timeoutId) { clearTimeout(timeoutId); } const endTime = performance.now(); const durationMs = endTime - startTime; logger.info( { status: isError ? "Failure" : "Success", durationMs: Number.parseFloat(durationMs.toFixed(2)), changedFilesCount, dryRun, }, "rename_filesystem_entry_by_tsmorph tool finished", ); try { logger.flush(); logger.trace("Logs flushed after tool execution."); } catch (flushErr) { console.error("Failed to flush logs:", flushErr); } } const endTime = performance.now(); const durationMs = endTime - startTime; const durationSec = (durationMs / 1000).toFixed(2); const finalMessage = `${message}\nStatus: ${isError ? "Failure" : "Success"}\nProcessing time: ${durationSec} seconds`; resultPayload = { content: [{ type: "text", text: finalMessage }], isError: isError, }; return resultPayload; }, ); } ``` -------------------------------------------------------------------------------- /src/mcp/tools/register-move-symbol-to-file-tool.ts: -------------------------------------------------------------------------------- ```typescript import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { moveSymbolToFile } from "../../ts-morph/move-symbol-to-file/move-symbol-to-file"; import { initializeProject } from "../../ts-morph/_utils/ts-morph-project"; import { getChangedFiles } from "../../ts-morph/_utils/ts-morph-project"; import { SyntaxKind } from "ts-morph"; import { performance } from "node:perf_hooks"; import logger from "../../utils/logger"; import * as path from "node:path"; const syntaxKindMapping: { [key: string]: SyntaxKind } = { FunctionDeclaration: SyntaxKind.FunctionDeclaration, VariableStatement: SyntaxKind.VariableStatement, ClassDeclaration: SyntaxKind.ClassDeclaration, InterfaceDeclaration: SyntaxKind.InterfaceDeclaration, TypeAliasDeclaration: SyntaxKind.TypeAliasDeclaration, EnumDeclaration: SyntaxKind.EnumDeclaration, }; const moveSymbolSchema = z.object({ tsconfigPath: z .string() .describe( "Absolute path to the project's tsconfig.json file. Essential for ts-morph.", ), originalFilePath: z .string() .describe("Absolute path to the file containing the symbol to move."), targetFilePath: z .string() .describe( "Absolute path to the destination file. Can be an existing file; if the path does not exist, a new file will be created.", ), symbolToMove: z.string().describe("The name of the symbol to move."), declarationKindString: z .string() .optional() .describe( "Optional. The kind of the declaration as a string (e.g., 'VariableStatement', 'FunctionDeclaration', 'ClassDeclaration', 'InterfaceDeclaration', 'TypeAliasDeclaration', 'EnumDeclaration'). Providing this helps resolve ambiguity if multiple symbols share the same name.", ), dryRun: z .boolean() .optional() .default(false) .describe("If true, only show intended changes without modifying files."), }); type MoveSymbolArgs = z.infer<typeof moveSymbolSchema>; /** * MCPサーバーに 'move_symbol_to_file_by_tsmorph' ツールを登録します。 * このツールは、指定されたシンボルをファイル間で移動し、関連する参照を更新します。 * * @param server McpServer インスタンス */ export function registerMoveSymbolToFileTool(server: McpServer): void { server.tool( "move_symbol_to_file_by_tsmorph", `[Uses ts-morph] Moves a specified symbol (function, variable, class, etc.) and its internal-only dependencies to a new file, automatically updating all references across the project. Aids refactoring tasks like file splitting and improving modularity. Analyzes the AST (Abstract Syntax Tree) to identify usages of the symbol and corrects import/export paths based on the new file location. It also handles moving necessary internal dependencies (those used only by the symbol being moved). ## Usage Use this tool for various code reorganization tasks: 1. **Moving a specific function/class/variable:** Relocate a specific piece of logic to a more appropriate file (e.g., moving a helper function from a general \`utils.ts\` to a feature-specific \`feature-utils.ts\`). **This tool moves the specified symbol and its internal-only dependencies.** 2. **Extracting or Moving related logic (File Splitting/Reorganization):** To split a large file or reorganize logic, move related functions, classes, types, or variables to a **different file (new or existing)** one by one using this tool. **You will need to run this tool multiple times, once for each top-level symbol you want to move.** 3. **Improving modularity:** Group related functionalities together by moving multiple symbols (functions, types, etc.) into separate, more focused files. **Run this tool for each symbol you wish to relocate.** ts-morph parses the project based on \`tsconfig.json\` to resolve references and perform the move safely, updating imports/exports automatically. ## Parameters - tsconfigPath (string, required): Absolute path to the project\'s root \`tsconfig.json\` - originalFilePath (string, required): Absolute path to the file currently containing the symbol to move. - targetFilePath (string, required): Absolute path to the destination file. Can be an existing file; if the path does not exist, a new file will be created. - symbolToMove (string, required): The name of the **single top-level symbol** you want to move in this execution. - declarationKindString (string, optional): The kind of the declaration (e.g., \'VariableStatement\', \'FunctionDeclaration\'). Recommended to resolve ambiguity if multiple symbols share the same name. - dryRun (boolean, optional): If true, only show intended changes without modifying files. Defaults to false. ## Result - On success: Returns a message confirming the move and reference updates, including a list of modified files (or files that would be modified if dryRun is true). - On failure: Returns an error message (e.g., symbol not found, default export, AST errors). ## Remarks - **Moves one top-level symbol per execution:** This tool is designed to move a single specified top-level symbol (and its internal-only dependencies) in each run. To move multiple related top-level symbols (e.g., several functions and types for file splitting), you need to invoke this tool multiple times, once for each symbol. - **Default exports cannot be moved.** - **Internal dependency handling:** Dependencies (functions, variables, types, etc.) used *only* by the moved symbol within the original file are moved along with it. Dependencies that are also used by other symbols remaining in the original file will stay, might gain an \`export\` keyword if they didn't have one, and will be imported by the new file where the symbol was moved. Symbols in the original file that are *not* dependencies of the moved symbol will remain untouched unless explicitly moved in a separate execution of this tool. - **Performance:** Moving symbols with many references in large projects might take time.`, moveSymbolSchema.extend({ symbolToMove: z .string() .describe( "The name of the single top-level symbol you want to move in this execution.", ), }).shape, async (args: MoveSymbolArgs) => { const startTime = performance.now(); let message = ""; let isError = false; let changedFilesCount = 0; let changedFiles: string[] = []; const { tsconfigPath, originalFilePath, targetFilePath, symbolToMove, declarationKindString, dryRun, } = args; const declarationKind: SyntaxKind | undefined = declarationKindString && syntaxKindMapping[declarationKindString] ? syntaxKindMapping[declarationKindString] : undefined; if (declarationKindString && declarationKind === undefined) { logger.warn( `Invalid declarationKindString provided: '${declarationKindString}'. Proceeding without kind specification.`, ); } const logArgs = { tsconfigPath, originalFilePath: path.basename(originalFilePath), targetFilePath: path.basename(targetFilePath), symbolToMove, declarationKindString, dryRun, }; try { const project = initializeProject(tsconfigPath); await moveSymbolToFile( project, originalFilePath, targetFilePath, symbolToMove, declarationKind, ); changedFiles = getChangedFiles(project).map((sf) => sf.getFilePath()); changedFilesCount = changedFiles.length; const baseMessage = `Moved symbol \"${symbolToMove}\" from ${originalFilePath} to ${targetFilePath}.`; const changedFilesList = changedFiles.length > 0 ? changedFiles.join("\n - ") : "(No changes)"; if (dryRun) { message = `Dry run: ${baseMessage}\nFiles that would be modified:\n - ${changedFilesList}`; logger.info({ changedFiles }, "Dry run: Skipping save."); } else { await project.save(); logger.debug("Project changes saved after symbol move."); message = `${baseMessage}\nThe following files were modified:\n - ${changedFilesList}`; } isError = false; } catch (error) { logger.error( { err: error, toolArgs: logArgs }, "Error executing move_symbol_to_file_by_tsmorph", ); const errorMessage = error instanceof Error ? error.message : String(error); message = `Error moving symbol: ${errorMessage}`; isError = true; } finally { const endTime = performance.now(); const durationMs = endTime - startTime; logger.info( { status: isError ? "Failure" : "Success", durationMs: Number.parseFloat(durationMs.toFixed(2)), changedFilesCount, dryRun, }, "move_symbol_to_file_by_tsmorph tool finished", ); try { logger.flush(); } catch (flushErr) { console.error("Failed to flush logs:", flushErr); } } const endTime = performance.now(); const durationMs = endTime - startTime; const durationSec = (durationMs / 1000).toFixed(2); const finalMessage = `${message}\nStatus: ${isError ? "Failure" : "Success"}\nProcessing time: ${durationSec} seconds`; return { content: [{ type: "text", text: finalMessage }], isError: isError, }; }, ); } ``` -------------------------------------------------------------------------------- /src/ts-morph/move-symbol-to-file/generate-content/generate-new-source-file-content.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from "vitest"; import { Project, SyntaxKind, ts } from "ts-morph"; import { findTopLevelDeclarationByName } from "../find-declaration"; import { generateNewSourceFileContent } from "./generate-new-source-file-content"; import type { DependencyClassification, NeededExternalImports, } from "../../types"; // テストプロジェクト設定用ヘルパー const setupProjectWithCode = ( code: string, filePath = "/src/original.ts", project?: Project, ) => { const proj = project ?? new Project({ useInMemoryFileSystem: true }); proj.compilerOptions.set({ jsx: ts.JsxEmit.ReactJSX }); const originalSourceFile = proj.createSourceFile(filePath, code); return { project: proj, originalSourceFile }; }; describe("generateNewSourceFileContent", () => { it("依存関係のない VariableDeclaration から新しいファイルの内容を生成できる", () => { const code = "const myVar = 123;"; const { originalSourceFile } = setupProjectWithCode(code); const targetSymbolName = "myVar"; const declarationStatement = findTopLevelDeclarationByName( originalSourceFile, targetSymbolName, SyntaxKind.VariableStatement, ); expect(declarationStatement).toBeDefined(); if (!declarationStatement) return; const classifiedDependencies: DependencyClassification[] = []; const neededExternalImports: NeededExternalImports = new Map(); const newFileContent = generateNewSourceFileContent( declarationStatement, classifiedDependencies, originalSourceFile.getFilePath(), "/src/newLocation.ts", neededExternalImports, ); const expectedContent = "export const myVar = 123;\n"; expect(newFileContent.trim()).toBe(expectedContent.trim()); }); it("内部依存関係 (moveToNewFile) を持つ VariableDeclaration から新しいファイル内容を生成できる", () => { const code = ` function helperFunc(n: number): number { return n * 2; } const myVar = helperFunc(10); `; const { originalSourceFile } = setupProjectWithCode(code); const targetSymbolName = "myVar"; const dependencyName = "helperFunc"; const declarationStatement = findTopLevelDeclarationByName( originalSourceFile, targetSymbolName, SyntaxKind.VariableStatement, ); const dependencyStatement = findTopLevelDeclarationByName( originalSourceFile, dependencyName, SyntaxKind.FunctionDeclaration, ); expect(declarationStatement).toBeDefined(); expect(dependencyStatement).toBeDefined(); if (!declarationStatement || !dependencyStatement) return; const classifiedDependencies: DependencyClassification[] = [ { type: "moveToNewFile", statement: dependencyStatement }, ]; const neededExternalImports: NeededExternalImports = new Map(); const newFileContent = generateNewSourceFileContent( declarationStatement, classifiedDependencies, originalSourceFile.getFilePath(), "/src/newLocation.ts", neededExternalImports, ); const expectedContent = ` /* export なし */ function helperFunc(n: number): number { return n * 2; } export const myVar = helperFunc(10); `; const normalize = (str: string) => str.replace(/\s+/g, " ").trim(); expect(normalize(newFileContent)).toBe( normalize(expectedContent.replace("/* export なし */ ", "")), ); expect(newFileContent).not.toContain("export function helperFunc"); expect(newFileContent).toContain("function helperFunc"); }); it("外部依存関係 (import) を持つ VariableDeclaration から新しいファイル内容を生成できる", () => { const externalCode = "export function externalFunc(n: number): number { return n + 1; }"; const originalCode = ` import { externalFunc } from './external'; const myVar = externalFunc(99); `; const { project, originalSourceFile } = setupProjectWithCode( originalCode, "/src/moduleA/main.ts", ); project.createSourceFile("/src/moduleA/external.ts", externalCode); const targetSymbolName = "myVar"; const newFilePath = "/src/moduleB/newFile.ts"; const declarationStatement = findTopLevelDeclarationByName( originalSourceFile, targetSymbolName, SyntaxKind.VariableStatement, ); expect(declarationStatement).toBeDefined(); if (!declarationStatement) return; const classifiedDependencies: DependencyClassification[] = []; const neededExternalImports: NeededExternalImports = new Map(); const importDecl = originalSourceFile.getImportDeclaration("./external"); expect(importDecl).toBeDefined(); if (importDecl) { const moduleSourceFile = importDecl.getModuleSpecifierSourceFile(); const key = moduleSourceFile ? moduleSourceFile.getFilePath() : importDecl.getModuleSpecifierValue(); neededExternalImports.set(key, { names: new Set(["externalFunc"]), declaration: importDecl, }); } const newFileContent = generateNewSourceFileContent( declarationStatement, classifiedDependencies, originalSourceFile.getFilePath(), newFilePath, neededExternalImports, ); const expectedContent = ` import { externalFunc } from "../moduleA/external"; export const myVar = externalFunc(99); `.trim(); const normalize = (str: string) => str.replace(/\s+/g, " ").trim(); expect(normalize(newFileContent)).toBe(normalize(expectedContent)); }); it("node_modulesからの外部依存を持つシンボルを移動する際、インポートパスが維持される", () => { const originalCode = ` import { useState } from 'react'; const CounterComponent = () => { const [count, setCount] = useState(0); return \`Count: \${count}\`; }; `; const originalFilePath = "/src/components/Counter.tsx"; const newFilePath = "/src/features/NewCounter.tsx"; const targetSymbolName = "CounterComponent"; const { project, originalSourceFile } = setupProjectWithCode( originalCode, originalFilePath, ); const declarationStatement = findTopLevelDeclarationByName( originalSourceFile, targetSymbolName, SyntaxKind.VariableStatement, ); expect(declarationStatement).toBeDefined(); if (!declarationStatement) return; const neededExternalImports: NeededExternalImports = new Map(); const reactImportDecl = originalSourceFile.getImportDeclaration("react"); expect(reactImportDecl).toBeDefined(); if (reactImportDecl) { expect(reactImportDecl.getModuleSpecifierSourceFile()).toBeUndefined(); const key = reactImportDecl.getModuleSpecifierValue(); neededExternalImports.set(key, { names: new Set(["useState"]), declaration: reactImportDecl, }); } const classifiedDependencies: DependencyClassification[] = []; const newFileContent = generateNewSourceFileContent( declarationStatement, classifiedDependencies, originalFilePath, newFilePath, neededExternalImports, ); const expectedImportStatement = 'import { useState } from "react";'; const expectedContent = ` import { useState } from "react"; export const CounterComponent = () => { const [count, setCount] = useState(0); return \`Count: \${count}\`; }; `.trim(); const normalize = (str: string) => str.replace(/\s+/g, " ").trim(); expect(newFileContent.trim()).toContain(expectedImportStatement); expect(newFileContent).not.toContain("node_modules/react"); expect(newFileContent).not.toContain("../"); expect(normalize(newFileContent)).toBe(normalize(expectedContent)); }); it("名前空間インポート (import * as) を持つシンボルから新しいファイル内容を生成できる", () => { const originalCode = ` import * as path from 'node:path'; const resolveFullPath = (dir: string, file: string): string => { return path.resolve(dir, file); }; `; const originalFilePath = "/src/utils/pathHelper.ts"; const newFilePath = "/src/core/newPathHelper.ts"; const targetSymbolName = "resolveFullPath"; const { project, originalSourceFile } = setupProjectWithCode( originalCode, originalFilePath, ); const declarationStatement = findTopLevelDeclarationByName( originalSourceFile, targetSymbolName, SyntaxKind.VariableStatement, ); expect(declarationStatement).toBeDefined(); if (!declarationStatement) return; const neededExternalImports: NeededExternalImports = new Map(); const pathImportDecl = originalSourceFile.getImportDeclaration("node:path"); expect(pathImportDecl).toBeDefined(); if (pathImportDecl) { const key = pathImportDecl.getModuleSpecifierValue(); neededExternalImports.set(key, { names: new Set(), declaration: pathImportDecl, isNamespaceImport: true, namespaceImportName: "path", }); } const classifiedDependencies: DependencyClassification[] = []; const newFileContent = generateNewSourceFileContent( declarationStatement, classifiedDependencies, originalFilePath, newFilePath, neededExternalImports, ); const expectedImportStatement = 'import * as path from "node:path";'; const expectedContent = ` ${expectedImportStatement} export const resolveFullPath = (dir: string, file: string): string => { return path.resolve(dir, file); }; `.trim(); const normalize = (str: string) => str.replace(/\s+/g, " ").trim(); expect(newFileContent.trim()).toContain(expectedImportStatement); expect(normalize(newFileContent)).toBe(normalize(expectedContent)); }); it("デフォルトインポートに依存するシンボルから新しいファイル内容を生成できる", () => { const loggerCode = ` export default function logger(message: string) { console.log(message); } `; const originalCode = ` import myLogger from './logger'; function functionThatUsesLogger(msg: string) { myLogger(\`LOG: \${msg}\`); } `; const originalFilePath = "/src/module/main.ts"; const loggerFilePath = "/src/module/logger.ts"; const newFilePath = "/src/feature/newLoggerUser.ts"; const targetSymbolName = "functionThatUsesLogger"; const { project, originalSourceFile } = setupProjectWithCode( originalCode, originalFilePath, ); project.createSourceFile(loggerFilePath, loggerCode); // 移動対象の宣言を取得 const declarationStatement = findTopLevelDeclarationByName( originalSourceFile, targetSymbolName, SyntaxKind.FunctionDeclaration, ); expect(declarationStatement).toBeDefined(); if (!declarationStatement) return; // 必要な外部インポート情報を手動で設定 (デフォルトインポート) const neededExternalImports: NeededExternalImports = new Map(); const loggerImportDecl = originalSourceFile.getImportDeclaration("./logger"); expect(loggerImportDecl).toBeDefined(); if (loggerImportDecl) { const moduleSourceFile = loggerImportDecl.getModuleSpecifierSourceFile(); expect(moduleSourceFile).toBeDefined(); if (moduleSourceFile) { const key = moduleSourceFile.getFilePath(); neededExternalImports.set(key, { names: new Set(["default"]), declaration: loggerImportDecl, }); } } const classifiedDependencies: DependencyClassification[] = []; const newFileContent = generateNewSourceFileContent( declarationStatement, classifiedDependencies, originalFilePath, newFilePath, neededExternalImports, ); const expectedImportStatement = 'import myLogger from "../module/logger";'; const incorrectImport1 = 'import { default } from "../module/logger";'; const incorrectImport2 = 'import { default as myLogger } from "../module/logger";'; expect(newFileContent).not.toContain(incorrectImport1); expect(newFileContent).not.toContain(incorrectImport2); expect(newFileContent).toContain(expectedImportStatement); expect(newFileContent).toContain("export function functionThatUsesLogger"); }); }); ``` -------------------------------------------------------------------------------- /src/ts-morph/rename-file-system/rename-file-system-entry.special.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from "vitest"; import { Project } from "ts-morph"; import { renameFileSystemEntry } from "./rename-file-system-entry"; // --- Test Setup Helper --- const setupProject = () => { const project = new Project({ useInMemoryFileSystem: true, compilerOptions: { baseUrl: ".", paths: { "@/*": ["src/*"], }, esModuleInterop: true, allowJs: true, }, }); project.createDirectory("/src"); project.createDirectory("/src/utils"); project.createDirectory("/src/components"); return project; }; describe("renameFileSystemEntry Special Cases", () => { it("dryRun: true の場合、ファイルシステム(メモリ上)の変更を行わず、変更予定リストを返す", async () => { const project = setupProject(); const oldUtilPath = "/src/utils/old-util.ts"; const newUtilPath = "/src/utils/new-util.ts"; const componentPath = "/src/components/MyComponent.ts"; project.createSourceFile( oldUtilPath, 'export const oldUtil = () => "old";', ); project.createSourceFile( componentPath, `import { oldUtil } from '../utils/old-util';`, ); const result = await renameFileSystemEntry({ project, renames: [{ oldPath: oldUtilPath, newPath: newUtilPath }], dryRun: true, }); expect(project.getSourceFile(oldUtilPath)).toBeUndefined(); expect(project.getSourceFile(newUtilPath)).toBeDefined(); expect(result.changedFiles).toContain(newUtilPath); expect(result.changedFiles).toContain(componentPath); expect(result.changedFiles).not.toContain(oldUtilPath); }); it("どのファイルからも参照されていないファイルをリネームする", async () => { const project = setupProject(); const oldPath = "/src/utils/unreferenced.ts"; const newPath = "/src/utils/renamed-unreferenced.ts"; project.createSourceFile(oldPath, "export const lonely = true;"); const result = await renameFileSystemEntry({ project, renames: [{ oldPath, newPath }], dryRun: false, }); expect(project.getSourceFile(oldPath)).toBeUndefined(); expect(project.getSourceFile(newPath)).toBeDefined(); expect(project.getSourceFileOrThrow(newPath).getFullText()).toContain( "export const lonely = true;", ); expect(result.changedFiles).toEqual([newPath]); }); it("デフォルトインポートのパスが正しく更新される", async () => { const project = setupProject(); const oldDefaultPath = "/src/utils/defaultExport.ts"; const newDefaultPath = "/src/utils/renamedDefaultExport.ts"; const importerPath = "/src/importer.ts"; project.createSourceFile( oldDefaultPath, "export default function myDefaultFunction() { return 'default'; }", ); project.createSourceFile( importerPath, "import MyDefaultImport from './utils/defaultExport';\nconsole.log(MyDefaultImport());", ); await renameFileSystemEntry({ project, renames: [{ oldPath: oldDefaultPath, newPath: newDefaultPath }], dryRun: false, }); const updatedImporterContent = project .getSourceFileOrThrow(importerPath) .getFullText(); expect(project.getSourceFile(oldDefaultPath)).toBeUndefined(); expect(project.getSourceFile(newDefaultPath)).toBeDefined(); expect(updatedImporterContent).toContain( "import MyDefaultImport from './utils/renamedDefaultExport';", ); }); it("デフォルトエクスポートされた変数 (export default variableName) のパスが正しく更新される", async () => { const project = setupProject(); const oldVarDefaultPath = "/src/utils/variableDefaultExport.ts"; const newVarDefaultPath = "/src/utils/renamedVariableDefaultExport.ts"; const importerPath = "/src/importerVar.ts"; project.createSourceFile( oldVarDefaultPath, "const myVar = { value: 'default var' };\nexport default myVar;", ); project.createSourceFile( importerPath, "import MyVarImport from './utils/variableDefaultExport';\nconsole.log(MyVarImport.value);", ); await renameFileSystemEntry({ project, renames: [{ oldPath: oldVarDefaultPath, newPath: newVarDefaultPath }], dryRun: false, }); const updatedImporterContent = project .getSourceFileOrThrow(importerPath) .getFullText(); expect(project.getSourceFile(oldVarDefaultPath)).toBeUndefined(); expect(project.getSourceFile(newVarDefaultPath)).toBeDefined(); expect(updatedImporterContent).toContain( "import MyVarImport from './utils/renamedVariableDefaultExport';", ); }); }); describe("renameFileSystemEntry Extension Preservation", () => { it("import文のパスに .js 拡張子が含まれている場合、リネーム後も維持される", async () => { const project = setupProject(); const oldJsPath = "/src/utils/legacy-util.js"; const newJsPath = "/src/utils/modern-util.js"; const importerPath = "/src/components/MyComponent.ts"; const otherTsPath = "/src/utils/helper.ts"; const newOtherTsPath = "/src/utils/renamed-helper.ts"; project.createSourceFile(oldJsPath, "export const legacyValue = 1;"); project.createSourceFile(otherTsPath, "export const helperValue = 2;"); project.createSourceFile( importerPath, `import { legacyValue } from '../utils/legacy-util.js'; import { helperValue } from '../utils/helper'; console.log(legacyValue, helperValue); `, ); await renameFileSystemEntry({ project, renames: [ { oldPath: oldJsPath, newPath: newJsPath }, { oldPath: otherTsPath, newPath: newOtherTsPath }, ], dryRun: false, }); const updatedImporterContent = project .getSourceFileOrThrow(importerPath) .getFullText(); expect(updatedImporterContent).toContain( "import { legacyValue } from '../utils/modern-util.js';", ); expect(updatedImporterContent).toContain( "import { helperValue } from '../utils/renamed-helper';", ); expect(project.getSourceFile(oldJsPath)).toBeUndefined(); expect(project.getSourceFile(newJsPath)).toBeDefined(); expect(project.getSourceFile(otherTsPath)).toBeUndefined(); expect(project.getSourceFile(newOtherTsPath)).toBeDefined(); }); }); describe("renameFileSystemEntry with index.ts re-exports", () => { it("index.ts が 'export * from \"./moduleB\"' 形式で moduleB.ts を再エクスポートし、moduleB.ts をリネームした場合", async () => { const project = setupProject(); const utilsDir = "/src/utils"; const moduleBOriginalPath = `${utilsDir}/moduleB.ts`; const moduleBRenamedPath = `${utilsDir}/moduleBRenamed.ts`; const indexTsPath = `${utilsDir}/index.ts`; const componentPath = "/src/components/MyComponent.ts"; project.createSourceFile( moduleBOriginalPath, "export const importantValue = 'Hello from B';", ); project.createSourceFile(indexTsPath, 'export * from "./moduleB";'); project.createSourceFile( componentPath, "import { importantValue } from '@/utils';\\nconsole.log(importantValue);", ); const result = await renameFileSystemEntry({ project, renames: [{ oldPath: moduleBOriginalPath, newPath: moduleBRenamedPath }], dryRun: false, }); expect(project.getSourceFile(moduleBOriginalPath)).toBeUndefined(); expect(project.getSourceFile(moduleBRenamedPath)).toBeDefined(); expect(project.getSourceFileOrThrow(moduleBRenamedPath).getFullText()).toBe( "export const importantValue = 'Hello from B';", ); const indexTsContent = project .getSourceFileOrThrow(indexTsPath) .getFullText(); expect(indexTsContent).toContain('export * from "./moduleBRenamed";'); expect(indexTsContent).not.toContain('export * from "./moduleB";'); const componentContent = project .getSourceFileOrThrow(componentPath) .getFullText(); expect(componentContent).toContain( "import { importantValue } from '@/utils';", ); expect(result.changedFiles).toHaveLength(3); expect(result.changedFiles).toEqual( expect.arrayContaining([moduleBRenamedPath, indexTsPath, componentPath]), ); }); it("index.ts が 'export { specificExport } from \"./moduleC\"' 形式で moduleC.ts を再エクスポートし、moduleC.ts をリネームした場合", async () => { const project = setupProject(); const utilsDir = "/src/utils"; const moduleCOriginalPath = `${utilsDir}/moduleC.ts`; const moduleCRenamedPath = `${utilsDir}/moduleCRenamed.ts`; const indexTsPath = `${utilsDir}/index.ts`; const componentPath = "/src/components/MyComponentForC.ts"; project.createSourceFile( moduleCOriginalPath, "export const specificExport = 'Hello from C';", ); project.createSourceFile( indexTsPath, 'export { specificExport } from "./moduleC";', ); project.createSourceFile( componentPath, "import { specificExport } from '@/utils';\\nconsole.log(specificExport);", ); const result = await renameFileSystemEntry({ project, renames: [{ oldPath: moduleCOriginalPath, newPath: moduleCRenamedPath }], dryRun: false, }); expect(project.getSourceFile(moduleCOriginalPath)).toBeUndefined(); expect(project.getSourceFile(moduleCRenamedPath)).toBeDefined(); expect(project.getSourceFileOrThrow(moduleCRenamedPath).getFullText()).toBe( "export const specificExport = 'Hello from C';", ); const indexTsContent = project .getSourceFileOrThrow(indexTsPath) .getFullText(); expect(indexTsContent).toContain( 'export { specificExport } from "./moduleCRenamed";', ); expect(indexTsContent).not.toContain( 'export { specificExport } from "./moduleC";', ); const componentContent = project .getSourceFileOrThrow(componentPath) .getFullText(); expect(componentContent).toContain( "import { specificExport } from '@/utils';", ); expect(result.changedFiles).toHaveLength(3); expect(result.changedFiles).toEqual( expect.arrayContaining([moduleCRenamedPath, indexTsPath, componentPath]), ); }); it("index.ts が再エクスポートを行い、その utils ディレクトリ全体をリネームした場合", async () => { const project = setupProject(); const oldUtilsDir = "/src/utils"; const newUtilsDir = "/src/newUtils"; const moduleDOriginalPath = `${oldUtilsDir}/moduleD.ts`; const indexTsOriginalPath = `${oldUtilsDir}/index.ts`; const componentPath = "/src/components/MyComponentForD.ts"; project.createSourceFile( moduleDOriginalPath, "export const valueFromD = 'Hello from D';", ); project.createSourceFile(indexTsOriginalPath, 'export * from "./moduleD";'); project.createSourceFile( componentPath, "import { valueFromD } from '@/utils';\\nconsole.log(valueFromD);", ); const result = await renameFileSystemEntry({ project, renames: [{ oldPath: oldUtilsDir, newPath: newUtilsDir }], dryRun: false, }); const moduleDRenamedPath = `${newUtilsDir}/moduleD.ts`; const indexTsRenamedPath = `${newUtilsDir}/index.ts`; expect(project.getSourceFile(moduleDOriginalPath)).toBeUndefined(); expect(project.getSourceFile(indexTsOriginalPath)).toBeUndefined(); // expect(project.getDirectory(oldUtilsDir)).toBeUndefined(); // ユーザーの指示によりコメントアウト expect(project.getDirectory(newUtilsDir)).toBeDefined(); expect(project.getSourceFile(moduleDRenamedPath)).toBeDefined(); expect(project.getSourceFile(indexTsRenamedPath)).toBeDefined(); expect(project.getSourceFileOrThrow(moduleDRenamedPath).getFullText()).toBe( "export const valueFromD = 'Hello from D';", ); expect(project.getSourceFileOrThrow(indexTsRenamedPath).getFullText()).toBe( 'export * from "./moduleD";', ); const componentContent = project .getSourceFileOrThrow(componentPath) .getFullText(); expect(componentContent).toContain( "import { valueFromD } from '../newUtils/index';", ); expect(result.changedFiles).toHaveLength(3); expect(result.changedFiles).toEqual( expect.arrayContaining([ moduleDRenamedPath, indexTsRenamedPath, componentPath, ]), ); }); }); describe("renameFileSystemEntry with index.ts re-exports (actual bug reproduction)", () => { it("index.tsが複数のモジュールを再エクスポートし、そのうちの1つをリネームした際、インポート元のパスがindex.tsを指し続けること", async () => { const project = setupProject(); const utilsDir = "/src/utils"; const moduleAOriginalPath = `${utilsDir}/moduleA.ts`; const moduleARenamedPath = `${utilsDir}/moduleARenamed.ts`; const moduleBPath = `${utilsDir}/moduleB.ts`; const indexTsPath = `${utilsDir}/index.ts`; const componentPath = "/src/components/MyComponent.ts"; project.createSourceFile( moduleAOriginalPath, "export const funcA = () => 'original_A';", ); project.createSourceFile(moduleBPath, "export const funcB = () => 'B';"); project.createSourceFile( indexTsPath, 'export * from "./moduleA";\nexport * from "./moduleB";', ); project.createSourceFile( componentPath, "import { funcA, funcB } from '@/utils';\nconsole.log(funcA(), funcB());", ); const originalComponentContent = project .getSourceFileOrThrow(componentPath) .getFullText(); await renameFileSystemEntry({ project, renames: [{ oldPath: moduleAOriginalPath, newPath: moduleARenamedPath }], dryRun: false, }); // 1. moduleA.ts がリネームされていること expect(project.getSourceFile(moduleAOriginalPath)).toBeUndefined(); expect(project.getSourceFile(moduleARenamedPath)).toBeDefined(); expect(project.getSourceFileOrThrow(moduleARenamedPath).getFullText()).toBe( "export const funcA = () => 'original_A';", ); // 2. index.ts が正しく更新されていること const indexTsContent = project .getSourceFileOrThrow(indexTsPath) .getFullText(); expect(indexTsContent).toContain('export * from "./moduleARenamed";'); expect(indexTsContent).toContain('export * from "./moduleB";'); expect(indexTsContent).not.toContain('export * from "./moduleA";'); // 3. MyComponent.ts のインポートパスが変更されていないこと const updatedComponentContent = project .getSourceFileOrThrow(componentPath) .getFullText(); expect(updatedComponentContent).toBe(originalComponentContent); // さらに具体的に確認 expect(updatedComponentContent).toContain( "import { funcA, funcB } from '@/utils';", ); }); }); ```