vue3-migration 1.4.0 → 1.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vue3-migration",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Automatically migrate Vue 2 mixins to Vue 3 composables — data, computed, methods, watchers, lifecycle hooks, and all",
5
5
  "bin": {
6
6
  "vue3-migration": "./bin/cli.js"
@@ -1,3 +1,3 @@
1
1
  """Vue Mixin Migration Tool — Automates Vue 2 mixin to Vue 3 composable migration."""
2
2
 
3
- __version__ = "1.4.0"
3
+ __version__ = "1.4.1"
@@ -517,9 +517,10 @@ def main(argv: list[str] | None = None):
517
517
 
518
518
  parser = argparse.ArgumentParser(add_help=False)
519
519
  parser.add_argument("--root", default=None)
520
+ parser.add_argument("--regenerate", "-R", action="store_true", default=False)
520
521
  known, args = parser.parse_known_args(args)
521
522
  project_root = Path(known.root).resolve() if known.root else Path.cwd()
522
- config = MigrationConfig(project_root=project_root)
523
+ config = MigrationConfig(project_root=project_root, regenerate=known.regenerate)
523
524
 
524
525
  if not args:
525
526
  interactive_menu(config)
@@ -49,7 +49,7 @@ def find_used_members(component_source: str, member_names: list[str]) -> list[st
49
49
  """
50
50
  sections = []
51
51
  for tag in ("script", "template"):
52
- tag_match = re.search(rf"<{tag}[^>]*>(.*?)</{tag}>", component_source, re.DOTALL)
52
+ tag_match = re.search(rf"<{tag}[^>]*>(.*)</{tag}>", component_source, re.DOTALL)
53
53
  if tag_match:
54
54
  sections.append(tag_match.group(1))
55
55
  search_text = "\n".join(sections) if sections else component_source
@@ -114,8 +114,10 @@ def extract_own_members(component_source: str) -> set[str]:
114
114
  brace_pos = ret.start() + ret.group().index("{")
115
115
  own_members.update(extract_property_names(extract_brace_block(body, brace_pos)))
116
116
 
117
- # computed, methods, watch sections
118
- for section in ("computed", "methods", "watch"):
117
+ # computed, methods sections (NOT watch — watch keys observe properties,
118
+ # they don't define/override them; a component watching a mixin property
119
+ # still needs the composable to provide that property)
120
+ for section in ("computed", "methods"):
119
121
  match = re.search(rf"\b{section}\s*:\s*\{{", source)
120
122
  if match:
121
123
  own_members.update(
@@ -204,3 +204,4 @@ class MigrationConfig:
204
204
  dry_run: bool = False
205
205
  auto_confirm: bool = False
206
206
  indent: str = " "
207
+ regenerate: bool = False
@@ -56,15 +56,19 @@ def _extract_func_body(section_body: str, name: str) -> str | None:
56
56
 
57
57
  def _extract_func_params(section_body: str, name: str) -> str:
58
58
  """Extract the parameter list of a named function inside a section body."""
59
- # Standard: name(params)
60
- m = re.search(rf'\b{re.escape(name)}\s*\(([^)]*)\)', section_body)
59
+ # Standard shorthand: name(params) {
60
+ m = re.search(rf'\b{re.escape(name)}\s*\(([^)]*)\)\s*\{{', section_body)
61
61
  if m:
62
62
  return m.group(1)
63
- # name: function(params)
64
- m = re.search(rf'\b{re.escape(name)}\s*:\s*function\s*\(([^)]*)\)', section_body)
63
+ # name: function(params) {
64
+ m = re.search(rf'\b{re.escape(name)}\s*:\s*function\s*\(([^)]*)\)\s*\{{', section_body)
65
65
  if m:
66
66
  return m.group(1)
67
- # name: (params) =>
67
+ # name: (params) => {
68
+ m = re.search(rf'\b{re.escape(name)}\s*:\s*\(([^)]*)\)\s*=>\s*\{{', section_body)
69
+ if m:
70
+ return m.group(1)
71
+ # name: (params) => expr (single-expression arrow without braces)
68
72
  m = re.search(rf'\b{re.escape(name)}\s*:\s*\(([^)]*)\)\s*=>', section_body)
69
73
  if m:
70
74
  return m.group(1)
@@ -10,6 +10,7 @@ from ..models import MixinMembers
10
10
  from .this_rewriter import rewrite_this_refs, rewrite_this_dollar_refs
11
11
  from .lifecycle_converter import (
12
12
  extract_hook_body, convert_lifecycle_hooks, get_required_imports, HOOK_MAP,
13
+ find_lifecycle_referenced_members,
13
14
  )
14
15
 
15
16
 
@@ -25,22 +26,41 @@ def _remove_stale_comments(source: str) -> str:
25
26
 
26
27
  for line in lines:
27
28
  # Check for "NOT defined" or "NOT returned" comments
28
- # Match patterns like: "count is NOT defined", "count NOT returned", "count is NOT returned"
29
- not_match = re.search(r'//.*?\b(\w+)\s+(?:is\s+)?NOT\s+(?:defined|returned)', line)
29
+ # Match patterns like: "count is NOT defined", "count NOT returned",
30
+ # "count is NOT returned", "canDelete and hasRole are NOT defined"
31
+ not_match = re.search(r'//.*?\b(\w+)\s+(?:(?:is|are)\s+)?NOT\s+(?:defined|returned)', line)
30
32
  if not_match:
31
- member_name = not_match.group(1)
32
- # Skip non-identifiers that might be matched (e.g. "MIGRATION")
33
- if member_name.upper() == member_name and len(member_name) > 2:
34
- result_lines.append(line)
35
- continue
36
- # Check if the member IS actually defined or returned elsewhere in the source
37
- is_defined = (
38
- re.search(rf'\b(?:const|let|var|function)\s+{re.escape(member_name)}\b', source)
39
- or re.search(rf'\breturn\s*\{{[^}}]*\b{re.escape(member_name)}\b', source)
40
- )
41
- if is_defined:
42
- # Skip this stale comment line
43
- continue
33
+ # Extract all lowercase-starting identifiers before "NOT" in the comment
34
+ comment_start = line.index('//')
35
+ comment_text = line[comment_start:]
36
+ not_word = re.search(r'\bNOT\b', comment_text)
37
+ not_pos = not_word.start() if not_word else comment_text.index('NOT')
38
+ before_not = comment_text[:not_pos]
39
+ noise = {'is', 'are', 'and', 'or', 'not', 'in', 'this', 'composable', 'the', 'from'}
40
+ identifiers = [m for m in re.findall(r'\b([a-z]\w*)\b', before_not) if m not in noise]
41
+
42
+ if identifiers:
43
+ # All mentioned identifiers must be defined for the comment to be stale
44
+ all_defined = all(
45
+ re.search(rf'\b(?:const|let|var|function)\s+{re.escape(name)}\b', source)
46
+ or re.search(rf'\breturn\s*\{{[^}}]*\b{re.escape(name)}\b', source, re.DOTALL)
47
+ for name in identifiers
48
+ )
49
+ if all_defined:
50
+ continue # Remove stale comment
51
+ else:
52
+ # Fallback to single-member logic
53
+ member_name = not_match.group(1)
54
+ if member_name.upper() == member_name and len(member_name) > 2:
55
+ result_lines.append(line)
56
+ continue
57
+ is_defined = (
58
+ re.search(rf'\b(?:const|let|var|function)\s+{re.escape(member_name)}\b', source)
59
+ or re.search(rf'\breturn\s*\{{[^}}]*\b{re.escape(member_name)}\b', source, re.DOTALL)
60
+ )
61
+ if is_defined:
62
+ continue
63
+
44
64
  result_lines.append(line)
45
65
 
46
66
  return '\n'.join(result_lines)
@@ -119,9 +139,22 @@ def add_keys_to_return(content: str, keys: list[str]) -> str:
119
139
  if stripped and not stripped.startswith('}'):
120
140
  member_indent = line[:len(line) - len(stripped)]
121
141
  break
122
- # Insert new keys before the closing }
123
- new_key_lines = "".join(f"{member_indent}{k},\n" for k in new_keys)
124
- return content[:close_pos] + new_key_lines + content[close_pos:]
142
+
143
+ # Strip trailing whitespace before closing }, add comma if needed
144
+ before_close = content[:close_pos].rstrip()
145
+ if before_close and before_close[-1] not in (',', '{'):
146
+ before_close += ','
147
+
148
+ # Detect the closing brace's indentation from the original content
149
+ line_start_of_close = content.rfind('\n', 0, close_pos)
150
+ if line_start_of_close >= 0:
151
+ close_indent = content[line_start_of_close + 1:close_pos]
152
+ else:
153
+ close_indent = ''
154
+
155
+ # Insert new keys between last member and closing }
156
+ new_key_lines = "\n".join(f"{member_indent}{k}," for k in new_keys)
157
+ return before_close + "\n" + new_key_lines + "\n" + close_indent + content[close_pos:]
125
158
  else:
126
159
  # Single-line return: check if adding keys would make it too long
127
160
  # Extract existing keys from the return block content
@@ -152,9 +185,10 @@ def add_members_to_composable(content: str, member_lines: list[str]) -> str:
152
185
  """
153
186
  # Normalize CRLF (R-7)
154
187
  content = content.replace('\r\n', '\n').replace('\r', '\n')
155
- m = re.search(r'\n([ \t]*)\breturn\s*\{', content)
156
- if not m:
188
+ matches = list(re.finditer(r'\n([ \t]*)\breturn\s*\{', content))
189
+ if not matches:
157
190
  return content
191
+ m = matches[-1]
158
192
  existing_ids = set(extract_all_identifiers(content))
159
193
  lines_to_add = []
160
194
  for line in member_lines:
@@ -512,6 +546,35 @@ def patch_composable(
512
546
  if lifecycle_hooks:
513
547
  hooks_to_add = _missing_hooks(content, lifecycle_hooks)
514
548
  if hooks_to_add:
549
+ # Step 3a: Find mixin members referenced inside lifecycle hook bodies
550
+ # that are not yet in the composable (e.g. _handleEscapeKey).
551
+ all_member_names = (
552
+ mixin_members.data + mixin_members.computed
553
+ + mixin_members.methods + mixin_members.watch
554
+ )
555
+ lifecycle_deps = find_lifecycle_referenced_members(
556
+ mixin_content, hooks_to_add, all_member_names,
557
+ )
558
+ existing_ids = set(extract_all_identifiers(content))
559
+ missing_deps = [m for m in lifecycle_deps if m not in existing_ids]
560
+ if missing_deps:
561
+ dep_decls = [
562
+ generate_member_declaration(
563
+ name, mixin_content, mixin_members,
564
+ ref_members, plain_members, indent,
565
+ )
566
+ for name in missing_deps
567
+ ]
568
+ content = add_members_to_composable(content, dep_decls)
569
+ content = add_keys_to_return(content, missing_deps)
570
+ if any("watch(" in d for d in dep_decls):
571
+ content = _add_vue_import(content, "watch")
572
+ if any("ref(" in d for d in dep_decls):
573
+ content = _add_vue_import(content, "ref")
574
+ if any("computed(" in d for d in dep_decls):
575
+ content = _add_vue_import(content, "computed")
576
+
577
+ # Step 3b: Convert and insert lifecycle hooks
515
578
  inline_lines, wrapped_lines = convert_lifecycle_hooks(
516
579
  mixin_content, hooks_to_add, ref_members, plain_members, indent,
517
580
  )
@@ -378,27 +378,11 @@ def plan_component_injections(
378
378
  severity="warning",
379
379
  ))
380
380
 
381
- content = comp_source
382
- changes_desc = []
383
-
384
- for entry in ready_entries:
385
- new = remove_import_line(content, entry.mixin_stem)
386
- if new != content:
387
- changes_desc.append(f"Removed import {entry.mixin_stem}")
388
- content = new
389
- injectable = entry.classification.injectable if entry.classification else entry.used_members
390
- if injectable and entry.composable:
391
- new = add_composable_import(content, entry.composable.fn_name, entry.composable.import_path)
392
- if new != content:
393
- changes_desc.append(f"Added import {{{entry.composable.fn_name}}}")
394
- content = new
395
-
396
- for entry in ready_entries:
397
- new = remove_mixin_from_array(content, entry.local_name)
398
- if new != content:
399
- changes_desc.append(f"Removed {entry.local_name} from mixins")
400
- content = new
401
-
381
+ # Pre-compute which entries will produce a composable call.
382
+ # Only migratable entries (those with injectable members) get their
383
+ # mixin removed. Entries that won't produce a composable call are
384
+ # skipped and flagged for manual review.
385
+ migratable_entries = []
402
386
  composable_calls = []
403
387
 
404
388
  for entry in ready_entries:
@@ -420,15 +404,114 @@ def plan_component_injections(
420
404
  # precedence over setup() returns, so injecting them is redundant.
421
405
  # Keep lifecycle-referenced members even if overridden, since the
422
406
  # composable's lifecycle wrapper closure may depend on them.
407
+ overridden_in_injectable: list[str] = []
423
408
  if injectable and entry.composable:
424
409
  lifecycle_members_set = set(lifecycle_members) if entry.lifecycle_hooks else set()
410
+ overridden_in_injectable = [
411
+ m for m in injectable
412
+ if m in own_members and m not in lifecycle_members_set
413
+ ]
425
414
  injectable = [
426
415
  m for m in injectable
427
416
  if m not in own_members or m in lifecycle_members_set
428
417
  ]
429
418
 
430
419
  if injectable and entry.composable:
420
+ migratable_entries.append(entry)
431
421
  composable_calls.append((entry.composable.fn_name, injectable))
422
+ # Warn about overridden members that won't be destructured
423
+ if overridden_in_injectable:
424
+ from ..models import MigrationWarning
425
+ names = ", ".join(overridden_in_injectable)
426
+ entry.warnings.append(MigrationWarning(
427
+ mixin_stem=entry.mixin_stem,
428
+ category="overridden-member",
429
+ message=(
430
+ f"Component overrides mixin member(s): {names}. "
431
+ "These are NOT destructured from the composable."
432
+ ),
433
+ action_required=(
434
+ f"Verify that the component's own {names} "
435
+ "implementation does not depend on other mixin members."
436
+ ),
437
+ line_hint=None,
438
+ severity="info",
439
+ ))
440
+ else:
441
+ # Entry won't produce a composable call — keep the mixin.
442
+ from ..models import MigrationWarning
443
+ if not entry.used_members:
444
+ if entry.lifecycle_hooks:
445
+ hooks_str = ", ".join(entry.lifecycle_hooks)
446
+ entry.warnings.append(MigrationWarning(
447
+ mixin_stem=entry.mixin_stem,
448
+ category="skipped-lifecycle-only",
449
+ message=(
450
+ f"Mixin '{entry.mixin_stem}' was NOT migrated: "
451
+ f"it provides lifecycle hooks ({hooks_str}) but "
452
+ "no members are directly referenced by the component."
453
+ ),
454
+ action_required=(
455
+ "Manually convert lifecycle hooks to the composable, "
456
+ "or remove the mixin if unused."
457
+ ),
458
+ line_hint=None,
459
+ severity="warning",
460
+ ))
461
+ else:
462
+ entry.warnings.append(MigrationWarning(
463
+ mixin_stem=entry.mixin_stem,
464
+ category="skipped-no-usage",
465
+ message=(
466
+ f"Mixin '{entry.mixin_stem}' was NOT migrated: "
467
+ "no members are referenced by the component."
468
+ ),
469
+ action_required=(
470
+ "Remove the mixin if unused, or manually convert "
471
+ "if it provides side-effect functionality."
472
+ ),
473
+ line_hint=None,
474
+ severity="warning",
475
+ ))
476
+ else:
477
+ overridden_names = ", ".join(
478
+ entry.classification.overridden + entry.classification.overridden_not_returned
479
+ ) if entry.classification else ""
480
+ entry.warnings.append(MigrationWarning(
481
+ mixin_stem=entry.mixin_stem,
482
+ category="skipped-all-overridden",
483
+ message=(
484
+ f"Mixin '{entry.mixin_stem}' was NOT migrated: "
485
+ f"all used members ({overridden_names}) are overridden "
486
+ "by the component."
487
+ ),
488
+ action_required=(
489
+ "Remove the mixin if the component's overrides are "
490
+ "self-contained, or keep it if they depend on mixin internals."
491
+ ),
492
+ line_hint=None,
493
+ severity="info",
494
+ ))
495
+
496
+ content = comp_source
497
+ changes_desc = []
498
+
499
+ for entry in migratable_entries:
500
+ new = remove_import_line(content, entry.mixin_stem)
501
+ if new != content:
502
+ changes_desc.append(f"Removed import {entry.mixin_stem}")
503
+ content = new
504
+ if entry.composable:
505
+ new = add_composable_import(content, entry.composable.fn_name, entry.composable.import_path)
506
+ if new != content:
507
+ changes_desc.append(f"Added import {{{entry.composable.fn_name}}}")
508
+ content = new
509
+
510
+ for entry in migratable_entries:
511
+ new = remove_mixin_from_array(content, entry.local_name)
512
+ if new != content:
513
+ changes_desc.append(f"Removed {entry.local_name} from mixins")
514
+ content = new
432
515
 
433
516
  if composable_calls:
434
517
  new = inject_setup(
@@ -454,11 +537,57 @@ def plan_component_injections(
454
537
  return component_changes
455
538
 
456
539
 
540
+ def plan_regenerated_composables(
541
+ entries_by_component: list[tuple[Path, list[MixinEntry]]],
542
+ project_root: Path,
543
+ ) -> list[FileChange]:
544
+ """Regenerate existing composables from their mixin source.
545
+
546
+ For each mixin that HAS an existing composable on disk, generates
547
+ fresh content using the generator instead of just patching.
548
+ """
549
+ seen_stems: set[str] = set()
550
+ changes = []
551
+
552
+ for _comp_path, entries in entries_by_component:
553
+ for entry in entries:
554
+ if entry.mixin_stem in seen_stems:
555
+ continue
556
+ # Only regenerate if composable already exists (not BLOCKED_NO_COMPOSABLE)
557
+ if entry.status == MigrationStatus.BLOCKED_NO_COMPOSABLE:
558
+ continue
559
+ if not entry.composable:
560
+ continue
561
+ seen_stems.add(entry.mixin_stem)
562
+
563
+ mixin_source = entry.mixin_path.read_text(errors="ignore").replace('\r\n', '\n').replace('\r', '\n')
564
+ content = generate_composable_from_mixin(
565
+ mixin_source=mixin_source,
566
+ mixin_stem=entry.mixin_stem,
567
+ mixin_members=entry.members,
568
+ lifecycle_hooks=entry.lifecycle_hooks,
569
+ )
570
+ original = entry.composable.file_path.read_text(errors="ignore").replace('\r\n', '\n').replace('\r', '\n')
571
+ if content != original:
572
+ changes.append(FileChange(
573
+ file_path=entry.composable.file_path,
574
+ original_content=original,
575
+ new_content=content,
576
+ changes=[f"Regenerated composable from {entry.mixin_stem}"],
577
+ ))
578
+ return changes
579
+
580
+
457
581
  def _build_all_composable_changes(
458
582
  entries: list[tuple[Path, list[MixinEntry]]],
459
583
  project_root: Path,
584
+ config: MigrationConfig | None = None,
460
585
  ) -> list[FileChange]:
461
586
  """Combine patched-existing + newly-generated composable changes."""
587
+ if config and config.regenerate:
588
+ regenerated = plan_regenerated_composables(entries, project_root)
589
+ generated = plan_new_composables(entries, project_root)
590
+ return regenerated + generated
462
591
  patched = plan_composable_patches(entries)
463
592
  generated = plan_new_composables(entries, project_root)
464
593
  return patched + generated
@@ -470,7 +599,7 @@ def run(project_root: Path, config: MigrationConfig) -> MigrationPlan:
470
599
  No file I/O. Returns a MigrationPlan the CLI can show as a diff and write.
471
600
  """
472
601
  entries = collect_all_mixin_entries(project_root, config)
473
- composable_changes = _build_all_composable_changes(entries, project_root)
602
+ composable_changes = _build_all_composable_changes(entries, project_root, config)
474
603
  component_changes = plan_component_injections(entries, composable_changes, config)
475
604
  return MigrationPlan(
476
605
  component_changes=component_changes,
@@ -509,7 +638,7 @@ def run_scoped(
509
638
  if any(e.mixin_stem == mixin_stem for e in es)
510
639
  ]
511
640
 
512
- composable_changes = _build_all_composable_changes(entries, project_root)
641
+ composable_changes = _build_all_composable_changes(entries, project_root, config)
513
642
  component_changes = plan_component_injections(entries, composable_changes, config)
514
643
  return MigrationPlan(
515
644
  component_changes=component_changes,