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,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