vue3-migration 1.0.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 (56) hide show
  1. package/bin/cli.js +41 -0
  2. package/package.json +24 -0
  3. package/vue3_migration/__init__.py +3 -0
  4. package/vue3_migration/__main__.py +6 -0
  5. package/vue3_migration/__pycache__/__init__.cpython-312.pyc +0 -0
  6. package/vue3_migration/__pycache__/__init__.cpython-313.pyc +0 -0
  7. package/vue3_migration/__pycache__/__main__.cpython-312.pyc +0 -0
  8. package/vue3_migration/__pycache__/cli.cpython-312.pyc +0 -0
  9. package/vue3_migration/__pycache__/models.cpython-312.pyc +0 -0
  10. package/vue3_migration/__pycache__/models.cpython-313.pyc +0 -0
  11. package/vue3_migration/cli.py +323 -0
  12. package/vue3_migration/core/__init__.py +0 -0
  13. package/vue3_migration/core/__pycache__/__init__.cpython-312.pyc +0 -0
  14. package/vue3_migration/core/__pycache__/__init__.cpython-313.pyc +0 -0
  15. package/vue3_migration/core/__pycache__/component_analyzer.cpython-312.pyc +0 -0
  16. package/vue3_migration/core/__pycache__/component_analyzer.cpython-313.pyc +0 -0
  17. package/vue3_migration/core/__pycache__/composable_analyzer.cpython-312.pyc +0 -0
  18. package/vue3_migration/core/__pycache__/composable_analyzer.cpython-313.pyc +0 -0
  19. package/vue3_migration/core/__pycache__/composable_search.cpython-312.pyc +0 -0
  20. package/vue3_migration/core/__pycache__/composable_search.cpython-313.pyc +0 -0
  21. package/vue3_migration/core/__pycache__/file_resolver.cpython-312.pyc +0 -0
  22. package/vue3_migration/core/__pycache__/file_resolver.cpython-313.pyc +0 -0
  23. package/vue3_migration/core/__pycache__/js_parser.cpython-312.pyc +0 -0
  24. package/vue3_migration/core/__pycache__/js_parser.cpython-313.pyc +0 -0
  25. package/vue3_migration/core/__pycache__/mixin_analyzer.cpython-312.pyc +0 -0
  26. package/vue3_migration/core/__pycache__/mixin_analyzer.cpython-313.pyc +0 -0
  27. package/vue3_migration/core/component_analyzer.py +76 -0
  28. package/vue3_migration/core/composable_analyzer.py +86 -0
  29. package/vue3_migration/core/composable_search.py +141 -0
  30. package/vue3_migration/core/file_resolver.py +98 -0
  31. package/vue3_migration/core/js_parser.py +177 -0
  32. package/vue3_migration/core/mixin_analyzer.py +54 -0
  33. package/vue3_migration/models.py +168 -0
  34. package/vue3_migration/reporting/__init__.py +0 -0
  35. package/vue3_migration/reporting/__pycache__/__init__.cpython-312.pyc +0 -0
  36. package/vue3_migration/reporting/__pycache__/__init__.cpython-313.pyc +0 -0
  37. package/vue3_migration/reporting/__pycache__/markdown.cpython-312.pyc +0 -0
  38. package/vue3_migration/reporting/__pycache__/markdown.cpython-313.pyc +0 -0
  39. package/vue3_migration/reporting/__pycache__/terminal.cpython-312.pyc +0 -0
  40. package/vue3_migration/reporting/__pycache__/terminal.cpython-313.pyc +0 -0
  41. package/vue3_migration/reporting/markdown.py +202 -0
  42. package/vue3_migration/reporting/terminal.py +61 -0
  43. package/vue3_migration/transform/__init__.py +0 -0
  44. package/vue3_migration/transform/__pycache__/__init__.cpython-312.pyc +0 -0
  45. package/vue3_migration/transform/__pycache__/__init__.cpython-313.pyc +0 -0
  46. package/vue3_migration/transform/__pycache__/injector.cpython-312.pyc +0 -0
  47. package/vue3_migration/transform/__pycache__/injector.cpython-313.pyc +0 -0
  48. package/vue3_migration/transform/injector.py +134 -0
  49. package/vue3_migration/workflows/__init__.py +0 -0
  50. package/vue3_migration/workflows/__pycache__/__init__.cpython-312.pyc +0 -0
  51. package/vue3_migration/workflows/__pycache__/__init__.cpython-313.pyc +0 -0
  52. package/vue3_migration/workflows/__pycache__/component_workflow.cpython-312.pyc +0 -0
  53. package/vue3_migration/workflows/__pycache__/component_workflow.cpython-313.pyc +0 -0
  54. package/vue3_migration/workflows/__pycache__/mixin_workflow.cpython-312.pyc +0 -0
  55. package/vue3_migration/workflows/component_workflow.py +446 -0
  56. package/vue3_migration/workflows/mixin_workflow.py +387 -0
@@ -0,0 +1,446 @@
1
+ """
2
+ Component-centric migration workflow.
3
+
4
+ Analyzes a single Vue component's mixins, matches them to composables,
5
+ generates a migration report, and optionally injects setup() for
6
+ ready composables.
7
+ """
8
+
9
+ import re
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from ..core.component_analyzer import (
15
+ extract_own_members,
16
+ find_used_members,
17
+ parse_imports,
18
+ parse_mixins_array,
19
+ )
20
+ from ..core.composable_analyzer import (
21
+ extract_all_identifiers,
22
+ extract_function_name,
23
+ extract_return_keys,
24
+ )
25
+ from ..core.composable_search import (
26
+ find_composable_dirs,
27
+ generate_candidates,
28
+ search_for_composable,
29
+ )
30
+ from ..core.file_resolver import (
31
+ compute_import_path,
32
+ resolve_import_path,
33
+ try_resolve_with_extensions,
34
+ )
35
+ from ..core.mixin_analyzer import extract_lifecycle_hooks, extract_mixin_members
36
+ from ..models import (
37
+ ComposableCoverage,
38
+ FileChange,
39
+ MigrationConfig,
40
+ MigrationStatus,
41
+ MixinEntry,
42
+ MixinMembers,
43
+ )
44
+ from ..reporting.markdown import build_component_report
45
+ from ..reporting.terminal import bold, cyan, dim, green, red, yellow
46
+ from ..transform.injector import (
47
+ add_composable_import,
48
+ inject_setup,
49
+ remove_import_line,
50
+ remove_mixin_from_array,
51
+ )
52
+
53
+
54
+ def analyze_mixin(
55
+ local_name: str,
56
+ import_path: str,
57
+ component_path: Path,
58
+ component_source: str,
59
+ composable_dirs: list[Path],
60
+ project_root: Path,
61
+ component_own_members: set[str],
62
+ ) -> Optional[MixinEntry]:
63
+ """Analyze a single mixin: resolve file, extract members, find composable."""
64
+
65
+ # -- Resolve mixin file --
66
+ mixin_file = resolve_import_path(import_path, component_path)
67
+ if not mixin_file:
68
+ print(f" {yellow('WARNING')}: Could not resolve file for '{import_path}'. Skipping.")
69
+ return None
70
+
71
+ print(f" File: {green(str(mixin_file))}")
72
+
73
+ # -- Extract mixin members and hooks --
74
+ mixin_source = mixin_file.read_text(errors="ignore")
75
+ members_dict = extract_mixin_members(mixin_source)
76
+ members = MixinMembers(**members_dict)
77
+ hooks = extract_lifecycle_hooks(mixin_source)
78
+ all_member_names = members.all_names
79
+ print(f" {len(all_member_names)} members, {len(hooks)} lifecycle hooks")
80
+
81
+ # -- Find which members the component actually uses --
82
+ used = find_used_members(component_source, all_member_names)
83
+ print(f" {len(used)} members used by component")
84
+
85
+ entry = MixinEntry(
86
+ local_name=local_name,
87
+ mixin_path=mixin_file,
88
+ mixin_stem=mixin_file.stem,
89
+ members=members,
90
+ lifecycle_hooks=hooks,
91
+ used_members=used,
92
+ )
93
+
94
+ # -- Search for matching composable (skip if no members are used) --
95
+ if not used:
96
+ print(f" {dim('No members used -- composable search skipped.')}")
97
+ entry.compute_status()
98
+ return entry
99
+
100
+ candidates = generate_candidates(mixin_file.stem)
101
+ print(f" Looking for: {cyan(', '.join(candidates))}")
102
+
103
+ matches = search_for_composable(mixin_file.stem, composable_dirs)
104
+ composable_file = None
105
+
106
+ if len(matches) == 1:
107
+ composable_file = matches[0]
108
+ print(f" Found: {green(str(composable_file))}")
109
+ elif len(matches) > 1:
110
+ print(f" {yellow('Multiple candidates found')}:")
111
+ for i, fp in enumerate(matches, 1):
112
+ print(f" {i}. {green(str(fp))}")
113
+ choice = input(f" Pick one (1-{len(matches)}), or 0 for none: ").strip()
114
+ try:
115
+ idx = int(choice)
116
+ if 1 <= idx <= len(matches):
117
+ composable_file = matches[idx - 1]
118
+ except ValueError:
119
+ pass
120
+ else:
121
+ print(f" {yellow('No composable found automatically.')}")
122
+
123
+ # If still no match, ask user
124
+ if not composable_file:
125
+ answer = input(f" Is there a composable for {green(mixin_file.stem)}? (y/n): ").strip().lower()
126
+ if answer == "y":
127
+ user_path = input(" Enter composable path: ").strip()
128
+ if user_path:
129
+ resolved = resolve_import_path(user_path, component_path)
130
+ if not resolved:
131
+ resolved = try_resolve_with_extensions((project_root / user_path).resolve())
132
+ if resolved:
133
+ composable_file = resolved
134
+ else:
135
+ print(f" {red('File not found')}: {user_path}")
136
+
137
+ # -- Analyze composable coverage --
138
+ if composable_file:
139
+ comp_source = composable_file.read_text(errors="ignore")
140
+ fn_name = extract_function_name(comp_source)
141
+ if not fn_name:
142
+ print(f" {yellow('Could not detect function name.')}")
143
+ fn_name = input(" Enter function name (e.g. useSelection): ").strip()
144
+
145
+ if fn_name:
146
+ all_identifiers = extract_all_identifiers(comp_source)
147
+ return_keys = extract_return_keys(comp_source)
148
+ import_path_str = compute_import_path(composable_file, project_root)
149
+
150
+ coverage = ComposableCoverage(
151
+ file_path=composable_file,
152
+ fn_name=fn_name,
153
+ import_path=import_path_str,
154
+ all_identifiers=all_identifiers,
155
+ return_keys=return_keys,
156
+ )
157
+ classification = coverage.classify_members(used, component_own_members)
158
+
159
+ entry.composable = coverage
160
+ entry.classification = classification
161
+
162
+ status = "READY" if classification.is_ready else "BLOCKED"
163
+ status_colored = green(status) if status == "READY" else red(status)
164
+ print(f" Status: {status_colored}", end="")
165
+ if classification.truly_missing:
166
+ print(f" | {red('Missing')}: {', '.join(classification.truly_missing)}", end="")
167
+ if classification.truly_not_returned:
168
+ print(f" | {yellow('Not returned')}: {', '.join(classification.truly_not_returned)}", end="")
169
+ if classification.overridden:
170
+ print(f" | {dim('Overridden by component')}: {', '.join(classification.overridden)}", end="")
171
+ if classification.overridden_not_returned:
172
+ print(f" | {dim('Overridden (not returned)')}: {', '.join(classification.overridden_not_returned)}", end="")
173
+ print()
174
+
175
+ entry.compute_status()
176
+ return entry
177
+
178
+
179
+ def plan_injection(
180
+ component_path: Path,
181
+ entries_to_inject: list[MixinEntry],
182
+ ) -> FileChange:
183
+ """Plan injection changes without writing to disk.
184
+
185
+ Returns a FileChange with original and modified content.
186
+ """
187
+ content = component_path.read_text(errors="ignore")
188
+ original = content
189
+ changes: list[str] = []
190
+
191
+ # Step 1: Swap imports
192
+ for entry in entries_to_inject:
193
+ new = remove_import_line(content, entry.mixin_stem)
194
+ if new != content:
195
+ changes.append(f"Removed import for {entry.mixin_stem}")
196
+ content = new
197
+
198
+ injectable = _get_injectable_members(entry)
199
+ if injectable and entry.composable:
200
+ comp = entry.composable
201
+ new = add_composable_import(content, comp.fn_name, comp.import_path)
202
+ if new != content:
203
+ changes.append(f"Added import {{ {comp.fn_name} }}")
204
+ content = new
205
+ elif not injectable:
206
+ changes.append(f"Skipped composable import for {entry.mixin_stem} (no injectable members)")
207
+
208
+ # Step 2: Remove processed mixins from the mixins: [] array
209
+ for entry in entries_to_inject:
210
+ new = remove_mixin_from_array(content, entry.local_name)
211
+ if new != content:
212
+ changes.append(f"Removed {entry.local_name} from mixins array")
213
+ content = new
214
+
215
+ # Step 3: Create or merge setup()
216
+ composable_calls = [
217
+ (entry.composable.fn_name, _get_injectable_members(entry))
218
+ for entry in entries_to_inject
219
+ if entry.composable and _get_injectable_members(entry)
220
+ ]
221
+
222
+ if composable_calls:
223
+ new = inject_setup(content, composable_calls)
224
+ if new != content:
225
+ fn_names = [c[0] for c in composable_calls]
226
+ all_members = [m for _, members in composable_calls for m in members]
227
+ changes.append(f"setup() with {', '.join(fn_names)} -> {{ {', '.join(all_members)} }}")
228
+ content = new
229
+
230
+ # Clean up excessive blank lines
231
+ if content != original:
232
+ content = re.sub(r"\n{3,}", "\n\n", content)
233
+
234
+ return FileChange(
235
+ file_path=component_path,
236
+ original_content=original,
237
+ new_content=content,
238
+ changes=changes,
239
+ )
240
+
241
+
242
+ def apply_changes(file_change: FileChange) -> None:
243
+ """Write planned changes to disk."""
244
+ if file_change.has_changes:
245
+ file_change.file_path.write_text(file_change.new_content)
246
+
247
+
248
+ def _get_injectable_members(entry: MixinEntry) -> list[str]:
249
+ """Get the list of members that should be destructured from the composable."""
250
+ if entry.classification:
251
+ return entry.classification.injectable
252
+ return entry.used_members
253
+
254
+
255
+ def run(component_arg: str, config: MigrationConfig | None = None):
256
+ """Main entry point for the component migration workflow."""
257
+ if config is None:
258
+ config = MigrationConfig()
259
+
260
+ project_root = config.project_root
261
+ component_path = Path(component_arg).resolve()
262
+ if not component_path.is_file():
263
+ sys.exit(f"Component not found: {component_path}")
264
+
265
+ # ---- Phase 1: Analyze ----
266
+ print(f"Analyzing: {green(component_arg)}")
267
+ component_source = component_path.read_text(errors="ignore")
268
+
269
+ all_imports = parse_imports(component_source)
270
+ mixin_names = parse_mixins_array(component_source)
271
+
272
+ if not mixin_names:
273
+ sys.exit("No mixins found in this component.")
274
+
275
+ print(f"Found {bold(str(len(mixin_names)))} mixin(s): {cyan(', '.join(mixin_names))}\n")
276
+
277
+ # Scan all Composables directories once
278
+ print("Scanning for Composables directories...")
279
+ composable_dirs = find_composable_dirs(project_root)
280
+ print(f"Found {bold(str(len(composable_dirs)))} director(ies).\n")
281
+
282
+ # Extract component's own members for override detection
283
+ component_own_members = extract_own_members(component_source)
284
+
285
+ # Analyze each mixin
286
+ mixin_entries: list[MixinEntry] = []
287
+ for local_name in mixin_names:
288
+ print(f"[{bold(local_name)}]")
289
+ import_path = all_imports.get(local_name)
290
+ if not import_path:
291
+ print(f" {yellow('WARNING')}: No import statement found for {local_name}. Skipping.")
292
+ continue
293
+
294
+ entry = analyze_mixin(
295
+ local_name, import_path, component_path,
296
+ component_source, composable_dirs, project_root,
297
+ component_own_members,
298
+ )
299
+ if entry:
300
+ mixin_entries.append(entry)
301
+
302
+ if not mixin_entries:
303
+ sys.exit("No mixins could be processed.")
304
+
305
+ # ---- Phase 2: Report ----
306
+ print("\n" + "=" * 60)
307
+ print("Generating report...")
308
+
309
+ report = build_component_report(component_path, mixin_entries, project_root)
310
+ report_path = project_root / f"migration_{component_path.stem}.md"
311
+ report_path.write_text(report)
312
+ print(f"Report: {green(str(report_path))}")
313
+
314
+ # ---- Phase 3: Inject ----
315
+
316
+ # Split into ready vs blocked
317
+ ready = [e for e in mixin_entries if e.status == MigrationStatus.READY]
318
+ blocked = [e for e in mixin_entries if e.status != MigrationStatus.READY]
319
+
320
+ if not ready and not blocked:
321
+ print(f"\n{yellow('No mixins are ready for injection.')} Fix the issues in the report and re-run.")
322
+ return
323
+
324
+ # Show what's blocked
325
+ if blocked:
326
+ print(f"\n{yellow(str(len(blocked)))} mixin(s) still need work:")
327
+ for e in blocked:
328
+ comp = e.composable
329
+ cls = e.classification
330
+ detail_parts = []
331
+ if comp and cls:
332
+ if cls.truly_missing:
333
+ detail_parts.append(f"{red('missing')}: {', '.join(cls.truly_missing)}")
334
+ if cls.truly_not_returned:
335
+ detail_parts.append(f"{yellow('not returned')}: {', '.join(cls.truly_not_returned)}")
336
+ else:
337
+ detail_parts.append(yellow("no composable found"))
338
+ detail = f" ({'; '.join(detail_parts)})" if detail_parts else ""
339
+ print(f" - {red(e.mixin_stem)}{detail}")
340
+
341
+ # Show what's ready
342
+ if ready:
343
+ label = (
344
+ f"\n{green(str(len(ready)))} mixin(s) are ready:"
345
+ if blocked
346
+ else f"\nAll {green(str(len(ready)))} mixin(s) are ready:"
347
+ )
348
+ print(label)
349
+
350
+ for e in ready:
351
+ if not e.used_members:
352
+ print(f" - {green(e.mixin_stem)} {dim('(no members used -- will just remove mixin)')}")
353
+ else:
354
+ comp = e.composable
355
+ injectable = _get_injectable_members(e)
356
+ override_count = len(e.classification.overridden) + len(e.classification.overridden_not_returned) if e.classification else 0
357
+ override_note = f" {dim(f'({override_count} overridden by component)')}" if override_count else ""
358
+ print(f" - {green(e.mixin_stem)} -> {cyan(comp.fn_name)}() ({len(injectable)} members){override_note}")
359
+
360
+ # --- Manual unblock option ---
361
+ if blocked:
362
+ print(f"\n{bold('Unblock option')}: Some blocked mixins may have members that are intentionally")
363
+ print(f" overridden by another mixin or dynamically defined. You can force-unblock them.")
364
+ unblock_answer = input(
365
+ f"\n Would you like to unblock any of the {yellow(str(len(blocked)))} blocked mixin(s)? (y/n): "
366
+ ).strip().lower()
367
+
368
+ if unblock_answer == "y":
369
+ unblockable = [e for e in blocked if e.composable]
370
+ non_unblockable = [e for e in blocked if not e.composable]
371
+
372
+ if non_unblockable:
373
+ print(f"\n {dim('Cannot unblock (no composable found):')}")
374
+ for e in non_unblockable:
375
+ print(f" - {dim(e.mixin_stem)}")
376
+
377
+ if unblockable:
378
+ print(f"\n Select mixin(s) to unblock (comma-separated numbers, or 'a' for all):")
379
+ for i, e in enumerate(unblockable, 1):
380
+ cls = e.classification
381
+ missing_list = (cls.truly_missing + cls.truly_not_returned) if cls else []
382
+ print(f" {i}. {yellow(e.mixin_stem)} -> {cyan(e.composable.fn_name)}()")
383
+ print(f" Unresolved members: {red(', '.join(missing_list))}")
384
+ print(f" {dim('These members will NOT be destructured from the composable.')}")
385
+
386
+ choice = input(f"\n Unblock (1-{len(unblockable)}, comma-sep, or 'a'): ").strip().lower()
387
+
388
+ indices_to_unblock: set[int] = set()
389
+ if choice == "a":
390
+ indices_to_unblock = set(range(len(unblockable)))
391
+ else:
392
+ for part in choice.split(","):
393
+ part = part.strip()
394
+ try:
395
+ idx = int(part) - 1
396
+ if 0 <= idx < len(unblockable):
397
+ indices_to_unblock.add(idx)
398
+ except ValueError:
399
+ pass
400
+
401
+ for idx in sorted(indices_to_unblock):
402
+ e = unblockable[idx]
403
+ cls = e.classification
404
+ if cls:
405
+ all_unresolved = set(cls.truly_missing + cls.truly_not_returned)
406
+ cls.injectable = [
407
+ m for m in e.used_members
408
+ if m not in all_unresolved
409
+ and m not in cls.overridden
410
+ and m not in cls.overridden_not_returned
411
+ ]
412
+ e.status = MigrationStatus.FORCE_UNBLOCKED
413
+ blocked.remove(e)
414
+ ready.append(e)
415
+ print(f" {green('Unblocked')}: {e.mixin_stem}")
416
+
417
+ if not ready:
418
+ print(f"\n{yellow('No mixins are ready for injection.')} Fix the issues in the report and re-run.")
419
+ return
420
+
421
+ # Ask user
422
+ if blocked:
423
+ answer = input(
424
+ f"\nWould you like to inject the {green(str(len(ready)))} ready composable(s) now? "
425
+ f"(the {yellow(str(len(blocked)))} blocked one(s) will remain as mixins) (y/n): "
426
+ ).strip().lower()
427
+ else:
428
+ answer = input("\nInject all composables? (y/n): ").strip().lower()
429
+
430
+ if answer != "y":
431
+ print("Skipped injection.")
432
+ return
433
+
434
+ # Plan and apply injection
435
+ print("\nInjecting...")
436
+ file_change = plan_injection(component_path, ready)
437
+
438
+ if file_change.has_changes:
439
+ apply_changes(file_change)
440
+ print(f"\n{green('Changes applied')}:")
441
+ for change in file_change.changes:
442
+ print(f" - {change}")
443
+ else:
444
+ print("\nNo changes needed.")
445
+
446
+ print(f"\n{bold('Done.')} Review the changes and test your application.")