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.
Files changed (113) hide show
  1. package/README.md +136 -2
  2. package/dist/chunk-2NTK7H4W.js +10 -0
  3. package/dist/chunk-3X4FTHLC.cjs +369 -0
  4. package/dist/chunk-BXVNR4E2.js +399 -0
  5. package/dist/chunk-C7DGE6MY.cjs +1456 -0
  6. package/dist/chunk-DUUCVLC3.cjs +254 -0
  7. package/dist/chunk-FAHI53KO.cjs +125 -0
  8. package/dist/chunk-G7KVJ7NF.js +369 -0
  9. package/dist/chunk-GNEUSRGP.js +52 -0
  10. package/dist/chunk-IGNEAOLT.cjs +130 -0
  11. package/dist/chunk-IS5XFUDB.js +125 -0
  12. package/dist/chunk-JLYC76XL.js +2448 -0
  13. package/dist/chunk-KQOABC2O.cjs +52 -0
  14. package/dist/chunk-OVMK33AC.cjs +104 -0
  15. package/dist/chunk-OWYK2IGV.js +250 -0
  16. package/dist/chunk-PQSQN4CN.js +126 -0
  17. package/dist/chunk-QF6AG3M5.cjs +410 -0
  18. package/dist/chunk-QSAMLXML.js +1456 -0
  19. package/dist/chunk-VEKYRKPF.cjs +399 -0
  20. package/dist/chunk-Y6H7W7PI.cjs +2451 -0
  21. package/dist/chunk-YKSYW77R.js +410 -0
  22. package/dist/chunk-Z2Y65YOY.cjs +7 -0
  23. package/dist/chunk-ZJQRNIK7.js +104 -0
  24. package/dist/cli-FDS2C2CZ.cjs +651 -0
  25. package/dist/cli-HHRGYPSM.js +649 -0
  26. package/dist/cli-JQEIE7RQ.js +120 -0
  27. package/dist/cli-K3OS2QQH.cjs +122 -0
  28. package/dist/cli-OSYG6LJD.cjs +89 -0
  29. package/dist/cli-TXRW5PG6.js +89 -0
  30. package/dist/cli.cjs +81 -0
  31. package/dist/cli.js +81 -0
  32. package/dist/config-5KEQLN6L.cjs +13 -0
  33. package/dist/config-PJPYKDLQ.js +13 -0
  34. package/dist/graph-IH56SCPK.js +8 -0
  35. package/dist/graph-ZUXXCJ5A.cjs +8 -0
  36. package/dist/index.cjs +481 -0
  37. package/dist/index.d.cts +461 -0
  38. package/dist/index.d.ts +461 -0
  39. package/dist/index.js +479 -0
  40. package/dist/locate-5XFSXJ5J.cjs +15 -0
  41. package/dist/locate-NKSUGL3A.js +15 -0
  42. package/dist/refactor-5FWSZIBN.cjs +19 -0
  43. package/dist/refactor-BOB3SZSA.js +19 -0
  44. package/dist/scan-4R7GQG2W.cjs +9 -0
  45. package/dist/scan-VF54GAAX.js +9 -0
  46. package/dist/ui/probe/server.cjs +505 -0
  47. package/dist/ui/probe/server.js +507 -0
  48. package/dist/vite.cjs +12 -0
  49. package/dist/vite.d.cts +12 -0
  50. package/dist/vite.d.ts +12 -0
  51. package/dist/vite.js +12 -0
  52. package/package.json +82 -9
  53. package/src/cli.ts +107 -0
  54. package/src/index.ts +54 -0
  55. package/src/read/cli.ts +650 -0
  56. package/src/read/compact.ts +286 -0
  57. package/src/read/config.ts +62 -0
  58. package/src/read/graph.ts +182 -0
  59. package/src/read/index.ts +12 -0
  60. package/src/read/inject.ts +121 -0
  61. package/src/read/locate.ts +104 -0
  62. package/src/read/panel.ts +335 -0
  63. package/src/read/pipeline.ts +78 -0
  64. package/src/read/refactor.ts +576 -0
  65. package/src/read/render.ts +1118 -0
  66. package/src/read/scan.ts +61 -0
  67. package/src/read/seam.ts +0 -0
  68. package/src/read/security.ts +171 -0
  69. package/src/read/signals.ts +333 -0
  70. package/src/read/state.ts +71 -0
  71. package/src/read/stategraph.ts +205 -0
  72. package/src/read/types.ts +162 -0
  73. package/src/read/vite.ts +77 -0
  74. package/src/ui/babel/line-profiler.ts +197 -0
  75. package/src/ui/babel/source-loc.ts +68 -0
  76. package/src/ui/bridge/cdp-bridge.ts +138 -0
  77. package/src/ui/bridge/compile-probe.ts +80 -0
  78. package/src/ui/bridge/transport.ts +26 -0
  79. package/src/ui/bridge/vite-bridge.ts +116 -0
  80. package/src/ui/client/client-patch.ts +899 -0
  81. package/src/ui/client/client.ts +2562 -0
  82. package/src/ui/core/action.ts +747 -0
  83. package/src/ui/core/candidates.ts +348 -0
  84. package/src/ui/core/canvas.ts +305 -0
  85. package/src/ui/core/check.ts +34 -0
  86. package/src/ui/core/compact.ts +314 -0
  87. package/src/ui/core/detail.ts +244 -0
  88. package/src/ui/core/diff.ts +253 -0
  89. package/src/ui/core/emit.ts +198 -0
  90. package/src/ui/core/knob-exec.ts +137 -0
  91. package/src/ui/core/perf.ts +254 -0
  92. package/src/ui/core/types.ts +164 -0
  93. package/src/ui/core/util.ts +221 -0
  94. package/src/ui/index.ts +5 -0
  95. package/src/ui/probe/cli.ts +139 -0
  96. package/src/ui/probe/server.ts +468 -0
  97. package/src/ui/self/act.ts +47 -0
  98. package/src/ui/self/discover.ts +101 -0
  99. package/src/ui/self/grow.ts +121 -0
  100. package/src/ui/self/install.ts +100 -0
  101. package/src/ui/self/probe.ts +105 -0
  102. package/src/ui/self/screen-hook.ts +44 -0
  103. package/src/ui/self/self.ts +48 -0
  104. package/src/ui/self/store-refs.ts +123 -0
  105. package/src/ui/self/store-schema.ts +65 -0
  106. package/src/ui/self/synth.ts +37 -0
  107. package/src/ui/server/cli.ts +102 -0
  108. package/src/ui/server/dispatch.ts +276 -0
  109. package/src/ui/server/help-text.ts +237 -0
  110. package/src/ui/server/knob-schema.ts +87 -0
  111. package/src/ui/server/plugin.ts +1151 -0
  112. package/src/vite.ts +39 -0
  113. 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
+ }