aihand 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -2
- package/dist/chunk-2NTK7H4W.js +10 -0
- package/dist/chunk-3X4FTHLC.cjs +369 -0
- package/dist/chunk-BXVNR4E2.js +399 -0
- package/dist/chunk-C7DGE6MY.cjs +1456 -0
- package/dist/chunk-DUUCVLC3.cjs +254 -0
- package/dist/chunk-FAHI53KO.cjs +125 -0
- package/dist/chunk-G7KVJ7NF.js +369 -0
- package/dist/chunk-GNEUSRGP.js +52 -0
- package/dist/chunk-IGNEAOLT.cjs +130 -0
- package/dist/chunk-IS5XFUDB.js +125 -0
- package/dist/chunk-JLYC76XL.js +2448 -0
- package/dist/chunk-KQOABC2O.cjs +52 -0
- package/dist/chunk-OVMK33AC.cjs +104 -0
- package/dist/chunk-OWYK2IGV.js +250 -0
- package/dist/chunk-PQSQN4CN.js +126 -0
- package/dist/chunk-QF6AG3M5.cjs +410 -0
- package/dist/chunk-QSAMLXML.js +1456 -0
- package/dist/chunk-VEKYRKPF.cjs +399 -0
- package/dist/chunk-Y6H7W7PI.cjs +2451 -0
- package/dist/chunk-YKSYW77R.js +410 -0
- package/dist/chunk-Z2Y65YOY.cjs +7 -0
- package/dist/chunk-ZJQRNIK7.js +104 -0
- package/dist/cli-FDS2C2CZ.cjs +651 -0
- package/dist/cli-HHRGYPSM.js +649 -0
- package/dist/cli-JQEIE7RQ.js +120 -0
- package/dist/cli-K3OS2QQH.cjs +122 -0
- package/dist/cli-OSYG6LJD.cjs +89 -0
- package/dist/cli-TXRW5PG6.js +89 -0
- package/dist/cli.cjs +81 -0
- package/dist/cli.js +81 -0
- package/dist/config-5KEQLN6L.cjs +13 -0
- package/dist/config-PJPYKDLQ.js +13 -0
- package/dist/graph-IH56SCPK.js +8 -0
- package/dist/graph-ZUXXCJ5A.cjs +8 -0
- package/dist/index.cjs +481 -0
- package/dist/index.d.cts +461 -0
- package/dist/index.d.ts +461 -0
- package/dist/index.js +479 -0
- package/dist/locate-5XFSXJ5J.cjs +15 -0
- package/dist/locate-NKSUGL3A.js +15 -0
- package/dist/refactor-5FWSZIBN.cjs +19 -0
- package/dist/refactor-BOB3SZSA.js +19 -0
- package/dist/scan-4R7GQG2W.cjs +9 -0
- package/dist/scan-VF54GAAX.js +9 -0
- package/dist/ui/probe/server.cjs +505 -0
- package/dist/ui/probe/server.js +507 -0
- package/dist/vite.cjs +12 -0
- package/dist/vite.d.cts +12 -0
- package/dist/vite.d.ts +12 -0
- package/dist/vite.js +12 -0
- package/package.json +82 -9
- package/src/cli.ts +107 -0
- package/src/index.ts +54 -0
- package/src/read/cli.ts +650 -0
- package/src/read/compact.ts +286 -0
- package/src/read/config.ts +62 -0
- package/src/read/graph.ts +182 -0
- package/src/read/index.ts +12 -0
- package/src/read/inject.ts +121 -0
- package/src/read/locate.ts +104 -0
- package/src/read/panel.ts +335 -0
- package/src/read/pipeline.ts +78 -0
- package/src/read/refactor.ts +576 -0
- package/src/read/render.ts +1118 -0
- package/src/read/scan.ts +61 -0
- package/src/read/seam.ts +0 -0
- package/src/read/security.ts +171 -0
- package/src/read/signals.ts +333 -0
- package/src/read/state.ts +71 -0
- package/src/read/stategraph.ts +205 -0
- package/src/read/types.ts +162 -0
- package/src/read/vite.ts +77 -0
- package/src/ui/babel/line-profiler.ts +197 -0
- package/src/ui/babel/source-loc.ts +68 -0
- package/src/ui/bridge/cdp-bridge.ts +138 -0
- package/src/ui/bridge/compile-probe.ts +80 -0
- package/src/ui/bridge/transport.ts +26 -0
- package/src/ui/bridge/vite-bridge.ts +116 -0
- package/src/ui/client/client-patch.ts +899 -0
- package/src/ui/client/client.ts +2562 -0
- package/src/ui/core/action.ts +747 -0
- package/src/ui/core/candidates.ts +348 -0
- package/src/ui/core/canvas.ts +305 -0
- package/src/ui/core/check.ts +34 -0
- package/src/ui/core/compact.ts +314 -0
- package/src/ui/core/detail.ts +244 -0
- package/src/ui/core/diff.ts +253 -0
- package/src/ui/core/emit.ts +198 -0
- package/src/ui/core/knob-exec.ts +137 -0
- package/src/ui/core/perf.ts +254 -0
- package/src/ui/core/types.ts +164 -0
- package/src/ui/core/util.ts +221 -0
- package/src/ui/index.ts +5 -0
- package/src/ui/probe/cli.ts +139 -0
- package/src/ui/probe/server.ts +468 -0
- package/src/ui/self/act.ts +47 -0
- package/src/ui/self/discover.ts +101 -0
- package/src/ui/self/grow.ts +121 -0
- package/src/ui/self/install.ts +100 -0
- package/src/ui/self/probe.ts +105 -0
- package/src/ui/self/screen-hook.ts +44 -0
- package/src/ui/self/self.ts +48 -0
- package/src/ui/self/store-refs.ts +123 -0
- package/src/ui/self/store-schema.ts +65 -0
- package/src/ui/self/synth.ts +37 -0
- package/src/ui/server/cli.ts +102 -0
- package/src/ui/server/dispatch.ts +276 -0
- package/src/ui/server/help-text.ts +237 -0
- package/src/ui/server/knob-schema.ts +87 -0
- package/src/ui/server/plugin.ts +1151 -0
- package/src/vite.ts +39 -0
- package/index.js +0 -2
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import type { ExportDeclaration, ImportDeclaration, ImportDeclarationStructure, ImportSpecifierStructure, Node, Project, SourceFile, VariableStatement } from 'ts-morph'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { relative, resolve } from 'node:path'
|
|
4
|
+
import process from 'node:process'
|
|
5
|
+
import { Node as TsNode, SyntaxKind } from 'ts-morph'
|
|
6
|
+
|
|
7
|
+
// AST-level refactors (ts-morph). Counterpart to the read-only tree-sitter
|
|
8
|
+
// analysis: moving a file rewrites every importer's specifier through the type
|
|
9
|
+
// checker, so ./x and ../x all resolve — never regex.
|
|
10
|
+
//
|
|
11
|
+
// ts-morph's move() rewrites RELATIVE specifiers but leaves path-alias ones
|
|
12
|
+
// (`@/...`) pointing at the old location — it can't re-derive an alias path.
|
|
13
|
+
// We patch those ourselves from compilerOptions.paths, else move() would
|
|
14
|
+
// silently break every alias importer (the exact regex-style 误伤 AST is meant to prevent).
|
|
15
|
+
|
|
16
|
+
export interface AliasPrefix { alias: string, target: string }
|
|
17
|
+
|
|
18
|
+
async function openProject(): Promise<Project> {
|
|
19
|
+
const { Project, QuoteKind } = await import('ts-morph')
|
|
20
|
+
// The tsconfig that carries compilerOptions.paths + a real `include`. A solution-style
|
|
21
|
+
// root tsconfig (files:[] + references) has no source graph, so prefer the app one.
|
|
22
|
+
const tsConfigFilePath = ['tsconfig.app.json', 'tsconfig.json'].map(f => resolve(f)).find(existsSync)
|
|
23
|
+
if (!tsConfigFilePath) {
|
|
24
|
+
console.error('refactor: no tsconfig.json (or tsconfig.app.json) found in cwd')
|
|
25
|
+
process.exit(1)
|
|
26
|
+
}
|
|
27
|
+
// Single quotes match this repo's style; without this, generated imports come out double-quoted.
|
|
28
|
+
return new Project({ tsConfigFilePath, manipulationSettings: { quoteKind: QuoteKind.Single } })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Alias prefixes from compilerOptions.paths, e.g. {"@/*":["./src/*"]} → [{ alias: '@', target: '<abs>/src' }]
|
|
32
|
+
// Both sides strip the trailing '/*'; the joining '/' is added back in toAlias so neither side double-slashes.
|
|
33
|
+
function aliasPrefixes(project: Project): AliasPrefix[] {
|
|
34
|
+
const opts = project.getCompilerOptions()
|
|
35
|
+
const base = opts.baseUrl ?? process.cwd()
|
|
36
|
+
const out: AliasPrefix[] = []
|
|
37
|
+
for (const [pattern, targets] of Object.entries(opts.paths ?? {})) {
|
|
38
|
+
if (!pattern.endsWith('/*') || !targets[0]?.endsWith('/*'))
|
|
39
|
+
continue
|
|
40
|
+
out.push({ alias: pattern.slice(0, -2), target: resolve(base, targets[0].slice(0, -2)) })
|
|
41
|
+
}
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Strip a TS/JS extension if present — specifiers may carry an explicit `.ts` or none, so any path
|
|
46
|
+
// comparison must normalise both sides through this. (`@/x` and `@/x.ts` resolve to the same module.)
|
|
47
|
+
function stripExt(p: string): string {
|
|
48
|
+
const ext = ['.tsx', '.ts', '.jsx', '.js'].find(e => p.endsWith(e))
|
|
49
|
+
return ext ? p.slice(0, -ext.length) : p
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Build the extensionless alias specifier for an absolute path (matches ts-morph's relative-rewrite style).
|
|
53
|
+
export function toAlias(absPath: string, prefixes: AliasPrefix[]): string | null {
|
|
54
|
+
const noExt = stripExt(absPath)
|
|
55
|
+
for (const { alias, target } of prefixes) {
|
|
56
|
+
if (noExt === target || noExt.startsWith(`${target}/`))
|
|
57
|
+
return `${alias}/${noExt.slice(target.length + 1)}`
|
|
58
|
+
}
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Reverse of toAlias: resolve an alias-form specifier (`@/a`) to the extensionless absolute path it
|
|
63
|
+
// points at (`<base>/src/a`), or null if it's not an alias specifier. Used to match dynamic-import
|
|
64
|
+
// specifiers against the file being moved — getModuleSpecifierSourceFile() only covers import/export
|
|
65
|
+
// declarations, never `import('...')` calls, so those must be resolved by hand.
|
|
66
|
+
function aliasSpecToAbs(spec: string, prefixes: AliasPrefix[]): string | null {
|
|
67
|
+
for (const { alias, target } of prefixes) {
|
|
68
|
+
if (spec === alias)
|
|
69
|
+
return target
|
|
70
|
+
if (spec.startsWith(`${alias}/`))
|
|
71
|
+
return `${target}/${spec.slice(alias.length + 1)}`
|
|
72
|
+
}
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Pure core: move a file inside an already-open project, patching alias importers.
|
|
77
|
+
// Returns the changed source files (unsaved). No IO, no exit — testable in isolation.
|
|
78
|
+
// Throws if src isn't in the project (the caller decides how to surface it).
|
|
79
|
+
export function moveInProject(project: Project, srcAbs: string, destAbs: string): SourceFile[] {
|
|
80
|
+
const sf = project.getSourceFile(srcAbs)
|
|
81
|
+
if (!sf)
|
|
82
|
+
throw new Error(`${srcAbs} not in project`)
|
|
83
|
+
// Refuse to clobber an existing file. ts-morph's move() would throw a raw "provide the overwrite
|
|
84
|
+
// option" error; surface a clear one instead (overwriting would silently destroy the dest's content).
|
|
85
|
+
if (srcAbs !== destAbs && project.getSourceFile(destAbs))
|
|
86
|
+
throw new Error(`destination ${destAbs} already exists; move to a fresh path or delete it first`)
|
|
87
|
+
|
|
88
|
+
const prefixes = aliasPrefixes(project)
|
|
89
|
+
const srcNoExt = stripExt(srcAbs)
|
|
90
|
+
// An alias-form specifier that resolves to the file we're moving. The aliasSpecToAbs match (vs.
|
|
91
|
+
// getModuleSpecifierSourceFile()) excludes npm scoped packages (`@scope/pkg` → node_modules, not sf)
|
|
92
|
+
// AND works for forms ts-morph doesn't resolve: dynamic imports and side-effect imports.
|
|
93
|
+
const pointsAtSrc = (spec: string) => {
|
|
94
|
+
const resolved = aliasSpecToAbs(spec, prefixes)
|
|
95
|
+
return resolved !== null && stripExt(resolved) === srcNoExt
|
|
96
|
+
}
|
|
97
|
+
// ts-morph's move() rewrites RELATIVE specifiers (any kind) but leaves alias ones stale — and never
|
|
98
|
+
// touches alias dynamic imports / side-effect imports at all. We capture every alias-form reference
|
|
99
|
+
// BEFORE move() (which invalidates resolution), then re-point each from the NEW location. Crucially
|
|
100
|
+
// the new specifier is computed per-importer via specifierFrom: alias if dest is still under an alias
|
|
101
|
+
// root, else a relative path — so moving OUT of the alias root degrades importers to `../x`, not stale.
|
|
102
|
+
const declFixups: { decl: ImportDeclaration | ExportDeclaration, file: SourceFile }[] = []
|
|
103
|
+
const litFixups: { lit: Node & { setLiteralValue: (v: string) => void }, file: SourceFile }[] = []
|
|
104
|
+
// Every importer (relative or alias) is a changed file — collect them all, not via isSaved()
|
|
105
|
+
// (which conflates "never saved" with "modified" and breaks on in-memory / freshly-created files).
|
|
106
|
+
const changed = new Set<SourceFile>()
|
|
107
|
+
// getReferencingSourceFiles() covers named/default/namespace imports + re-exports, but MISSES pure
|
|
108
|
+
// side-effect alias imports (`import '@/a'`) — those resolve to undefined and aren't tracked as refs.
|
|
109
|
+
// So scan every source file's imports/exports directly by spec, not just the referencing set.
|
|
110
|
+
for (const ref of project.getSourceFiles()) {
|
|
111
|
+
if (ref === sf)
|
|
112
|
+
continue
|
|
113
|
+
for (const d of ref.getImportDeclarations()) {
|
|
114
|
+
if (pointsAtSrc(d.getModuleSpecifierValue())) {
|
|
115
|
+
changed.add(ref)
|
|
116
|
+
declFixups.push({ decl: d, file: ref })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const d of ref.getExportDeclarations()) {
|
|
120
|
+
const spec = d.getModuleSpecifierValue()
|
|
121
|
+
if (spec && pointsAtSrc(spec)) {
|
|
122
|
+
changed.add(ref)
|
|
123
|
+
declFixups.push({ decl: d, file: ref })
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const call of ref.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
127
|
+
if (call.getExpression().getKind() !== SyntaxKind.ImportKeyword)
|
|
128
|
+
continue
|
|
129
|
+
const arg = call.getArguments()[0]
|
|
130
|
+
if (arg && TsNode.isStringLiteral(arg) && pointsAtSrc(arg.getLiteralValue())) {
|
|
131
|
+
changed.add(ref)
|
|
132
|
+
litFixups.push({ lit: arg, file: ref })
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Relative importers are rewritten natively by move() but still count as changed files. They surface
|
|
137
|
+
// through getReferencingSourceFiles() (alias-only side-effect imports don't, hence the spec scan above).
|
|
138
|
+
for (const ref of sf.getReferencingSourceFiles())
|
|
139
|
+
changed.add(ref)
|
|
140
|
+
|
|
141
|
+
sf.move(destAbs) // rewrites relative-specifier importers (incl. relative dynamic imports)
|
|
142
|
+
changed.add(sf) // sf is now the moved file (same node, new path)
|
|
143
|
+
|
|
144
|
+
// Re-point each captured alias reference from the new location. specifierFrom yields the alias form
|
|
145
|
+
// when dest sits under an alias root, else the relative path — one rule covers in-root and out-of-root.
|
|
146
|
+
for (const { decl, file } of declFixups)
|
|
147
|
+
decl.setModuleSpecifier(specifierFrom(file, destAbs, prefixes))
|
|
148
|
+
for (const { lit, file } of litFixups)
|
|
149
|
+
lit.setLiteralValue(specifierFrom(file, destAbs, prefixes))
|
|
150
|
+
|
|
151
|
+
return [...changed]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Find a top-level named declaration (var/function/class/interface/type/enum) by name.
|
|
155
|
+
// Returns a renameable node, or null if absent/ambiguous-as-unsupported.
|
|
156
|
+
function findNamedDecl(sf: SourceFile, name: string) {
|
|
157
|
+
return (
|
|
158
|
+
sf.getVariableDeclaration(name)
|
|
159
|
+
?? sf.getFunction(name)
|
|
160
|
+
?? sf.getClass(name)
|
|
161
|
+
?? sf.getInterface(name)
|
|
162
|
+
?? sf.getTypeAlias(name)
|
|
163
|
+
?? sf.getEnum(name)
|
|
164
|
+
?? null
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Pure core: rename a top-level symbol across the whole project (type-checker driven, so it
|
|
169
|
+
// rewrites alias imports too and never touches same-named-but-unrelated locals). Returns changed files.
|
|
170
|
+
export function renameInProject(project: Project, fileAbs: string, oldName: string, newName: string): SourceFile[] {
|
|
171
|
+
const sf = project.getSourceFile(fileAbs)
|
|
172
|
+
if (!sf)
|
|
173
|
+
throw new Error(`${fileAbs} not in project`)
|
|
174
|
+
const decl = findNamedDecl(sf, oldName)
|
|
175
|
+
if (!decl)
|
|
176
|
+
throw new Error(`no top-level declaration named '${oldName}' in ${fileAbs}`)
|
|
177
|
+
|
|
178
|
+
// Export-alias barrier: `export { old as pub }` decouples the local name from the public name —
|
|
179
|
+
// importers reference `pub`, not `old`. TS's reference resolver treats both as one symbol, so
|
|
180
|
+
// ts-morph's rename corrupts importers (`import { pub }` → `import { fresh }`) while the public
|
|
181
|
+
// name should stay `pub`. Same stance as the barrel barrier in move-symbol: reject rather than
|
|
182
|
+
// silently break. Rename the public name directly, or rename local+export together by hand.
|
|
183
|
+
for (const exp of sf.getExportDeclarations()) {
|
|
184
|
+
for (const spec of exp.getNamedExports()) {
|
|
185
|
+
if (spec.getAliasNode() && spec.getNameNode().getText() === oldName)
|
|
186
|
+
throw new Error(`'${oldName}' is exported under the alias '${spec.getAliasNode()!.getText()}' (export { ${oldName} as ${spec.getAliasNode()!.getText()} }); renaming the local name would corrupt importers that reference the public name. Rename '${spec.getAliasNode()!.getText()}' instead, or rewrite the export by hand.`)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Capture referencing files before rename so we can report them (rename mutates in place).
|
|
191
|
+
const changed = new Set<SourceFile>([sf])
|
|
192
|
+
for (const ref of decl.findReferences()) {
|
|
193
|
+
for (const r of ref.getReferences())
|
|
194
|
+
changed.add(r.getSourceFile())
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
decl.rename(newName)
|
|
198
|
+
return [...changed]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The module specifier B should use to reach a target source file: alias form if the target sits
|
|
202
|
+
// under an alias root, else a relative path. Mirrors what move/rename produce.
|
|
203
|
+
function specifierFrom(from: SourceFile, toAbs: string, prefixes: AliasPrefix[]): string {
|
|
204
|
+
const alias = toAlias(toAbs, prefixes)
|
|
205
|
+
if (alias)
|
|
206
|
+
return alias
|
|
207
|
+
const to = from.getProject().getSourceFile(toAbs) ?? from.getProject().addSourceFileAtPath(toAbs)
|
|
208
|
+
return from.getRelativePathAsModuleSpecifierTo(to)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The local binding a named import introduces. For `delay as _delay` the binding is `_delay`
|
|
212
|
+
// (the alias), not the imported name `delay` — code references the alias, so its symbol is the
|
|
213
|
+
// one usage checks must compare against. Falls back to the name node when there's no alias.
|
|
214
|
+
function bindingNodeOf(ni: { getNameNode: () => Node, getAliasNode: () => Node | undefined }): Node {
|
|
215
|
+
return ni.getAliasNode() ?? ni.getNameNode()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Does any identifier inside `scope` resolve (by symbol identity) to the binding `bindingNode`?
|
|
219
|
+
// Symbol identity — not text — is the line that makes `arr.length` (a property access whose `length`
|
|
220
|
+
// has Array.length's symbol) NOT count as a use of an imported binding also named `length`.
|
|
221
|
+
function scopeUsesBinding(scope: Node, bindingNode: Node): boolean {
|
|
222
|
+
const target = bindingNode.getSymbol()
|
|
223
|
+
if (!target)
|
|
224
|
+
return false
|
|
225
|
+
let used = false
|
|
226
|
+
scope.forEachDescendant((node, traversal) => {
|
|
227
|
+
if (used) {
|
|
228
|
+
traversal.stop()
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
if (!TsNode.isIdentifier(node))
|
|
232
|
+
return
|
|
233
|
+
// The binding's own declaration node carries the same symbol — it's not a *use*. Skip it so
|
|
234
|
+
// whole-file scans (dead-import pruning) don't count the import line itself as usage.
|
|
235
|
+
if (node === bindingNode)
|
|
236
|
+
return
|
|
237
|
+
// Skip the property side of `a.b` and the key side of `{ b: ... }` — those names are
|
|
238
|
+
// member/property symbols, never an imported binding.
|
|
239
|
+
const parent = node.getParent()
|
|
240
|
+
if (TsNode.isPropertyAccessExpression(parent) && parent.getNameNode() === node)
|
|
241
|
+
return
|
|
242
|
+
if (TsNode.isPropertyAssignment(parent) && parent.getNameNode() === node)
|
|
243
|
+
return
|
|
244
|
+
if (node.getSymbol() === target)
|
|
245
|
+
used = true
|
|
246
|
+
})
|
|
247
|
+
return used
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Pure core: move a single exported symbol from A to B. Carries the declaration + the imports it
|
|
251
|
+
// depends on, rewrites every external importer (A→B), and back-imports into A if A still uses it.
|
|
252
|
+
// Throws if the symbol is missing, or if B already exports something with the same name.
|
|
253
|
+
export function moveSymbolInProject(project: Project, fromAbs: string, name: string, toAbs: string): SourceFile[] {
|
|
254
|
+
const from = project.getSourceFile(fromAbs)
|
|
255
|
+
if (!from)
|
|
256
|
+
throw new Error(`${fromAbs} not in project`)
|
|
257
|
+
const decl = findNamedDecl(from, name)
|
|
258
|
+
if (!decl)
|
|
259
|
+
throw new Error(`no top-level declaration named '${name}' in ${fromAbs}`)
|
|
260
|
+
// Moving a symbol to its own file is a no-op that ts-morph mishandles (removes then re-reads a
|
|
261
|
+
// forgotten node). Refuse with a clear message rather than leak the internal error.
|
|
262
|
+
if (fromAbs === toAbs)
|
|
263
|
+
throw new Error(`source and destination are the same file (${fromAbs}); nothing to move`)
|
|
264
|
+
|
|
265
|
+
const to = project.getSourceFile(toAbs) ?? project.createSourceFile(toAbs, '')
|
|
266
|
+
if (to !== from && findNamedDecl(to, name))
|
|
267
|
+
throw new Error(`${toAbs} already declares '${name}'`)
|
|
268
|
+
|
|
269
|
+
// ── Safety gates: refuse forms we'd otherwise silently half-move into broken code. ──
|
|
270
|
+
const guardScope = (decl.getKindName() === 'VariableDeclaration'
|
|
271
|
+
? (decl as { getVariableStatementOrThrow: () => Node }).getVariableStatementOrThrow()
|
|
272
|
+
: decl) as Node
|
|
273
|
+
|
|
274
|
+
// Gap A: the declaration references a same-file, top-level symbol other than itself. If that symbol
|
|
275
|
+
// is NOT exported, moving would leave it undefined in B (it can't be imported) — refuse. If it IS
|
|
276
|
+
// exported, B can import it back from A: collect those names so step 1b adds `import { … } from A`.
|
|
277
|
+
// Without this back-import the moved declaration references a name that no longer exists in B (TS2304).
|
|
278
|
+
const siblingDeps = new Set<string>()
|
|
279
|
+
for (const id of guardScope.getDescendantsOfKind(80 /* Identifier */)) {
|
|
280
|
+
const parent = id.getParent()
|
|
281
|
+
if (TsNode.isPropertyAccessExpression(parent) && parent.getNameNode() === id)
|
|
282
|
+
continue
|
|
283
|
+
const sym = id.getSymbol()
|
|
284
|
+
if (!sym)
|
|
285
|
+
continue
|
|
286
|
+
for (const d of sym.getDeclarations()) {
|
|
287
|
+
if (d.getSourceFile() !== from || d === decl)
|
|
288
|
+
continue
|
|
289
|
+
const stmt = TsNode.isVariableDeclaration(d) ? d.getVariableStatementOrThrow() : d
|
|
290
|
+
if (stmt.getParent()?.getKindName() !== 'SourceFile')
|
|
291
|
+
continue // only top-level declarations matter
|
|
292
|
+
const exported = TsNode.isExportable(stmt) && stmt.isExported()
|
|
293
|
+
if (!exported)
|
|
294
|
+
throw new Error(`'${name}' depends on local symbol '${id.getText()}' (not exported); export it or move them together`)
|
|
295
|
+
siblingDeps.add(id.getText())
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Gap C: the symbol is re-exported by a barrel (`export { name } from A`). After the move that
|
|
300
|
+
// barrel still points at A, which no longer has it — an indirect break. Refuse for now.
|
|
301
|
+
for (const ref of from.getReferencingSourceFiles()) {
|
|
302
|
+
for (const ed of ref.getExportDeclarations()) {
|
|
303
|
+
if (ed.getModuleSpecifierSourceFile() === from && ed.getNamedExports().some(ne => ne.getName() === name))
|
|
304
|
+
throw new Error(`'${name}' is re-exported by ${ref.getFilePath()}; update the barrel first or move it manually`)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Gap D: the symbol is reached through a namespace import (`import * as A` then `A.name`). We can't
|
|
309
|
+
// rewrite `A.name` to point at B, so that access would break. Refuse for now.
|
|
310
|
+
for (const r of decl.findReferences()) {
|
|
311
|
+
for (const ref of r.getReferences()) {
|
|
312
|
+
const node = ref.getNode()
|
|
313
|
+
const parent = node.getParent()
|
|
314
|
+
if (TsNode.isPropertyAccessExpression(parent) && parent.getNameNode() === node && ref.getSourceFile() !== from)
|
|
315
|
+
throw new Error(`'${name}' is accessed via a namespace import in ${ref.getSourceFile().getFilePath()}; move it manually`)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Gap E: the symbol is the file's default export (`export default function name` or
|
|
320
|
+
// `export { name as default }` / `export default name`). Default importers (`import f from A`)
|
|
321
|
+
// bind their own local name to A's default slot — we can't know all those local names to rewrite,
|
|
322
|
+
// and the moved declaration loses its default-ness, so A no longer has a default → TS2306. Refuse.
|
|
323
|
+
if (TsNode.isExportable(guardScope) && (guardScope as { isDefaultExport: () => boolean }).isDefaultExport())
|
|
324
|
+
throw new Error(`'${name}' is the default export of ${fromAbs}; default importers bind arbitrary local names that can't be rewritten. Make it a named export first, or move it manually.`)
|
|
325
|
+
for (const ed of from.getExportDeclarations()) {
|
|
326
|
+
for (const ne of ed.getNamedExports()) {
|
|
327
|
+
if (ed.getModuleSpecifierValue()) continue // re-export from elsewhere, not our local symbol
|
|
328
|
+
if (ne.getNameNode().getText() === name && ne.getAliasNode()?.getText() === 'default')
|
|
329
|
+
throw new Error(`'${name}' is the default export of ${fromAbs} (export { ${name} as default }); default importers bind arbitrary local names that can't be rewritten. Make it a named export first, or move it manually.`)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Gap F: the symbol is exported under an alias (`export { name as pub }`). Importers reference the
|
|
334
|
+
// public name `pub`; after the move the moved declaration is a plain `const name` in B (not exported
|
|
335
|
+
// under `pub`), and A's `export { name as pub }` now points at a local `import { name } from B` —
|
|
336
|
+
// which B doesn't export → TS2459. The public-name boundary can't be preserved by a plain move. Refuse.
|
|
337
|
+
for (const ed of from.getExportDeclarations()) {
|
|
338
|
+
if (ed.getModuleSpecifierValue()) continue
|
|
339
|
+
for (const ne of ed.getNamedExports()) {
|
|
340
|
+
const alias = ne.getAliasNode()?.getText()
|
|
341
|
+
if (ne.getNameNode().getText() === name && alias && alias !== 'default')
|
|
342
|
+
throw new Error(`'${name}' is exported under the alias '${alias}' (export { ${name} as ${alias} }); the public name '${alias}' that importers reference can't survive a plain move. Rewrite the export, or move it manually.`)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const prefixes = aliasPrefixes(project)
|
|
347
|
+
|
|
348
|
+
// The declaration text to carry. For a variable, the statement is shared across declarators
|
|
349
|
+
// (`export const a = 1, b = 2`), so we must NOT carry/remove the whole statement — only the
|
|
350
|
+
// target declarator, reconstructing its own `export const X = ...` so siblings stay untouched.
|
|
351
|
+
const varStmt: VariableStatement | null = decl.getKindName() === 'VariableDeclaration'
|
|
352
|
+
? (decl as { getVariableStatementOrThrow: () => VariableStatement }).getVariableStatementOrThrow()
|
|
353
|
+
: null
|
|
354
|
+
const sharedDeclarators = varStmt ? varStmt.getDeclarations().length > 1 : false
|
|
355
|
+
// decl.getText() for a VariableDeclaration is just `X = ...` (no keyword) — rebuild `export const`.
|
|
356
|
+
// For a multi-declarator statement we rebuild just the target declarator (siblings stay in A), so
|
|
357
|
+
// we can't carry the shared statement's JSDoc unambiguously. Otherwise getText(true) keeps the
|
|
358
|
+
// leading JSDoc with the moved declaration.
|
|
359
|
+
const declText = varStmt
|
|
360
|
+
? sharedDeclarators
|
|
361
|
+
? `${varStmt.isExported() ? 'export ' : ''}${varStmt.getDeclarationKind()} ${decl.getText()}`
|
|
362
|
+
: varStmt.getText(true)
|
|
363
|
+
: (decl as { getText: (includeJsDoc?: boolean) => string }).getText(true)
|
|
364
|
+
|
|
365
|
+
// Which of A's imports does the declaration actually depend on? Decide per binding by symbol
|
|
366
|
+
// identity over the declaration's scope — this excludes property-name collisions, and naturally
|
|
367
|
+
// covers named / default / namespace / type-only bindings alike.
|
|
368
|
+
const depScope = (varStmt ?? decl) as Node
|
|
369
|
+
const carried: ImportDeclarationStructure[] = []
|
|
370
|
+
for (const imp of from.getImportDeclarations()) {
|
|
371
|
+
const struct = imp.getStructure()
|
|
372
|
+
const keptNamed = imp.getNamedImports().filter(n => scopeUsesBinding(depScope, bindingNodeOf(n)))
|
|
373
|
+
const dflt = imp.getDefaultImport()
|
|
374
|
+
const ns = imp.getNamespaceImport()
|
|
375
|
+
const keepDflt = dflt ? scopeUsesBinding(depScope, dflt) : false
|
|
376
|
+
const keepNs = ns ? scopeUsesBinding(depScope, ns) : false
|
|
377
|
+
if (!keptNamed.length && !keepDflt && !keepNs)
|
|
378
|
+
continue
|
|
379
|
+
// Relative specifiers must be re-pathed from B; bare/alias carry as-is.
|
|
380
|
+
let spec = struct.moduleSpecifier
|
|
381
|
+
if (spec.startsWith('.')) {
|
|
382
|
+
const target = imp.getModuleSpecifierSourceFile()
|
|
383
|
+
if (target)
|
|
384
|
+
spec = specifierFrom(to, target.getFilePath(), prefixes)
|
|
385
|
+
}
|
|
386
|
+
carried.push({
|
|
387
|
+
...struct,
|
|
388
|
+
moduleSpecifier: spec,
|
|
389
|
+
defaultImport: keepDflt ? struct.defaultImport : undefined,
|
|
390
|
+
namespaceImport: keepNs ? struct.namespaceImport : undefined,
|
|
391
|
+
namedImports: keptNamed.map(n => n.getStructure()),
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// External importers of the symbol (before we mutate) → their files need the A→B rewrite.
|
|
396
|
+
// CRITICAL: capture the matching import declarations + the moved named-import's structure NOW.
|
|
397
|
+
// Once the declaration leaves A, A may export nothing and `from`'s importers stop resolving to it
|
|
398
|
+
// — so a post-removal `getModuleSpecifierSourceFile() === from` check would miss them all.
|
|
399
|
+
const refFiles = new Set<SourceFile>()
|
|
400
|
+
const rewrites: { rf: SourceFile, imp: ImportDeclaration, moved: ImportSpecifierStructure, typeOnly: boolean }[] = []
|
|
401
|
+
for (const ref of decl.findReferences()) {
|
|
402
|
+
for (const r of ref.getReferences()) {
|
|
403
|
+
const rf = r.getSourceFile()
|
|
404
|
+
if (rf !== from)
|
|
405
|
+
refFiles.add(rf)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
for (const rf of refFiles) {
|
|
409
|
+
for (const imp of rf.getImportDeclarations()) {
|
|
410
|
+
if (imp.getModuleSpecifierSourceFile() !== from)
|
|
411
|
+
continue
|
|
412
|
+
const ni = imp.getNamedImports().find(n => n.getName() === name)
|
|
413
|
+
// `import type { F }` carries type-only at the DECLARATION level, not the specifier — capture both.
|
|
414
|
+
if (ni)
|
|
415
|
+
rewrites.push({ rf, imp, moved: ni.getStructure(), typeOnly: imp.isTypeOnly() })
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const aStillUses = decl.findReferences().some(ref =>
|
|
419
|
+
ref.getReferences().some(r => r.getSourceFile() === from && r.getNode() !== (decl as { getNameNode?: () => Node }).getNameNode?.()))
|
|
420
|
+
|
|
421
|
+
// 1. Write B: the declaration first, then its carried imports prepended in structured form
|
|
422
|
+
// (preserves type-only / namespace / default exactly — no string concatenation).
|
|
423
|
+
// If B already has content not ending in a newline, prepend one so the declaration doesn't
|
|
424
|
+
// glue onto the previous statement (`Y = 1export const F...` is a syntax error).
|
|
425
|
+
const existing = to.getFullText()
|
|
426
|
+
const lead = existing.length && !existing.endsWith('\n') ? '\n' : ''
|
|
427
|
+
to.insertText(existing.length, `${lead}${declText}\n`)
|
|
428
|
+
for (const c of carried) {
|
|
429
|
+
// Merge a carried dependency into B's existing same-module import rather than duplicating the line.
|
|
430
|
+
const existingImp = to.getImportDeclarations().find(d => d.getModuleSpecifierValue() === c.moduleSpecifier)
|
|
431
|
+
if (existingImp && !c.defaultImport && !c.namespaceImport && Array.isArray(c.namedImports)) {
|
|
432
|
+
for (const ni of c.namedImports)
|
|
433
|
+
existingImp.addNamedImport(ni)
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
to.insertImportDeclaration(0, c)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// 1b. Back-import the exported same-file siblings the moved declaration depends on (Gap A's allowed
|
|
441
|
+
// case): `F` stays referencing `helper`, but `helper` lives in A — B must import it from A or
|
|
442
|
+
// it's an undefined name (TS2304). Skipped when moving within the same file.
|
|
443
|
+
if (to !== from && siblingDeps.size) {
|
|
444
|
+
const aSpec = specifierFrom(to, fromAbs, prefixes)
|
|
445
|
+
const existingA = to.getImportDeclarations().find(d => d.getModuleSpecifierValue() === aSpec && !d.isTypeOnly())
|
|
446
|
+
if (existingA) {
|
|
447
|
+
const have = new Set(existingA.getNamedImports().map(n => n.getName()))
|
|
448
|
+
for (const s of siblingDeps)
|
|
449
|
+
if (!have.has(s))
|
|
450
|
+
existingA.addNamedImport(s)
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
to.insertImportDeclaration(0, { moduleSpecifier: aSpec, namedImports: [...siblingDeps] })
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// 2. Remove the declaration from A. For a multi-declarator statement, remove only this declarator
|
|
458
|
+
// so the siblings survive; otherwise remove the whole statement/declaration.
|
|
459
|
+
if (sharedDeclarators)
|
|
460
|
+
(decl as { remove: () => void }).remove()
|
|
461
|
+
else
|
|
462
|
+
(varStmt ?? decl).remove()
|
|
463
|
+
|
|
464
|
+
// 2b. Prune imports A no longer uses. The moved symbol may have been the sole user of a dependency
|
|
465
|
+
// import; left behind it's a dead import (TS6133). Re-check whole-file usage AFTER removal, so
|
|
466
|
+
// a binding still used by other code in A correctly stays (it was carried to B in parallel).
|
|
467
|
+
for (const imp of from.getImportDeclarations()) {
|
|
468
|
+
for (const ni of imp.getNamedImports()) {
|
|
469
|
+
if (!scopeUsesBinding(from, bindingNodeOf(ni)))
|
|
470
|
+
ni.remove()
|
|
471
|
+
}
|
|
472
|
+
const dflt = imp.getDefaultImport()
|
|
473
|
+
if (dflt && !scopeUsesBinding(from, dflt))
|
|
474
|
+
imp.removeDefaultImport()
|
|
475
|
+
const ns = imp.getNamespaceImport()
|
|
476
|
+
if (ns && !scopeUsesBinding(from, ns))
|
|
477
|
+
imp.removeNamespaceImport()
|
|
478
|
+
if (!imp.getNamedImports().length && !imp.getDefaultImport() && !imp.getNamespaceImport())
|
|
479
|
+
imp.remove()
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// 3. Rewrite external importers from the handles captured before mutation: drop the name from the
|
|
483
|
+
// A-import (preserving its alias / type-only structure via the saved structure), then re-add it
|
|
484
|
+
// under B's specifier — merging into an existing B-import rather than duplicating a same-module line.
|
|
485
|
+
const toSpecFor = (f: SourceFile) => specifierFrom(f, toAbs, prefixes)
|
|
486
|
+
for (const { rf, imp, moved, typeOnly } of rewrites) {
|
|
487
|
+
const bSpec = toSpecFor(rf)
|
|
488
|
+
const ni = imp.getNamedImports().find(n => n.getName() === name)
|
|
489
|
+
if (ni) {
|
|
490
|
+
ni.remove()
|
|
491
|
+
if (imp.getNamedImports().length === 0 && !imp.getDefaultImport() && !imp.getNamespaceImport())
|
|
492
|
+
imp.remove()
|
|
493
|
+
}
|
|
494
|
+
// Merge only into a B-import of matching type-only-ness (a `type` import can't host a value one).
|
|
495
|
+
const existingB = rf.getImportDeclarations().find(d => d.getModuleSpecifierValue() === bSpec && d.isTypeOnly() === typeOnly)
|
|
496
|
+
if (existingB)
|
|
497
|
+
existingB.addNamedImport(moved)
|
|
498
|
+
else
|
|
499
|
+
rf.addImportDeclaration({ moduleSpecifier: bSpec, namedImports: [moved], isTypeOnly: typeOnly })
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// 4. If A still uses the symbol, back-import it from B.
|
|
503
|
+
if (aStillUses)
|
|
504
|
+
from.addImportDeclaration({ moduleSpecifier: specifierFrom(from, toAbs, prefixes), namedImports: [name] })
|
|
505
|
+
|
|
506
|
+
return [...new Set<SourceFile>([from, to, ...refFiles])]
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export async function moveFile(src: string, dest: string, dry = false): Promise<void> {
|
|
510
|
+
const project = await openProject()
|
|
511
|
+
const root = process.cwd()
|
|
512
|
+
let changed: SourceFile[]
|
|
513
|
+
try {
|
|
514
|
+
changed = moveInProject(project, resolve(src), resolve(dest))
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
console.error(`refactor: ${src} not in tsconfig project (check include globs)`)
|
|
518
|
+
process.exit(1)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
console.log(`move ${relative(root, src)} → ${relative(root, dest)}`)
|
|
522
|
+
console.log(`${changed.length} file(s) ${dry ? 'would change' : 'changed'}:`)
|
|
523
|
+
for (const f of changed)
|
|
524
|
+
console.log(` ${relative(root, f.getFilePath())}`)
|
|
525
|
+
|
|
526
|
+
if (dry)
|
|
527
|
+
return
|
|
528
|
+
await project.save()
|
|
529
|
+
console.log('saved. verify: tsc --noEmit')
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export async function moveSymbol(from: string, name: string, to: string, dry = false): Promise<void> {
|
|
533
|
+
const project = await openProject()
|
|
534
|
+
const root = process.cwd()
|
|
535
|
+
let changed: SourceFile[]
|
|
536
|
+
try {
|
|
537
|
+
changed = moveSymbolInProject(project, resolve(from), name, resolve(to))
|
|
538
|
+
}
|
|
539
|
+
catch (e) {
|
|
540
|
+
console.error(`refactor: ${(e as Error).message}`)
|
|
541
|
+
process.exit(1)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
console.log(`move-symbol ${name}: ${relative(root, from)} → ${relative(root, to)}`)
|
|
545
|
+
console.log(`${changed.length} file(s) ${dry ? 'would change' : 'changed'}:`)
|
|
546
|
+
for (const f of changed)
|
|
547
|
+
console.log(` ${relative(root, f.getFilePath())}`)
|
|
548
|
+
|
|
549
|
+
if (dry)
|
|
550
|
+
return
|
|
551
|
+
await project.save()
|
|
552
|
+
console.log('saved. verify: tsc --noEmit')
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export async function renameSymbol(file: string, oldName: string, newName: string, dry = false): Promise<void> {
|
|
556
|
+
const project = await openProject()
|
|
557
|
+
const root = process.cwd()
|
|
558
|
+
let changed: SourceFile[]
|
|
559
|
+
try {
|
|
560
|
+
changed = renameInProject(project, resolve(file), oldName, newName)
|
|
561
|
+
}
|
|
562
|
+
catch (e) {
|
|
563
|
+
console.error(`refactor: ${(e as Error).message}`)
|
|
564
|
+
process.exit(1)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
console.log(`rename ${oldName} → ${newName} (in ${relative(root, file)})`)
|
|
568
|
+
console.log(`${changed.length} file(s) ${dry ? 'would change' : 'changed'}:`)
|
|
569
|
+
for (const f of changed)
|
|
570
|
+
console.log(` ${relative(root, f.getFilePath())}`)
|
|
571
|
+
|
|
572
|
+
if (dry)
|
|
573
|
+
return
|
|
574
|
+
await project.save()
|
|
575
|
+
console.log('saved. verify: tsc --noEmit')
|
|
576
|
+
}
|