398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import ts from 'typescript'
|
|
|
|
export interface FileMove {
|
|
readonly from: string
|
|
readonly to: string
|
|
}
|
|
|
|
export interface MoveOptions {
|
|
readonly rootDir?: string
|
|
readonly dryRun?: boolean
|
|
readonly verbose?: boolean
|
|
}
|
|
|
|
export interface MoveResult {
|
|
readonly movedFiles: number
|
|
readonly updatedFiles: number
|
|
readonly rewrittenSpecifiers: number
|
|
}
|
|
|
|
type ExtMode = 'exact' | 'js-for-ts' | 'no-ext' | 'dir-index'
|
|
|
|
interface ResolvedTarget {
|
|
readonly absPath: string
|
|
readonly extMode: ExtMode
|
|
}
|
|
|
|
const EXCLUDED_DIRS = new Set([
|
|
'node_modules',
|
|
'.git',
|
|
'.agents',
|
|
'.worktrees',
|
|
'dist',
|
|
'dist-game',
|
|
'public',
|
|
'samples',
|
|
'coverage',
|
|
])
|
|
|
|
function isExcludedPath(relPath: string): boolean {
|
|
const normalized = relPath.split(path.sep).join('/')
|
|
if (
|
|
normalized === 'TEST_INFRA.md' ||
|
|
normalized === 'TEST_READY.md' ||
|
|
normalized.startsWith('tests/e2e-module-split/') ||
|
|
normalized === 'tests/e2e-module-split'
|
|
) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function collectTsFiles(rootDir: string): string[] {
|
|
const results: string[] = []
|
|
const scanDirs = ['src', 'scripts', 'tools', 'tests']
|
|
|
|
function walk(dirAbs: string): void {
|
|
if (!fs.existsSync(dirAbs)) return
|
|
const entries = fs.readdirSync(dirAbs, { withFileTypes: true })
|
|
for (const entry of entries) {
|
|
if (EXCLUDED_DIRS.has(entry.name)) continue
|
|
const fullPath = path.join(dirAbs, entry.name)
|
|
const relPath = path.relative(rootDir, fullPath)
|
|
if (isExcludedPath(relPath)) continue
|
|
if (entry.isDirectory()) {
|
|
walk(fullPath)
|
|
} else if (
|
|
entry.isFile() &&
|
|
(entry.name.endsWith('.ts') || entry.name.endsWith('.mts') || entry.name.endsWith('.tsx'))
|
|
) {
|
|
results.push(fullPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const sub of scanDirs) {
|
|
walk(path.join(rootDir, sub))
|
|
}
|
|
return results
|
|
}
|
|
|
|
function resolveOldSpecifier(
|
|
oldDir: string,
|
|
spec: string,
|
|
knownFiles: ReadonlySet<string>,
|
|
): ResolvedTarget | null {
|
|
const baseAbs = path.resolve(oldDir, spec)
|
|
const hasKnownOrDisk = (p: string): boolean => knownFiles.has(p) || fs.existsSync(p)
|
|
|
|
if (
|
|
spec.endsWith('.ts') ||
|
|
spec.endsWith('.mts') ||
|
|
spec.endsWith('.tsx') ||
|
|
spec.endsWith('.json') ||
|
|
spec.endsWith('.css')
|
|
) {
|
|
if (hasKnownOrDisk(baseAbs)) {
|
|
return { absPath: baseAbs, extMode: 'exact' }
|
|
}
|
|
return { absPath: baseAbs, extMode: 'exact' }
|
|
}
|
|
|
|
if (spec.endsWith('.js')) {
|
|
const asTs = baseAbs.slice(0, -3) + '.ts'
|
|
if (hasKnownOrDisk(asTs)) {
|
|
return { absPath: asTs, extMode: 'js-for-ts' }
|
|
}
|
|
if (hasKnownOrDisk(baseAbs)) {
|
|
return { absPath: baseAbs, extMode: 'exact' }
|
|
}
|
|
}
|
|
|
|
if (hasKnownOrDisk(baseAbs + '.ts')) {
|
|
return { absPath: baseAbs + '.ts', extMode: 'no-ext' }
|
|
}
|
|
if (hasKnownOrDisk(baseAbs + '.mts')) {
|
|
return { absPath: baseAbs + '.mts', extMode: 'no-ext' }
|
|
}
|
|
const indexTs = path.join(baseAbs, 'index.ts')
|
|
if (hasKnownOrDisk(indexTs)) {
|
|
return { absPath: indexTs, extMode: 'dir-index' }
|
|
}
|
|
if (hasKnownOrDisk(baseAbs) && fs.existsSync(baseAbs) && fs.statSync(baseAbs).isFile()) {
|
|
return { absPath: baseAbs, extMode: 'exact' }
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function formatNewSpecifier(newDir: string, newTargetAbs: string, extMode: ExtMode): string {
|
|
let rel = path.relative(newDir, newTargetAbs).split(path.sep).join('/')
|
|
if (extMode === 'js-for-ts' && rel.endsWith('.ts')) {
|
|
rel = rel.slice(0, -3) + '.js'
|
|
} else if (extMode === 'no-ext') {
|
|
if (rel.endsWith('.ts')) rel = rel.slice(0, -3)
|
|
else if (rel.endsWith('.mts')) rel = rel.slice(0, -4)
|
|
} else if (extMode === 'dir-index' && rel.endsWith('/index.ts')) {
|
|
rel = rel.slice(0, -'/index.ts'.length)
|
|
}
|
|
if (!rel.startsWith('.')) {
|
|
rel = './' + rel
|
|
}
|
|
return rel
|
|
}
|
|
|
|
function collectModuleSpecifierLiterals(sourceFile: ts.SourceFile): ts.StringLiteral[] {
|
|
const literals: ts.StringLiteral[] = []
|
|
|
|
function visit(node: ts.Node): void {
|
|
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
|
if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
literals.push(node.moduleSpecifier)
|
|
}
|
|
} else if (ts.isCallExpression(node)) {
|
|
if (node.expression.kind === ts.SyntaxKind.ImportKeyword && node.arguments.length >= 1) {
|
|
const arg0 = node.arguments[0]
|
|
if (arg0 && ts.isStringLiteral(arg0)) {
|
|
literals.push(arg0)
|
|
}
|
|
} else if (
|
|
ts.isPropertyAccessExpression(node.expression) &&
|
|
ts.isIdentifier(node.expression.expression) &&
|
|
node.expression.expression.text === 'vi' &&
|
|
(node.expression.name.text === 'mock' || node.expression.name.text === 'doMock') &&
|
|
node.arguments.length >= 1
|
|
) {
|
|
const arg0 = node.arguments[0]
|
|
if (arg0 && ts.isStringLiteral(arg0)) {
|
|
literals.push(arg0)
|
|
}
|
|
}
|
|
} else if (ts.isImportTypeNode(node)) {
|
|
if (
|
|
ts.isLiteralTypeNode(node.argument) &&
|
|
ts.isStringLiteral(node.argument.literal)
|
|
) {
|
|
literals.push(node.argument.literal)
|
|
}
|
|
}
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
|
|
visit(sourceFile)
|
|
return literals
|
|
}
|
|
|
|
function removeEmptyParentDirs(dirAbs: string, stopAtAbs: string): void {
|
|
let curr = dirAbs
|
|
while (curr.startsWith(stopAtAbs) && curr !== stopAtAbs) {
|
|
if (!fs.existsSync(curr)) {
|
|
curr = path.dirname(curr)
|
|
continue
|
|
}
|
|
const entries = fs.readdirSync(curr)
|
|
if (entries.length === 0) {
|
|
fs.rmdirSync(curr)
|
|
curr = path.dirname(curr)
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
export function moveFiles(moves: readonly FileMove[], options: MoveOptions = {}): MoveResult {
|
|
const rootDir = path.resolve(options.rootDir ?? process.cwd())
|
|
const dryRun = options.dryRun ?? false
|
|
const verbose = options.verbose ?? false
|
|
|
|
const moveMap = new Map<string, string>()
|
|
for (const m of moves) {
|
|
const fromAbs = path.resolve(rootDir, m.from)
|
|
const toAbs = path.resolve(rootDir, m.to)
|
|
if (fromAbs === toAbs) continue
|
|
if (!fs.existsSync(fromAbs)) {
|
|
throw new Error(`Source file does not exist: ${m.from} (${fromAbs})`)
|
|
}
|
|
if (isExcludedPath(path.relative(rootDir, fromAbs)) || isExcludedPath(path.relative(rootDir, toAbs))) {
|
|
throw new Error(`Forbidden move touching excluded path: ${m.from} -> ${m.to}`)
|
|
}
|
|
moveMap.set(fromAbs, toAbs)
|
|
}
|
|
|
|
const allTsFiles = collectTsFiles(rootDir)
|
|
const knownFiles = new Set<string>(allTsFiles)
|
|
for (const fromAbs of moveMap.keys()) {
|
|
knownFiles.add(fromAbs)
|
|
}
|
|
|
|
let updatedFiles = 0
|
|
let rewrittenSpecifiers = 0
|
|
const pendingWrites = new Map<string, string>()
|
|
|
|
for (const fileAbs of allTsFiles) {
|
|
const oldDir = path.dirname(fileAbs)
|
|
const newFileAbs = moveMap.get(fileAbs) ?? fileAbs
|
|
const newDir = path.dirname(newFileAbs)
|
|
const fileMoved = oldDir !== newDir
|
|
|
|
const content = fs.readFileSync(fileAbs, 'utf8')
|
|
const sourceFile = ts.createSourceFile(
|
|
fileAbs,
|
|
content,
|
|
ts.ScriptTarget.Latest,
|
|
false,
|
|
fileAbs.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
)
|
|
|
|
const literals = collectModuleSpecifierLiterals(sourceFile)
|
|
if (literals.length === 0) continue
|
|
|
|
const replacements: Array<{ start: number; end: number; newText: string }> = []
|
|
|
|
for (const lit of literals) {
|
|
const spec = lit.text
|
|
if (!spec.startsWith('./') && !spec.startsWith('../')) continue
|
|
|
|
const resolved = resolveOldSpecifier(oldDir, spec, knownFiles)
|
|
if (!resolved) continue
|
|
|
|
const newTargetAbs = moveMap.get(resolved.absPath) ?? resolved.absPath
|
|
const targetMoved = newTargetAbs !== resolved.absPath
|
|
|
|
if (!fileMoved && !targetMoved) continue
|
|
|
|
const newSpec = formatNewSpecifier(newDir, newTargetAbs, resolved.extMode)
|
|
if (newSpec !== spec) {
|
|
const rawLiteral = content.slice(lit.getStart(sourceFile), lit.getEnd())
|
|
const quote = rawLiteral.startsWith('"') ? '"' : "'"
|
|
replacements.push({
|
|
start: lit.getStart(sourceFile),
|
|
end: lit.getEnd(),
|
|
newText: `${quote}${newSpec}${quote}`,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (replacements.length > 0) {
|
|
replacements.sort((a, b) => b.start - a.start)
|
|
let updated = content
|
|
for (const r of replacements) {
|
|
updated = updated.slice(0, r.start) + r.newText + updated.slice(r.end)
|
|
}
|
|
pendingWrites.set(fileAbs, updated)
|
|
updatedFiles++
|
|
rewrittenSpecifiers += replacements.length
|
|
if (verbose) {
|
|
console.log(
|
|
`[rewrite] ${path.relative(rootDir, fileAbs)} (${replacements.length} specifier(s))`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!dryRun) {
|
|
// 1. Write updated contents to original locations first
|
|
for (const [fileAbs, updatedContent] of pendingWrites.entries()) {
|
|
fs.writeFileSync(fileAbs, updatedContent, 'utf8')
|
|
}
|
|
|
|
// 2. Perform file moves via temporary paths if there are any cycles/overwrites
|
|
const tempMoves: Array<{ tempAbs: string; toAbs: string; oldParent: string }> = []
|
|
let idx = 0
|
|
for (const [fromAbs, toAbs] of moveMap.entries()) {
|
|
const tempAbs = `${fromAbs}.__moving_${process.pid}_${idx++}__`
|
|
fs.renameSync(fromAbs, tempAbs)
|
|
tempMoves.push({ tempAbs, toAbs, oldParent: path.dirname(fromAbs) })
|
|
}
|
|
|
|
for (const { tempAbs, toAbs } of tempMoves) {
|
|
fs.mkdirSync(path.dirname(toAbs), { recursive: true })
|
|
fs.renameSync(tempAbs, toAbs)
|
|
}
|
|
|
|
for (const { oldParent } of tempMoves) {
|
|
removeEmptyParentDirs(oldParent, rootDir)
|
|
}
|
|
}
|
|
|
|
return {
|
|
movedFiles: moveMap.size,
|
|
updatedFiles,
|
|
rewrittenSpecifiers,
|
|
}
|
|
}
|
|
|
|
function main(): void {
|
|
const args = process.argv.slice(2)
|
|
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
console.log(
|
|
'Usage:\n' +
|
|
' npx tsx tools/refactor/move.ts [--dry-run] [--verbose] <from> <to> [<from2> <to2> ...]\n' +
|
|
' npx tsx tools/refactor/move.ts [--dry-run] [--verbose] --manifest <moves.json>',
|
|
)
|
|
process.exit(0)
|
|
}
|
|
|
|
let dryRun = false
|
|
let verbose = false
|
|
let manifestPath: string | null = null
|
|
const positional: string[] = []
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i]!
|
|
if (arg === '--dry-run') {
|
|
dryRun = true
|
|
} else if (arg === '--verbose' || arg === '-v') {
|
|
verbose = true
|
|
} else if (arg === '--manifest' || arg === '--batch') {
|
|
manifestPath = args[++i] ?? null
|
|
if (!manifestPath) throw new Error(`${arg} requires a file path`)
|
|
} else {
|
|
positional.push(arg)
|
|
}
|
|
}
|
|
|
|
const moves: FileMove[] = []
|
|
if (manifestPath) {
|
|
const raw = JSON.parse(fs.readFileSync(path.resolve(manifestPath), 'utf8')) as
|
|
| Array<{ from: string; to: string } | [string, string]>
|
|
| Record<string, string>
|
|
if (Array.isArray(raw)) {
|
|
for (const item of raw) {
|
|
if (Array.isArray(item)) {
|
|
moves.push({ from: item[0], to: item[1] })
|
|
} else {
|
|
moves.push({ from: item.from, to: item.to })
|
|
}
|
|
}
|
|
} else {
|
|
for (const [from, to] of Object.entries(raw)) {
|
|
moves.push({ from, to })
|
|
}
|
|
}
|
|
}
|
|
|
|
if (positional.length % 2 !== 0) {
|
|
throw new Error(`Expected even number of positional <from> <to> arguments, got ${positional.length}`)
|
|
}
|
|
for (let i = 0; i < positional.length; i += 2) {
|
|
moves.push({ from: positional[i]!, to: positional[i + 1]! })
|
|
}
|
|
|
|
const res = moveFiles(moves, { dryRun, verbose })
|
|
console.log(
|
|
`[move] ${dryRun ? '(dry-run) ' : ''}moved ${res.movedFiles} file(s), updated ${res.updatedFiles} file(s), rewrote ${res.rewrittenSpecifiers} import/export specifier(s).`,
|
|
)
|
|
}
|
|
|
|
const isMain =
|
|
typeof process !== 'undefined' &&
|
|
process.argv[1] &&
|
|
path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)
|
|
|
|
if (isMain) {
|
|
main()
|
|
}
|