diablo2-web/tools/refactor/move.ts

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()
}