# 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: -------------------------------------------------------------------------------- ``` 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | .env.cloud 82 | 83 | # parcel-bundler cache (https://parceljs.org/) 84 | .cache 85 | .parcel-cache 86 | 87 | # Next.js build output 88 | .next 89 | out 90 | 91 | # Nuxt.js build / generate output 92 | .nuxt 93 | dist 94 | 95 | # Gatsby files 96 | .cache/ 97 | # Comment in the public line in if your project uses Gatsby and not Next.js 98 | # https://nextjs.org/blog/next-9-1#public-directory-support 99 | # public 100 | 101 | # vuepress build output 102 | .vuepress/dist 103 | 104 | # vuepress v2.x temp and cache directory 105 | .temp 106 | .cache 107 | 108 | # vitepress build output 109 | **/.vitepress/dist 110 | 111 | # vitepress cache directory 112 | **/.vitepress/cache 113 | 114 | # Docusaurus cache and generated files 115 | .docusaurus 116 | 117 | # Serverless directories 118 | .serverless/ 119 | 120 | # FuseBox cache 121 | .fusebox/ 122 | 123 | # DynamoDB Local files 124 | .dynamodb/ 125 | 126 | # TernJS port file 127 | .tern-port 128 | 129 | # Stores VSCode versions used for testing VSCode extensions 130 | .vscode-test 131 | 132 | # yarn v2 133 | .yarn/cache 134 | .yarn/unplugged 135 | .yarn/build-state.yml 136 | .yarn/install-state.gz 137 | .pnp.* 138 | 139 | # Taskfile cache 140 | .task 141 | ``` -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- ```json 1 | {} 2 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | import { runStdioServer } from "./mcp/stdio"; 3 | 4 | // サーバー起動 5 | runStdioServer().catch((error: Error) => { 6 | process.stderr.write(JSON.stringify({ error: `Fatal error: ${error}` })); 7 | process.exit(1); 8 | }); 9 | ``` -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "organizeImports": { 4 | "enabled": false 5 | }, 6 | "files": { 7 | "ignore": ["dist/**/*", "coverage/**/*"] 8 | }, 9 | "linter": { 10 | "enabled": true, 11 | "rules": { 12 | "recommended": true 13 | } 14 | } 15 | } 16 | ``` -------------------------------------------------------------------------------- /packages/sandbox/src/moduleA.ts: -------------------------------------------------------------------------------- ```typescript 1 | export const valueA = "Value from Module A"; 2 | 3 | export function funcA(): string { 4 | console.log("Function A executed"); 5 | return "Result from Func A"; 6 | } 7 | 8 | export interface InterfaceA { 9 | id: number; 10 | name: string; 11 | } 12 | 13 | // Add more complex scenarios later if needed 14 | ``` -------------------------------------------------------------------------------- /src/errors/timeout-error.ts: -------------------------------------------------------------------------------- ```typescript 1 | export class TimeoutError extends Error { 2 | constructor( 3 | message: string, 4 | public readonly durationSeconds: number, 5 | ) { 6 | super(message); 7 | this.name = "TimeoutError"; 8 | // Set the prototype explicitly. 9 | Object.setPrototypeOf(this, TimeoutError.prototype); 10 | } 11 | } 12 | ``` -------------------------------------------------------------------------------- /src/mcp/stdio.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 2 | import { createMcpServer } from "./config"; 3 | 4 | export async function runStdioServer() { 5 | const mcpServer = createMcpServer(); 6 | const transport = new StdioServerTransport(); 7 | await mcpServer.connect(transport); 8 | } 9 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "outDir": "dist", 7 | "rootDir": "src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules", "dist"] 16 | } 17 | ``` -------------------------------------------------------------------------------- /packages/sandbox/tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "outDir": "./dist", 11 | "rootDir": "./src", 12 | "baseUrl": ".", 13 | "paths": { 14 | "@/*": ["src/*"] 15 | } 16 | }, 17 | "include": ["src/**/*"], 18 | "exclude": ["node_modules", "dist"] 19 | } 20 | ``` -------------------------------------------------------------------------------- /lefthook.yaml: -------------------------------------------------------------------------------- ```yaml 1 | pre-commit: 2 | parallel: true 3 | commands: 4 | format: 5 | glob: "**/*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}" 6 | run: pnpm biome check --write --no-errors-on-unmatched --files-ignore-unknown=true {staged_files} && git update-index --again 7 | pre-push: 8 | parallel: true 9 | commands: 10 | format: 11 | glob: "**/*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}" 12 | run: pnpm biome check --no-errors-on-unmatched --files-ignore-unknown=true {push_files} 13 | test: 14 | run: pnpm test 15 | ``` -------------------------------------------------------------------------------- /packages/sandbox/src/moduleB.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { valueA, funcA, type InterfaceA } from "./moduleA"; 2 | import { utilFunc1, internalUtil } from "@/utils"; // Use path alias 3 | 4 | export const valueB = `Value from Module B using ${valueA}`; 5 | 6 | function privateHelperB() { 7 | return `${internalUtil()} from B`; 8 | } 9 | 10 | export function funcB(): InterfaceA { 11 | console.log("Function B executed"); 12 | utilFunc1(); 13 | const resultA = funcA(); 14 | console.log("Result from funcA:", resultA); 15 | console.log(privateHelperB()); 16 | return { id: 1, name: valueB }; 17 | } 18 | 19 | console.log(valueB); 20 | ``` -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build_and_test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up pnpm 18 | uses: pnpm/action-setup@v4 19 | 20 | - name: Set up Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version-file: 'package.json' 24 | cache: 'pnpm' 25 | 26 | - name: Install dependencies 27 | run: pnpm install 28 | 29 | - name: Run type check 30 | run: pnpm run check-types 31 | 32 | - name: Run lint 33 | run: pnpm run lint 34 | 35 | - name: Run tests 36 | run: pnpm run test 37 | ``` -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { defineConfig } from "vitest/config"; 2 | 3 | // https://vitejs.dev/config/ 4 | export default defineConfig({ 5 | test: { 6 | env: { 7 | API_ADDRESS: "http://localhost:8080", 8 | }, 9 | restoreMocks: true, 10 | mockReset: true, 11 | clearMocks: true, 12 | coverage: { 13 | provider: "v8", 14 | reporter: ["text", "json", "html", "lcov"], 15 | exclude: [ 16 | "node_modules/**", 17 | "dist/**", 18 | "packages/sandbox/**", 19 | "**/*.test.ts", 20 | "**/*.spec.ts", 21 | "**/index.ts", 22 | "vitest.config.ts", 23 | "src/mcp/index.ts", 24 | "src/mcp/stdio.ts", 25 | "src/utils/logger.ts", 26 | "src/utils/logger-helpers.ts", 27 | "src/errors/**", 28 | ], 29 | thresholds: { 30 | lines: 70, 31 | functions: 70, 32 | branches: 65, 33 | statements: 70, 34 | }, 35 | clean: true, 36 | all: true, 37 | }, 38 | }, 39 | }); 40 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/ts-morph-tools.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | 3 | import { registerRenameSymbolTool } from "./register-rename-symbol-tool"; 4 | import { registerRenameFileSystemEntryTool } from "./register-rename-file-system-entry-tool"; 5 | import { registerFindReferencesTool } from "./register-find-references-tool"; 6 | import { registerRemovePathAliasTool } from "./register-remove-path-alias-tool"; 7 | import { registerMoveSymbolToFileTool } from "./register-move-symbol-to-file-tool"; 8 | 9 | /** 10 | * ts-morph を利用したリファクタリングツール群を MCP サーバーに登録する 11 | */ 12 | export function registerTsMorphTools(server: McpServer): void { 13 | registerRenameSymbolTool(server); 14 | registerRenameFileSystemEntryTool(server); 15 | registerFindReferencesTool(server); 16 | registerRemovePathAliasTool(server); 17 | registerMoveSymbolToFileTool(server); 18 | } 19 | ``` -------------------------------------------------------------------------------- /src/ts-morph/rename-file-system/move-file-system-entries.ts: -------------------------------------------------------------------------------- ```typescript 1 | import logger from "../../utils/logger"; 2 | import type { RenameOperation } from "../types"; 3 | import { performance } from "node:perf_hooks"; 4 | 5 | export function moveFileSystemEntries( 6 | renameOperations: RenameOperation[], 7 | signal?: AbortSignal, 8 | ) { 9 | const startTime = performance.now(); 10 | signal?.throwIfAborted(); 11 | logger.debug( 12 | { count: renameOperations.length }, 13 | "Starting file system moves", 14 | ); 15 | for (const { sourceFile, newPath, oldPath } of renameOperations) { 16 | signal?.throwIfAborted(); 17 | logger.trace({ from: oldPath, to: newPath }, "Moving file"); 18 | try { 19 | sourceFile.move(newPath); 20 | } catch (err) { 21 | logger.error( 22 | { err, from: oldPath, to: newPath }, 23 | "Error during sourceFile.move()", 24 | ); 25 | throw err; 26 | } 27 | } 28 | const durationMs = (performance.now() - startTime).toFixed(2); 29 | logger.debug({ durationMs }, "Finished file system moves"); 30 | } 31 | ``` -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- ```typescript 1 | import pino from "pino"; 2 | import { 3 | configureTransport, 4 | parseEnvVariables, 5 | setupExitHandlers, 6 | } from "./logger-helpers"; 7 | 8 | const env = parseEnvVariables(); 9 | 10 | const isTestEnv = env.NODE_ENV === "test"; 11 | 12 | const pinoOptions: pino.LoggerOptions = { 13 | level: isTestEnv ? "silent" : env.LOG_LEVEL, 14 | base: { pid: process.pid }, 15 | timestamp: pino.stdTimeFunctions.isoTime, 16 | formatters: { 17 | level: (label) => ({ level: label.toUpperCase() }), 18 | }, 19 | }; 20 | 21 | const transport = !isTestEnv 22 | ? configureTransport(env.NODE_ENV, env.LOG_OUTPUT, env.LOG_FILE_PATH) 23 | : undefined; 24 | 25 | const baseLogger = transport 26 | ? pino(pinoOptions, pino.transport(transport)) 27 | : pino(pinoOptions); 28 | 29 | setupExitHandlers(baseLogger); 30 | 31 | // テスト環境では初期化ログを出力しない 32 | if (!isTestEnv) { 33 | baseLogger.info( 34 | { 35 | logLevel: env.LOG_LEVEL, 36 | logOutput: env.LOG_OUTPUT, 37 | logFilePath: env.LOG_OUTPUT === "file" ? env.LOG_FILE_PATH : undefined, 38 | nodeEnv: env.NODE_ENV, 39 | }, 40 | "ロガー初期化完了", 41 | ); 42 | } 43 | 44 | export default baseLogger; 45 | ``` -------------------------------------------------------------------------------- /src/ts-morph/rename-file-system/_utils/find-referencing-declarations-for-identifier.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { 2 | type ExportDeclaration, 3 | type Identifier, 4 | type ImportDeclaration, 5 | SyntaxKind, 6 | } from "ts-morph"; 7 | import logger from "../../../utils/logger"; 8 | 9 | export function findReferencingDeclarationsForIdentifier( 10 | identifierNode: Identifier, 11 | signal?: AbortSignal, 12 | ): Set<ImportDeclaration | ExportDeclaration> { 13 | const referencingDeclarations = new Set< 14 | ImportDeclaration | ExportDeclaration 15 | >(); 16 | 17 | logger.trace( 18 | { identifierText: identifierNode.getText() }, 19 | "Finding references for identifier", 20 | ); 21 | 22 | const references = identifierNode.findReferencesAsNodes(); 23 | 24 | for (const referenceNode of references) { 25 | signal?.throwIfAborted(); 26 | 27 | const importOrExportDecl = 28 | referenceNode.getFirstAncestorByKind(SyntaxKind.ImportDeclaration) ?? 29 | referenceNode.getFirstAncestorByKind(SyntaxKind.ExportDeclaration); 30 | 31 | if (importOrExportDecl?.getModuleSpecifier()) { 32 | referencingDeclarations.add(importOrExportDecl); 33 | } 34 | } 35 | logger.trace( 36 | { 37 | identifierText: identifierNode.getText(), 38 | count: referencingDeclarations.size, 39 | }, 40 | "Found referencing declarations for identifier", 41 | ); 42 | return referencingDeclarations; 43 | } 44 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "@sirosuzume/mcp-tsmorph-refactor", 3 | "version": "0.2.9", 4 | "description": "ts-morph を利用した MCP リファクタリングサーバー", 5 | "main": "dist/index.js", 6 | "bin": { 7 | "mcp-tsmorph-refactor": "dist/index.js" 8 | }, 9 | "files": ["dist", "package.json", "README.md"], 10 | "publishConfig": { 11 | "access": "public" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/SiroSuzume/mcp-ts-morph.git" 16 | }, 17 | "packageManager": "[email protected]", 18 | "scripts": { 19 | "preinstall": "npx only-allow pnpm", 20 | "clean": "shx rm -rf dist", 21 | "build": "pnpm run clean && tsc && shx chmod +x dist/index.js", 22 | "prepublishOnly": "pnpm run build", 23 | "inspector": "npx @modelcontextprotocol/inspector node build/index.js", 24 | "test": "vitest run --pool threads --poolOptions.threads.singleThread", 25 | "test:watch": "vitest", 26 | "test:coverage": "vitest run --coverage", 27 | "check-types": "tsc --noEmit", 28 | "lint": "biome lint ./", 29 | "lint:fix": "biome lint --write ./", 30 | "format": "biome check --write ./" 31 | }, 32 | "keywords": ["mcp", "ts-morph", "refactoring"], 33 | "author": "SiroSuzume", 34 | "license": "MIT", 35 | "volta": { 36 | "node": "20.19.0" 37 | }, 38 | "devDependencies": { 39 | "@biomejs/biome": "^1.9.4", 40 | "@types/node": "^22.14.0", 41 | "@vitest/coverage-v8": "3.1.2", 42 | "lefthook": "^1.11.8", 43 | "pino-pretty": "^13.0.0", 44 | "shx": "^0.4.0", 45 | "tsx": "^4.19.3", 46 | "vitest": "^3.1.1" 47 | }, 48 | "dependencies": { 49 | "@modelcontextprotocol/sdk": "^1.17.5", 50 | "pino": "^9.6.0", 51 | "ts-morph": "^25.0.1", 52 | "typescript": "^5.8.3", 53 | "zod": "^3.24.2" 54 | } 55 | } 56 | ``` -------------------------------------------------------------------------------- /src/ts-morph/rename-file-system/_utils/find-declarations-for-rename-operation.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { ExportDeclaration, ImportDeclaration } from "ts-morph"; 2 | import logger from "../../../utils/logger"; 3 | import type { RenameOperation } from "../../types"; 4 | import { findReferencingDeclarationsForIdentifier } from "./find-referencing-declarations-for-identifier"; 5 | import { getIdentifierNodeFromDeclaration } from "./get-identifier-node-from-declaration"; 6 | 7 | export function findDeclarationsForRenameOperation( 8 | renameOperation: RenameOperation, 9 | signal?: AbortSignal, 10 | ): Set<ImportDeclaration | ExportDeclaration> { 11 | const { sourceFile } = renameOperation; 12 | const declarationsForThisOperation = new Set< 13 | ImportDeclaration | ExportDeclaration 14 | >(); 15 | 16 | try { 17 | const exportSymbols = sourceFile.getExportSymbols(); 18 | logger.trace( 19 | { file: sourceFile.getFilePath(), count: exportSymbols.length }, 20 | "Found export symbols for rename operation", 21 | ); 22 | 23 | for (const symbol of exportSymbols) { 24 | signal?.throwIfAborted(); 25 | const symbolDeclarations = symbol.getDeclarations(); 26 | 27 | for (const symbolDeclaration of symbolDeclarations) { 28 | signal?.throwIfAborted(); 29 | const identifierNode = 30 | getIdentifierNodeFromDeclaration(symbolDeclaration); 31 | 32 | if (!identifierNode) { 33 | continue; 34 | } 35 | 36 | const foundDecls = findReferencingDeclarationsForIdentifier( 37 | identifierNode, 38 | signal, 39 | ); 40 | 41 | for (const decl of foundDecls) { 42 | declarationsForThisOperation.add(decl); 43 | } 44 | } 45 | } 46 | } catch (error) { 47 | logger.warn( 48 | { file: sourceFile.getFilePath(), err: error }, 49 | "Error processing rename operation symbols", 50 | ); 51 | } 52 | return declarationsForThisOperation; 53 | } 54 | ``` -------------------------------------------------------------------------------- /src/ts-morph/_utils/ts-morph-project.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Project, type SourceFile } from "ts-morph"; 2 | import * as path from "node:path"; 3 | import { NewLineKind } from "typescript"; 4 | import logger from "../../utils/logger"; 5 | 6 | export function initializeProject(tsconfigPath: string): Project { 7 | const absoluteTsconfigPath = path.resolve(tsconfigPath); 8 | return new Project({ 9 | tsConfigFilePath: absoluteTsconfigPath, 10 | manipulationSettings: { 11 | newLineKind: NewLineKind.LineFeed, 12 | }, 13 | }); 14 | } 15 | 16 | export function getChangedFiles(project: Project): SourceFile[] { 17 | return project.getSourceFiles().filter((sf) => !sf.isSaved()); 18 | } 19 | 20 | export async function saveProjectChanges( 21 | project: Project, 22 | signal?: AbortSignal, 23 | ): Promise<void> { 24 | signal?.throwIfAborted(); 25 | try { 26 | await project.save(); 27 | } catch (error) { 28 | if (error instanceof Error && error.name === "AbortError") { 29 | throw error; 30 | } 31 | const message = error instanceof Error ? error.message : String(error); 32 | throw new Error(`ファイル保存中にエラーが発生しました: ${message}`); 33 | } 34 | } 35 | 36 | export function getTsConfigPaths( 37 | project: Project, 38 | ): Record<string, string[]> | undefined { 39 | try { 40 | const options = project.compilerOptions.get(); 41 | if (!options.paths) { 42 | return undefined; 43 | } 44 | if (typeof options.paths !== "object") { 45 | logger.warn( 46 | { paths: options.paths }, 47 | "Compiler options 'paths' is not an object.", 48 | ); 49 | return undefined; 50 | } 51 | 52 | const validPaths: Record<string, string[]> = {}; 53 | for (const [key, value] of Object.entries(options.paths)) { 54 | if ( 55 | Array.isArray(value) && 56 | value.every((item) => typeof item === "string") 57 | ) { 58 | validPaths[key] = value; 59 | } else { 60 | logger.warn( 61 | { pathKey: key, pathValue: value }, 62 | "Invalid format for paths entry, skipping.", 63 | ); 64 | } 65 | } 66 | return validPaths; 67 | } catch (error) { 68 | logger.error({ err: error }, "Failed to get compiler options or paths"); 69 | return undefined; 70 | } 71 | } 72 | 73 | export function getTsConfigBaseUrl(project: Project): string | undefined { 74 | try { 75 | const options = project.compilerOptions.get(); 76 | return options.baseUrl; 77 | } catch (error) { 78 | logger.error({ err: error }, "Failed to get compiler options baseUrl"); 79 | return undefined; 80 | } 81 | } 82 | ``` -------------------------------------------------------------------------------- /src/ts-morph/move-symbol-to-file/update-target-file.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from "vitest"; 2 | import { Project } from "ts-morph"; 3 | import { updateTargetFile } from "./update-target-file"; 4 | import type { ImportMap } from "./generate-content/build-new-file-import-section"; 5 | 6 | describe("updateTargetFile", () => { 7 | it("既存ファイルに新しい宣言と、それに必要な新しい名前付きインポートを追加・マージできる", () => { 8 | const project = new Project({ useInMemoryFileSystem: true }); 9 | const targetFilePath = "/src/target.ts"; 10 | project.createSourceFile( 11 | "/utils.ts", 12 | "export const foo = 1; export const bar = 2; export const qux = 3;", 13 | ); 14 | 15 | const initialContent = `import { foo, bar } from "../utils"; 16 | 17 | console.log(foo); 18 | console.log(bar); 19 | `; 20 | const targetSourceFile = project.createSourceFile( 21 | targetFilePath, 22 | initialContent, 23 | ); 24 | 25 | const requiredImportMap: ImportMap = new Map([ 26 | [ 27 | "../utils", 28 | { 29 | namedImports: new Set(["qux"]), 30 | isNamespaceImport: false, 31 | }, 32 | ], 33 | ]); 34 | 35 | const declarationStrings: string[] = [ 36 | "export function baz() { return qux(); }", 37 | ]; 38 | 39 | const expectedContent = `import { bar, foo, qux } from "../utils"; 40 | 41 | console.log(foo); 42 | console.log(bar); 43 | 44 | export function baz() { return qux(); } 45 | `; 46 | 47 | updateTargetFile(targetSourceFile, requiredImportMap, declarationStrings); 48 | 49 | expect(targetSourceFile.getFullText().trim()).toBe(expectedContent.trim()); 50 | }); 51 | 52 | it("requiredImportMap に自己参照パスが含まれていても、自己参照インポートは追加しない", () => { 53 | const project = new Project({ useInMemoryFileSystem: true }); 54 | const targetFilePath = "/src/target.ts"; 55 | const initialContent = `export type ExistingType = number; 56 | 57 | console.log('hello'); 58 | `; 59 | const targetSourceFile = project.createSourceFile( 60 | targetFilePath, 61 | initialContent, 62 | ); 63 | 64 | const requiredImportMap: ImportMap = new Map([ 65 | [ 66 | ".", 67 | { 68 | namedImports: new Set(["ExistingType"]), 69 | isNamespaceImport: false, 70 | }, 71 | ], 72 | ]); 73 | 74 | const declarationStrings: string[] = []; 75 | 76 | const expectedContent = initialContent; 77 | 78 | updateTargetFile(targetSourceFile, requiredImportMap, declarationStrings); 79 | 80 | expect(targetSourceFile.getFullText().trim()).toBe(expectedContent.trim()); 81 | }); 82 | 83 | // TODO: Add more realistic test cases (e.g., default imports, different modules) 84 | }); 85 | ``` -------------------------------------------------------------------------------- /src/ts-morph/rename-file-system/prepare-renames.ts: -------------------------------------------------------------------------------- ```typescript 1 | import logger from "../../utils/logger"; 2 | import type { PathMapping, RenameOperation } from "../types"; 3 | import * as path from "node:path"; 4 | import { performance } from "node:perf_hooks"; 5 | import type { Project } from "ts-morph"; 6 | 7 | function checkDestinationExists( 8 | project: Project, 9 | pathToCheck: string, 10 | signal?: AbortSignal, 11 | ): void { 12 | signal?.throwIfAborted(); 13 | if (project.getSourceFile(pathToCheck)) { 14 | throw new Error(`リネーム先パスに既にファイルが存在します: ${pathToCheck}`); 15 | } 16 | if (project.getDirectory(pathToCheck)) { 17 | throw new Error( 18 | `リネーム先パスに既にディレクトリが存在します: ${pathToCheck}`, 19 | ); 20 | } 21 | } 22 | 23 | export function prepareRenames( 24 | project: Project, 25 | renames: PathMapping[], 26 | signal?: AbortSignal, 27 | ): RenameOperation[] { 28 | const startTime = performance.now(); 29 | signal?.throwIfAborted(); 30 | const renameOperations: RenameOperation[] = []; 31 | const uniqueNewPaths = new Set<string>(); 32 | logger.debug({ count: renames.length }, "Starting rename preparation"); 33 | 34 | for (const rename of renames) { 35 | signal?.throwIfAborted(); 36 | const logRename = { old: rename.oldPath, new: rename.newPath }; 37 | logger.trace({ rename: logRename }, "Processing rename request"); 38 | 39 | const absoluteOldPath = path.resolve(rename.oldPath); 40 | const absoluteNewPath = path.resolve(rename.newPath); 41 | 42 | if (uniqueNewPaths.has(absoluteNewPath)) { 43 | throw new Error(`リネーム先のパスが重複しています: ${absoluteNewPath}`); 44 | } 45 | uniqueNewPaths.add(absoluteNewPath); 46 | 47 | checkDestinationExists(project, absoluteNewPath, signal); 48 | 49 | signal?.throwIfAborted(); 50 | const sourceFile = project.getSourceFile(absoluteOldPath); 51 | const directory = project.getDirectory(absoluteOldPath); 52 | 53 | if (sourceFile) { 54 | logger.trace({ path: absoluteOldPath }, "Identified as file rename"); 55 | renameOperations.push({ 56 | sourceFile, 57 | oldPath: absoluteOldPath, 58 | newPath: absoluteNewPath, 59 | }); 60 | } else if (directory) { 61 | logger.trace({ path: absoluteOldPath }, "Identified as directory rename"); 62 | signal?.throwIfAborted(); 63 | const filesInDir = directory.getDescendantSourceFiles(); 64 | logger.trace( 65 | { path: absoluteOldPath, count: filesInDir.length }, 66 | "Found files in directory to rename", 67 | ); 68 | for (const sf of filesInDir) { 69 | const oldFilePath = sf.getFilePath(); 70 | const relative = path.relative(absoluteOldPath, oldFilePath); 71 | const newFilePath = path.resolve(absoluteNewPath, relative); 72 | logger.trace( 73 | { oldFile: oldFilePath, newFile: newFilePath }, 74 | "Adding directory file to rename operations", 75 | ); 76 | renameOperations.push({ 77 | sourceFile: sf, 78 | oldPath: oldFilePath, 79 | newPath: newFilePath, 80 | }); 81 | } 82 | } else { 83 | throw new Error(`リネーム対象が見つかりません: ${absoluteOldPath}`); 84 | } 85 | } 86 | const durationMs = (performance.now() - startTime).toFixed(2); 87 | logger.debug( 88 | { operationCount: renameOperations.length, durationMs }, 89 | "Finished rename preparation", 90 | ); 91 | return renameOperations; 92 | } 93 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/register-remove-path-alias-tool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { z } from "zod"; 3 | import { removePathAlias } from "../../ts-morph/remove-path-alias/remove-path-alias"; 4 | import { Project } from "ts-morph"; 5 | import * as path from "node:path"; // path モジュールが必要 6 | import { performance } from "node:perf_hooks"; 7 | 8 | export function registerRemovePathAliasTool(server: McpServer): void { 9 | server.tool( 10 | "remove_path_alias_by_tsmorph", 11 | `[Uses ts-morph] Replaces path aliases (e.g., '@/') with relative paths in import/export statements within the specified target path. 12 | 13 | Analyzes the project based on \`tsconfig.json\` to resolve aliases and calculate relative paths. 14 | 15 | ## Usage 16 | 17 | 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. 18 | 19 | 1. Specify the **absolute path** to the project\`tsconfig.json\`. 20 | 2. Specify the **absolute path** to the target file or directory where path aliases should be removed. 21 | 3. Optionally, run with \`dryRun: true\` to preview the changes without modifying files. 22 | 23 | ## Parameters 24 | 25 | - tsconfigPath (string, required): Absolute path to the project\`tsconfig.json\` file. **Must be an absolute path.** 26 | - targetPath (string, required): The absolute path to the file or directory to process. **Must be an absolute path.** 27 | - dryRun (boolean, optional): If true, only show intended changes without modifying files. Defaults to false. 28 | 29 | ## Result 30 | 31 | - On success: Returns a message containing the list of file paths modified (or scheduled to be modified if dryRun). 32 | - On failure: Returns a message indicating the error.`, 33 | { 34 | tsconfigPath: z 35 | .string() 36 | .describe("Absolute path to the project's tsconfig.json file."), 37 | targetPath: z 38 | .string() 39 | .describe("Absolute path to the target file or directory."), 40 | dryRun: z 41 | .boolean() 42 | .optional() 43 | .default(false) 44 | .describe( 45 | "If true, only show intended changes without modifying files.", 46 | ), 47 | }, 48 | async (args) => { 49 | const startTime = performance.now(); 50 | let message = ""; 51 | let isError = false; 52 | let duration = "0.00"; 53 | const project = new Project({ 54 | tsConfigFilePath: args.tsconfigPath, 55 | }); 56 | 57 | try { 58 | const { tsconfigPath, targetPath, dryRun } = args; 59 | const compilerOptions = project.compilerOptions.get(); 60 | const tsconfigDir = path.dirname(tsconfigPath); 61 | const baseUrl = path.resolve( 62 | tsconfigDir, 63 | compilerOptions.baseUrl ?? ".", 64 | ); 65 | const pathsOption = compilerOptions.paths ?? {}; 66 | 67 | const result = await removePathAlias({ 68 | project, 69 | targetPath, 70 | dryRun, 71 | baseUrl, 72 | paths: pathsOption, 73 | }); 74 | 75 | if (!dryRun) { 76 | await project.save(); 77 | } 78 | 79 | const changedFilesList = 80 | result.changedFiles.length > 0 81 | ? result.changedFiles.join("\n - ") 82 | : "(No changes)"; 83 | const actionVerb = dryRun ? "scheduled for modification" : "modified"; 84 | message = `Path alias removal (${ 85 | dryRun ? "Dry run" : "Execute" 86 | }): Within the specified path '${targetPath}', the following files were ${actionVerb}:\n - ${changedFilesList}`; 87 | } catch (error) { 88 | const errorMessage = 89 | error instanceof Error ? error.message : String(error); 90 | message = `Error during path alias removal process: ${errorMessage}`; 91 | isError = true; 92 | } finally { 93 | const endTime = performance.now(); 94 | duration = ((endTime - startTime) / 1000).toFixed(2); 95 | } 96 | 97 | const finalMessage = `${message}\nStatus: ${ 98 | isError ? "Failure" : "Success" 99 | }\nProcessing time: ${duration} seconds`; 100 | 101 | return { 102 | content: [{ type: "text", text: finalMessage }], 103 | isError: isError, 104 | }; 105 | }, 106 | ); 107 | } 108 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/register-find-references-tool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { z } from "zod"; 3 | import { findSymbolReferences } from "../../ts-morph/find-references"; // 新しい関数と型をインポート 4 | import { performance } from "node:perf_hooks"; 5 | 6 | export function registerFindReferencesTool(server: McpServer): void { 7 | server.tool( 8 | "find_references_by_tsmorph", 9 | `[Uses ts-morph] Finds the definition and all references to a symbol at a given position throughout the project. 10 | 11 | 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. 12 | 13 | ## Usage 14 | 15 | 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. 16 | 17 | 1. Specify the **absolute path** to the project's \`tsconfig.json\`. 18 | 2. Specify the **absolute path** to the file containing the symbol you want to investigate. 19 | 3. Specify the exact **position** (line and column) of the symbol within the file. 20 | 21 | ## Parameters 22 | 23 | - 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.** 24 | - targetFilePath (string, required): The absolute path to the file containing the symbol to find references for. **Must be an absolute path.** 25 | - position (object, required): The exact position of the symbol to find references for. 26 | - line (number, required): 1-based line number. 27 | - column (number, required): 1-based column number. 28 | 29 | ## Result 30 | 31 | - 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). 32 | - On failure: Returns a message indicating the error.`, 33 | { 34 | tsconfigPath: z 35 | .string() 36 | .describe("Absolute path to the project's tsconfig.json file."), 37 | targetFilePath: z 38 | .string() 39 | .describe("Absolute path to the file containing the symbol."), 40 | position: z 41 | .object({ 42 | line: z.number().describe("1-based line number."), 43 | column: z.number().describe("1-based column number."), 44 | }) 45 | .describe("The exact position of the symbol."), 46 | }, 47 | async (args) => { 48 | const startTime = performance.now(); 49 | let message = ""; 50 | let isError = false; 51 | let duration = "0.00"; // duration を外で宣言・初期化 52 | 53 | try { 54 | const { tsconfigPath, targetFilePath, position } = args; 55 | const { references, definition } = await findSymbolReferences({ 56 | tsconfigPath: tsconfigPath, 57 | targetFilePath: targetFilePath, 58 | position, 59 | }); 60 | 61 | let resultText = ""; 62 | 63 | if (definition) { 64 | resultText += "Definition:\n"; 65 | resultText += `- ${definition.filePath}:${definition.line}:${definition.column}\n`; 66 | resultText += ` \`\`\`typescript\n ${definition.text}\n \`\`\`\n\n`; 67 | } else { 68 | resultText += "Definition not found.\n\n"; 69 | } 70 | 71 | if (references.length > 0) { 72 | resultText += `References (${references.length} found):\n`; 73 | const formattedReferences = references 74 | .map( 75 | (ref) => 76 | `- ${ref.filePath}:${ref.line}:${ref.column}\n \`\`\`typescript\n ${ref.text}\n \`\`\`\``, 77 | ) 78 | .join("\n\n"); 79 | resultText += formattedReferences; 80 | } else { 81 | resultText += "References not found."; 82 | } 83 | message = resultText.trim(); 84 | } catch (error) { 85 | const errorMessage = 86 | error instanceof Error ? error.message : String(error); 87 | message = `Error during reference search: ${errorMessage}`; 88 | isError = true; 89 | } finally { 90 | const endTime = performance.now(); 91 | duration = ((endTime - startTime) / 1000).toFixed(2); // duration を更新 92 | } 93 | 94 | // finally の外で return する 95 | const finalMessage = `${message}\nStatus: ${ 96 | isError ? "Failure" : "Success" 97 | }\nProcessing time: ${duration} seconds`; 98 | 99 | return { 100 | content: [{ type: "text", text: finalMessage }], 101 | isError: isError, 102 | }; 103 | }, 104 | ); 105 | } 106 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/register-rename-symbol-tool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { z } from "zod"; 3 | import { renameSymbol } from "../../ts-morph/rename-symbol/rename-symbol"; 4 | import { performance } from "node:perf_hooks"; 5 | 6 | export function registerRenameSymbolTool(server: McpServer): void { 7 | server.tool( 8 | "rename_symbol_by_tsmorph", 9 | // Note for developers: 10 | // The following English description is primarily intended for the LLM's understanding. 11 | // Please refer to the JSDoc comment above for the original Japanese description. 12 | `[Uses ts-morph] Renames TypeScript/JavaScript symbols across the project. 13 | 14 | Analyzes the AST (Abstract Syntax Tree) to track and update references 15 | throughout the project, not just the definition site. 16 | Useful for cross-file refactoring tasks during Vibe Coding. 17 | 18 | ## Usage 19 | 20 | Use this tool, for example, when you change a function name defined in one file 21 | and want to reflect that change in other files that import and use it. 22 | ts-morph parses the project based on \`tsconfig.json\` to resolve symbol references 23 | and perform the rename. 24 | 25 | 1. Specify the exact location (file path, line, column) of the symbol 26 | (function name, variable name, class name, etc.) you want to rename. 27 | This is necessary for ts-morph to identify the target Identifier node in the AST. 28 | 2. Specify the current symbol name and the new symbol name. 29 | 3. It\'s recommended to first run with \`dryRun: true\` to check which files 30 | ts-morph will modify. 31 | 4. If the preview looks correct, run with \`dryRun: false\` (or omit it) 32 | to actually save the changes to the file system. 33 | 34 | ## Parameters 35 | 36 | - tsconfigPath (string, required): Path to the project\'s root \`tsconfig.json\` file. 37 | Essential for ts-morph to correctly parse the project structure and file references. **Must be an absolute path (relative paths can be misinterpreted).** 38 | - targetFilePath (string, required): Path to the file where the symbol to be renamed 39 | is defined (or first appears). **Must be an absolute path (relative paths can be misinterpreted).** 40 | - position (object, required): The exact position on the symbol to be renamed. 41 | Serves as the starting point for ts-morph to locate the AST node. 42 | - line (number, required): 1-based line number, typically obtained from an editor. 43 | - column (number, required): 1-based column number (position of the first character 44 | of the symbol name), typically obtained from an editor. 45 | - symbolName (string, required): The current name of the symbol before renaming. 46 | Used to verify against the node name found at the specified position. 47 | - newName (string, required): The new name for the symbol after renaming. 48 | - dryRun (boolean, optional): If set to true, prevents ts-morph from making and saving 49 | file changes, returning only the list of files that would be affected. 50 | Useful for verification. Defaults to false. 51 | 52 | ## Result 53 | 54 | - On success: Returns a message containing the list of file paths modified 55 | (or scheduled to be modified if dryRun) by the rename. 56 | - On failure: Returns a message indicating the error.`, 57 | { 58 | tsconfigPath: z 59 | .string() 60 | .describe("Path to the project's tsconfig.json file."), 61 | targetFilePath: z 62 | .string() 63 | .describe("Path to the file containing the symbol to rename."), 64 | position: z 65 | .object({ 66 | line: z.number().describe("1-based line number."), 67 | column: z.number().describe("1-based column number."), 68 | }) 69 | .describe("The exact position of the symbol to rename."), 70 | symbolName: z.string().describe("The current name of the symbol."), 71 | newName: z.string().describe("The new name for the symbol."), 72 | dryRun: z 73 | .boolean() 74 | .optional() 75 | .default(false) 76 | .describe( 77 | "If true, only show intended changes without modifying files.", 78 | ), 79 | }, 80 | async (args) => { 81 | const startTime = performance.now(); 82 | let message = ""; 83 | let isError = false; 84 | let duration = "0.00"; 85 | 86 | try { 87 | const { 88 | tsconfigPath, 89 | targetFilePath, 90 | position, 91 | symbolName, 92 | newName, 93 | dryRun, 94 | } = args; 95 | const result = await renameSymbol({ 96 | tsconfigPath: tsconfigPath, 97 | targetFilePath: targetFilePath, 98 | position: position, 99 | symbolName: symbolName, 100 | newName: newName, 101 | dryRun: dryRun, 102 | }); 103 | 104 | const changedFilesList = 105 | result.changedFiles.length > 0 106 | ? result.changedFiles.join("\n - ") 107 | : "(No changes)"; 108 | 109 | if (dryRun) { 110 | message = `Dry run complete: Renaming symbol '${symbolName}' to '${newName}' would modify the following files:\n - ${changedFilesList}`; 111 | } else { 112 | message = `Rename successful: Renamed symbol '${symbolName}' to '${newName}'. The following files were modified:\n - ${changedFilesList}`; 113 | } 114 | } catch (error) { 115 | const errorMessage = 116 | error instanceof Error ? error.message : String(error); 117 | message = `Error during rename process: ${errorMessage}`; 118 | isError = true; 119 | } finally { 120 | const endTime = performance.now(); 121 | duration = ((endTime - startTime) / 1000).toFixed(2); 122 | } 123 | 124 | const finalMessage = `${message}\nStatus: ${ 125 | isError ? "Failure" : "Success" 126 | }\nProcessing time: ${duration} seconds`; 127 | 128 | return { 129 | content: [{ type: "text", text: finalMessage }], 130 | isError: isError, 131 | }; 132 | }, 133 | ); 134 | } 135 | ``` -------------------------------------------------------------------------------- /src/ts-morph/rename-file-system/rename-file-system-entry.complex.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from "vitest"; 2 | import * as path from "node:path"; 3 | import { Project } from "ts-morph"; 4 | import { renameFileSystemEntry } from "./rename-file-system-entry"; 5 | 6 | // --- Test Setup Helper --- 7 | 8 | const setupProject = () => { 9 | const project = new Project({ 10 | useInMemoryFileSystem: true, 11 | compilerOptions: { 12 | baseUrl: ".", 13 | paths: { 14 | "@/*": ["src/*"], 15 | }, 16 | esModuleInterop: true, 17 | allowJs: true, 18 | }, 19 | }); 20 | 21 | project.createDirectory("/src"); 22 | project.createDirectory("/src/utils"); 23 | project.createDirectory("/src/components"); 24 | project.createDirectory("/src/internal-feature"); 25 | 26 | return project; 27 | }; 28 | 29 | describe("renameFileSystemEntry Complex Cases", () => { 30 | it("内部参照を持つフォルダをリネームする", async () => { 31 | const project = setupProject(); 32 | const oldDirPath = "/src/internal-feature"; 33 | const newDirPath = "/src/cool-feature"; 34 | const file1Path = path.join(oldDirPath, "file1.ts"); 35 | const file2Path = path.join(oldDirPath, "file2.ts"); 36 | 37 | project.createSourceFile( 38 | file1Path, 39 | `import { value2 } from './file2'; export const value1 = value2 + 1;`, 40 | ); 41 | project.createSourceFile(file2Path, "export const value2 = 100;"); 42 | 43 | await renameFileSystemEntry({ 44 | project, 45 | renames: [{ oldPath: oldDirPath, newPath: newDirPath }], 46 | dryRun: false, 47 | }); 48 | 49 | expect(project.getDirectory(newDirPath)).toBeDefined(); 50 | const movedFile1 = project.getSourceFile(path.join(newDirPath, "file1.ts")); 51 | expect(movedFile1).toBeDefined(); 52 | expect(movedFile1?.getFullText()).toContain( 53 | "import { value2 } from './file2';", 54 | ); 55 | }); 56 | 57 | it("複数のファイルを同時にリネームし、それぞれの参照が正しく更新される", async () => { 58 | const project = setupProject(); 59 | const oldFile1 = "/src/utils/file1.ts"; 60 | const newFile1 = "/src/utils/renamed1.ts"; 61 | const oldFile2 = "/src/components/file2.ts"; 62 | const newFile2 = "/src/components/renamed2.ts"; 63 | const refFile = "/src/ref.ts"; 64 | 65 | project.createSourceFile(oldFile1, "export const val1 = 1;"); 66 | project.createSourceFile(oldFile2, "export const val2 = 2;"); 67 | project.createSourceFile( 68 | refFile, 69 | `import { val1 } from './utils/file1';\nimport { val2 } from './components/file2';`, 70 | ); 71 | 72 | await renameFileSystemEntry({ 73 | project, 74 | renames: [ 75 | { oldPath: oldFile1, newPath: newFile1 }, 76 | { oldPath: oldFile2, newPath: newFile2 }, 77 | ], 78 | dryRun: false, 79 | }); 80 | 81 | expect(project.getSourceFile(oldFile1)).toBeUndefined(); 82 | expect(project.getSourceFile(newFile1)).toBeDefined(); 83 | expect(project.getSourceFile(oldFile2)).toBeUndefined(); 84 | expect(project.getSourceFile(newFile2)).toBeDefined(); 85 | const updatedRef = project.getSourceFileOrThrow(refFile).getFullText(); 86 | expect(updatedRef).toContain("import { val1 } from './utils/renamed1';"); 87 | expect(updatedRef).toContain( 88 | "import { val2 } from './components/renamed2';", 89 | ); 90 | }); 91 | 92 | it("ファイルとディレクトリを同時にリネームし、それぞれの参照が正しく更新される", async () => { 93 | const project = setupProject(); 94 | const oldFile = "/src/utils/fileA.ts"; 95 | const newFile = "/src/utils/fileRenamed.ts"; 96 | const oldDir = "/src/components"; 97 | const newDir = "/src/widgets"; 98 | const compInDir = path.join(oldDir, "comp.ts"); 99 | const refFile = "/src/ref.ts"; 100 | 101 | project.createSourceFile(oldFile, "export const valA = 'A';"); 102 | project.createSourceFile(compInDir, "export const valComp = 'Comp';"); 103 | project.createSourceFile( 104 | refFile, 105 | `import { valA } from './utils/fileA';\nimport { valComp } from './components/comp';`, 106 | ); 107 | 108 | await renameFileSystemEntry({ 109 | project, 110 | renames: [ 111 | { oldPath: oldFile, newPath: newFile }, 112 | { oldPath: oldDir, newPath: newDir }, 113 | ], 114 | dryRun: false, 115 | }); 116 | 117 | expect(project.getSourceFile(oldFile)).toBeUndefined(); 118 | expect(project.getSourceFile(newFile)).toBeDefined(); 119 | expect(project.getDirectory(newDir)).toBeDefined(); 120 | expect(project.getSourceFile(path.join(newDir, "comp.ts"))).toBeDefined(); 121 | const updatedRef = project.getSourceFileOrThrow(refFile).getFullText(); 122 | expect(updatedRef).toContain("import { valA } from './utils/fileRenamed';"); 123 | expect(updatedRef).toContain("import { valComp } from './widgets/comp';"); 124 | }); 125 | 126 | it("ファイル名をスワップする(一時ファイル経由)", async () => { 127 | const project = setupProject(); 128 | const fileA = "/src/fileA.ts"; 129 | const fileB = "/src/fileB.ts"; 130 | const tempFile = "/src/temp.ts"; 131 | const refFile = "/src/ref.ts"; 132 | 133 | project.createSourceFile(fileA, "export const valA = 'A';"); 134 | project.createSourceFile(fileB, "export const valB = 'B';"); 135 | project.createSourceFile( 136 | refFile, 137 | `import { valA } from './fileA';\nimport { valB } from './fileB';`, 138 | ); 139 | 140 | await renameFileSystemEntry({ 141 | project, 142 | renames: [{ oldPath: fileA, newPath: tempFile }], 143 | dryRun: false, 144 | }); 145 | await renameFileSystemEntry({ 146 | project, 147 | renames: [{ oldPath: fileB, newPath: fileA }], 148 | dryRun: false, 149 | }); 150 | await renameFileSystemEntry({ 151 | project, 152 | renames: [{ oldPath: tempFile, newPath: fileB }], 153 | dryRun: false, 154 | }); 155 | 156 | expect(project.getSourceFile(tempFile)).toBeUndefined(); 157 | expect(project.getSourceFile(fileA)?.getFullText()).toContain( 158 | "export const valB = 'B';", 159 | ); 160 | expect(project.getSourceFile(fileB)?.getFullText()).toContain( 161 | "export const valA = 'A';", 162 | ); 163 | const updatedRef = project.getSourceFileOrThrow(refFile).getFullText(); 164 | expect(updatedRef).toContain("import { valA } from './fileB';"); 165 | expect(updatedRef).toContain("import { valB } from './fileA';"); 166 | }); 167 | }); 168 | ``` -------------------------------------------------------------------------------- /src/ts-morph/rename-file-system/rename-file-system-entry.base.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from "vitest"; 2 | import { Project } from "ts-morph"; 3 | import * as path from "node:path"; 4 | import { renameFileSystemEntry } from "./rename-file-system-entry"; 5 | 6 | // --- Test Setup Helper --- 7 | 8 | const setupProject = () => { 9 | const project = new Project({ 10 | useInMemoryFileSystem: true, 11 | compilerOptions: { 12 | baseUrl: ".", 13 | paths: { 14 | "@/*": ["src/*"], 15 | }, 16 | esModuleInterop: true, 17 | allowJs: true, 18 | }, 19 | }); 20 | 21 | // 共通のディレクトリ構造をメモリ上に作成 22 | project.createDirectory("/src"); 23 | project.createDirectory("/src/utils"); 24 | project.createDirectory("/src/components"); 25 | project.createDirectory("/src/old-feature"); 26 | project.createDirectory("/src/myFeature"); 27 | project.createDirectory("/src/anotherFeature"); 28 | project.createDirectory("/src/dirA"); 29 | project.createDirectory("/src/dirB"); 30 | project.createDirectory("/src/dirC"); 31 | project.createDirectory("/src/core"); 32 | project.createDirectory("/src/widgets"); 33 | 34 | return project; 35 | }; 36 | 37 | describe("renameFileSystemEntry Base Cases", () => { 38 | it("ファイルリネーム時に相対パスとエイリアスパスのimport文を正しく更新する", async () => { 39 | const project = setupProject(); 40 | const oldUtilPath = "/src/utils/old-util.ts"; 41 | const newUtilPath = "/src/utils/new-util.ts"; 42 | const componentPath = "/src/components/MyComponent.ts"; 43 | const utilIndexPath = "/src/utils/index.ts"; 44 | 45 | project.createSourceFile( 46 | oldUtilPath, 47 | 'export const oldUtil = () => "old";', 48 | ); 49 | project.createSourceFile(utilIndexPath, 'export * from "./old-util";'); 50 | project.createSourceFile( 51 | componentPath, 52 | `import { oldUtil as relativeImport } from '../utils/old-util'; 53 | import { oldUtil as aliasImport } from '@/utils/old-util'; 54 | import { oldUtil as indexImport } from '../utils'; 55 | 56 | console.log(relativeImport(), aliasImport(), indexImport()); 57 | `, 58 | ); 59 | 60 | await renameFileSystemEntry({ 61 | project, 62 | renames: [{ oldPath: oldUtilPath, newPath: newUtilPath }], 63 | dryRun: false, 64 | }); 65 | 66 | const updatedComponentContent = project 67 | .getSourceFileOrThrow(componentPath) 68 | .getFullText(); 69 | 70 | expect(updatedComponentContent).toBe( 71 | `import { oldUtil as relativeImport } from '../utils/new-util'; 72 | import { oldUtil as aliasImport } from '../utils/new-util'; 73 | import { oldUtil as indexImport } from '../utils'; 74 | 75 | console.log(relativeImport(), aliasImport(), indexImport()); 76 | `, 77 | ); 78 | expect(project.getSourceFile(oldUtilPath)).toBeUndefined(); 79 | expect(project.getSourceFile(newUtilPath)).toBeDefined(); 80 | }); 81 | 82 | it("フォルダリネーム時に相対パスとエイリアスパスのimport文を正しく更新する", async () => { 83 | const project = setupProject(); 84 | const oldFeatureDir = "/src/old-feature"; 85 | const newFeatureDir = "/src/new-feature"; 86 | const featureFilePath = path.join(oldFeatureDir, "feature.ts"); 87 | const componentPath = "/src/components/AnotherComponent.ts"; 88 | const featureIndexPath = path.join(oldFeatureDir, "index.ts"); 89 | 90 | project.createSourceFile( 91 | featureFilePath, 92 | 'export const feature = () => "feature";', 93 | ); 94 | project.createSourceFile(featureIndexPath, 'export * from "./feature";'); 95 | project.createSourceFile( 96 | componentPath, 97 | `import { feature as relativeImport } from '../old-feature/feature'; 98 | import { feature as aliasImport } from '@/old-feature/feature'; 99 | import { feature as indexImport } from '../old-feature'; 100 | 101 | console.log(relativeImport(), aliasImport(), indexImport()); 102 | `, 103 | ); 104 | 105 | await renameFileSystemEntry({ 106 | project, 107 | renames: [{ oldPath: oldFeatureDir, newPath: newFeatureDir }], 108 | dryRun: false, 109 | }); 110 | 111 | const updatedComponentContent = project 112 | .getSourceFileOrThrow(componentPath) 113 | .getFullText(); 114 | expect( 115 | updatedComponentContent, 116 | ).toBe(`import { feature as relativeImport } from '../new-feature/feature'; 117 | import { feature as aliasImport } from '../new-feature/feature'; 118 | import { feature as indexImport } from '../new-feature/index'; 119 | 120 | console.log(relativeImport(), aliasImport(), indexImport()); 121 | `); 122 | 123 | expect(project.getDirectory(newFeatureDir)).toBeDefined(); 124 | expect( 125 | project.getSourceFile(path.join(newFeatureDir, "feature.ts")), 126 | ).toBeDefined(); 127 | expect( 128 | project.getSourceFile(path.join(newFeatureDir, "index.ts")), 129 | ).toBeDefined(); 130 | }); 131 | 132 | it("同階層(.)や親階層(..)への相対パスimport文を持つファイルをリネームした際に、参照元のパスが正しく更新される", async () => { 133 | const project = setupProject(); 134 | const dirA = "/src/dirA"; 135 | const dirB = "/src/dirB"; 136 | 137 | const fileA1Path = path.join(dirA, "fileA1.ts"); 138 | const fileA2Path = path.join(dirA, "fileA2.ts"); 139 | const fileBPath = path.join(dirB, "fileB.ts"); 140 | const fileA3Path = path.join(dirA, "fileA3.ts"); 141 | 142 | project.createSourceFile(fileA1Path, "export const valA1 = 1;"); 143 | project.createSourceFile(fileA2Path, "export const valA2 = 2;"); 144 | project.createSourceFile( 145 | fileBPath, 146 | ` 147 | import { valA2 } from '../dirA/fileA2'; 148 | import { valA1 } from '../dirA/fileA1'; 149 | console.log(valA2, valA1); 150 | `, 151 | ); 152 | project.createSourceFile( 153 | fileA3Path, 154 | ` 155 | import { valA2 } from './fileA2'; 156 | console.log(valA2); 157 | `, 158 | ); 159 | 160 | const newFileA2Path = path.join(dirA, "renamedA2.ts"); 161 | 162 | await renameFileSystemEntry({ 163 | project, 164 | renames: [{ oldPath: fileA2Path, newPath: newFileA2Path }], 165 | dryRun: false, 166 | }); 167 | 168 | const updatedFileBContent = project 169 | .getSourceFileOrThrow(fileBPath) 170 | .getFullText(); 171 | const updatedFileA3Content = project 172 | .getSourceFileOrThrow(fileA3Path) 173 | .getFullText(); 174 | 175 | expect(updatedFileBContent).toContain( 176 | "import { valA2 } from '../dirA/renamedA2';", 177 | ); 178 | expect(updatedFileBContent).toContain( 179 | "import { valA1 } from '../dirA/fileA1';", 180 | ); 181 | expect(updatedFileA3Content).toContain( 182 | "import { valA2 } from './renamedA2';", 183 | ); 184 | 185 | expect(project.getSourceFile(fileA2Path)).toBeUndefined(); 186 | expect(project.getSourceFile(newFileA2Path)).toBeDefined(); 187 | }); 188 | 189 | it("親階層(..)への相対パスimport文を持つファイルを、別のディレクトリに移動(リネーム)した際に、参照元のパスが正しく更新される", async () => { 190 | const project = setupProject(); 191 | const dirA = "/src/dirA"; 192 | const dirC = "/src/dirC"; 193 | 194 | const fileA1Path = path.join(dirA, "fileA1.ts"); 195 | const fileA2Path = path.join(dirA, "fileA2.ts"); 196 | 197 | project.createSourceFile(fileA1Path, "export const valA1 = 1;"); 198 | project.createSourceFile( 199 | fileA2Path, 200 | ` 201 | import { valA1 } from './fileA1'; 202 | console.log(valA1); 203 | `, 204 | ); 205 | 206 | const newFileA1Path = path.join(dirC, "movedA1.ts"); 207 | 208 | await renameFileSystemEntry({ 209 | project, 210 | renames: [{ oldPath: fileA1Path, newPath: newFileA1Path }], 211 | dryRun: false, 212 | }); 213 | 214 | const updatedFileA2Content = project 215 | .getSourceFileOrThrow(fileA2Path) 216 | .getFullText(); 217 | expect(updatedFileA2Content).toContain( 218 | "import { valA1 } from '../dirC/movedA1';", 219 | ); 220 | 221 | expect(project.getSourceFile(fileA1Path)).toBeUndefined(); 222 | expect(project.getSourceFile(newFileA1Path)).toBeDefined(); 223 | }); 224 | }); 225 | ``` -------------------------------------------------------------------------------- /src/ts-morph/move-symbol-to-file/generate-content/build-new-file-import-section.ts: -------------------------------------------------------------------------------- ```typescript 1 | import logger from "../../../utils/logger"; 2 | import { calculateRelativePath } from "../../_utils/calculate-relative-path"; 3 | import type { 4 | DependencyClassification, 5 | NeededExternalImports, 6 | } from "../../types"; 7 | 8 | type ExtendedImportInfo = { 9 | defaultName?: string; 10 | namedImports: Set<string>; 11 | isNamespaceImport: boolean; 12 | namespaceImportName?: string; 13 | }; 14 | 15 | export type ImportMap = Map<string, ExtendedImportInfo>; 16 | 17 | function aggregateImports( 18 | importMap: ImportMap, 19 | relativePath: string, 20 | importName: string, 21 | isDefault: boolean, 22 | ) { 23 | if (isDefault) { 24 | const actualDefaultName = importName; 25 | if (!importMap.has(relativePath)) { 26 | importMap.set(relativePath, { 27 | namedImports: new Set(), 28 | isNamespaceImport: false, 29 | }); 30 | } 31 | const entry = importMap.get(relativePath); 32 | if (!entry || entry.isNamespaceImport) { 33 | logger.warn( 34 | `Skipping default import aggregation for ${relativePath} due to existing namespace import or missing entry.`, 35 | ); 36 | return; 37 | } 38 | entry.defaultName = actualDefaultName; 39 | logger.debug( 40 | `Aggregated default import: ${actualDefaultName} for path: ${relativePath}`, 41 | ); 42 | return; 43 | } 44 | const nameToAdd = importName; 45 | if (!importMap.has(relativePath)) { 46 | importMap.set(relativePath, { 47 | namedImports: new Set(), 48 | isNamespaceImport: false, 49 | }); 50 | } 51 | const entry = importMap.get(relativePath); 52 | if (!entry || entry.isNamespaceImport) { 53 | logger.warn( 54 | `Skipping named import aggregation for ${relativePath} due to existing namespace import or missing entry.`, 55 | ); 56 | return; 57 | } 58 | entry.namedImports.add(nameToAdd); 59 | logger.debug( 60 | `Aggregated named import: ${nameToAdd} for path: ${relativePath}`, 61 | ); 62 | } 63 | 64 | function processExternalImports( 65 | importMap: ImportMap, 66 | neededExternalImports: NeededExternalImports, 67 | newFilePath: string, 68 | ): void { 69 | logger.debug("Processing external imports..."); 70 | for (const [ 71 | originalModuleSpecifier, 72 | { names, declaration, isNamespaceImport, namespaceImportName }, 73 | ] of neededExternalImports.entries()) { 74 | const moduleSourceFile = declaration?.getModuleSpecifierSourceFile(); 75 | let relativePath = ""; 76 | let isSelfReference = false; 77 | 78 | if ( 79 | moduleSourceFile && 80 | !moduleSourceFile.getFilePath().includes("/node_modules/") 81 | ) { 82 | const absoluteModulePath = moduleSourceFile.getFilePath(); 83 | if (absoluteModulePath === newFilePath) { 84 | isSelfReference = true; 85 | } else { 86 | relativePath = calculateRelativePath(newFilePath, absoluteModulePath); 87 | logger.debug( 88 | `Calculated relative path for NON-node_modules import: ${relativePath} (from ${absoluteModulePath})`, 89 | ); 90 | } 91 | } else { 92 | relativePath = originalModuleSpecifier; 93 | logger.debug( 94 | `Using original module specifier for node_modules or unresolved import: ${relativePath}`, 95 | ); 96 | } 97 | 98 | if (isSelfReference) { 99 | logger.debug(`Skipping self-reference import for path: ${newFilePath}`); 100 | continue; 101 | } 102 | 103 | if (isNamespaceImport && namespaceImportName) { 104 | if (!importMap.has(relativePath)) { 105 | importMap.set(relativePath, { 106 | namedImports: new Set(), 107 | isNamespaceImport: true, 108 | namespaceImportName: namespaceImportName, 109 | }); 110 | logger.debug( 111 | `Added namespace import: ${namespaceImportName} for path: ${relativePath}`, 112 | ); 113 | } else { 114 | logger.warn( 115 | `Namespace import for ${relativePath} conflicts with existing non-namespace imports. Skipping.`, 116 | ); 117 | } 118 | continue; 119 | } 120 | 121 | const defaultImportNode = declaration?.getDefaultImport(); 122 | const actualDefaultName = defaultImportNode?.getText(); 123 | 124 | for (const name of names) { 125 | const isDefaultFlag = name === "default" && !!actualDefaultName; 126 | if (isDefaultFlag) { 127 | if (!actualDefaultName) { 128 | logger.warn( 129 | `Default import name was expected but not found for ${relativePath}. Skipping default import.`, 130 | ); 131 | continue; 132 | } 133 | aggregateImports(importMap, relativePath, actualDefaultName, true); 134 | } else { 135 | aggregateImports(importMap, relativePath, name, false); 136 | } 137 | } 138 | } 139 | } 140 | 141 | function processInternalDependencies( 142 | importMap: ImportMap, 143 | classifiedDependencies: DependencyClassification[], 144 | newFilePath: string, 145 | originalFilePath: string, 146 | ): void { 147 | logger.debug("Processing internal dependencies for import map..."); 148 | 149 | if (newFilePath === originalFilePath) { 150 | logger.debug( 151 | "Skipping internal dependency processing as source and target files are the same.", 152 | ); 153 | return; 154 | } 155 | 156 | const dependenciesToImportNames = new Set<string>(); 157 | 158 | for (const dep of classifiedDependencies) { 159 | if (dep.type === "importFromOriginal" || dep.type === "addExport") { 160 | logger.debug(`Internal dependency to import from original: ${dep.name}`); 161 | dependenciesToImportNames.add(dep.name); 162 | } 163 | } 164 | 165 | if (dependenciesToImportNames.size === 0) { 166 | logger.debug("No internal dependencies need importing from original file."); 167 | return; 168 | } 169 | 170 | const internalImportPath = calculateRelativePath( 171 | newFilePath, 172 | originalFilePath, 173 | ); 174 | logger.debug( 175 | `Calculated relative path for internal import: ${internalImportPath}`, 176 | ); 177 | 178 | if (internalImportPath !== "." && internalImportPath !== "./") { 179 | for (const name of dependenciesToImportNames) { 180 | aggregateImports(importMap, internalImportPath, name, false); 181 | } 182 | } else { 183 | logger.debug("Skipping aggregation for self-referencing internal path."); 184 | } 185 | } 186 | 187 | function buildImportStatementString( 188 | defaultImportName: string | undefined, 189 | namedImportSpecifiers: string, 190 | relativePath: string, 191 | isNamespaceImport: boolean, 192 | namespaceImportName?: string, 193 | ): string { 194 | const fromPart = `from "${relativePath}";`; 195 | if (isNamespaceImport && namespaceImportName) { 196 | return `import * as ${namespaceImportName} ${fromPart}`; 197 | } 198 | if (!defaultImportName && !namedImportSpecifiers) { 199 | logger.debug(`Building side-effect import for ${relativePath}`); 200 | return `import ${fromPart}`; 201 | } 202 | const defaultPart = defaultImportName ? `${defaultImportName}` : ""; 203 | const namedPart = namedImportSpecifiers ? `{ ${namedImportSpecifiers} }` : ""; 204 | const separator = defaultPart && namedPart ? ", " : ""; 205 | return `import ${defaultPart}${separator}${namedPart} ${fromPart}`; 206 | } 207 | 208 | export function calculateRequiredImportMap( 209 | neededExternalImports: NeededExternalImports, 210 | classifiedDependencies: DependencyClassification[], 211 | newFilePath: string, 212 | originalFilePath: string, 213 | ): ImportMap { 214 | const importMap: ImportMap = new Map(); 215 | processExternalImports(importMap, neededExternalImports, newFilePath); 216 | processInternalDependencies( 217 | importMap, 218 | classifiedDependencies, 219 | newFilePath, 220 | originalFilePath, 221 | ); 222 | return importMap; 223 | } 224 | 225 | export function buildImportSectionStringFromMap(importMap: ImportMap): string { 226 | logger.debug("Generating import section string..."); 227 | let importSection = ""; 228 | const sortedPaths = [...importMap.keys()].sort(); 229 | for (const path of sortedPaths) { 230 | const importData = importMap.get(path); 231 | if (!importData) { 232 | logger.warn(`Import data not found for path ${path} during generation.`); 233 | continue; 234 | } 235 | const { 236 | defaultName, 237 | namedImports, 238 | isNamespaceImport, 239 | namespaceImportName, 240 | } = importData; 241 | const sortedNamedImports = [...namedImports].sort().join(", "); 242 | const importStatement = buildImportStatementString( 243 | defaultName, 244 | sortedNamedImports, 245 | path, 246 | isNamespaceImport, 247 | namespaceImportName, 248 | ); 249 | if (importStatement) { 250 | importSection += `${importStatement}\n`; 251 | } 252 | } 253 | if (importSection) { 254 | importSection += "\n"; 255 | } 256 | logger.debug(`Generated Import Section String: 257 | ${importSection}`); 258 | return importSection; 259 | } 260 | ``` -------------------------------------------------------------------------------- /src/ts-morph/move-symbol-to-file/update-imports-in-referencing-files.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from "vitest"; 2 | import { Project, IndentationText, QuoteKind } from "ts-morph"; 3 | import { updateImportsInReferencingFiles } from "./update-imports-in-referencing-files"; 4 | 5 | describe("updateImportsInReferencingFiles", () => { 6 | const oldDirPath = "/src/moduleA"; 7 | const oldFilePath = `${oldDirPath}/old-location.ts`; 8 | const moduleAIndexPath = `${oldDirPath}/index.ts`; 9 | const newFilePath = "/src/moduleC/new-location.ts"; 10 | 11 | // --- Setup Helper Function --- 12 | const setupTestProject = () => { 13 | const project = new Project({ 14 | manipulationSettings: { 15 | indentationText: IndentationText.TwoSpaces, 16 | quoteKind: QuoteKind.Single, 17 | }, 18 | useInMemoryFileSystem: true, 19 | compilerOptions: { 20 | baseUrl: ".", 21 | paths: { 22 | "@/*": ["src/*"], 23 | }, 24 | typeRoots: [], 25 | }, 26 | }); 27 | 28 | project.createDirectory("/src"); 29 | project.createDirectory(oldDirPath); 30 | project.createDirectory("/src/moduleB"); 31 | project.createDirectory("/src/moduleC"); 32 | project.createDirectory("/src/moduleD"); 33 | project.createDirectory("/src/moduleE"); 34 | project.createDirectory("/src/moduleF"); 35 | project.createDirectory("/src/moduleG"); 36 | 37 | // Use literal strings for symbols in setup 38 | project.createSourceFile( 39 | oldFilePath, 40 | `export const exportedSymbol = 123; 41 | export const anotherSymbol = 456; 42 | export type MyType = { id: number }; 43 | `, 44 | ); 45 | 46 | project.createSourceFile( 47 | moduleAIndexPath, 48 | `export { exportedSymbol, anotherSymbol } from './old-location'; 49 | export type { MyType } from './old-location'; 50 | `, 51 | ); 52 | 53 | const importerRel = project.createSourceFile( 54 | "/src/moduleB/importer-relative.ts", 55 | `import { exportedSymbol } from '../moduleA/old-location';\nconsole.log(exportedSymbol);`, 56 | ); 57 | 58 | const importerAlias = project.createSourceFile( 59 | "/src/moduleD/importer-alias.ts", 60 | `import { anotherSymbol } from '@/moduleA/old-location';\nconsole.log(anotherSymbol);`, 61 | ); 62 | 63 | const importerIndex = project.createSourceFile( 64 | "/src/moduleE/importer-index.ts", 65 | `import { exportedSymbol } from '../moduleA';\nconsole.log(exportedSymbol);`, 66 | ); 67 | 68 | const importerMulti = project.createSourceFile( 69 | "/src/moduleF/importer-multi.ts", 70 | `import { exportedSymbol, anotherSymbol } from '../moduleA/old-location';\nconsole.log(exportedSymbol, anotherSymbol);`, 71 | ); 72 | 73 | const importerType = project.createSourceFile( 74 | "/src/moduleG/importer-type.ts", 75 | `import type { MyType } from '../moduleA/old-location';\nlet val: MyType;`, 76 | ); 77 | 78 | const noRefFile = project.createSourceFile( 79 | "/src/no-ref.ts", 80 | 'console.log("hello");', 81 | ); 82 | 83 | return { 84 | project, 85 | importerRelPath: "/src/moduleB/importer-relative.ts", 86 | importerAliasPath: "/src/moduleD/importer-alias.ts", 87 | importerIndexPath: "/src/moduleE/importer-index.ts", 88 | importerMultiPath: "/src/moduleF/importer-multi.ts", 89 | importerTypePath: "/src/moduleG/importer-type.ts", 90 | noRefFilePath: "/src/no-ref.ts", 91 | oldFilePath, 92 | newFilePath, 93 | }; 94 | }; 95 | 96 | it("相対パスでインポートしているファイルのパスを正しく更新する", async () => { 97 | const { project, oldFilePath, newFilePath, importerRelPath } = 98 | setupTestProject(); 99 | await updateImportsInReferencingFiles( 100 | project, 101 | oldFilePath, 102 | newFilePath, 103 | "exportedSymbol", 104 | ); 105 | const expected = `import { exportedSymbol } from '../moduleC/new-location'; 106 | console.log(exportedSymbol);`; 107 | expect(project.getSourceFile(importerRelPath)?.getText()).toBe(expected); 108 | }); 109 | 110 | it("エイリアスパスでインポートしているファイルのパスを正しく更新する (相対パスになる)", async () => { 111 | const { project, oldFilePath, newFilePath, importerAliasPath } = 112 | setupTestProject(); 113 | await updateImportsInReferencingFiles( 114 | project, 115 | oldFilePath, 116 | newFilePath, 117 | "anotherSymbol", 118 | ); 119 | const expected = `import { anotherSymbol } from '../moduleC/new-location'; 120 | console.log(anotherSymbol);`; 121 | expect(project.getSourceFile(importerAliasPath)?.getText()).toBe(expected); 122 | }); 123 | 124 | it("複数のファイルから参照されている場合、指定したシンボルのパスのみ更新する", async () => { 125 | const { 126 | project, 127 | oldFilePath, 128 | newFilePath, 129 | importerRelPath, 130 | importerAliasPath, 131 | } = setupTestProject(); 132 | await updateImportsInReferencingFiles( 133 | project, 134 | oldFilePath, 135 | newFilePath, 136 | "exportedSymbol", 137 | ); 138 | 139 | const expectedRel = `import { exportedSymbol } from '../moduleC/new-location'; 140 | console.log(exportedSymbol);`; 141 | expect(project.getSourceFile(importerRelPath)?.getText()).toBe(expectedRel); 142 | 143 | const expectedAlias = `import { anotherSymbol } from '@/moduleA/old-location'; 144 | console.log(anotherSymbol);`; 145 | expect(project.getSourceFile(importerAliasPath)?.getText()).toBe( 146 | expectedAlias, 147 | ); 148 | }); 149 | 150 | it("複数の名前付きインポートを持つファイルのパスを、指定したシンボルのみ更新する", async () => { 151 | const { project, oldFilePath, newFilePath, importerMultiPath } = 152 | setupTestProject(); 153 | const symbolToMove = "exportedSymbol"; 154 | 155 | await updateImportsInReferencingFiles( 156 | project, 157 | oldFilePath, 158 | newFilePath, 159 | symbolToMove, 160 | ); 161 | 162 | const expected = `import { anotherSymbol } from '../moduleA/old-location'; 163 | import { exportedSymbol } from '../moduleC/new-location'; 164 | 165 | console.log(exportedSymbol, anotherSymbol);`; 166 | expect(project.getSourceFile(importerMultiPath)?.getText()).toBe(expected); 167 | }); 168 | 169 | it("Typeインポートを持つファイルのパスを正しく更新する", async () => { 170 | const { project, oldFilePath, newFilePath, importerTypePath } = 171 | setupTestProject(); 172 | await updateImportsInReferencingFiles( 173 | project, 174 | oldFilePath, 175 | newFilePath, 176 | "MyType", 177 | ); 178 | const expected = `import type { MyType } from '../moduleC/new-location'; 179 | let val: MyType;`; 180 | expect(project.getSourceFile(importerTypePath)?.getText()).toBe(expected); 181 | }); 182 | 183 | it("移動元ファイルへの参照がない場合、エラーなく完了し、他のファイルは変更されない", async () => { 184 | const { project, oldFilePath, newFilePath, noRefFilePath } = 185 | setupTestProject(); 186 | const originalContent = 187 | project.getSourceFile(noRefFilePath)?.getText() ?? ""; 188 | 189 | await expect( 190 | updateImportsInReferencingFiles( 191 | project, 192 | oldFilePath, 193 | newFilePath, 194 | "exportedSymbol", 195 | ), 196 | ).resolves.toBeUndefined(); 197 | 198 | expect(project.getSourceFile(noRefFilePath)?.getText()).toBe( 199 | originalContent, 200 | ); 201 | }); 202 | 203 | it("移動先ファイルが元々移動元シンボルをインポートしていた場合、そのインポート指定子/宣言を削除する", async () => { 204 | const project = new Project({ useInMemoryFileSystem: true }); 205 | const oldPath = "/src/old.ts"; 206 | const newPath = "/src/new.ts"; 207 | 208 | project.createSourceFile( 209 | oldPath, 210 | "export const symbolToMove = 1; export const keepSymbol = 2;", 211 | ); 212 | const referencingFile = project.createSourceFile( 213 | newPath, 214 | `import { symbolToMove, keepSymbol } from './old'; 215 | console.log(symbolToMove, keepSymbol);`, 216 | ); 217 | 218 | await updateImportsInReferencingFiles( 219 | project, 220 | oldPath, 221 | newPath, 222 | "symbolToMove", 223 | ); 224 | 225 | const expected = `import { keepSymbol } from './old'; 226 | console.log(symbolToMove, keepSymbol);`; 227 | expect(referencingFile.getText()).toBe(expected); 228 | 229 | // --- ケース2: 移動対象シンボルのみインポートしていた場合 --- 230 | const project2 = new Project({ useInMemoryFileSystem: true }); 231 | project2.createSourceFile(oldPath, "export const symbolToMove = 1;"); 232 | const referencingFile2 = project2.createSourceFile( 233 | newPath, 234 | `import { symbolToMove } from './old'; 235 | console.log(symbolToMove);`, 236 | ); 237 | 238 | await updateImportsInReferencingFiles( 239 | project2, 240 | oldPath, 241 | newPath, 242 | "symbolToMove", 243 | ); 244 | 245 | expect(referencingFile2.getText()).toBe("console.log(symbolToMove);"); 246 | }); 247 | 248 | // --- 【制限事項確認】将来的に対応したいケース --- 249 | it.skip("【制限事項】バレルファイル経由でインポートしているファイルのパスは更新される", async () => { 250 | const { project, oldFilePath, newFilePath, importerIndexPath } = 251 | setupTestProject(); 252 | await updateImportsInReferencingFiles( 253 | project, 254 | oldFilePath, 255 | newFilePath, 256 | "exportedSymbol", 257 | ); 258 | const updatedContent = 259 | project.getSourceFile(importerIndexPath)?.getText() ?? ""; 260 | const expectedImportPath = "../../moduleC/new-location"; 261 | const expected = `import { exportedSymbol } from '${expectedImportPath}'; 262 | console.log(exportedSymbol);`; 263 | expect(updatedContent).toBe(expected); 264 | }); 265 | }); 266 | ``` -------------------------------------------------------------------------------- /src/ts-morph/_utils/find-declarations-to-update.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from "vitest"; 2 | import { Project, IndentationText, QuoteKind } from "ts-morph"; 3 | import { findDeclarationsReferencingFile } from "./find-declarations-to-update"; 4 | 5 | // --- Setup Helper Function --- 6 | const setupTestProject = () => { 7 | const project = new Project({ 8 | manipulationSettings: { 9 | indentationText: IndentationText.TwoSpaces, 10 | quoteKind: QuoteKind.Single, 11 | }, 12 | useInMemoryFileSystem: true, 13 | compilerOptions: { 14 | baseUrl: ".", 15 | paths: { 16 | "@/*": ["src/*"], 17 | "@utils/*": ["src/utils/*"], 18 | }, 19 | // typeRoots: [], // Avoids errors on potentially missing node types if not installed 20 | }, 21 | }); 22 | 23 | // Target file 24 | const targetFilePath = "/src/target.ts"; 25 | const targetFile = project.createSourceFile( 26 | targetFilePath, 27 | `export const targetSymbol = 'target'; 28 | export type TargetType = number;`, 29 | ); 30 | 31 | // File importing with relative path 32 | const importerRelPath = "/src/importer-relative.ts"; 33 | project.createSourceFile( 34 | importerRelPath, 35 | `import { targetSymbol } from './target'; 36 | import type { TargetType } from './target'; 37 | console.log(targetSymbol);`, 38 | ); 39 | 40 | // File importing with alias path 41 | const importerAliasPath = "/src/importer-alias.ts"; 42 | project.createSourceFile( 43 | importerAliasPath, 44 | `import { targetSymbol } from '@/target'; 45 | console.log(targetSymbol);`, 46 | ); 47 | 48 | // Barrel file re-exporting from target 49 | const barrelFilePath = "/src/index.ts"; 50 | project.createSourceFile( 51 | barrelFilePath, 52 | `export { targetSymbol } from './target'; // 値を再エクスポート 53 | export type { TargetType } from './target'; // 型を再エクスポート`, 54 | ); 55 | 56 | // File importing from barrel file 57 | const importerBarrelPath = "/src/importer-barrel.ts"; 58 | project.createSourceFile( 59 | importerBarrelPath, 60 | `import { targetSymbol } from './index'; // バレルファイルからインポート 61 | console.log(targetSymbol);`, 62 | ); 63 | 64 | // File with no reference 65 | const noRefFilePath = "/src/no-ref.ts"; 66 | project.createSourceFile(noRefFilePath, "const unrelated = 1;"); 67 | 68 | return { 69 | project, 70 | targetFile, 71 | targetFilePath, 72 | importerRelPath, 73 | importerAliasPath, 74 | barrelFilePath, 75 | importerBarrelPath, 76 | noRefFilePath, 77 | }; 78 | }; 79 | 80 | describe("findDeclarationsReferencingFile", () => { 81 | it("target.ts を直接参照している全ての宣言 (Import/Export) を見つける", async () => { 82 | const { 83 | project, 84 | targetFile, 85 | targetFilePath, 86 | importerRelPath, 87 | importerAliasPath, 88 | barrelFilePath, 89 | } = setupTestProject(); 90 | const results = await findDeclarationsReferencingFile(targetFile); 91 | 92 | // 期待値: 5つの宣言 (相対パスインポートx2, エイリアスパスインポートx1, バレルエクスポートx2) 93 | expect(results).toHaveLength(5); 94 | 95 | // --- 相対パスインポートの検証 --- 96 | const relativeImports = results.filter( 97 | (r) => 98 | r.referencingFilePath === importerRelPath && 99 | r.declaration.getKindName() === "ImportDeclaration", 100 | ); 101 | expect(relativeImports).toHaveLength(2); 102 | const valueRelImport = relativeImports.find((r) => 103 | r.declaration.getText().includes("targetSymbol"), 104 | ); 105 | expect(valueRelImport?.originalSpecifierText).toBe("./target"); 106 | const typeRelImport = relativeImports.find((r) => 107 | r.declaration.getText().includes("TargetType"), 108 | ); 109 | expect(typeRelImport?.originalSpecifierText).toBe("./target"); 110 | 111 | // --- エイリアスパスインポートの検証 --- 112 | const aliasImports = results.filter( 113 | (r) => 114 | r.referencingFilePath === importerAliasPath && 115 | r.declaration.getKindName() === "ImportDeclaration", 116 | ); 117 | expect(aliasImports).toHaveLength(1); 118 | expect(aliasImports[0].originalSpecifierText).toBe("@/target"); 119 | expect(aliasImports[0].wasPathAlias).toBe(true); 120 | 121 | // --- バレルエクスポートの検証 --- 122 | const barrelExports = results.filter( 123 | (r) => 124 | r.referencingFilePath === barrelFilePath && 125 | r.declaration.getKindName() === "ExportDeclaration", 126 | ); 127 | expect(barrelExports).toHaveLength(2); 128 | const valueBarrelExport = barrelExports.find((r) => 129 | r.declaration.getText().includes("targetSymbol"), 130 | ); 131 | expect(valueBarrelExport?.originalSpecifierText).toBe("./target"); 132 | const typeBarrelExport = barrelExports.find((r) => 133 | r.declaration.getText().includes("TargetType"), 134 | ); 135 | expect(typeBarrelExport?.originalSpecifierText).toBe("./target"); 136 | }); 137 | 138 | it("エイリアスパスでインポートしている ImportDeclaration を見つけ、wasPathAlias が true になる", async () => { 139 | const { project, targetFile, targetFilePath, importerAliasPath } = 140 | setupTestProject(); 141 | const results = await findDeclarationsReferencingFile(targetFile); 142 | 143 | // エイリアスパスによるインポートを特定する 144 | const aliasImports = results.filter( 145 | (r) => r.referencingFilePath === importerAliasPath, 146 | ); 147 | expect(aliasImports).toHaveLength(1); 148 | const aliasImport = aliasImports[0]; 149 | 150 | expect(aliasImport).toBeDefined(); 151 | expect(aliasImport.referencingFilePath).toBe(importerAliasPath); 152 | expect(aliasImport.resolvedPath).toBe(targetFilePath); 153 | expect(aliasImport.originalSpecifierText).toBe("@/target"); 154 | expect(aliasImport.declaration.getKindName()).toBe("ImportDeclaration"); 155 | expect(aliasImport.wasPathAlias).toBe(true); // エイリアスが検出されるべき 156 | }); 157 | 158 | it("バレルファイルで再エクスポートしている ExportDeclaration を見つける", async () => { 159 | const { project, targetFile, targetFilePath, barrelFilePath } = 160 | setupTestProject(); 161 | const results = await findDeclarationsReferencingFile(targetFile); 162 | 163 | // バレルファイルからのエクスポートを特定する 164 | const exportDeclarations = results.filter( 165 | (r) => r.referencingFilePath === barrelFilePath, 166 | ); 167 | expect(exportDeclarations).toHaveLength(2); 168 | 169 | const valueExport = exportDeclarations.find((r) => 170 | r.declaration.getText().includes("targetSymbol"), 171 | ); 172 | expect(valueExport).toBeDefined(); 173 | expect(valueExport?.referencingFilePath).toBe(barrelFilePath); 174 | expect(valueExport?.resolvedPath).toBe(targetFilePath); 175 | expect(valueExport?.originalSpecifierText).toBe("./target"); 176 | expect(valueExport?.declaration.getKindName()).toBe("ExportDeclaration"); 177 | expect(valueExport?.wasPathAlias).toBe(false); 178 | 179 | const typeExport = exportDeclarations.find((r) => 180 | r.declaration.getText().includes("TargetType"), 181 | ); 182 | expect(typeExport).toBeDefined(); 183 | expect(typeExport?.referencingFilePath).toBe(barrelFilePath); 184 | expect(typeExport?.resolvedPath).toBe(targetFilePath); 185 | expect(typeExport?.originalSpecifierText).toBe("./target"); 186 | expect(typeExport?.declaration.getKindName()).toBe("ExportDeclaration"); 187 | expect(typeExport?.wasPathAlias).toBe(false); 188 | }); 189 | 190 | // findDeclarationsReferencingFile は getReferencingSourceFiles を使うため、 191 | // バレルファイルを経由した参照は見つけられない (これは想定される動作) 192 | it("バレルファイル経由のインポートは見つけられない (getReferencingSourceFiles の仕様)", async () => { 193 | const { project, targetFile, importerBarrelPath } = setupTestProject(); 194 | const results = await findDeclarationsReferencingFile(targetFile); 195 | 196 | // 結果に importerBarrelPath からのインポートが含まれないことを確認 197 | const barrelImport = results.find( 198 | (r) => r.referencingFilePath === importerBarrelPath, 199 | ); 200 | expect(barrelImport).toBeUndefined(); 201 | }); 202 | 203 | it("対象ファイルへの参照がない場合は空の配列を返す", async () => { 204 | const { project } = setupTestProject(); 205 | // 参照されていないファイルを作成 206 | const unreferencedFile = project.createSourceFile( 207 | "/src/unreferenced.ts", 208 | "export const x = 1;", 209 | ); 210 | const results = await findDeclarationsReferencingFile(unreferencedFile); 211 | expect(results).toHaveLength(0); 212 | }); 213 | 214 | it("Import と Export が混在する場合、両方を見つけられる", async () => { 215 | const { project, targetFile, targetFilePath } = setupTestProject(); 216 | // target からインポートとエクスポートの両方を行う別のファイルを追加 217 | const mixedRefPath = "/src/mixed-ref.ts"; 218 | project.createSourceFile( 219 | mixedRefPath, 220 | ` 221 | import { targetSymbol } from './target'; 222 | export { TargetType } from './target'; 223 | console.log(targetSymbol); 224 | `, 225 | ); 226 | const results = await findDeclarationsReferencingFile(targetFile); 227 | 228 | // mixedRefPath からの2つの宣言 + セットアップからの他の宣言を期待 229 | const mixedRefs = results.filter( 230 | (r) => r.referencingFilePath === mixedRefPath, 231 | ); 232 | expect(mixedRefs).toHaveLength(2); 233 | 234 | const importDecl = mixedRefs.find( 235 | (d) => d.declaration.getKindName() === "ImportDeclaration", 236 | ); 237 | const exportDecl = mixedRefs.find( 238 | (d) => d.declaration.getKindName() === "ExportDeclaration", 239 | ); 240 | expect(importDecl).toBeDefined(); 241 | expect(exportDecl).toBeDefined(); 242 | }); 243 | }); 244 | ``` -------------------------------------------------------------------------------- /src/ts-morph/remove-path-alias/remove-path-alias.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Project } from "ts-morph"; 2 | import { describe, it, expect } from "vitest"; 3 | import * as path from "node:path"; 4 | import { removePathAlias } from "./remove-path-alias"; 5 | 6 | const TEST_TSCONFIG_PATH = "/tsconfig.json"; 7 | const TEST_BASE_URL = "/src"; 8 | const TEST_PATHS = { 9 | "@/*": ["*"], 10 | "@components/*": ["components/*"], 11 | "@utils/helpers": ["utils/helpers.ts"], 12 | }; 13 | 14 | const setupProject = () => { 15 | const project = new Project({ 16 | useInMemoryFileSystem: true, 17 | compilerOptions: { 18 | baseUrl: path.relative(path.dirname(TEST_TSCONFIG_PATH), TEST_BASE_URL), 19 | paths: TEST_PATHS, 20 | allowJs: true, 21 | }, 22 | }); 23 | project.createSourceFile( 24 | TEST_TSCONFIG_PATH, 25 | JSON.stringify({ 26 | compilerOptions: { baseUrl: "./src", paths: TEST_PATHS }, 27 | }), 28 | ); 29 | return project; 30 | }; 31 | 32 | describe("removePathAlias", () => { 33 | it("単純なワイルドカードエイリアス (@/*) を相対パスに変換できること", async () => { 34 | const project = setupProject(); 35 | const importerPath = "/src/features/featureA/index.ts"; 36 | const componentPath = "/src/components/Button.ts"; 37 | project.createSourceFile(componentPath, "export const Button = {};"); 38 | const importerContent = `import { Button } from '@/components/Button';`; 39 | project.createSourceFile(importerPath, importerContent); 40 | 41 | const result = await removePathAlias({ 42 | project, 43 | targetPath: importerPath, 44 | baseUrl: TEST_BASE_URL, 45 | paths: TEST_PATHS, 46 | dryRun: false, 47 | }); 48 | 49 | const sourceFile = project.getSourceFileOrThrow(importerPath); 50 | const importDeclaration = sourceFile.getImportDeclarations()[0]; 51 | expect(importDeclaration?.getModuleSpecifierValue()).toBe( 52 | "../../components/Button", 53 | ); 54 | expect(result.changedFiles).toEqual([importerPath]); 55 | }); 56 | 57 | it("特定のパスエイリアス (@components/*) を相対パスに変換できること", async () => { 58 | const project = setupProject(); 59 | const importerPath = "/src/index.ts"; 60 | const componentPath = "/src/components/Input/index.ts"; 61 | project.createSourceFile(componentPath, "export const Input = {};"); 62 | const importerContent = `import { Input } from '@components/Input';`; 63 | project.createSourceFile(importerPath, importerContent); 64 | 65 | const result = await removePathAlias({ 66 | project, 67 | targetPath: importerPath, 68 | baseUrl: TEST_BASE_URL, 69 | paths: TEST_PATHS, 70 | dryRun: false, 71 | }); 72 | 73 | const sourceFile = project.getSourceFileOrThrow(importerPath); 74 | expect( 75 | sourceFile.getImportDeclarations()[0]?.getModuleSpecifierValue(), 76 | ).toBe("./components/Input/index"); 77 | expect(result.changedFiles).toEqual([importerPath]); 78 | }); 79 | 80 | it("ファイルへの直接エイリアス (@utils/helpers) を相対パスに変換できること", async () => { 81 | const project = setupProject(); 82 | const importerPath = "/src/features/featureB/utils.ts"; 83 | const helperPath = "/src/utils/helpers.ts"; 84 | project.createSourceFile(helperPath, "export const helperFunc = () => {};"); 85 | const importerContent = `import { helperFunc } from '@utils/helpers';`; 86 | project.createSourceFile(importerPath, importerContent); 87 | 88 | const result = await removePathAlias({ 89 | project, 90 | targetPath: importerPath, 91 | baseUrl: TEST_BASE_URL, 92 | paths: TEST_PATHS, 93 | dryRun: false, 94 | }); 95 | 96 | const sourceFile = project.getSourceFileOrThrow(importerPath); 97 | expect( 98 | sourceFile.getImportDeclarations()[0]?.getModuleSpecifierValue(), 99 | ).toBe("../../utils/helpers"); 100 | expect(result.changedFiles).toEqual([importerPath]); 101 | }); 102 | 103 | it("エイリアスでない通常の相対パスは変更しないこと", async () => { 104 | const project = setupProject(); 105 | const importerPath = "/src/features/featureA/index.ts"; 106 | const servicePath = "/src/features/featureA/service.ts"; 107 | project.createSourceFile(servicePath, "export class Service {}"); 108 | const importerContent = `import { Service } from './service';`; 109 | const sourceFile = project.createSourceFile(importerPath, importerContent); 110 | const originalContent = sourceFile.getFullText(); 111 | 112 | const result = await removePathAlias({ 113 | project, 114 | targetPath: importerPath, 115 | baseUrl: TEST_BASE_URL, 116 | paths: TEST_PATHS, 117 | dryRun: false, 118 | }); 119 | 120 | expect(sourceFile.getFullText()).toBe(originalContent); 121 | expect(result.changedFiles).toEqual([]); 122 | }); 123 | 124 | it("エイリアスでない node_modules パスは変更しないこと", async () => { 125 | const project = setupProject(); 126 | const importerPath = "/src/index.ts"; 127 | const importerContent = `import * as fs from 'fs';`; 128 | const sourceFile = project.createSourceFile(importerPath, importerContent); 129 | const originalContent = sourceFile.getFullText(); 130 | 131 | const result = await removePathAlias({ 132 | project, 133 | targetPath: importerPath, 134 | baseUrl: TEST_BASE_URL, 135 | paths: TEST_PATHS, 136 | dryRun: false, 137 | }); 138 | 139 | expect(sourceFile.getFullText()).toBe(originalContent); 140 | expect(result.changedFiles).toEqual([]); 141 | }); 142 | 143 | it("dryRun モードではファイルを変更せず、変更予定リストを返すこと", async () => { 144 | const project = setupProject(); 145 | const importerPath = "/src/features/featureA/index.ts"; 146 | const componentPath = "/src/components/Button.ts"; 147 | project.createSourceFile(componentPath, "export const Button = {};"); 148 | const importerContent = `import { Button } from '@/components/Button';`; 149 | const sourceFile = project.createSourceFile(importerPath, importerContent); 150 | const originalContent = sourceFile.getFullText(); 151 | 152 | const result = await removePathAlias({ 153 | project, 154 | targetPath: importerPath, 155 | baseUrl: TEST_BASE_URL, 156 | paths: TEST_PATHS, 157 | dryRun: true, 158 | }); 159 | 160 | expect(sourceFile.getFullText()).toBe(originalContent); 161 | expect(result.changedFiles).toEqual([importerPath]); 162 | }); 163 | 164 | it("ディレクトリを対象とした場合に、内部の複数ファイルのエイリアスを変換できること", async () => { 165 | const project = setupProject(); 166 | const dirPath = "/src/features/multi"; 167 | const file1Path = path.join(dirPath, "file1.ts"); 168 | const file2Path = path.join(dirPath, "sub/file2.ts"); 169 | const buttonPath = "/src/components/Button.ts"; 170 | const inputPath = "/src/components/Input.ts"; 171 | 172 | project.createSourceFile(buttonPath, "export const Button = {};"); 173 | project.createSourceFile(inputPath, "export const Input = {};"); 174 | project.createSourceFile( 175 | file1Path, 176 | "import { Button } from '@/components/Button';", 177 | ); 178 | project.createSourceFile( 179 | file2Path, 180 | "import { Input } from '@components/Input';", 181 | ); 182 | 183 | const result = await removePathAlias({ 184 | project, 185 | targetPath: dirPath, 186 | baseUrl: TEST_BASE_URL, 187 | paths: TEST_PATHS, 188 | dryRun: false, 189 | }); 190 | 191 | const file1 = project.getSourceFileOrThrow(file1Path); 192 | const file2 = project.getSourceFileOrThrow(file2Path); 193 | 194 | expect(file1.getImportDeclarations()[0]?.getModuleSpecifierValue()).toBe( 195 | "../../components/Button", 196 | ); 197 | expect(file2.getImportDeclarations()[0]?.getModuleSpecifierValue()).toBe( 198 | "../../../components/Input", 199 | ); 200 | expect(result.changedFiles.sort()).toEqual([file1Path, file2Path].sort()); 201 | }); 202 | 203 | it("解決できないエイリアスパスを変更しないこと", async () => { 204 | const project = setupProject(); 205 | const importerPath = "/src/index.ts"; 206 | const importerContent = `import { Something } from '@unknown/package';`; 207 | const sourceFile = project.createSourceFile(importerPath, importerContent); 208 | const originalContent = sourceFile.getFullText(); 209 | 210 | const result = await removePathAlias({ 211 | project, 212 | targetPath: importerPath, 213 | baseUrl: TEST_BASE_URL, 214 | paths: TEST_PATHS, 215 | dryRun: false, 216 | }); 217 | 218 | expect(sourceFile.getFullText()).toBe(originalContent); 219 | expect(result.changedFiles).toEqual([]); 220 | }); 221 | 222 | it("エイリアスが index.ts を指す場合、結果は /index で終わる (省略されない)", async () => { 223 | const project = setupProject(); 224 | const importerPath = "/src/features/featureA/component.ts"; 225 | const indexPath = "/src/components/index.ts"; 226 | 227 | project.createSourceFile(indexPath, "export const CompIndex = 1;"); 228 | project.createSourceFile( 229 | importerPath, 230 | "import { CompIndex } from '@/components';", 231 | ); 232 | 233 | const result = await removePathAlias({ 234 | project, 235 | targetPath: importerPath, 236 | baseUrl: "/", 237 | paths: { "@/*": ["src/*"] }, 238 | dryRun: false, 239 | }); 240 | 241 | const sourceFile = project.getSourceFileOrThrow(importerPath); 242 | expect( 243 | sourceFile.getImportDeclarations()[0]?.getModuleSpecifierValue(), 244 | ).toBe("../../components/index"); 245 | expect(result.changedFiles).toEqual([importerPath]); 246 | }); 247 | 248 | it("エイリアスが .js ファイルを指す場合、結果から拡張子は削除される", async () => { 249 | const project = setupProject(); 250 | const importerPath = "/src/app.ts"; 251 | const jsPath = "/src/utils/legacy.js"; 252 | 253 | project.createSourceFile(jsPath, "export const legacyFunc = () => {};"); 254 | project.createSourceFile( 255 | importerPath, 256 | "import { legacyFunc } from '@/utils/legacy.js';", 257 | ); 258 | 259 | const result = await removePathAlias({ 260 | project, 261 | targetPath: importerPath, 262 | baseUrl: "/", 263 | paths: { "@/*": ["src/*"] }, 264 | dryRun: false, 265 | }); 266 | 267 | const sourceFile = project.getSourceFileOrThrow(importerPath); 268 | expect( 269 | sourceFile.getImportDeclarations()[0]?.getModuleSpecifierValue(), 270 | ).toBe("./utils/legacy"); 271 | expect(result.changedFiles).toEqual([importerPath]); 272 | }); 273 | }); 274 | ``` -------------------------------------------------------------------------------- /src/ts-morph/move-symbol-to-file/internal-dependencies.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from "vitest"; 2 | import { 3 | type FunctionDeclaration, 4 | Project, 5 | SyntaxKind, 6 | type VariableStatement, 7 | } from "ts-morph"; 8 | import { findTopLevelDeclarationByName } from "./find-declaration"; 9 | import { getInternalDependencies } from "./internal-dependencies"; 10 | // --- Test Setup Helper --- 11 | const setupProject = () => { 12 | const project = new Project({ 13 | useInMemoryFileSystem: true, 14 | compilerOptions: { target: 99, module: 99 }, 15 | }); 16 | project.createDirectory("/src"); 17 | return project; 18 | }; 19 | 20 | describe("getInternalDependencies", () => { 21 | it("関数宣言が依存する内部関数と内部変数を特定できる", () => { 22 | const project = setupProject(); 23 | const filePath = "/src/internal-deps-advanced.ts"; 24 | const sourceFile = project.createSourceFile( 25 | filePath, 26 | ` 27 | const configValue = 10; 28 | const calculatedValue = configValue * 2; 29 | function helperFunc(n: number): number { return n + calculatedValue; } 30 | export function mainFunc(x: number): void { const result = helperFunc(x); console.log(result); } 31 | `, 32 | ); 33 | const mainFuncDecl = findTopLevelDeclarationByName( 34 | sourceFile, 35 | "mainFunc", 36 | SyntaxKind.FunctionDeclaration, 37 | ) as FunctionDeclaration; 38 | const helperFuncDecl = findTopLevelDeclarationByName( 39 | sourceFile, 40 | "helperFunc", 41 | SyntaxKind.FunctionDeclaration, 42 | ) as FunctionDeclaration; 43 | const calculatedValueStmt = findTopLevelDeclarationByName( 44 | sourceFile, 45 | "calculatedValue", 46 | SyntaxKind.VariableStatement, 47 | ) as VariableStatement; 48 | const configValueStmt = findTopLevelDeclarationByName( 49 | sourceFile, 50 | "configValue", 51 | SyntaxKind.VariableStatement, 52 | ) as VariableStatement; 53 | 54 | expect(mainFuncDecl).toBeDefined(); 55 | expect(helperFuncDecl).toBeDefined(); 56 | expect(calculatedValueStmt).toBeDefined(); 57 | expect(configValueStmt).toBeDefined(); 58 | 59 | const dependencies = getInternalDependencies(mainFuncDecl); 60 | 61 | expect(dependencies).toBeInstanceOf(Array); 62 | expect(dependencies).toHaveLength(3); // helperFunc, calculatedValue, configValue 63 | expect(dependencies).toEqual( 64 | expect.arrayContaining([ 65 | helperFuncDecl, 66 | calculatedValueStmt, 67 | configValueStmt, 68 | ]), 69 | ); 70 | }); 71 | 72 | it("関数宣言が依存する内部変数を特定できる (間接依存)", () => { 73 | const project = setupProject(); 74 | const filePath = "/src/internal-deps-advanced.ts"; 75 | const sourceFile = project.createSourceFile( 76 | filePath, 77 | ` 78 | const configValue = 10; // <- さらに依存 79 | const calculatedValue = configValue * 2; // <- 依存先 80 | function helperFunc(n: number): number { return n + calculatedValue; } // <- これを対象 81 | `, 82 | ); 83 | const helperFuncDecl = findTopLevelDeclarationByName( 84 | sourceFile, 85 | "helperFunc", 86 | SyntaxKind.FunctionDeclaration, 87 | ) as FunctionDeclaration; 88 | const calculatedValueStmt = findTopLevelDeclarationByName( 89 | sourceFile, 90 | "calculatedValue", 91 | SyntaxKind.VariableStatement, 92 | ) as VariableStatement; 93 | const configValueStmt = findTopLevelDeclarationByName( 94 | sourceFile, 95 | "configValue", 96 | SyntaxKind.VariableStatement, 97 | ) as VariableStatement; 98 | 99 | expect(helperFuncDecl).toBeDefined(); 100 | expect(calculatedValueStmt).toBeDefined(); 101 | expect(configValueStmt).toBeDefined(); 102 | 103 | const dependencies = getInternalDependencies(helperFuncDecl); 104 | 105 | expect(dependencies).toBeInstanceOf(Array); 106 | expect(dependencies).toHaveLength(2); // calculatedValue, configValue 107 | expect(dependencies).toEqual( 108 | expect.arrayContaining([calculatedValueStmt, configValueStmt]), 109 | ); 110 | }); 111 | 112 | it("変数宣言が依存する内部変数を特定できる", () => { 113 | const project = setupProject(); 114 | const filePath = "/src/internal-deps-advanced.ts"; 115 | const sourceFile = project.createSourceFile( 116 | filePath, 117 | ` 118 | const configValue = 10; // <- さらに依存 119 | const calculatedValue = configValue * 2; // <- 依存先 120 | export const derivedConst = calculatedValue + 5; // <- これを対象 121 | `, 122 | ); 123 | const derivedConstStmt = findTopLevelDeclarationByName( 124 | sourceFile, 125 | "derivedConst", 126 | SyntaxKind.VariableStatement, 127 | ) as VariableStatement; 128 | const calculatedValueStmt = findTopLevelDeclarationByName( 129 | sourceFile, 130 | "calculatedValue", 131 | SyntaxKind.VariableStatement, 132 | ) as VariableStatement; 133 | const configValueStmt = findTopLevelDeclarationByName( 134 | sourceFile, 135 | "configValue", 136 | SyntaxKind.VariableStatement, 137 | ) as VariableStatement; 138 | 139 | expect(derivedConstStmt).toBeDefined(); 140 | expect(calculatedValueStmt).toBeDefined(); 141 | expect(configValueStmt).toBeDefined(); 142 | 143 | const dependencies = getInternalDependencies(derivedConstStmt); 144 | 145 | expect(dependencies).toBeInstanceOf(Array); 146 | expect(dependencies).toHaveLength(2); // calculatedValue, configValue 147 | expect(dependencies).toEqual( 148 | expect.arrayContaining([calculatedValueStmt, configValueStmt]), 149 | ); 150 | }); 151 | 152 | it("変数宣言が依存する内部変数を特定できる (直接依存)", () => { 153 | const project = setupProject(); 154 | const filePath = "/src/internal-deps-advanced.ts"; 155 | const sourceFile = project.createSourceFile( 156 | filePath, 157 | ` 158 | const configValue = 10; // <- 依存先 159 | const calculatedValue = configValue * 2; // <- これを対象 160 | `, 161 | ); 162 | const calculatedValueStmt = findTopLevelDeclarationByName( 163 | sourceFile, 164 | "calculatedValue", 165 | SyntaxKind.VariableStatement, 166 | ) as VariableStatement; 167 | const configValueStmt = findTopLevelDeclarationByName( 168 | sourceFile, 169 | "configValue", 170 | SyntaxKind.VariableStatement, 171 | ) as VariableStatement; 172 | 173 | expect( 174 | calculatedValueStmt, 175 | "Test setup failed: calculatedValue not found", 176 | ).toBeDefined(); 177 | expect( 178 | configValueStmt, 179 | "Test setup failed: configValue not found", 180 | ).toBeDefined(); 181 | 182 | const dependencies = getInternalDependencies(calculatedValueStmt); 183 | 184 | expect(dependencies).toBeInstanceOf(Array); 185 | expect(dependencies).toHaveLength(1); 186 | expect(dependencies[0]).toBe(configValueStmt); 187 | }); 188 | 189 | it("依存関係がない場合は空配列を返す", () => { 190 | const project = setupProject(); 191 | const filePath = "/src/internal-deps-advanced.ts"; 192 | const sourceFile = project.createSourceFile( 193 | filePath, 194 | ` 195 | const configValue = 10; 196 | function unusedFunc() {} 197 | `, 198 | ); 199 | const configValueStmt = findTopLevelDeclarationByName( 200 | sourceFile, 201 | "configValue", 202 | SyntaxKind.VariableStatement, 203 | ) as VariableStatement; 204 | const unusedFuncDecl = findTopLevelDeclarationByName( 205 | sourceFile, 206 | "unusedFunc", 207 | SyntaxKind.FunctionDeclaration, 208 | ) as FunctionDeclaration; 209 | 210 | expect( 211 | configValueStmt, 212 | "Test setup failed: configValue not found", 213 | ).toBeDefined(); 214 | expect( 215 | unusedFuncDecl, 216 | "Test setup failed: unusedFunc not found", 217 | ).toBeDefined(); 218 | 219 | const configDeps = getInternalDependencies(configValueStmt); 220 | const unusedDeps = getInternalDependencies(unusedFuncDecl); 221 | 222 | expect(configDeps).toEqual([]); 223 | expect(unusedDeps).toEqual([]); 224 | }); 225 | 226 | it("関数宣言が依存する非エクスポートのアロー関数を特定できる", () => { 227 | const project = setupProject(); 228 | const filePath = "/src/arrow-func-dep.ts"; 229 | const sourceFile = project.createSourceFile( 230 | filePath, 231 | ` 232 | const arrowHelper = (n: number): number => n * n; 233 | export function mainFunc(x: number): number { return arrowHelper(x); } 234 | `, 235 | ); 236 | const mainFuncDecl = findTopLevelDeclarationByName( 237 | sourceFile, 238 | "mainFunc", 239 | SyntaxKind.FunctionDeclaration, 240 | ) as FunctionDeclaration; 241 | const arrowHelperStmt = findTopLevelDeclarationByName( 242 | sourceFile, 243 | "arrowHelper", 244 | SyntaxKind.VariableStatement, 245 | ) as VariableStatement; 246 | 247 | expect(mainFuncDecl).toBeDefined(); 248 | expect(arrowHelperStmt).toBeDefined(); 249 | 250 | const dependencies = getInternalDependencies(mainFuncDecl); 251 | 252 | expect(dependencies.length).toBe(1); 253 | expect(dependencies[0]).toBe(arrowHelperStmt); 254 | }); 255 | 256 | it("複数の間接的な内部依存関係を再帰的に特定できる", () => { 257 | const project = setupProject(); 258 | const filePath = "/src/recursive-deps.ts"; 259 | const sourceFile = project.createSourceFile( 260 | filePath, 261 | ` 262 | const d = 4; 263 | const c = () => d; 264 | const b = () => c(); 265 | export const a = () => b(); // a -> b -> c -> d 266 | const e = () => d; // d は a 以外からも参照されるが、ここでは a の依存のみ見る 267 | `, 268 | ); 269 | const aStmt = findTopLevelDeclarationByName( 270 | sourceFile, 271 | "a", 272 | SyntaxKind.VariableStatement, 273 | ) as VariableStatement; 274 | const bStmt = findTopLevelDeclarationByName( 275 | sourceFile, 276 | "b", 277 | SyntaxKind.VariableStatement, 278 | ) as VariableStatement; 279 | const cStmt = findTopLevelDeclarationByName( 280 | sourceFile, 281 | "c", 282 | SyntaxKind.VariableStatement, 283 | ) as VariableStatement; 284 | const dStmt = findTopLevelDeclarationByName( 285 | sourceFile, 286 | "d", 287 | SyntaxKind.VariableStatement, 288 | ) as VariableStatement; 289 | 290 | expect(aStmt).toBeDefined(); 291 | expect(bStmt).toBeDefined(); 292 | expect(cStmt).toBeDefined(); 293 | expect(dStmt).toBeDefined(); 294 | 295 | const dependencies = getInternalDependencies(aStmt); 296 | 297 | expect(dependencies).toBeInstanceOf(Array); 298 | expect(dependencies).toHaveLength(3); // b, c, d が含まれるはず 299 | expect(dependencies).toEqual(expect.arrayContaining([bStmt, cStmt, dStmt])); 300 | }); 301 | }); 302 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/register-rename-file-system-entry-tool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { z } from "zod"; 3 | import { renameFileSystemEntry } from "../../ts-morph/rename-file-system/rename-file-system-entry"; 4 | import { initializeProject } from "../../ts-morph/_utils/ts-morph-project"; 5 | import * as path from "node:path"; 6 | import { performance } from "node:perf_hooks"; 7 | import { TimeoutError } from "../../errors/timeout-error"; 8 | import logger from "../../utils/logger"; 9 | 10 | const renameSchema = z.object({ 11 | tsconfigPath: z 12 | .string() 13 | .describe("Absolute path to the project's tsconfig.json file."), 14 | renames: z 15 | .array( 16 | z.object({ 17 | oldPath: z 18 | .string() 19 | .describe( 20 | "The current absolute path of the file or folder to rename.", 21 | ), 22 | newPath: z 23 | .string() 24 | .describe("The new desired absolute path for the file or folder."), 25 | }), 26 | ) 27 | .nonempty() 28 | .describe("An array of rename operations, each with oldPath and newPath."), 29 | dryRun: z 30 | .boolean() 31 | .optional() 32 | .default(false) 33 | .describe("If true, only show intended changes without modifying files."), 34 | timeoutSeconds: z 35 | .number() 36 | .int() 37 | .positive() 38 | .optional() 39 | .default(120) 40 | .describe( 41 | "Maximum time in seconds allowed for the operation before it times out. Defaults to 120.", 42 | ), 43 | }); 44 | 45 | type RenameArgs = z.infer<typeof renameSchema>; 46 | 47 | export function registerRenameFileSystemEntryTool(server: McpServer): void { 48 | server.tool( 49 | "rename_filesystem_entry_by_tsmorph", 50 | `[Uses ts-morph] Renames **one or more** TypeScript/JavaScript files **and/or folders** and updates all import/export paths referencing them throughout the project. 51 | 52 | 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. 53 | 54 | ## Usage 55 | 56 | 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. 57 | 58 | 1. Specify the path to the project's \`tsconfig.json\` file. **Must be an absolute path.** 59 | 2. Provide an array of rename operations. Each object in the array must contain: 60 | - \`oldPath\`: The current **absolute path** of the file or folder to rename. 61 | - \`newPath\`: The new desired **absolute path** for the file or folder. 62 | 3. It\'s recommended to first run with \`dryRun: true\` to check which files will be affected. 63 | 4. If the preview looks correct, run with \`dryRun: false\` (or omit it) to actually save the changes to the file system. 64 | 65 | ## Parameters 66 | 67 | - tsconfigPath (string, required): Absolute path to the project's root \`tsconfig.json\` file. **Must be an absolute path.** 68 | - renames (array of objects, required): An array where each object specifies a rename operation with: 69 | - oldPath (string, required): The current absolute path of the file or folder. **Must be an absolute path.** 70 | - newPath (string, required): The new desired absolute path for the file or folder. **Must be an absolute path.** 71 | - 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. 72 | - timeoutSeconds (number, optional): Maximum time in seconds allowed for the operation before it times out. Defaults to 120 seconds. 73 | 74 | ## Result 75 | 76 | - On success: Returns a message listing the file paths modified or scheduled to be modified. 77 | - On failure: Returns a message indicating the error (e.g., path conflict, file not found, timeout). 78 | 79 | ## Remarks 80 | - **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. 81 | - **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. 82 | - **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'\`). 83 | - **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. 84 | - **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. 85 | - **Conflicts:** The tool checks for conflicts (e.g., renaming to an existing path, duplicate targets) before applying changes. 86 | - **Timeout:** Operations exceeding the specified \`timeoutSeconds\` will be canceled.`, 87 | renameSchema.shape, 88 | async (args: RenameArgs) => { 89 | const startTime = performance.now(); 90 | let message = ""; 91 | let isError = false; 92 | let changedFilesCount = 0; 93 | const { tsconfigPath, renames, dryRun, timeoutSeconds } = args; 94 | const TIMEOUT_MS = timeoutSeconds * 1000; 95 | 96 | let resultPayload: { 97 | content: { type: "text"; text: string }[]; 98 | isError: boolean; 99 | } = { 100 | content: [{ type: "text", text: "An unexpected error occurred." }], 101 | isError: true, 102 | }; 103 | 104 | const controller = new AbortController(); 105 | let timeoutId: NodeJS.Timeout | undefined = undefined; 106 | const logArgs = { 107 | tsconfigPath, 108 | renames: renames.map((r) => ({ 109 | old: path.basename(r.oldPath), 110 | new: path.basename(r.newPath), 111 | })), 112 | dryRun, 113 | timeoutSeconds, 114 | }; 115 | 116 | try { 117 | timeoutId = setTimeout(() => { 118 | const errorMessage = `Operation timed out after ${timeoutSeconds} seconds`; 119 | logger.error( 120 | { toolArgs: logArgs, durationSeconds: timeoutSeconds }, 121 | errorMessage, 122 | ); 123 | controller.abort(new TimeoutError(errorMessage, timeoutSeconds)); 124 | }, TIMEOUT_MS); 125 | 126 | const project = initializeProject(tsconfigPath); 127 | const result = await renameFileSystemEntry({ 128 | project, 129 | renames, 130 | dryRun, 131 | signal: controller.signal, 132 | }); 133 | 134 | changedFilesCount = result.changedFiles.length; 135 | 136 | const changedFilesList = 137 | result.changedFiles.length > 0 138 | ? result.changedFiles.join("\n - ") 139 | : "(No changes)"; 140 | const renameSummary = renames 141 | .map( 142 | (r) => 143 | `'${path.basename(r.oldPath)}' -> '${path.basename(r.newPath)}'`, 144 | ) 145 | .join(", "); 146 | 147 | if (dryRun) { 148 | message = `Dry run complete: Renaming [${renameSummary}] would modify the following files:\n - ${changedFilesList}`; 149 | } else { 150 | message = `Rename successful: Renamed [${renameSummary}]. The following files were modified:\n - ${changedFilesList}`; 151 | } 152 | isError = false; 153 | } catch (error) { 154 | logger.error( 155 | { err: error, toolArgs: logArgs }, 156 | "Error executing rename_filesystem_entry_by_tsmorph", 157 | ); 158 | 159 | if (error instanceof TimeoutError) { 160 | message = `処理が ${error.durationSeconds} 秒以内に完了しなかったため、タイムアウトしました。操作はキャンセルされました.\nプロジェクトの規模が大きいか、変更箇所が多い可能性があります.`; 161 | } else if (error instanceof Error && error.name === "AbortError") { 162 | message = `操作がキャンセルされました: ${error.message}`; 163 | } else { 164 | const errorMessage = 165 | error instanceof Error ? error.message : String(error); 166 | message = `Error during rename process: ${errorMessage}`; 167 | } 168 | isError = true; 169 | } finally { 170 | if (timeoutId) { 171 | clearTimeout(timeoutId); 172 | } 173 | const endTime = performance.now(); 174 | const durationMs = endTime - startTime; 175 | 176 | logger.info( 177 | { 178 | status: isError ? "Failure" : "Success", 179 | durationMs: Number.parseFloat(durationMs.toFixed(2)), 180 | changedFilesCount, 181 | dryRun, 182 | }, 183 | "rename_filesystem_entry_by_tsmorph tool finished", 184 | ); 185 | try { 186 | logger.flush(); 187 | logger.trace("Logs flushed after tool execution."); 188 | } catch (flushErr) { 189 | console.error("Failed to flush logs:", flushErr); 190 | } 191 | } 192 | 193 | const endTime = performance.now(); 194 | const durationMs = endTime - startTime; 195 | const durationSec = (durationMs / 1000).toFixed(2); 196 | const finalMessage = `${message}\nStatus: ${isError ? "Failure" : "Success"}\nProcessing time: ${durationSec} seconds`; 197 | resultPayload = { 198 | content: [{ type: "text", text: finalMessage }], 199 | isError: isError, 200 | }; 201 | 202 | return resultPayload; 203 | }, 204 | ); 205 | } 206 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/register-move-symbol-to-file-tool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { z } from "zod"; 3 | import { moveSymbolToFile } from "../../ts-morph/move-symbol-to-file/move-symbol-to-file"; 4 | import { initializeProject } from "../../ts-morph/_utils/ts-morph-project"; 5 | import { getChangedFiles } from "../../ts-morph/_utils/ts-morph-project"; 6 | import { SyntaxKind } from "ts-morph"; 7 | import { performance } from "node:perf_hooks"; 8 | import logger from "../../utils/logger"; 9 | import * as path from "node:path"; 10 | 11 | const syntaxKindMapping: { [key: string]: SyntaxKind } = { 12 | FunctionDeclaration: SyntaxKind.FunctionDeclaration, 13 | VariableStatement: SyntaxKind.VariableStatement, 14 | ClassDeclaration: SyntaxKind.ClassDeclaration, 15 | InterfaceDeclaration: SyntaxKind.InterfaceDeclaration, 16 | TypeAliasDeclaration: SyntaxKind.TypeAliasDeclaration, 17 | EnumDeclaration: SyntaxKind.EnumDeclaration, 18 | }; 19 | const moveSymbolSchema = z.object({ 20 | tsconfigPath: z 21 | .string() 22 | .describe( 23 | "Absolute path to the project's tsconfig.json file. Essential for ts-morph.", 24 | ), 25 | originalFilePath: z 26 | .string() 27 | .describe("Absolute path to the file containing the symbol to move."), 28 | targetFilePath: z 29 | .string() 30 | .describe( 31 | "Absolute path to the destination file. Can be an existing file; if the path does not exist, a new file will be created.", 32 | ), 33 | symbolToMove: z.string().describe("The name of the symbol to move."), 34 | declarationKindString: z 35 | .string() 36 | .optional() 37 | .describe( 38 | "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.", 39 | ), 40 | dryRun: z 41 | .boolean() 42 | .optional() 43 | .default(false) 44 | .describe("If true, only show intended changes without modifying files."), 45 | }); 46 | 47 | type MoveSymbolArgs = z.infer<typeof moveSymbolSchema>; 48 | 49 | /** 50 | * MCPサーバーに 'move_symbol_to_file_by_tsmorph' ツールを登録します。 51 | * このツールは、指定されたシンボルをファイル間で移動し、関連する参照を更新します。 52 | * 53 | * @param server McpServer インスタンス 54 | */ 55 | export function registerMoveSymbolToFileTool(server: McpServer): void { 56 | server.tool( 57 | "move_symbol_to_file_by_tsmorph", 58 | `[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. 59 | 60 | 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). 61 | 62 | ## Usage 63 | 64 | Use this tool for various code reorganization tasks: 65 | 66 | 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.** 67 | 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.** 68 | 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.** 69 | 70 | ts-morph parses the project based on \`tsconfig.json\` to resolve references and perform the move safely, updating imports/exports automatically. 71 | 72 | ## Parameters 73 | 74 | - tsconfigPath (string, required): Absolute path to the project\'s root \`tsconfig.json\` 75 | - originalFilePath (string, required): Absolute path to the file currently containing the symbol to move. 76 | - 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. 77 | - symbolToMove (string, required): The name of the **single top-level symbol** you want to move in this execution. 78 | - declarationKindString (string, optional): The kind of the declaration (e.g., \'VariableStatement\', \'FunctionDeclaration\'). Recommended to resolve ambiguity if multiple symbols share the same name. 79 | - dryRun (boolean, optional): If true, only show intended changes without modifying files. Defaults to false. 80 | 81 | ## Result 82 | 83 | - 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). 84 | - On failure: Returns an error message (e.g., symbol not found, default export, AST errors). 85 | 86 | ## Remarks 87 | 88 | - **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. 89 | - **Default exports cannot be moved.** 90 | - **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. 91 | - **Performance:** Moving symbols with many references in large projects might take time.`, 92 | moveSymbolSchema.extend({ 93 | symbolToMove: z 94 | .string() 95 | .describe( 96 | "The name of the single top-level symbol you want to move in this execution.", 97 | ), 98 | }).shape, 99 | async (args: MoveSymbolArgs) => { 100 | const startTime = performance.now(); 101 | let message = ""; 102 | let isError = false; 103 | let changedFilesCount = 0; 104 | let changedFiles: string[] = []; 105 | const { 106 | tsconfigPath, 107 | originalFilePath, 108 | targetFilePath, 109 | symbolToMove, 110 | declarationKindString, 111 | dryRun, 112 | } = args; 113 | 114 | const declarationKind: SyntaxKind | undefined = 115 | declarationKindString && syntaxKindMapping[declarationKindString] 116 | ? syntaxKindMapping[declarationKindString] 117 | : undefined; 118 | 119 | if (declarationKindString && declarationKind === undefined) { 120 | logger.warn( 121 | `Invalid declarationKindString provided: '${declarationKindString}'. Proceeding without kind specification.`, 122 | ); 123 | } 124 | 125 | const logArgs = { 126 | tsconfigPath, 127 | originalFilePath: path.basename(originalFilePath), 128 | targetFilePath: path.basename(targetFilePath), 129 | symbolToMove, 130 | declarationKindString, 131 | dryRun, 132 | }; 133 | 134 | try { 135 | const project = initializeProject(tsconfigPath); 136 | await moveSymbolToFile( 137 | project, 138 | originalFilePath, 139 | targetFilePath, 140 | symbolToMove, 141 | declarationKind, 142 | ); 143 | 144 | changedFiles = getChangedFiles(project).map((sf) => sf.getFilePath()); 145 | changedFilesCount = changedFiles.length; 146 | 147 | const baseMessage = `Moved symbol \"${symbolToMove}\" from ${originalFilePath} to ${targetFilePath}.`; 148 | const changedFilesList = 149 | changedFiles.length > 0 ? changedFiles.join("\n - ") : "(No changes)"; 150 | 151 | if (dryRun) { 152 | message = `Dry run: ${baseMessage}\nFiles that would be modified:\n - ${changedFilesList}`; 153 | logger.info({ changedFiles }, "Dry run: Skipping save."); 154 | } else { 155 | await project.save(); 156 | logger.debug("Project changes saved after symbol move."); 157 | message = `${baseMessage}\nThe following files were modified:\n - ${changedFilesList}`; 158 | } 159 | isError = false; 160 | } catch (error) { 161 | logger.error( 162 | { err: error, toolArgs: logArgs }, 163 | "Error executing move_symbol_to_file_by_tsmorph", 164 | ); 165 | const errorMessage = 166 | error instanceof Error ? error.message : String(error); 167 | message = `Error moving symbol: ${errorMessage}`; 168 | isError = true; 169 | } finally { 170 | const endTime = performance.now(); 171 | const durationMs = endTime - startTime; 172 | 173 | logger.info( 174 | { 175 | status: isError ? "Failure" : "Success", 176 | durationMs: Number.parseFloat(durationMs.toFixed(2)), 177 | changedFilesCount, 178 | dryRun, 179 | }, 180 | "move_symbol_to_file_by_tsmorph tool finished", 181 | ); 182 | try { 183 | logger.flush(); 184 | } catch (flushErr) { 185 | console.error("Failed to flush logs:", flushErr); 186 | } 187 | } 188 | 189 | const endTime = performance.now(); 190 | const durationMs = endTime - startTime; 191 | const durationSec = (durationMs / 1000).toFixed(2); 192 | const finalMessage = `${message}\nStatus: ${isError ? "Failure" : "Success"}\nProcessing time: ${durationSec} seconds`; 193 | 194 | return { 195 | content: [{ type: "text", text: finalMessage }], 196 | isError: isError, 197 | }; 198 | }, 199 | ); 200 | } 201 | ``` -------------------------------------------------------------------------------- /src/ts-morph/move-symbol-to-file/generate-content/generate-new-source-file-content.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from "vitest"; 2 | import { Project, SyntaxKind, ts } from "ts-morph"; 3 | import { findTopLevelDeclarationByName } from "../find-declaration"; 4 | import { generateNewSourceFileContent } from "./generate-new-source-file-content"; 5 | import type { 6 | DependencyClassification, 7 | NeededExternalImports, 8 | } from "../../types"; 9 | 10 | // テストプロジェクト設定用ヘルパー 11 | const setupProjectWithCode = ( 12 | code: string, 13 | filePath = "/src/original.ts", 14 | project?: Project, 15 | ) => { 16 | const proj = project ?? new Project({ useInMemoryFileSystem: true }); 17 | proj.compilerOptions.set({ jsx: ts.JsxEmit.ReactJSX }); 18 | const originalSourceFile = proj.createSourceFile(filePath, code); 19 | return { project: proj, originalSourceFile }; 20 | }; 21 | 22 | describe("generateNewSourceFileContent", () => { 23 | it("依存関係のない VariableDeclaration から新しいファイルの内容を生成できる", () => { 24 | const code = "const myVar = 123;"; 25 | const { originalSourceFile } = setupProjectWithCode(code); 26 | const targetSymbolName = "myVar"; 27 | 28 | const declarationStatement = findTopLevelDeclarationByName( 29 | originalSourceFile, 30 | targetSymbolName, 31 | SyntaxKind.VariableStatement, 32 | ); 33 | expect(declarationStatement).toBeDefined(); 34 | if (!declarationStatement) return; 35 | 36 | const classifiedDependencies: DependencyClassification[] = []; 37 | const neededExternalImports: NeededExternalImports = new Map(); 38 | 39 | const newFileContent = generateNewSourceFileContent( 40 | declarationStatement, 41 | classifiedDependencies, 42 | originalSourceFile.getFilePath(), 43 | "/src/newLocation.ts", 44 | neededExternalImports, 45 | ); 46 | 47 | const expectedContent = "export const myVar = 123;\n"; 48 | expect(newFileContent.trim()).toBe(expectedContent.trim()); 49 | }); 50 | 51 | it("内部依存関係 (moveToNewFile) を持つ VariableDeclaration から新しいファイル内容を生成できる", () => { 52 | const code = ` 53 | function helperFunc(n: number): number { 54 | return n * 2; 55 | } 56 | const myVar = helperFunc(10); 57 | `; 58 | const { originalSourceFile } = setupProjectWithCode(code); 59 | const targetSymbolName = "myVar"; 60 | const dependencyName = "helperFunc"; 61 | 62 | const declarationStatement = findTopLevelDeclarationByName( 63 | originalSourceFile, 64 | targetSymbolName, 65 | SyntaxKind.VariableStatement, 66 | ); 67 | const dependencyStatement = findTopLevelDeclarationByName( 68 | originalSourceFile, 69 | dependencyName, 70 | SyntaxKind.FunctionDeclaration, 71 | ); 72 | 73 | expect(declarationStatement).toBeDefined(); 74 | expect(dependencyStatement).toBeDefined(); 75 | if (!declarationStatement || !dependencyStatement) return; 76 | 77 | const classifiedDependencies: DependencyClassification[] = [ 78 | { type: "moveToNewFile", statement: dependencyStatement }, 79 | ]; 80 | const neededExternalImports: NeededExternalImports = new Map(); 81 | 82 | const newFileContent = generateNewSourceFileContent( 83 | declarationStatement, 84 | classifiedDependencies, 85 | originalSourceFile.getFilePath(), 86 | "/src/newLocation.ts", 87 | neededExternalImports, 88 | ); 89 | 90 | const expectedContent = ` 91 | /* export なし */ function helperFunc(n: number): number { 92 | return n * 2; 93 | } 94 | 95 | export const myVar = helperFunc(10); 96 | `; 97 | const normalize = (str: string) => str.replace(/\s+/g, " ").trim(); 98 | expect(normalize(newFileContent)).toBe( 99 | normalize(expectedContent.replace("/* export なし */ ", "")), 100 | ); 101 | expect(newFileContent).not.toContain("export function helperFunc"); 102 | expect(newFileContent).toContain("function helperFunc"); 103 | }); 104 | 105 | it("外部依存関係 (import) を持つ VariableDeclaration から新しいファイル内容を生成できる", () => { 106 | const externalCode = 107 | "export function externalFunc(n: number): number { return n + 1; }"; 108 | const originalCode = ` 109 | import { externalFunc } from './external'; 110 | const myVar = externalFunc(99); 111 | `; 112 | const { project, originalSourceFile } = setupProjectWithCode( 113 | originalCode, 114 | "/src/moduleA/main.ts", 115 | ); 116 | project.createSourceFile("/src/moduleA/external.ts", externalCode); 117 | const targetSymbolName = "myVar"; 118 | const newFilePath = "/src/moduleB/newFile.ts"; 119 | 120 | const declarationStatement = findTopLevelDeclarationByName( 121 | originalSourceFile, 122 | targetSymbolName, 123 | SyntaxKind.VariableStatement, 124 | ); 125 | expect(declarationStatement).toBeDefined(); 126 | if (!declarationStatement) return; 127 | 128 | const classifiedDependencies: DependencyClassification[] = []; 129 | const neededExternalImports: NeededExternalImports = new Map(); 130 | const importDecl = originalSourceFile.getImportDeclaration("./external"); 131 | expect(importDecl).toBeDefined(); 132 | if (importDecl) { 133 | const moduleSourceFile = importDecl.getModuleSpecifierSourceFile(); 134 | const key = moduleSourceFile 135 | ? moduleSourceFile.getFilePath() 136 | : importDecl.getModuleSpecifierValue(); 137 | neededExternalImports.set(key, { 138 | names: new Set(["externalFunc"]), 139 | declaration: importDecl, 140 | }); 141 | } 142 | 143 | const newFileContent = generateNewSourceFileContent( 144 | declarationStatement, 145 | classifiedDependencies, 146 | originalSourceFile.getFilePath(), 147 | newFilePath, 148 | neededExternalImports, 149 | ); 150 | 151 | const expectedContent = ` 152 | import { externalFunc } from "../moduleA/external"; 153 | export const myVar = externalFunc(99); 154 | `.trim(); 155 | const normalize = (str: string) => str.replace(/\s+/g, " ").trim(); 156 | expect(normalize(newFileContent)).toBe(normalize(expectedContent)); 157 | }); 158 | 159 | it("node_modulesからの外部依存を持つシンボルを移動する際、インポートパスが維持される", () => { 160 | const originalCode = ` 161 | import { useState } from 'react'; 162 | 163 | const CounterComponent = () => { 164 | const [count, setCount] = useState(0); 165 | return \`Count: \${count}\`; 166 | }; 167 | `; 168 | const originalFilePath = "/src/components/Counter.tsx"; 169 | const newFilePath = "/src/features/NewCounter.tsx"; 170 | const targetSymbolName = "CounterComponent"; 171 | 172 | const { project, originalSourceFile } = setupProjectWithCode( 173 | originalCode, 174 | originalFilePath, 175 | ); 176 | 177 | const declarationStatement = findTopLevelDeclarationByName( 178 | originalSourceFile, 179 | targetSymbolName, 180 | SyntaxKind.VariableStatement, 181 | ); 182 | expect(declarationStatement).toBeDefined(); 183 | if (!declarationStatement) return; 184 | 185 | const neededExternalImports: NeededExternalImports = new Map(); 186 | const reactImportDecl = originalSourceFile.getImportDeclaration("react"); 187 | expect(reactImportDecl).toBeDefined(); 188 | if (reactImportDecl) { 189 | expect(reactImportDecl.getModuleSpecifierSourceFile()).toBeUndefined(); 190 | const key = reactImportDecl.getModuleSpecifierValue(); 191 | neededExternalImports.set(key, { 192 | names: new Set(["useState"]), 193 | declaration: reactImportDecl, 194 | }); 195 | } 196 | 197 | const classifiedDependencies: DependencyClassification[] = []; 198 | 199 | const newFileContent = generateNewSourceFileContent( 200 | declarationStatement, 201 | classifiedDependencies, 202 | originalFilePath, 203 | newFilePath, 204 | neededExternalImports, 205 | ); 206 | 207 | const expectedImportStatement = 'import { useState } from "react";'; 208 | const expectedContent = ` 209 | import { useState } from "react"; 210 | 211 | export const CounterComponent = () => { 212 | const [count, setCount] = useState(0); 213 | return \`Count: \${count}\`; 214 | }; 215 | `.trim(); 216 | const normalize = (str: string) => str.replace(/\s+/g, " ").trim(); 217 | 218 | expect(newFileContent.trim()).toContain(expectedImportStatement); 219 | 220 | expect(newFileContent).not.toContain("node_modules/react"); 221 | expect(newFileContent).not.toContain("../"); 222 | 223 | expect(normalize(newFileContent)).toBe(normalize(expectedContent)); 224 | }); 225 | 226 | it("名前空間インポート (import * as) を持つシンボルから新しいファイル内容を生成できる", () => { 227 | const originalCode = ` 228 | import * as path from 'node:path'; 229 | 230 | const resolveFullPath = (dir: string, file: string): string => { 231 | return path.resolve(dir, file); 232 | }; 233 | `; 234 | const originalFilePath = "/src/utils/pathHelper.ts"; 235 | const newFilePath = "/src/core/newPathHelper.ts"; 236 | const targetSymbolName = "resolveFullPath"; 237 | 238 | const { project, originalSourceFile } = setupProjectWithCode( 239 | originalCode, 240 | originalFilePath, 241 | ); 242 | 243 | const declarationStatement = findTopLevelDeclarationByName( 244 | originalSourceFile, 245 | targetSymbolName, 246 | SyntaxKind.VariableStatement, 247 | ); 248 | expect(declarationStatement).toBeDefined(); 249 | if (!declarationStatement) return; 250 | 251 | const neededExternalImports: NeededExternalImports = new Map(); 252 | const pathImportDecl = originalSourceFile.getImportDeclaration("node:path"); 253 | expect(pathImportDecl).toBeDefined(); 254 | if (pathImportDecl) { 255 | const key = pathImportDecl.getModuleSpecifierValue(); 256 | neededExternalImports.set(key, { 257 | names: new Set(), 258 | declaration: pathImportDecl, 259 | isNamespaceImport: true, 260 | namespaceImportName: "path", 261 | }); 262 | } 263 | 264 | const classifiedDependencies: DependencyClassification[] = []; 265 | 266 | const newFileContent = generateNewSourceFileContent( 267 | declarationStatement, 268 | classifiedDependencies, 269 | originalFilePath, 270 | newFilePath, 271 | neededExternalImports, 272 | ); 273 | 274 | const expectedImportStatement = 'import * as path from "node:path";'; 275 | const expectedContent = ` 276 | ${expectedImportStatement} 277 | 278 | export const resolveFullPath = (dir: string, file: string): string => { 279 | return path.resolve(dir, file); 280 | }; 281 | `.trim(); 282 | const normalize = (str: string) => str.replace(/\s+/g, " ").trim(); 283 | 284 | expect(newFileContent.trim()).toContain(expectedImportStatement); 285 | expect(normalize(newFileContent)).toBe(normalize(expectedContent)); 286 | }); 287 | 288 | it("デフォルトインポートに依存するシンボルから新しいファイル内容を生成できる", () => { 289 | const loggerCode = ` 290 | export default function logger(message: string) { 291 | console.log(message); 292 | } 293 | `; 294 | const originalCode = ` 295 | import myLogger from './logger'; 296 | 297 | function functionThatUsesLogger(msg: string) { 298 | myLogger(\`LOG: \${msg}\`); 299 | } 300 | `; 301 | const originalFilePath = "/src/module/main.ts"; 302 | const loggerFilePath = "/src/module/logger.ts"; 303 | const newFilePath = "/src/feature/newLoggerUser.ts"; 304 | const targetSymbolName = "functionThatUsesLogger"; 305 | 306 | const { project, originalSourceFile } = setupProjectWithCode( 307 | originalCode, 308 | originalFilePath, 309 | ); 310 | project.createSourceFile(loggerFilePath, loggerCode); 311 | 312 | // 移動対象の宣言を取得 313 | const declarationStatement = findTopLevelDeclarationByName( 314 | originalSourceFile, 315 | targetSymbolName, 316 | SyntaxKind.FunctionDeclaration, 317 | ); 318 | expect(declarationStatement).toBeDefined(); 319 | if (!declarationStatement) return; 320 | 321 | // 必要な外部インポート情報を手動で設定 (デフォルトインポート) 322 | const neededExternalImports: NeededExternalImports = new Map(); 323 | const loggerImportDecl = 324 | originalSourceFile.getImportDeclaration("./logger"); 325 | expect(loggerImportDecl).toBeDefined(); 326 | if (loggerImportDecl) { 327 | const moduleSourceFile = loggerImportDecl.getModuleSpecifierSourceFile(); 328 | expect(moduleSourceFile).toBeDefined(); 329 | if (moduleSourceFile) { 330 | const key = moduleSourceFile.getFilePath(); 331 | neededExternalImports.set(key, { 332 | names: new Set(["default"]), 333 | declaration: loggerImportDecl, 334 | }); 335 | } 336 | } 337 | 338 | const classifiedDependencies: DependencyClassification[] = []; 339 | 340 | const newFileContent = generateNewSourceFileContent( 341 | declarationStatement, 342 | classifiedDependencies, 343 | originalFilePath, 344 | newFilePath, 345 | neededExternalImports, 346 | ); 347 | 348 | const expectedImportStatement = 'import myLogger from "../module/logger";'; 349 | const incorrectImport1 = 'import { default } from "../module/logger";'; 350 | const incorrectImport2 = 351 | 'import { default as myLogger } from "../module/logger";'; 352 | 353 | expect(newFileContent).not.toContain(incorrectImport1); 354 | expect(newFileContent).not.toContain(incorrectImport2); 355 | 356 | expect(newFileContent).toContain(expectedImportStatement); 357 | 358 | expect(newFileContent).toContain("export function functionThatUsesLogger"); 359 | }); 360 | }); 361 | ``` -------------------------------------------------------------------------------- /src/ts-morph/rename-file-system/rename-file-system-entry.special.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from "vitest"; 2 | import { Project } from "ts-morph"; 3 | import { renameFileSystemEntry } from "./rename-file-system-entry"; 4 | 5 | // --- Test Setup Helper --- 6 | 7 | const setupProject = () => { 8 | const project = new Project({ 9 | useInMemoryFileSystem: true, 10 | compilerOptions: { 11 | baseUrl: ".", 12 | paths: { 13 | "@/*": ["src/*"], 14 | }, 15 | esModuleInterop: true, 16 | allowJs: true, 17 | }, 18 | }); 19 | 20 | project.createDirectory("/src"); 21 | project.createDirectory("/src/utils"); 22 | project.createDirectory("/src/components"); 23 | 24 | return project; 25 | }; 26 | 27 | describe("renameFileSystemEntry Special Cases", () => { 28 | it("dryRun: true の場合、ファイルシステム(メモリ上)の変更を行わず、変更予定リストを返す", async () => { 29 | const project = setupProject(); 30 | const oldUtilPath = "/src/utils/old-util.ts"; 31 | const newUtilPath = "/src/utils/new-util.ts"; 32 | const componentPath = "/src/components/MyComponent.ts"; 33 | 34 | project.createSourceFile( 35 | oldUtilPath, 36 | 'export const oldUtil = () => "old";', 37 | ); 38 | project.createSourceFile( 39 | componentPath, 40 | `import { oldUtil } from '../utils/old-util';`, 41 | ); 42 | 43 | const result = await renameFileSystemEntry({ 44 | project, 45 | renames: [{ oldPath: oldUtilPath, newPath: newUtilPath }], 46 | dryRun: true, 47 | }); 48 | 49 | expect(project.getSourceFile(oldUtilPath)).toBeUndefined(); 50 | expect(project.getSourceFile(newUtilPath)).toBeDefined(); 51 | 52 | expect(result.changedFiles).toContain(newUtilPath); 53 | expect(result.changedFiles).toContain(componentPath); 54 | expect(result.changedFiles).not.toContain(oldUtilPath); 55 | }); 56 | 57 | it("どのファイルからも参照されていないファイルをリネームする", async () => { 58 | const project = setupProject(); 59 | const oldPath = "/src/utils/unreferenced.ts"; 60 | const newPath = "/src/utils/renamed-unreferenced.ts"; 61 | project.createSourceFile(oldPath, "export const lonely = true;"); 62 | 63 | const result = await renameFileSystemEntry({ 64 | project, 65 | renames: [{ oldPath, newPath }], 66 | dryRun: false, 67 | }); 68 | 69 | expect(project.getSourceFile(oldPath)).toBeUndefined(); 70 | expect(project.getSourceFile(newPath)).toBeDefined(); 71 | expect(project.getSourceFileOrThrow(newPath).getFullText()).toContain( 72 | "export const lonely = true;", 73 | ); 74 | expect(result.changedFiles).toEqual([newPath]); 75 | }); 76 | 77 | it("デフォルトインポートのパスが正しく更新される", async () => { 78 | const project = setupProject(); 79 | const oldDefaultPath = "/src/utils/defaultExport.ts"; 80 | const newDefaultPath = "/src/utils/renamedDefaultExport.ts"; 81 | const importerPath = "/src/importer.ts"; 82 | 83 | project.createSourceFile( 84 | oldDefaultPath, 85 | "export default function myDefaultFunction() { return 'default'; }", 86 | ); 87 | project.createSourceFile( 88 | importerPath, 89 | "import MyDefaultImport from './utils/defaultExport';\nconsole.log(MyDefaultImport());", 90 | ); 91 | 92 | await renameFileSystemEntry({ 93 | project, 94 | renames: [{ oldPath: oldDefaultPath, newPath: newDefaultPath }], 95 | dryRun: false, 96 | }); 97 | 98 | const updatedImporterContent = project 99 | .getSourceFileOrThrow(importerPath) 100 | .getFullText(); 101 | expect(project.getSourceFile(oldDefaultPath)).toBeUndefined(); 102 | expect(project.getSourceFile(newDefaultPath)).toBeDefined(); 103 | expect(updatedImporterContent).toContain( 104 | "import MyDefaultImport from './utils/renamedDefaultExport';", 105 | ); 106 | }); 107 | 108 | it("デフォルトエクスポートされた変数 (export default variableName) のパスが正しく更新される", async () => { 109 | const project = setupProject(); 110 | const oldVarDefaultPath = "/src/utils/variableDefaultExport.ts"; 111 | const newVarDefaultPath = "/src/utils/renamedVariableDefaultExport.ts"; 112 | const importerPath = "/src/importerVar.ts"; 113 | 114 | project.createSourceFile( 115 | oldVarDefaultPath, 116 | "const myVar = { value: 'default var' };\nexport default myVar;", 117 | ); 118 | project.createSourceFile( 119 | importerPath, 120 | "import MyVarImport from './utils/variableDefaultExport';\nconsole.log(MyVarImport.value);", 121 | ); 122 | 123 | await renameFileSystemEntry({ 124 | project, 125 | renames: [{ oldPath: oldVarDefaultPath, newPath: newVarDefaultPath }], 126 | dryRun: false, 127 | }); 128 | 129 | const updatedImporterContent = project 130 | .getSourceFileOrThrow(importerPath) 131 | .getFullText(); 132 | expect(project.getSourceFile(oldVarDefaultPath)).toBeUndefined(); 133 | expect(project.getSourceFile(newVarDefaultPath)).toBeDefined(); 134 | expect(updatedImporterContent).toContain( 135 | "import MyVarImport from './utils/renamedVariableDefaultExport';", 136 | ); 137 | }); 138 | }); 139 | 140 | describe("renameFileSystemEntry Extension Preservation", () => { 141 | it("import文のパスに .js 拡張子が含まれている場合、リネーム後も維持される", async () => { 142 | const project = setupProject(); 143 | const oldJsPath = "/src/utils/legacy-util.js"; 144 | const newJsPath = "/src/utils/modern-util.js"; 145 | const importerPath = "/src/components/MyComponent.ts"; 146 | const otherTsPath = "/src/utils/helper.ts"; 147 | const newOtherTsPath = "/src/utils/renamed-helper.ts"; 148 | 149 | project.createSourceFile(oldJsPath, "export const legacyValue = 1;"); 150 | project.createSourceFile(otherTsPath, "export const helperValue = 2;"); 151 | project.createSourceFile( 152 | importerPath, 153 | `import { legacyValue } from '../utils/legacy-util.js'; 154 | import { helperValue } from '../utils/helper'; 155 | 156 | console.log(legacyValue, helperValue); 157 | `, 158 | ); 159 | 160 | await renameFileSystemEntry({ 161 | project, 162 | renames: [ 163 | { oldPath: oldJsPath, newPath: newJsPath }, 164 | { oldPath: otherTsPath, newPath: newOtherTsPath }, 165 | ], 166 | dryRun: false, 167 | }); 168 | 169 | const updatedImporterContent = project 170 | .getSourceFileOrThrow(importerPath) 171 | .getFullText(); 172 | 173 | expect(updatedImporterContent).toContain( 174 | "import { legacyValue } from '../utils/modern-util.js';", 175 | ); 176 | expect(updatedImporterContent).toContain( 177 | "import { helperValue } from '../utils/renamed-helper';", 178 | ); 179 | 180 | expect(project.getSourceFile(oldJsPath)).toBeUndefined(); 181 | expect(project.getSourceFile(newJsPath)).toBeDefined(); 182 | expect(project.getSourceFile(otherTsPath)).toBeUndefined(); 183 | expect(project.getSourceFile(newOtherTsPath)).toBeDefined(); 184 | }); 185 | }); 186 | 187 | describe("renameFileSystemEntry with index.ts re-exports", () => { 188 | it("index.ts が 'export * from \"./moduleB\"' 形式で moduleB.ts を再エクスポートし、moduleB.ts をリネームした場合", async () => { 189 | const project = setupProject(); 190 | const utilsDir = "/src/utils"; 191 | const moduleBOriginalPath = `${utilsDir}/moduleB.ts`; 192 | const moduleBRenamedPath = `${utilsDir}/moduleBRenamed.ts`; 193 | const indexTsPath = `${utilsDir}/index.ts`; 194 | const componentPath = "/src/components/MyComponent.ts"; 195 | 196 | project.createSourceFile( 197 | moduleBOriginalPath, 198 | "export const importantValue = 'Hello from B';", 199 | ); 200 | project.createSourceFile(indexTsPath, 'export * from "./moduleB";'); 201 | project.createSourceFile( 202 | componentPath, 203 | "import { importantValue } from '@/utils';\\nconsole.log(importantValue);", 204 | ); 205 | 206 | const result = await renameFileSystemEntry({ 207 | project, 208 | renames: [{ oldPath: moduleBOriginalPath, newPath: moduleBRenamedPath }], 209 | dryRun: false, 210 | }); 211 | 212 | expect(project.getSourceFile(moduleBOriginalPath)).toBeUndefined(); 213 | expect(project.getSourceFile(moduleBRenamedPath)).toBeDefined(); 214 | expect(project.getSourceFileOrThrow(moduleBRenamedPath).getFullText()).toBe( 215 | "export const importantValue = 'Hello from B';", 216 | ); 217 | 218 | const indexTsContent = project 219 | .getSourceFileOrThrow(indexTsPath) 220 | .getFullText(); 221 | expect(indexTsContent).toContain('export * from "./moduleBRenamed";'); 222 | expect(indexTsContent).not.toContain('export * from "./moduleB";'); 223 | 224 | const componentContent = project 225 | .getSourceFileOrThrow(componentPath) 226 | .getFullText(); 227 | expect(componentContent).toContain( 228 | "import { importantValue } from '@/utils';", 229 | ); 230 | 231 | expect(result.changedFiles).toHaveLength(3); 232 | expect(result.changedFiles).toEqual( 233 | expect.arrayContaining([moduleBRenamedPath, indexTsPath, componentPath]), 234 | ); 235 | }); 236 | 237 | it("index.ts が 'export { specificExport } from \"./moduleC\"' 形式で moduleC.ts を再エクスポートし、moduleC.ts をリネームした場合", async () => { 238 | const project = setupProject(); 239 | const utilsDir = "/src/utils"; 240 | const moduleCOriginalPath = `${utilsDir}/moduleC.ts`; 241 | const moduleCRenamedPath = `${utilsDir}/moduleCRenamed.ts`; 242 | const indexTsPath = `${utilsDir}/index.ts`; 243 | const componentPath = "/src/components/MyComponentForC.ts"; 244 | 245 | project.createSourceFile( 246 | moduleCOriginalPath, 247 | "export const specificExport = 'Hello from C';", 248 | ); 249 | project.createSourceFile( 250 | indexTsPath, 251 | 'export { specificExport } from "./moduleC";', 252 | ); 253 | project.createSourceFile( 254 | componentPath, 255 | "import { specificExport } from '@/utils';\\nconsole.log(specificExport);", 256 | ); 257 | 258 | const result = await renameFileSystemEntry({ 259 | project, 260 | renames: [{ oldPath: moduleCOriginalPath, newPath: moduleCRenamedPath }], 261 | dryRun: false, 262 | }); 263 | 264 | expect(project.getSourceFile(moduleCOriginalPath)).toBeUndefined(); 265 | expect(project.getSourceFile(moduleCRenamedPath)).toBeDefined(); 266 | expect(project.getSourceFileOrThrow(moduleCRenamedPath).getFullText()).toBe( 267 | "export const specificExport = 'Hello from C';", 268 | ); 269 | 270 | const indexTsContent = project 271 | .getSourceFileOrThrow(indexTsPath) 272 | .getFullText(); 273 | expect(indexTsContent).toContain( 274 | 'export { specificExport } from "./moduleCRenamed";', 275 | ); 276 | expect(indexTsContent).not.toContain( 277 | 'export { specificExport } from "./moduleC";', 278 | ); 279 | 280 | const componentContent = project 281 | .getSourceFileOrThrow(componentPath) 282 | .getFullText(); 283 | expect(componentContent).toContain( 284 | "import { specificExport } from '@/utils';", 285 | ); 286 | 287 | expect(result.changedFiles).toHaveLength(3); 288 | expect(result.changedFiles).toEqual( 289 | expect.arrayContaining([moduleCRenamedPath, indexTsPath, componentPath]), 290 | ); 291 | }); 292 | 293 | it("index.ts が再エクスポートを行い、その utils ディレクトリ全体をリネームした場合", async () => { 294 | const project = setupProject(); 295 | const oldUtilsDir = "/src/utils"; 296 | const newUtilsDir = "/src/newUtils"; 297 | 298 | const moduleDOriginalPath = `${oldUtilsDir}/moduleD.ts`; 299 | const indexTsOriginalPath = `${oldUtilsDir}/index.ts`; 300 | const componentPath = "/src/components/MyComponentForD.ts"; 301 | 302 | project.createSourceFile( 303 | moduleDOriginalPath, 304 | "export const valueFromD = 'Hello from D';", 305 | ); 306 | project.createSourceFile(indexTsOriginalPath, 'export * from "./moduleD";'); 307 | project.createSourceFile( 308 | componentPath, 309 | "import { valueFromD } from '@/utils';\\nconsole.log(valueFromD);", 310 | ); 311 | 312 | const result = await renameFileSystemEntry({ 313 | project, 314 | renames: [{ oldPath: oldUtilsDir, newPath: newUtilsDir }], 315 | dryRun: false, 316 | }); 317 | 318 | const moduleDRenamedPath = `${newUtilsDir}/moduleD.ts`; 319 | const indexTsRenamedPath = `${newUtilsDir}/index.ts`; 320 | 321 | expect(project.getSourceFile(moduleDOriginalPath)).toBeUndefined(); 322 | expect(project.getSourceFile(indexTsOriginalPath)).toBeUndefined(); 323 | // expect(project.getDirectory(oldUtilsDir)).toBeUndefined(); // ユーザーの指示によりコメントアウト 324 | 325 | expect(project.getDirectory(newUtilsDir)).toBeDefined(); 326 | expect(project.getSourceFile(moduleDRenamedPath)).toBeDefined(); 327 | expect(project.getSourceFile(indexTsRenamedPath)).toBeDefined(); 328 | 329 | expect(project.getSourceFileOrThrow(moduleDRenamedPath).getFullText()).toBe( 330 | "export const valueFromD = 'Hello from D';", 331 | ); 332 | expect(project.getSourceFileOrThrow(indexTsRenamedPath).getFullText()).toBe( 333 | 'export * from "./moduleD";', 334 | ); 335 | 336 | const componentContent = project 337 | .getSourceFileOrThrow(componentPath) 338 | .getFullText(); 339 | expect(componentContent).toContain( 340 | "import { valueFromD } from '../newUtils/index';", 341 | ); 342 | 343 | expect(result.changedFiles).toHaveLength(3); 344 | expect(result.changedFiles).toEqual( 345 | expect.arrayContaining([ 346 | moduleDRenamedPath, 347 | indexTsRenamedPath, 348 | componentPath, 349 | ]), 350 | ); 351 | }); 352 | }); 353 | 354 | describe("renameFileSystemEntry with index.ts re-exports (actual bug reproduction)", () => { 355 | it("index.tsが複数のモジュールを再エクスポートし、そのうちの1つをリネームした際、インポート元のパスがindex.tsを指し続けること", async () => { 356 | const project = setupProject(); 357 | const utilsDir = "/src/utils"; 358 | const moduleAOriginalPath = `${utilsDir}/moduleA.ts`; 359 | const moduleARenamedPath = `${utilsDir}/moduleARenamed.ts`; 360 | const moduleBPath = `${utilsDir}/moduleB.ts`; 361 | const indexTsPath = `${utilsDir}/index.ts`; 362 | const componentPath = "/src/components/MyComponent.ts"; 363 | 364 | project.createSourceFile( 365 | moduleAOriginalPath, 366 | "export const funcA = () => 'original_A';", 367 | ); 368 | project.createSourceFile(moduleBPath, "export const funcB = () => 'B';"); 369 | project.createSourceFile( 370 | indexTsPath, 371 | 'export * from "./moduleA";\nexport * from "./moduleB";', 372 | ); 373 | project.createSourceFile( 374 | componentPath, 375 | "import { funcA, funcB } from '@/utils';\nconsole.log(funcA(), funcB());", 376 | ); 377 | 378 | const originalComponentContent = project 379 | .getSourceFileOrThrow(componentPath) 380 | .getFullText(); 381 | 382 | await renameFileSystemEntry({ 383 | project, 384 | renames: [{ oldPath: moduleAOriginalPath, newPath: moduleARenamedPath }], 385 | dryRun: false, 386 | }); 387 | 388 | // 1. moduleA.ts がリネームされていること 389 | expect(project.getSourceFile(moduleAOriginalPath)).toBeUndefined(); 390 | expect(project.getSourceFile(moduleARenamedPath)).toBeDefined(); 391 | expect(project.getSourceFileOrThrow(moduleARenamedPath).getFullText()).toBe( 392 | "export const funcA = () => 'original_A';", 393 | ); 394 | 395 | // 2. index.ts が正しく更新されていること 396 | const indexTsContent = project 397 | .getSourceFileOrThrow(indexTsPath) 398 | .getFullText(); 399 | expect(indexTsContent).toContain('export * from "./moduleARenamed";'); 400 | expect(indexTsContent).toContain('export * from "./moduleB";'); 401 | expect(indexTsContent).not.toContain('export * from "./moduleA";'); 402 | 403 | // 3. MyComponent.ts のインポートパスが変更されていないこと 404 | const updatedComponentContent = project 405 | .getSourceFileOrThrow(componentPath) 406 | .getFullText(); 407 | expect(updatedComponentContent).toBe(originalComponentContent); 408 | // さらに具体的に確認 409 | expect(updatedComponentContent).toContain( 410 | "import { funcA, funcB } from '@/utils';", 411 | ); 412 | }); 413 | }); 414 | ```