vue3-migration 1.4.0 → 1.4.1

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.1",
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)
@@ -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)
@@ -25,22 +25,41 @@ def _remove_stale_comments(source: str) -> str:
25
25
 
26
26
  for line in lines:
27
27
  # 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)
28
+ # Match patterns like: "count is NOT defined", "count NOT returned",
29
+ # "count is NOT returned", "canDelete and hasRole are NOT defined"
30
+ not_match = re.search(r'//.*?\b(\w+)\s+(?:(?:is|are)\s+)?NOT\s+(?:defined|returned)', line)
30
31
  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
32
+ # Extract all lowercase-starting identifiers before "NOT" in the comment
33
+ comment_start = line.index('//')
34
+ comment_text = line[comment_start:]
35
+ not_word = re.search(r'\bNOT\b', comment_text)
36
+ not_pos = not_word.start() if not_word else comment_text.index('NOT')
37
+ before_not = comment_text[:not_pos]
38
+ noise = {'is', 'are', 'and', 'or', 'not', 'in', 'this', 'composable', 'the', 'from'}
39
+ identifiers = [m for m in re.findall(r'\b([a-z]\w*)\b', before_not) if m not in noise]
40
+
41
+ if identifiers:
42
+ # All mentioned identifiers must be defined for the comment to be stale
43
+ all_defined = all(
44
+ re.search(rf'\b(?:const|let|var|function)\s+{re.escape(name)}\b', source)
45
+ or re.search(rf'\breturn\s*\{{[^}}]*\b{re.escape(name)}\b', source, re.DOTALL)
46
+ for name in identifiers
47
+ )
48
+ if all_defined:
49
+ continue # Remove stale comment
50
+ else:
51
+ # Fallback to single-member logic
52
+ member_name = not_match.group(1)
53
+ if member_name.upper() == member_name and len(member_name) > 2:
54
+ result_lines.append(line)
55
+ continue
56
+ is_defined = (
57
+ re.search(rf'\b(?:const|let|var|function)\s+{re.escape(member_name)}\b', source)
58
+ or re.search(rf'\breturn\s*\{{[^}}]*\b{re.escape(member_name)}\b', source, re.DOTALL)
59
+ )
60
+ if is_defined:
61
+ continue
62
+
44
63
  result_lines.append(line)
45
64
 
46
65
  return '\n'.join(result_lines)
@@ -119,9 +138,16 @@ def add_keys_to_return(content: str, keys: list[str]) -> str:
119
138
  if stripped and not stripped.startswith('}'):
120
139
  member_indent = line[:len(line) - len(stripped)]
121
140
  break
141
+ # Ensure the last existing member has a trailing comma
142
+ before_close = content[:close_pos]
143
+ temp = before_close.rstrip()
144
+ if temp and temp[-1] not in (',', '{'):
145
+ before_close = temp + ',' + before_close[len(temp):]
146
+ close_pos += 1
147
+
122
148
  # Insert new keys before the closing }
123
149
  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:]
150
+ return before_close + new_key_lines + content[close_pos:]
125
151
  else:
126
152
  # Single-line return: check if adding keys would make it too long
127
153
  # Extract existing keys from the return block content
@@ -454,11 +454,57 @@ def plan_component_injections(
454
454
  return component_changes
455
455
 
456
456
 
457
+ def plan_regenerated_composables(
458
+ entries_by_component: list[tuple[Path, list[MixinEntry]]],
459
+ project_root: Path,
460
+ ) -> list[FileChange]:
461
+ """Regenerate existing composables from their mixin source.
462
+
463
+ For each mixin that HAS an existing composable on disk, generates
464
+ fresh content using the generator instead of just patching.
465
+ """
466
+ seen_stems: set[str] = set()
467
+ changes = []
468
+
469
+ for _comp_path, entries in entries_by_component:
470
+ for entry in entries:
471
+ if entry.mixin_stem in seen_stems:
472
+ continue
473
+ # Only regenerate if composable already exists (not BLOCKED_NO_COMPOSABLE)
474
+ if entry.status == MigrationStatus.BLOCKED_NO_COMPOSABLE:
475
+ continue
476
+ if not entry.composable:
477
+ continue
478
+ seen_stems.add(entry.mixin_stem)
479
+
480
+ mixin_source = entry.mixin_path.read_text(errors="ignore").replace('\r\n', '\n').replace('\r', '\n')
481
+ content = generate_composable_from_mixin(
482
+ mixin_source=mixin_source,
483
+ mixin_stem=entry.mixin_stem,
484
+ mixin_members=entry.members,
485
+ lifecycle_hooks=entry.lifecycle_hooks,
486
+ )
487
+ original = entry.composable.file_path.read_text(errors="ignore").replace('\r\n', '\n').replace('\r', '\n')
488
+ if content != original:
489
+ changes.append(FileChange(
490
+ file_path=entry.composable.file_path,
491
+ original_content=original,
492
+ new_content=content,
493
+ changes=[f"Regenerated composable from {entry.mixin_stem}"],
494
+ ))
495
+ return changes
496
+
497
+
457
498
  def _build_all_composable_changes(
458
499
  entries: list[tuple[Path, list[MixinEntry]]],
459
500
  project_root: Path,
501
+ config: MigrationConfig | None = None,
460
502
  ) -> list[FileChange]:
461
503
  """Combine patched-existing + newly-generated composable changes."""
504
+ if config and config.regenerate:
505
+ regenerated = plan_regenerated_composables(entries, project_root)
506
+ generated = plan_new_composables(entries, project_root)
507
+ return regenerated + generated
462
508
  patched = plan_composable_patches(entries)
463
509
  generated = plan_new_composables(entries, project_root)
464
510
  return patched + generated
@@ -470,7 +516,7 @@ def run(project_root: Path, config: MigrationConfig) -> MigrationPlan:
470
516
  No file I/O. Returns a MigrationPlan the CLI can show as a diff and write.
471
517
  """
472
518
  entries = collect_all_mixin_entries(project_root, config)
473
- composable_changes = _build_all_composable_changes(entries, project_root)
519
+ composable_changes = _build_all_composable_changes(entries, project_root, config)
474
520
  component_changes = plan_component_injections(entries, composable_changes, config)
475
521
  return MigrationPlan(
476
522
  component_changes=component_changes,
@@ -509,7 +555,7 @@ def run_scoped(
509
555
  if any(e.mixin_stem == mixin_stem for e in es)
510
556
  ]
511
557
 
512
- composable_changes = _build_all_composable_changes(entries, project_root)
558
+ composable_changes = _build_all_composable_changes(entries, project_root, config)
513
559
  component_changes = plan_component_injections(entries, composable_changes, config)
514
560
  return MigrationPlan(
515
561
  component_changes=component_changes,