vue3-migration 1.1.1 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vue3-migration",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
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"
@@ -4,7 +4,7 @@ Mixin analysis — extract members and lifecycle hooks from Vue mixin source cod
4
4
 
5
5
  import re
6
6
 
7
- from .js_parser import extract_brace_block, extract_property_names
7
+ from .js_parser import extract_brace_block, extract_property_names, skip_non_code
8
8
 
9
9
  VUE_LIFECYCLE_HOOKS = [
10
10
  "beforeCreate", "created", "beforeMount", "mounted",
@@ -46,6 +46,103 @@ def extract_mixin_members(source: str) -> dict[str, list[str]]:
46
46
  return members
47
47
 
48
48
 
49
+ def find_external_this_refs(
50
+ code: str, own_member_names: list[str]
51
+ ) -> list[str]:
52
+ """Find ``this.X`` references where X is NOT a member of the mixin itself.
53
+
54
+ Skips:
55
+ - Members in *own_member_names* (data, computed, methods, watch of this mixin)
56
+ - ``this.$xxx`` references (handled by the ``this.$`` warning system)
57
+ - Matches inside strings, comments, and template literals
58
+
59
+ Returns a deduplicated list of external member names.
60
+ """
61
+ own_set = set(own_member_names)
62
+ pattern = re.compile(r"\bthis\.(\w+)")
63
+ seen: list[str] = []
64
+ pos = 0
65
+ while pos < len(code):
66
+ new_pos, skipped = skip_non_code(code, pos)
67
+ if skipped:
68
+ pos = new_pos
69
+ continue
70
+ m = pattern.match(code, pos)
71
+ if m:
72
+ name = m.group(1)
73
+ if name not in own_set and not name.startswith("$") and name not in seen:
74
+ seen.append(name)
75
+ pos = m.end()
76
+ else:
77
+ pos += 1
78
+ return seen
79
+
80
+
81
+ def resolve_external_dep_sources(
82
+ external_deps: list[str],
83
+ sibling_entries: list,
84
+ component_own_members: set[str],
85
+ component_name: str,
86
+ component_members_by_section: dict[str, list[str]] | None = None,
87
+ ) -> dict[str, dict]:
88
+ """Resolve where each external dependency comes from.
89
+
90
+ For each dep, checks sibling mixin entries and the component's own members.
91
+ Returns a dict mapping dep name to source info::
92
+
93
+ {
94
+ "entityId": {
95
+ "kind": "component", # or "sibling", "ambiguous", "unknown"
96
+ "detail": "TaskDetailView.data",
97
+ "sources": ["TaskDetailView.data"],
98
+ }
99
+ }
100
+
101
+ Args:
102
+ external_deps: Names of external ``this.X`` references.
103
+ sibling_entries: Other MixinEntry objects for the same component.
104
+ component_own_members: Members the component defines itself.
105
+ component_name: Component file stem (for display in warnings).
106
+ component_members_by_section: Optional categorized component members
107
+ (keys: data, computed, methods, watch) for more detailed source info.
108
+ """
109
+ result: dict[str, dict] = {}
110
+ for dep in external_deps:
111
+ sources: list[str] = []
112
+
113
+ # Check sibling mixin entries
114
+ for sibling in sibling_entries:
115
+ for section in ("data", "computed", "methods", "watch"):
116
+ section_members = getattr(sibling.members, section, [])
117
+ if dep in section_members:
118
+ sources.append(f"{sibling.mixin_stem}.{section}")
119
+ break # one hit per sibling is enough
120
+
121
+ # Check component's own members (with section detail if available)
122
+ if dep in component_own_members:
123
+ comp_section = None
124
+ if component_members_by_section:
125
+ for section in ("data", "computed", "methods", "watch"):
126
+ if dep in component_members_by_section.get(section, []):
127
+ comp_section = section
128
+ break
129
+ if comp_section:
130
+ sources.append(f"{component_name}.{comp_section}")
131
+ else:
132
+ sources.append(f"{component_name}")
133
+
134
+ if len(sources) == 0:
135
+ result[dep] = {"kind": "unknown", "detail": None, "sources": []}
136
+ elif len(sources) == 1:
137
+ src = sources[0]
138
+ kind = "component" if src.startswith(component_name) else "sibling"
139
+ result[dep] = {"kind": kind, "detail": src, "sources": sources}
140
+ else:
141
+ result[dep] = {"kind": "ambiguous", "detail": None, "sources": sources}
142
+
143
+ return result
144
+
145
+
49
146
  def extract_lifecycle_hooks(source: str) -> list[str]:
50
147
  """Find Vue lifecycle hooks defined in the mixin source."""
51
148
  return [
@@ -163,6 +163,9 @@ def collect_mixin_warnings(
163
163
  severity="warning",
164
164
  ))
165
165
 
166
+ # External dependencies (this.X where X is not in this mixin)
167
+ warnings.extend(detect_external_dependencies(mixin_source, mixin_members))
168
+
166
169
  # this-aliasing (const self = this)
167
170
  warnings.extend(detect_this_aliasing(mixin_source, ""))
168
171
 
@@ -175,6 +178,47 @@ def collect_mixin_warnings(
175
178
  return warnings
176
179
 
177
180
 
181
+ def detect_external_dependencies(
182
+ mixin_source: str,
183
+ mixin_members: MixinMembers,
184
+ ) -> list[MigrationWarning]:
185
+ """Detect ``this.X`` references where X is not defined in the mixin.
186
+
187
+ These are external dependencies — members that come from the component
188
+ or a sibling mixin via shared ``this`` context. They break in composables
189
+ because composables are isolated functions without ``this``.
190
+ """
191
+ from .mixin_analyzer import find_external_this_refs
192
+
193
+ external = find_external_this_refs(mixin_source, mixin_members.all_names)
194
+ warnings: list[MigrationWarning] = []
195
+
196
+ for name in external:
197
+ # Find the first line containing this.<name> for line_hint
198
+ match = re.search(rf"\bthis\.{re.escape(name)}\b", mixin_source)
199
+ line_hint = None
200
+ if match:
201
+ line_start = mixin_source.rfind("\n", 0, match.start()) + 1
202
+ line_end = mixin_source.find("\n", match.end())
203
+ if line_end == -1:
204
+ line_end = len(mixin_source)
205
+ line_hint = mixin_source[line_start:line_end].strip()
206
+
207
+ warnings.append(MigrationWarning(
208
+ mixin_stem="",
209
+ category="external-dependency",
210
+ message=(
211
+ f"'{name}' — external dep, not available in composable scope.\n"
212
+ f"// Accept as composable param: this.{name} → {name}.value or {name}."
213
+ ),
214
+ action_required=f"Accept '{name}' as a composable parameter and rewrite this.{name}",
215
+ line_hint=line_hint,
216
+ severity="error",
217
+ ))
218
+
219
+ return warnings
220
+
221
+
178
222
  def detect_this_aliasing(
179
223
  mixin_source: str, mixin_stem: str
180
224
  ) -> list[MigrationWarning]:
@@ -255,14 +299,20 @@ def inject_inline_warnings(
255
299
  For each warning with a line_hint, inserts ``// ⚠ MIGRATION: {message}``
256
300
  above the first line that contains the hint text.
257
301
 
302
+ Warnings that cannot be placed inline (no line_hint or no matching line)
303
+ are collected and inserted as a block after the confidence header.
304
+
258
305
  Optionally prepends a confidence header comment at the top.
259
306
  """
260
307
  if confidence is not None:
261
308
  header = f"// Migration confidence: {confidence.value} ({warning_count} warnings — see migration report)\n"
262
309
  source = header + source
263
310
 
311
+ unplaced: list[MigrationWarning] = []
312
+
264
313
  for w in warnings:
265
314
  if not w.line_hint:
315
+ unplaced.append(w)
266
316
  continue
267
317
  # Find a line containing the hint text
268
318
  lines = source.splitlines(keepends=True)
@@ -276,6 +326,20 @@ def inject_inline_warnings(
276
326
  new_lines.append(line)
277
327
  if injected:
278
328
  source = "".join(new_lines)
329
+ else:
330
+ unplaced.append(w)
331
+
332
+ # Place unmatched warnings as a block after the confidence header
333
+ if unplaced:
334
+ block = "".join(
335
+ f"// ⚠ MIGRATION: {w.message}\n" for w in unplaced
336
+ )
337
+ if confidence is not None:
338
+ # Insert right after the first line (confidence header)
339
+ idx = source.index('\n') + 1
340
+ source = source[:idx] + block + source[idx:]
341
+ else:
342
+ source = block + source
279
343
 
280
344
  return source
281
345
 
@@ -141,6 +141,8 @@ class MixinEntry:
141
141
  """Current migration status."""
142
142
  warnings: list[MigrationWarning] = field(default_factory=list)
143
143
  """Migration warnings detected during analysis and generation."""
144
+ external_deps: list[str] = field(default_factory=list)
145
+ """External this.X references not defined in this mixin."""
144
146
 
145
147
  def compute_status(self) -> MigrationStatus:
146
148
  """Determine the migration status based on analysis results."""
@@ -14,7 +14,10 @@ from ..core.composable_analyzer import (
14
14
  )
15
15
  from ..core.composable_search import find_composable_dirs, search_for_composable
16
16
  from ..core.file_resolver import compute_import_path, resolve_import_path
17
- from ..core.mixin_analyzer import extract_lifecycle_hooks, extract_mixin_members
17
+ from ..core.mixin_analyzer import (
18
+ extract_lifecycle_hooks, extract_mixin_members,
19
+ find_external_this_refs, resolve_external_dep_sources,
20
+ )
18
21
  from ..core.warning_collector import collect_mixin_warnings
19
22
  from ..models import (
20
23
  ComposableCoverage, FileChange, MigrationConfig, MigrationPlan, MigrationStatus,
@@ -54,6 +57,8 @@ def _analyze_mixin_silent(
54
57
  hooks = extract_lifecycle_hooks(mixin_source)
55
58
  used = find_used_members(component_source, members.all_names)
56
59
 
60
+ ext_deps = find_external_this_refs(mixin_source, members.all_names)
61
+
57
62
  entry = MixinEntry(
58
63
  local_name=local_name,
59
64
  mixin_path=mixin_file,
@@ -61,6 +66,7 @@ def _analyze_mixin_silent(
61
66
  members=members,
62
67
  lifecycle_hooks=hooks,
63
68
  used_members=used,
69
+ external_deps=ext_deps,
64
70
  )
65
71
 
66
72
  if not used:
@@ -241,6 +247,86 @@ def plan_new_composables(
241
247
  return changes
242
248
 
243
249
 
250
+ def _inject_setup_external_dep_warnings(
251
+ lines: list[str],
252
+ entry: "MixinEntry",
253
+ all_entries: list["MixinEntry"],
254
+ own_members: set[str],
255
+ component_name: str,
256
+ indent: str,
257
+ component_members_by_section: dict[str, list[str]] | None = None,
258
+ ) -> list[str]:
259
+ """Scan lifecycle lines for remaining ``this.X`` and prepend source-resolved warnings.
260
+
261
+ Only processes non-``$`` references that survived ``rewrite_this_refs``
262
+ (i.e. external deps not defined in the current mixin).
263
+ """
264
+ if not lines:
265
+ return lines
266
+
267
+ all_text = "\n".join(lines)
268
+ external_names: list[str] = []
269
+ for m in re.finditer(r"\bthis\.(\w+)", all_text):
270
+ name = m.group(1)
271
+ if not name.startswith("$") and name not in external_names:
272
+ external_names.append(name)
273
+
274
+ if not external_names:
275
+ return lines
276
+
277
+ siblings = [e for e in all_entries if e is not entry]
278
+ sources = resolve_external_dep_sources(
279
+ external_names, siblings, own_members, component_name,
280
+ component_members_by_section=component_members_by_section,
281
+ )
282
+
283
+ result: list[str] = []
284
+ warned: set[str] = set()
285
+ for line in lines:
286
+ for m in re.finditer(r"\bthis\.(\w+)", line):
287
+ name = m.group(1)
288
+ if name.startswith("$") or name in warned:
289
+ continue
290
+ warned.add(name)
291
+ src = sources.get(name, {"kind": "unknown", "detail": None, "sources": []})
292
+ result.extend(
293
+ _format_setup_warning(name, src, entry, indent)
294
+ )
295
+ result.append(line)
296
+ return result
297
+
298
+
299
+ def _format_setup_warning(
300
+ name: str, src: dict, entry: "MixinEntry", indent: str
301
+ ) -> list[str]:
302
+ """Format a source-resolved warning comment for a setup() external dep."""
303
+ if src["kind"] == "sibling":
304
+ detail = src["detail"] # e.g. "loadingMixin.data"
305
+ sibling_stem = detail.split(".")[0]
306
+ sibling_composable = mixin_stem_to_composable_name(sibling_stem)
307
+ return [
308
+ f"{indent}// ⚠ MIGRATION: '{name}' — external dep (source: {detail} → {sibling_composable}).",
309
+ f"{indent}// this.{name} is unavailable in setup(). Use return value from {sibling_composable}() instead.",
310
+ ]
311
+ elif src["kind"] == "component":
312
+ detail = src["detail"]
313
+ return [
314
+ f"{indent}// ⚠ MIGRATION: '{name}' — external dep (source: {detail}).",
315
+ f"{indent}// this.{name} is unavailable in setup(). Replace with local ref.",
316
+ ]
317
+ elif src["kind"] == "ambiguous":
318
+ sources_str = ", ".join(src["sources"])
319
+ return [
320
+ f"{indent}// ⚠ MIGRATION: '{name}' — external dep (sources: {sources_str}).",
321
+ f"{indent}// Ambiguous origin — verify correct source before replacing this.{name}.",
322
+ ]
323
+ else:
324
+ return [
325
+ f"{indent}// ⚠ MIGRATION: '{name}' — external dep, source not found in component or sibling mixins.",
326
+ f"{indent}// May come from props, inject, or a parent component. Replace this.{name} manually.",
327
+ ]
328
+
329
+
244
330
  def plan_component_injections(
245
331
  entries_by_component: list[tuple[Path, list[MixinEntry]]],
246
332
  composable_patches: list[FileChange],
@@ -269,6 +355,7 @@ def plan_component_injections(
269
355
  # R-7: normalize CRLF
270
356
  comp_source = comp_path.read_text(errors="ignore").replace('\r\n', '\n').replace('\r', '\n')
271
357
  own_members = extract_own_members(comp_source)
358
+ comp_members_by_section = extract_mixin_members(comp_source)
272
359
  ready_entries = []
273
360
 
274
361
  for entry in entries:
@@ -377,6 +464,19 @@ def plan_component_injections(
377
464
  mixin_content, entry.lifecycle_hooks, ref_m, plain_m,
378
465
  config.indent + config.indent, # double indent to match setup() body level
379
466
  )
467
+
468
+ # Inject source-resolved warnings for external deps in lifecycle code
469
+ comp_name = comp_path.stem
470
+ setup_indent = config.indent + config.indent
471
+ inline = _inject_setup_external_dep_warnings(
472
+ inline, entry, entries, own_members, comp_name, setup_indent,
473
+ component_members_by_section=comp_members_by_section,
474
+ )
475
+ wrapped = _inject_setup_external_dep_warnings(
476
+ wrapped, entry, entries, own_members, comp_name, setup_indent,
477
+ component_members_by_section=comp_members_by_section,
478
+ )
479
+
380
480
  all_inline_lines.extend(inline)
381
481
  all_lifecycle_calls.extend(wrapped)
382
482