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.
- package/bin/cli.js +41 -0
- package/package.json +24 -0
- package/vue3_migration/__init__.py +3 -0
- package/vue3_migration/__main__.py +6 -0
- package/vue3_migration/__pycache__/__init__.cpython-312.pyc +0 -0
- package/vue3_migration/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vue3_migration/__pycache__/__main__.cpython-312.pyc +0 -0
- package/vue3_migration/__pycache__/cli.cpython-312.pyc +0 -0
- package/vue3_migration/__pycache__/models.cpython-312.pyc +0 -0
- package/vue3_migration/__pycache__/models.cpython-313.pyc +0 -0
- package/vue3_migration/cli.py +323 -0
- package/vue3_migration/core/__init__.py +0 -0
- package/vue3_migration/core/__pycache__/__init__.cpython-312.pyc +0 -0
- package/vue3_migration/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vue3_migration/core/__pycache__/component_analyzer.cpython-312.pyc +0 -0
- package/vue3_migration/core/__pycache__/component_analyzer.cpython-313.pyc +0 -0
- package/vue3_migration/core/__pycache__/composable_analyzer.cpython-312.pyc +0 -0
- package/vue3_migration/core/__pycache__/composable_analyzer.cpython-313.pyc +0 -0
- package/vue3_migration/core/__pycache__/composable_search.cpython-312.pyc +0 -0
- package/vue3_migration/core/__pycache__/composable_search.cpython-313.pyc +0 -0
- package/vue3_migration/core/__pycache__/file_resolver.cpython-312.pyc +0 -0
- package/vue3_migration/core/__pycache__/file_resolver.cpython-313.pyc +0 -0
- package/vue3_migration/core/__pycache__/js_parser.cpython-312.pyc +0 -0
- package/vue3_migration/core/__pycache__/js_parser.cpython-313.pyc +0 -0
- package/vue3_migration/core/__pycache__/mixin_analyzer.cpython-312.pyc +0 -0
- package/vue3_migration/core/__pycache__/mixin_analyzer.cpython-313.pyc +0 -0
- package/vue3_migration/core/component_analyzer.py +76 -0
- package/vue3_migration/core/composable_analyzer.py +86 -0
- package/vue3_migration/core/composable_search.py +141 -0
- package/vue3_migration/core/file_resolver.py +98 -0
- package/vue3_migration/core/js_parser.py +177 -0
- package/vue3_migration/core/mixin_analyzer.py +54 -0
- package/vue3_migration/models.py +168 -0
- package/vue3_migration/reporting/__init__.py +0 -0
- package/vue3_migration/reporting/__pycache__/__init__.cpython-312.pyc +0 -0
- package/vue3_migration/reporting/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vue3_migration/reporting/__pycache__/markdown.cpython-312.pyc +0 -0
- package/vue3_migration/reporting/__pycache__/markdown.cpython-313.pyc +0 -0
- package/vue3_migration/reporting/__pycache__/terminal.cpython-312.pyc +0 -0
- package/vue3_migration/reporting/__pycache__/terminal.cpython-313.pyc +0 -0
- package/vue3_migration/reporting/markdown.py +202 -0
- package/vue3_migration/reporting/terminal.py +61 -0
- package/vue3_migration/transform/__init__.py +0 -0
- package/vue3_migration/transform/__pycache__/__init__.cpython-312.pyc +0 -0
- package/vue3_migration/transform/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vue3_migration/transform/__pycache__/injector.cpython-312.pyc +0 -0
- package/vue3_migration/transform/__pycache__/injector.cpython-313.pyc +0 -0
- package/vue3_migration/transform/injector.py +134 -0
- package/vue3_migration/workflows/__init__.py +0 -0
- package/vue3_migration/workflows/__pycache__/__init__.cpython-312.pyc +0 -0
- package/vue3_migration/workflows/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vue3_migration/workflows/__pycache__/component_workflow.cpython-312.pyc +0 -0
- package/vue3_migration/workflows/__pycache__/component_workflow.cpython-313.pyc +0 -0
- package/vue3_migration/workflows/__pycache__/mixin_workflow.cpython-312.pyc +0 -0
- package/vue3_migration/workflows/component_workflow.py +446 -0
- package/vue3_migration/workflows/mixin_workflow.py +387 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Composable search — find composable files matching mixin names.
|
|
3
|
+
|
|
4
|
+
Generates candidate composable names from mixin filenames, searches
|
|
5
|
+
Composables directories with exact then fuzzy matching.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_candidates(mixin_stem: str) -> list[str]:
|
|
14
|
+
"""Generate expected composable names from a mixin filename.
|
|
15
|
+
|
|
16
|
+
selectionMixin -> [useSelection]
|
|
17
|
+
LPiMixin -> [useLPi, useLpi]
|
|
18
|
+
PropertiesCommonMixin -> [usePropertiesCommon, useProperties]
|
|
19
|
+
mapHelpers -> [useMapHelpers, useMaphelpers]
|
|
20
|
+
"""
|
|
21
|
+
# Strip "Mixin" suffix (with optional _ or - prefix)
|
|
22
|
+
core_name = re.sub(r"[_-]?[Mm]ixin$", "", mixin_stem)
|
|
23
|
+
if not core_name:
|
|
24
|
+
return []
|
|
25
|
+
|
|
26
|
+
# Primary: preserve original casing after "use"
|
|
27
|
+
primary = "use" + core_name[0].upper() + core_name[1:]
|
|
28
|
+
# Secondary: capitalize-style for edge cases
|
|
29
|
+
secondary = "use" + core_name.capitalize()
|
|
30
|
+
|
|
31
|
+
candidates = [primary, secondary]
|
|
32
|
+
|
|
33
|
+
# Also try stripping "Common" if present
|
|
34
|
+
without_common = re.sub(r"[_-]?[Cc]ommon$", "", core_name)
|
|
35
|
+
if without_common and without_common != core_name:
|
|
36
|
+
candidates.append("use" + without_common[0].upper() + without_common[1:])
|
|
37
|
+
candidates.append("use" + without_common.capitalize())
|
|
38
|
+
|
|
39
|
+
return list(dict.fromkeys(candidates))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find_composable_dirs(project_root: Path) -> list[Path]:
|
|
43
|
+
"""Find all directories named 'Composables' (case-insensitive) in the project."""
|
|
44
|
+
found = []
|
|
45
|
+
for dirpath, dirnames, _ in os.walk(project_root):
|
|
46
|
+
# Skip node_modules, dist, .git
|
|
47
|
+
rel = Path(dirpath).relative_to(project_root)
|
|
48
|
+
if any(part in {"node_modules", "dist", ".git", "__pycache__"} for part in rel.parts):
|
|
49
|
+
continue
|
|
50
|
+
for dirname in dirnames:
|
|
51
|
+
if dirname.lower() == "composables":
|
|
52
|
+
found.append(Path(dirpath) / dirname)
|
|
53
|
+
return found
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def search_for_composable(mixin_stem: str, composable_dirs: list[Path]) -> list[Path]:
|
|
57
|
+
"""Search for composable files matching a mixin name.
|
|
58
|
+
|
|
59
|
+
Phase 1: Exact stem match against generated candidate names (case-insensitive).
|
|
60
|
+
Phase 2: Fuzzy -- any 'use' file whose name contains the mixin's core word.
|
|
61
|
+
"""
|
|
62
|
+
candidates = generate_candidates(mixin_stem)
|
|
63
|
+
matches = []
|
|
64
|
+
|
|
65
|
+
# Phase 1: Exact name match (case-insensitive)
|
|
66
|
+
for comp_dir in composable_dirs:
|
|
67
|
+
for dirpath, _, filenames in os.walk(comp_dir):
|
|
68
|
+
for filename in filenames:
|
|
69
|
+
filepath = Path(dirpath) / filename
|
|
70
|
+
if filepath.suffix not in (".js", ".ts"):
|
|
71
|
+
continue
|
|
72
|
+
if any(filepath.stem.lower() == c.lower() for c in candidates):
|
|
73
|
+
matches.append(filepath)
|
|
74
|
+
|
|
75
|
+
if matches:
|
|
76
|
+
return list(dict.fromkeys(matches))
|
|
77
|
+
|
|
78
|
+
# Phase 2: Fuzzy fallback -- "use" prefix + core word substring
|
|
79
|
+
core_word = re.sub(r"[_-]?[Mm]ixin$", "", mixin_stem).lower()
|
|
80
|
+
if not core_word:
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
for comp_dir in composable_dirs:
|
|
84
|
+
for dirpath, _, filenames in os.walk(comp_dir):
|
|
85
|
+
for filename in filenames:
|
|
86
|
+
filepath = Path(dirpath) / filename
|
|
87
|
+
if filepath.suffix not in (".js", ".ts"):
|
|
88
|
+
continue
|
|
89
|
+
if filepath.stem.lower().startswith("use") and core_word in filepath.stem.lower():
|
|
90
|
+
matches.append(filepath)
|
|
91
|
+
|
|
92
|
+
return list(dict.fromkeys(matches))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def collect_composable_stems(composable_dirs: list[Path]) -> set[str]:
|
|
96
|
+
"""Collect all composable file stems (e.g. 'useSelection') from all dirs.
|
|
97
|
+
|
|
98
|
+
Used for quick existence checks during scanning.
|
|
99
|
+
"""
|
|
100
|
+
stems: set[str] = set()
|
|
101
|
+
for comp_dir in composable_dirs:
|
|
102
|
+
for dirpath, _, filenames in os.walk(comp_dir):
|
|
103
|
+
for fn in filenames:
|
|
104
|
+
fp = Path(dirpath) / fn
|
|
105
|
+
if fp.suffix in (".js", ".ts") and fp.stem.startswith("use"):
|
|
106
|
+
stems.add(fp.stem.lower())
|
|
107
|
+
return stems
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def mixin_has_composable(mixin_stem: str, composable_stems: set[str]) -> bool:
|
|
111
|
+
"""Check if a matching composable likely exists for a mixin name.
|
|
112
|
+
|
|
113
|
+
Uses the same two-phase strategy as search_for_composable:
|
|
114
|
+
Phase 1 — exact candidate match (useFilter, useFilter).
|
|
115
|
+
Phase 2 — fuzzy: any composable stem starting with 'use' that contains
|
|
116
|
+
the core word as a substring (e.g. useAdvancedFilter for filterMixin).
|
|
117
|
+
"""
|
|
118
|
+
core = re.sub(r"[_-]?[Mm]ixin$", "", mixin_stem)
|
|
119
|
+
if not core:
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
names_to_check = [core]
|
|
123
|
+
without_common = re.sub(r"[_-]?[Cc]ommon$", "", core)
|
|
124
|
+
if without_common != core:
|
|
125
|
+
names_to_check.append(without_common)
|
|
126
|
+
|
|
127
|
+
# Phase 1: exact candidate match
|
|
128
|
+
for name in names_to_check:
|
|
129
|
+
candidate = "use" + name[0].upper() + name[1:]
|
|
130
|
+
if candidate.lower() in composable_stems:
|
|
131
|
+
return True
|
|
132
|
+
if ("use" + name.capitalize()).lower() in composable_stems:
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
# Phase 2: fuzzy — core word as substring of any use* composable
|
|
136
|
+
core_lower = core.lower()
|
|
137
|
+
for stem in composable_stems:
|
|
138
|
+
if stem.startswith("use") and core_lower in stem:
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
return False
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Import path resolution — resolve JS/TS import paths to actual files on disk.
|
|
3
|
+
|
|
4
|
+
Handles relative paths, @/ alias (mapped to src/), and @ prefix without slash.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
FILE_EXTENSIONS = (".js", ".ts")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def find_src_directory(starting_dir: Path) -> Optional[Path]:
|
|
15
|
+
"""Walk upward from starting_dir to find the nearest directory containing 'src/'."""
|
|
16
|
+
search = starting_dir.resolve()
|
|
17
|
+
while search != search.parent:
|
|
18
|
+
candidate = search / "src"
|
|
19
|
+
if candidate.is_dir():
|
|
20
|
+
return candidate
|
|
21
|
+
search = search.parent
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def try_resolve_with_extensions(base_path: Path) -> Optional[Path]:
|
|
26
|
+
"""Try to resolve a path as-is, then with .js and .ts extensions."""
|
|
27
|
+
for candidate in [base_path, base_path.with_suffix(".js"), base_path.with_suffix(".ts")]:
|
|
28
|
+
if candidate.is_file():
|
|
29
|
+
return candidate
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_import_path(import_path: str, component_path: Path) -> Optional[Path]:
|
|
34
|
+
"""Resolve an import path to an actual file on disk.
|
|
35
|
+
|
|
36
|
+
Handles three styles:
|
|
37
|
+
- Relative: '../mixins/foo'
|
|
38
|
+
- @/ alias: '@/components/foo' (@ = src/)
|
|
39
|
+
- @ prefix: '@components/foo' (@ = src/)
|
|
40
|
+
"""
|
|
41
|
+
component_dir = component_path.parent
|
|
42
|
+
|
|
43
|
+
# --- Case 1: Relative path (starts with . or /) ---
|
|
44
|
+
if import_path.startswith(".") or import_path.startswith("/"):
|
|
45
|
+
base = (component_dir / import_path).resolve()
|
|
46
|
+
return try_resolve_with_extensions(base)
|
|
47
|
+
|
|
48
|
+
# --- Case 2 & 3: @ alias (@/ or @ without /) ---
|
|
49
|
+
if import_path.startswith("@"):
|
|
50
|
+
src_dir = find_src_directory(component_dir)
|
|
51
|
+
if not src_dir:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
# "@/components/foo" -> strip "@/" -> "components/foo"
|
|
55
|
+
# "@components/foo" -> strip "@" -> "components/foo"
|
|
56
|
+
if import_path.startswith("@/"):
|
|
57
|
+
relative_part = import_path[2:]
|
|
58
|
+
else:
|
|
59
|
+
relative_part = import_path[1:]
|
|
60
|
+
|
|
61
|
+
base = (src_dir / relative_part).resolve()
|
|
62
|
+
return try_resolve_with_extensions(base)
|
|
63
|
+
|
|
64
|
+
# --- Fallback: treat as relative ---
|
|
65
|
+
base = (component_dir / import_path).resolve()
|
|
66
|
+
return try_resolve_with_extensions(base)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def compute_import_path(composable_path: Path, project_root: Path) -> str:
|
|
70
|
+
"""Build the import path for a composable, using @/ alias when under src/.
|
|
71
|
+
|
|
72
|
+
Resolves the actual file path relative to the project root and replaces
|
|
73
|
+
the src/ prefix with @/.
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
relative = composable_path.resolve().relative_to(project_root.resolve())
|
|
77
|
+
except ValueError:
|
|
78
|
+
relative = composable_path
|
|
79
|
+
|
|
80
|
+
normalized = str(relative).replace("\\", "/")
|
|
81
|
+
normalized = re.sub(r"\.(js|ts)$", "", normalized)
|
|
82
|
+
|
|
83
|
+
# Replace everything up to and including "src/" with "@/"
|
|
84
|
+
src_idx = normalized.find("src/")
|
|
85
|
+
if src_idx != -1:
|
|
86
|
+
return "@/" + normalized[src_idx + 4:]
|
|
87
|
+
|
|
88
|
+
return normalized
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def resolve_mixin_stem(import_path: str) -> str:
|
|
92
|
+
"""Extract the mixin filename stem from an import path.
|
|
93
|
+
|
|
94
|
+
'../mixins/selectionMixin' -> 'selectionMixin'
|
|
95
|
+
'@/mixins/LPiMixin.js' -> 'LPiMixin'
|
|
96
|
+
"""
|
|
97
|
+
basename = import_path.rsplit("/", 1)[-1]
|
|
98
|
+
return re.sub(r"\.(js|ts)$", "", basename)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JavaScript parsing helpers for Vue migration tool.
|
|
3
|
+
|
|
4
|
+
Provides low-level parsing of JS source code: skipping strings, comments,
|
|
5
|
+
regex literals, extracting brace-delimited blocks, and extracting top-level
|
|
6
|
+
property names from object literals. All functions are comment/string/regex
|
|
7
|
+
aware to avoid false matches inside non-code regions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def skip_string(source: str, start: int) -> int:
|
|
14
|
+
"""Skip past a quoted string (handles escaped chars).
|
|
15
|
+
|
|
16
|
+
Supports single quotes, double quotes, and template literals.
|
|
17
|
+
Returns the index after the closing quote.
|
|
18
|
+
"""
|
|
19
|
+
quote = source[start]
|
|
20
|
+
pos = start + 1
|
|
21
|
+
while pos < len(source):
|
|
22
|
+
if source[pos] == "\\":
|
|
23
|
+
pos += 2
|
|
24
|
+
continue
|
|
25
|
+
if source[pos] == quote:
|
|
26
|
+
return pos + 1
|
|
27
|
+
pos += 1
|
|
28
|
+
return pos
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def skip_regex_literal(source: str, start: int) -> int:
|
|
32
|
+
"""Skip past a regex literal /pattern/flags.
|
|
33
|
+
|
|
34
|
+
Handles escaped characters and character classes ([...]).
|
|
35
|
+
Returns the index after the closing / and any flags.
|
|
36
|
+
"""
|
|
37
|
+
pos = start + 1 # skip opening /
|
|
38
|
+
while pos < len(source):
|
|
39
|
+
ch = source[pos]
|
|
40
|
+
if ch == "\\":
|
|
41
|
+
pos += 2 # skip escaped char
|
|
42
|
+
continue
|
|
43
|
+
if ch == "[":
|
|
44
|
+
# Character class -- skip until unescaped ]
|
|
45
|
+
pos += 1
|
|
46
|
+
while pos < len(source):
|
|
47
|
+
if source[pos] == "\\":
|
|
48
|
+
pos += 2
|
|
49
|
+
continue
|
|
50
|
+
if source[pos] == "]":
|
|
51
|
+
break
|
|
52
|
+
pos += 1
|
|
53
|
+
elif ch == "/":
|
|
54
|
+
pos += 1
|
|
55
|
+
# Skip flags (g, i, m, s, u, y, d)
|
|
56
|
+
while pos < len(source) and source[pos].isalpha():
|
|
57
|
+
pos += 1
|
|
58
|
+
return pos
|
|
59
|
+
elif ch == "\n":
|
|
60
|
+
# Unterminated regex -- bail out (not a real regex)
|
|
61
|
+
return start + 1
|
|
62
|
+
pos += 1
|
|
63
|
+
return pos
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_regex_start(source: str, pos: int) -> bool:
|
|
67
|
+
"""Determine if '/' at pos is a regex start (True) or division operator (False).
|
|
68
|
+
|
|
69
|
+
Looks at the previous non-whitespace character/word for context.
|
|
70
|
+
Division follows values: ), ], identifiers, digits.
|
|
71
|
+
Regex follows operators, keywords, punctuation, or nothing.
|
|
72
|
+
"""
|
|
73
|
+
i = pos - 1
|
|
74
|
+
while i >= 0 and source[i] in " \t":
|
|
75
|
+
i -= 1
|
|
76
|
+
if i < 0:
|
|
77
|
+
return True # start of string
|
|
78
|
+
prev = source[i]
|
|
79
|
+
# After these, '/' is division (follows a value)
|
|
80
|
+
if prev in ")]}":
|
|
81
|
+
return False
|
|
82
|
+
if prev.isalnum() or prev == "_" or prev == "$":
|
|
83
|
+
# Could be an identifier (division) or a keyword (regex).
|
|
84
|
+
end = i + 1
|
|
85
|
+
while i >= 0 and (source[i].isalnum() or source[i] in "_$"):
|
|
86
|
+
i -= 1
|
|
87
|
+
word = source[i + 1: end]
|
|
88
|
+
# After these keywords, '/' starts a regex
|
|
89
|
+
regex_keywords = {
|
|
90
|
+
"return", "typeof", "instanceof", "in", "delete",
|
|
91
|
+
"void", "throw", "new", "case", "yield", "await",
|
|
92
|
+
}
|
|
93
|
+
return word in regex_keywords
|
|
94
|
+
# After everything else (=, (, [, {, ,, ;, :, !, &, |, ?, ~, +, -, *, <, >, ^, %, newline)
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def skip_non_code(source: str, pos: int) -> tuple[int, bool]:
|
|
99
|
+
"""Skip strings, comments, and regex literals at current position.
|
|
100
|
+
|
|
101
|
+
Returns (new_pos, did_skip). If did_skip is False, the character at pos
|
|
102
|
+
is actual code and should be processed.
|
|
103
|
+
"""
|
|
104
|
+
two = source[pos: pos + 2]
|
|
105
|
+
if two == "//":
|
|
106
|
+
nl = source.find("\n", pos)
|
|
107
|
+
return (nl + 1 if nl != -1 else len(source)), True
|
|
108
|
+
if two == "/*":
|
|
109
|
+
end = source.find("*/", pos + 2)
|
|
110
|
+
return (end + 2 if end != -1 else len(source)), True
|
|
111
|
+
if source[pos] in "\"'`":
|
|
112
|
+
return skip_string(source, pos), True
|
|
113
|
+
if source[pos] == "/" and is_regex_start(source, pos):
|
|
114
|
+
return skip_regex_literal(source, pos), True
|
|
115
|
+
return pos, False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def extract_brace_block(source: str, open_brace_pos: int) -> str:
|
|
119
|
+
"""Extract content between matching { } braces, skipping strings/comments/regex.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
source: The full source text.
|
|
123
|
+
open_brace_pos: Index of the opening '{'.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
The text between the braces (exclusive of the braces themselves).
|
|
127
|
+
"""
|
|
128
|
+
depth = 0
|
|
129
|
+
pos = open_brace_pos
|
|
130
|
+
while pos < len(source):
|
|
131
|
+
new_pos, skipped = skip_non_code(source, pos)
|
|
132
|
+
if skipped:
|
|
133
|
+
pos = new_pos
|
|
134
|
+
continue
|
|
135
|
+
if source[pos] == "{":
|
|
136
|
+
depth += 1
|
|
137
|
+
elif source[pos] == "}":
|
|
138
|
+
depth -= 1
|
|
139
|
+
if depth == 0:
|
|
140
|
+
return source[open_brace_pos + 1: pos]
|
|
141
|
+
pos += 1
|
|
142
|
+
return source[open_brace_pos + 1:]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def extract_property_names(object_body: str) -> list[str]:
|
|
146
|
+
"""Extract top-level property names from a JS object literal body.
|
|
147
|
+
|
|
148
|
+
Uses an expect_key flag: only captures identifiers right after a comma
|
|
149
|
+
or at block start. This prevents false positives from nested code
|
|
150
|
+
(e.g., function calls like clearInterval inside method bodies).
|
|
151
|
+
|
|
152
|
+
Returns deduplicated list preserving first-occurrence order.
|
|
153
|
+
"""
|
|
154
|
+
names = []
|
|
155
|
+
depth = 0
|
|
156
|
+
expect_key = True
|
|
157
|
+
pos = 0
|
|
158
|
+
while pos < len(object_body):
|
|
159
|
+
new_pos, skipped = skip_non_code(object_body, pos)
|
|
160
|
+
if skipped:
|
|
161
|
+
pos = new_pos
|
|
162
|
+
continue
|
|
163
|
+
ch = object_body[pos]
|
|
164
|
+
if ch in "{[(":
|
|
165
|
+
depth += 1
|
|
166
|
+
elif ch in "}])":
|
|
167
|
+
depth -= 1
|
|
168
|
+
elif depth == 0 and ch == ",":
|
|
169
|
+
expect_key = True
|
|
170
|
+
elif depth == 0 and expect_key and (ch.isalpha() or ch in "_$"):
|
|
171
|
+
match = re.match(r"(?:async\s+)?(\w+)", object_body[pos:])
|
|
172
|
+
if match:
|
|
173
|
+
names.append(match.group(1))
|
|
174
|
+
expect_key = False
|
|
175
|
+
pos += match.end() - 1
|
|
176
|
+
pos += 1
|
|
177
|
+
return list(dict.fromkeys(names))
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mixin analysis — extract members and lifecycle hooks from Vue mixin source code.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .js_parser import extract_brace_block, extract_property_names
|
|
8
|
+
|
|
9
|
+
VUE_LIFECYCLE_HOOKS = [
|
|
10
|
+
"beforeCreate", "created", "beforeMount", "mounted",
|
|
11
|
+
"beforeUpdate", "updated", "beforeDestroy", "destroyed",
|
|
12
|
+
"beforeUnmount", "unmounted", "activated", "deactivated",
|
|
13
|
+
"errorCaptured", "renderTracked", "renderTriggered",
|
|
14
|
+
"onBeforeMount", "onMounted", "onBeforeUpdate", "onUpdated",
|
|
15
|
+
"onBeforeUnmount", "onUnmounted", "onActivated", "onDeactivated",
|
|
16
|
+
"onErrorCaptured", "onRenderTracked", "onRenderTriggered",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def extract_mixin_members(source: str) -> dict[str, list[str]]:
|
|
21
|
+
"""Extract data, computed, and methods property names from a mixin.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Dict with keys 'data', 'computed', 'methods', each mapping to
|
|
25
|
+
a list of property name strings.
|
|
26
|
+
"""
|
|
27
|
+
members: dict[str, list[str]] = {"data": [], "computed": [], "methods": []}
|
|
28
|
+
|
|
29
|
+
# data() { return { ... } }
|
|
30
|
+
data_match = re.search(r"\bdata\s*\(\s*\)\s*\{", source)
|
|
31
|
+
if data_match:
|
|
32
|
+
body = source[data_match.end():]
|
|
33
|
+
ret = re.search(r"\breturn\s*\{", body)
|
|
34
|
+
if ret:
|
|
35
|
+
brace_pos = ret.start() + ret.group().index("{")
|
|
36
|
+
members["data"] = extract_property_names(extract_brace_block(body, brace_pos))
|
|
37
|
+
|
|
38
|
+
# computed: { ... } and methods: { ... }
|
|
39
|
+
for section in ("computed", "methods"):
|
|
40
|
+
match = re.search(rf"\b{section}\s*:\s*\{{", source)
|
|
41
|
+
if match:
|
|
42
|
+
members[section] = extract_property_names(
|
|
43
|
+
extract_brace_block(source, match.end() - 1)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return members
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def extract_lifecycle_hooks(source: str) -> list[str]:
|
|
50
|
+
"""Find Vue lifecycle hooks defined in the mixin source."""
|
|
51
|
+
return [
|
|
52
|
+
hook for hook in VUE_LIFECYCLE_HOOKS
|
|
53
|
+
if re.search(rf"\b{hook}\s*(?:\(|:\s*(?:function|\())", source)
|
|
54
|
+
]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models for the Vue mixin migration tool.
|
|
3
|
+
|
|
4
|
+
Typed dataclasses replace the raw dicts used in the original scripts,
|
|
5
|
+
providing clear structure and computed properties.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MigrationStatus(Enum):
|
|
15
|
+
"""Status of a single mixin's migration readiness."""
|
|
16
|
+
READY = "ready"
|
|
17
|
+
BLOCKED_NO_COMPOSABLE = "blocked_no_composable"
|
|
18
|
+
BLOCKED_MISSING_MEMBERS = "blocked_missing"
|
|
19
|
+
BLOCKED_NOT_RETURNED = "blocked_not_returned"
|
|
20
|
+
FORCE_UNBLOCKED = "force_unblocked"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class MixinMembers:
|
|
25
|
+
"""Members extracted from a mixin source file."""
|
|
26
|
+
data: list[str] = field(default_factory=list)
|
|
27
|
+
computed: list[str] = field(default_factory=list)
|
|
28
|
+
methods: list[str] = field(default_factory=list)
|
|
29
|
+
watch: list[str] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def all_names(self) -> list[str]:
|
|
33
|
+
return self.data + self.computed + self.methods + self.watch
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class MemberClassification:
|
|
38
|
+
"""Classification of how mixin members relate to a composable and the component.
|
|
39
|
+
|
|
40
|
+
Given a set of used members, a composable's capabilities, and the component's
|
|
41
|
+
own members, this classifies each member into categories that determine
|
|
42
|
+
whether injection can proceed.
|
|
43
|
+
"""
|
|
44
|
+
missing: list[str] = field(default_factory=list)
|
|
45
|
+
"""Members not found anywhere in the composable."""
|
|
46
|
+
truly_missing: list[str] = field(default_factory=list)
|
|
47
|
+
"""Missing members that the component does NOT override (blockers)."""
|
|
48
|
+
not_returned: list[str] = field(default_factory=list)
|
|
49
|
+
"""Members present in composable but not in its return statement."""
|
|
50
|
+
truly_not_returned: list[str] = field(default_factory=list)
|
|
51
|
+
"""Not-returned members that the component does NOT override (blockers)."""
|
|
52
|
+
overridden: list[str] = field(default_factory=list)
|
|
53
|
+
"""Missing members that the component defines itself (safe to skip)."""
|
|
54
|
+
overridden_not_returned: list[str] = field(default_factory=list)
|
|
55
|
+
"""Not-returned members that the component defines itself (safe to skip)."""
|
|
56
|
+
injectable: list[str] = field(default_factory=list)
|
|
57
|
+
"""Members the composable should provide (used - overridden)."""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_ready(self) -> bool:
|
|
61
|
+
return not self.truly_missing and not self.truly_not_returned
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class ComposableCoverage:
|
|
66
|
+
"""Analysis of a composable's coverage of a mixin's members."""
|
|
67
|
+
file_path: Path
|
|
68
|
+
fn_name: str
|
|
69
|
+
import_path: str
|
|
70
|
+
all_identifiers: list[str] = field(default_factory=list)
|
|
71
|
+
return_keys: list[str] = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
def classify_members(
|
|
74
|
+
self,
|
|
75
|
+
used: list[str],
|
|
76
|
+
component_own_members: set[str],
|
|
77
|
+
) -> MemberClassification:
|
|
78
|
+
"""Classify each used member by availability in the composable and component."""
|
|
79
|
+
missing = [m for m in used if m not in self.all_identifiers]
|
|
80
|
+
not_returned = [m for m in used if m in self.all_identifiers and m not in self.return_keys]
|
|
81
|
+
|
|
82
|
+
overridden = [m for m in missing if m in component_own_members]
|
|
83
|
+
truly_missing = [m for m in missing if m not in component_own_members]
|
|
84
|
+
overridden_not_returned = [m for m in not_returned if m in component_own_members]
|
|
85
|
+
truly_not_returned = [m for m in not_returned if m not in component_own_members]
|
|
86
|
+
|
|
87
|
+
injectable = [
|
|
88
|
+
m for m in used
|
|
89
|
+
if m not in overridden and m not in overridden_not_returned
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
return MemberClassification(
|
|
93
|
+
missing=missing,
|
|
94
|
+
truly_missing=truly_missing,
|
|
95
|
+
not_returned=not_returned,
|
|
96
|
+
truly_not_returned=truly_not_returned,
|
|
97
|
+
overridden=overridden,
|
|
98
|
+
overridden_not_returned=overridden_not_returned,
|
|
99
|
+
injectable=injectable,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class MixinEntry:
|
|
105
|
+
"""Complete analysis of a single mixin used by a component."""
|
|
106
|
+
local_name: str
|
|
107
|
+
"""The local import name (e.g. 'selectionMixin')."""
|
|
108
|
+
mixin_path: Path
|
|
109
|
+
"""Resolved path to the mixin file."""
|
|
110
|
+
mixin_stem: str
|
|
111
|
+
"""Filename stem of the mixin (e.g. 'selectionMixin')."""
|
|
112
|
+
members: MixinMembers
|
|
113
|
+
"""Extracted mixin members."""
|
|
114
|
+
lifecycle_hooks: list[str] = field(default_factory=list)
|
|
115
|
+
"""Lifecycle hooks found in the mixin."""
|
|
116
|
+
used_members: list[str] = field(default_factory=list)
|
|
117
|
+
"""Members actually referenced by the component."""
|
|
118
|
+
composable: Optional[ComposableCoverage] = None
|
|
119
|
+
"""Matched composable, if found."""
|
|
120
|
+
classification: Optional[MemberClassification] = None
|
|
121
|
+
"""Member classification against the composable."""
|
|
122
|
+
status: MigrationStatus = MigrationStatus.BLOCKED_NO_COMPOSABLE
|
|
123
|
+
"""Current migration status."""
|
|
124
|
+
|
|
125
|
+
def compute_status(self) -> MigrationStatus:
|
|
126
|
+
"""Determine the migration status based on analysis results."""
|
|
127
|
+
if not self.used_members:
|
|
128
|
+
self.status = MigrationStatus.READY
|
|
129
|
+
elif not self.composable or not self.classification:
|
|
130
|
+
self.status = MigrationStatus.BLOCKED_NO_COMPOSABLE
|
|
131
|
+
elif self.classification.is_ready:
|
|
132
|
+
self.status = MigrationStatus.READY
|
|
133
|
+
elif self.classification.truly_missing:
|
|
134
|
+
self.status = MigrationStatus.BLOCKED_MISSING_MEMBERS
|
|
135
|
+
else:
|
|
136
|
+
self.status = MigrationStatus.BLOCKED_NOT_RETURNED
|
|
137
|
+
return self.status
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class FileChange:
|
|
142
|
+
"""Represents a planned or completed file modification."""
|
|
143
|
+
file_path: Path
|
|
144
|
+
original_content: str
|
|
145
|
+
new_content: str
|
|
146
|
+
changes: list[str] = field(default_factory=list)
|
|
147
|
+
"""Human-readable descriptions of each change made."""
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def has_changes(self) -> bool:
|
|
151
|
+
return self.original_content != self.new_content
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class MigrationConfig:
|
|
156
|
+
"""Configuration for the migration tool."""
|
|
157
|
+
project_root: Path = field(default_factory=Path.cwd)
|
|
158
|
+
skip_dirs: set[str] = field(
|
|
159
|
+
default_factory=lambda: {"node_modules", "dist", ".git", "__pycache__"}
|
|
160
|
+
)
|
|
161
|
+
file_extensions: set[str] = field(
|
|
162
|
+
default_factory=lambda: {".vue", ".js", ".ts"}
|
|
163
|
+
)
|
|
164
|
+
composable_dir_name: str = "composables"
|
|
165
|
+
backup_enabled: bool = True
|
|
166
|
+
dry_run: bool = False
|
|
167
|
+
auto_confirm: bool = False
|
|
168
|
+
indent: str = " "
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|