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
package/bin/cli.js ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { spawnSync } = require('child_process');
5
+ const path = require('path');
6
+
7
+ const pythonCandidates = process.platform === 'win32'
8
+ ? ['python', 'python3', 'py']
9
+ : ['python3', 'python'];
10
+
11
+ // The package root is one level up from bin/
12
+ const pkgRoot = path.join(__dirname, '..');
13
+
14
+ let python = null;
15
+ for (const candidate of pythonCandidates) {
16
+ const probe = spawnSync(candidate, ['--version'], { encoding: 'utf8' });
17
+ if (probe.status === 0) {
18
+ python = candidate;
19
+ break;
20
+ }
21
+ }
22
+
23
+ if (!python) {
24
+ console.error(
25
+ '\n Error: Python 3 is required but was not found in PATH.\n' +
26
+ ' Install Python 3 from https://python.org and try again.\n'
27
+ );
28
+ process.exit(1);
29
+ }
30
+
31
+ const result = spawnSync(
32
+ python,
33
+ ['-m', 'vue3_migration', ...process.argv.slice(2)],
34
+ {
35
+ cwd: pkgRoot,
36
+ stdio: 'inherit',
37
+ env: process.env,
38
+ }
39
+ );
40
+
41
+ process.exit(result.status ?? 1);
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "vue3-migration",
3
+ "version": "1.0.0",
4
+ "description": "Vue 2 mixin to Vue 3 composable migration tool",
5
+ "bin": {
6
+ "vue3-migration": "./bin/cli.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "vue3_migration/"
11
+ ],
12
+ "engines": {
13
+ "node": ">=14"
14
+ },
15
+ "keywords": [
16
+ "vue",
17
+ "vue3",
18
+ "migration",
19
+ "composable",
20
+ "mixin",
21
+ "vue2"
22
+ ],
23
+ "license": "MIT"
24
+ }
@@ -0,0 +1,3 @@
1
+ """Vue Mixin Migration Tool — Automates Vue 2 mixin to Vue 3 composable migration."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,6 @@
1
+ """Allow running the package with: python -m vue3_migration"""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,323 @@
1
+ """
2
+ CLI entry point for the Vue Mixin Migration Tool.
3
+
4
+ Provides interactive menu and subcommands: scan, component, audit.
5
+ Replaces the original migrate.py subprocess-based delegation with
6
+ direct module imports.
7
+ """
8
+
9
+ import os
10
+ import re
11
+ import sys
12
+ from collections import Counter
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ from .core.component_analyzer import parse_imports, parse_mixins_array
17
+ from .core.composable_search import (
18
+ collect_composable_stems,
19
+ find_composable_dirs,
20
+ mixin_has_composable,
21
+ )
22
+ from .core.file_resolver import resolve_mixin_stem
23
+ from .models import MigrationConfig
24
+ from .reporting.terminal import bold, cyan, dim, green, red, yellow
25
+ from .workflows import component_workflow, mixin_workflow
26
+
27
+
28
+ # =============================================================================
29
+ # Scan
30
+ # =============================================================================
31
+
32
+ def _parse_mixins_array(source: str) -> list[str]:
33
+ """Quick extraction of mixin names from `mixins: [A, B, C]`."""
34
+ return parse_mixins_array(source)
35
+
36
+
37
+ def _parse_imports(source: str) -> dict[str, str]:
38
+ """Parse import statements. Returns { local_name: import_path }."""
39
+ return parse_imports(source)
40
+
41
+
42
+ def _find_mixin_file(mixin_stem: str, project_root: Path) -> Optional[Path]:
43
+ """Try to find a mixin file by its stem name."""
44
+ for dirpath, _, filenames in os.walk(project_root):
45
+ rel = Path(dirpath).relative_to(project_root)
46
+ if any(p in {"node_modules", "dist", ".git"} for p in rel.parts):
47
+ continue
48
+ for fn in filenames:
49
+ fp = Path(dirpath) / fn
50
+ if fp.suffix in (".js", ".ts") and fp.stem == mixin_stem:
51
+ return fp
52
+ return None
53
+
54
+
55
+ def scan_project(project_root: Path, config: MigrationConfig):
56
+ """Walk the project and find every component that still uses mixins."""
57
+
58
+ print(f"\n{bold('Scanning project for components with mixins...')}\n")
59
+
60
+ components = [] # list of (rel_path, mixin_local_names, mixin_stems)
61
+
62
+ for dirpath, _, filenames in os.walk(project_root):
63
+ rel_dir = Path(dirpath).relative_to(project_root)
64
+ if any(part in config.skip_dirs for part in rel_dir.parts):
65
+ continue
66
+
67
+ for fn in filenames:
68
+ if not fn.endswith(".vue"):
69
+ continue
70
+
71
+ filepath = Path(dirpath) / fn
72
+ try:
73
+ source = filepath.read_text(errors="ignore")
74
+ except Exception:
75
+ continue
76
+
77
+ mixin_names = _parse_mixins_array(source)
78
+ if not mixin_names:
79
+ continue
80
+
81
+ imports = _parse_imports(source)
82
+ stems = []
83
+ for name in mixin_names:
84
+ imp = imports.get(name, "")
85
+ stems.append(resolve_mixin_stem(imp) if imp else name)
86
+
87
+ try:
88
+ rel = filepath.relative_to(project_root)
89
+ except ValueError:
90
+ rel = filepath
91
+
92
+ components.append((rel, mixin_names, stems))
93
+
94
+ if not components:
95
+ print(f"{green('No components with mixins found.')} Migration complete!\n")
96
+ return
97
+
98
+ # Sort by number of mixins (most first)
99
+ components.sort(key=lambda c: -len(c[1]))
100
+
101
+ # Count mixin frequencies
102
+ mixin_counter: Counter[str] = Counter()
103
+ for _, _, stems in components:
104
+ for stem in stems:
105
+ mixin_counter[stem] += 1
106
+
107
+ # Check which composables already exist
108
+ composable_dirs = find_composable_dirs(project_root)
109
+ composable_stems = collect_composable_stems(composable_dirs)
110
+
111
+ # --- Display results ---
112
+ print(f"{bold('Project Overview')}")
113
+ print(f" Components with mixins: {yellow(str(len(components)))}")
114
+ print(f" Unique mixins in use: {yellow(str(len(mixin_counter)))}")
115
+ print(f" Composables directories: {len(composable_dirs)}")
116
+ print()
117
+
118
+ # Mixin frequency table
119
+ print(f"{bold('Mixin Usage Across Project')}")
120
+ print(f" {'Mixin':<40} {'Used in':<10} {'Composable?'}")
121
+ print(f" {'-' * 40} {'-' * 10} {'-' * 12}")
122
+
123
+ for mixin_stem, count in mixin_counter.most_common():
124
+ has_comp = mixin_has_composable(mixin_stem, composable_stems)
125
+ comp_status = green("found") if has_comp else dim("not found")
126
+ print(f" {mixin_stem:<40} {count:<10} {comp_status}")
127
+
128
+ print()
129
+
130
+ # Component list
131
+ print(f"{bold('Components')}")
132
+ print()
133
+
134
+ for idx, (rel_path, mixin_names, stems) in enumerate(components, 1):
135
+ mixin_count = len(mixin_names)
136
+ color = green if mixin_count <= 2 else (yellow if mixin_count <= 4 else red)
137
+ count_str = color(f"{mixin_count} mixin{'s' if mixin_count != 1 else ''}")
138
+
139
+ covered = sum(1 for s in stems if mixin_has_composable(s, composable_stems))
140
+ if covered == len(stems):
141
+ coverage = green("all composables found")
142
+ elif covered > 0:
143
+ coverage = yellow(f"{covered}/{len(stems)} composables found")
144
+ else:
145
+ coverage = dim("no composables yet")
146
+
147
+ print(f" {bold(str(idx) + '.')} {green(str(rel_path))}")
148
+ print(f" {count_str} -- {coverage}")
149
+ print(f" {dim(', '.join(stems))}")
150
+
151
+ # Actions
152
+ print(f"\n{bold('What would you like to do?')}\n")
153
+ print(f" Enter a {bold('number')} (1-{len(components)}) to migrate that component.")
154
+ print(f" Enter a {bold('mixin name')} to audit it across the project.")
155
+ print(f" Enter {bold('q')} to quit.\n")
156
+
157
+ choice = input(" > ").strip()
158
+
159
+ if not choice or choice.lower() == "q":
160
+ return
161
+
162
+ # Number -> migrate component
163
+ try:
164
+ idx = int(choice)
165
+ if 1 <= idx <= len(components):
166
+ rel_path = components[idx - 1][0]
167
+ print()
168
+ component_workflow.run(str(rel_path), config)
169
+ return
170
+ else:
171
+ print(f" {yellow('Number out of range.')}")
172
+ return
173
+ except ValueError:
174
+ pass
175
+
176
+ # Text -> try to match a mixin name
177
+ matched_stem = None
178
+ for mixin_stem in mixin_counter:
179
+ if choice.lower() == mixin_stem.lower():
180
+ matched_stem = mixin_stem
181
+ break
182
+
183
+ if not matched_stem:
184
+ for mixin_stem in mixin_counter:
185
+ if choice.lower() in mixin_stem.lower():
186
+ matched_stem = mixin_stem
187
+ break
188
+
189
+ if matched_stem:
190
+ mixin_file = _find_mixin_file(matched_stem, project_root)
191
+ if mixin_file:
192
+ print(f"\n Auditing {green(matched_stem)} -> {green(str(mixin_file))}\n")
193
+ mixin_workflow.run(str(mixin_file), config=config)
194
+ else:
195
+ print(f"\n {yellow('Could not locate the mixin file for')} {matched_stem}.")
196
+ user_path = input(f" Enter the mixin file path: ").strip()
197
+ if user_path:
198
+ mixin_workflow.run(user_path, config=config)
199
+ else:
200
+ print(f" {yellow('No matching mixin found for')} '{choice}'.")
201
+
202
+
203
+ # =============================================================================
204
+ # Interactive Menu
205
+ # =============================================================================
206
+
207
+ def interactive_menu(config: MigrationConfig):
208
+ """Show the main interactive menu when no arguments are provided."""
209
+
210
+ print()
211
+ print(f" {bold('Vue Mixin Migration Tool')}")
212
+ print(f" {dim('Helps you migrate Vue 2 mixins to Vue 3 composables.')}")
213
+ print()
214
+ print(f" This tool supports two migration workflows:\n")
215
+ print(f" {bold('1.')} {green('Scan project')}")
216
+ print(f" Get an overview of all components still using mixins,")
217
+ print(f" see which composables already exist, and pick a component")
218
+ print(f" or mixin to work on next.\n")
219
+ print(f" {bold('2.')} {green('Migrate a component')}")
220
+ print(f" Given a single .vue file, find all its mixins, match them")
221
+ print(f" to composables, check coverage, and inject setup() for")
222
+ print(f" every mixin that has a ready composable.\n")
223
+ print(f" {bold('3.')} {green('Audit a mixin')}")
224
+ print(f" Given a mixin file, find every component that uses it,")
225
+ print(f" show which members each component relies on, and compare")
226
+ print(f" against a composable to see what's covered.\n")
227
+ print(f" {bold('q.')} Quit\n")
228
+
229
+ choice = input(f" Choose (1/2/3/q): ").strip()
230
+ print()
231
+
232
+ if choice == "1":
233
+ scan_project(config.project_root, config)
234
+
235
+ elif choice == "2":
236
+ print(f" {bold('Migrate a component')}")
237
+ print(f" {dim('Enter the path to a .vue file relative to the project root.')}\n")
238
+ path = input(f" Component path: ").strip()
239
+ if path:
240
+ component_workflow.run(path, config)
241
+ else:
242
+ print(f" {yellow('No path provided.')}")
243
+
244
+ elif choice == "3":
245
+ print(f" {bold('Audit a mixin')}")
246
+ print(f" {dim('Enter the path to a mixin .js/.ts file.')}")
247
+ print(f" {dim('Optionally, also provide a composable path to compare against.')}\n")
248
+ mixin_path = input(f" Mixin path: ").strip()
249
+ if not mixin_path:
250
+ print(f" {yellow('No path provided.')}")
251
+ return
252
+ composable_path = input(f" Composable path (optional, press Enter to skip): ").strip()
253
+ mixin_workflow.run(mixin_path, composable_path or None, config)
254
+
255
+ elif choice.lower() == "q":
256
+ return
257
+
258
+ else:
259
+ print(f" {yellow('Invalid choice.')}")
260
+
261
+
262
+ # =============================================================================
263
+ # CLI Entry Point
264
+ # =============================================================================
265
+
266
+ def main():
267
+ config = MigrationConfig()
268
+
269
+ # No arguments -> interactive menu
270
+ if len(sys.argv) < 2:
271
+ interactive_menu(config)
272
+ return
273
+
274
+ command = sys.argv[1].lower()
275
+
276
+ if command == "scan":
277
+ scan_project(config.project_root, config)
278
+
279
+ elif command == "component":
280
+ if len(sys.argv) < 3:
281
+ print(f"\n {bold('Migrate a component')}")
282
+ print(f" {dim('Finds all mixins in a component, matches them to composables,')}")
283
+ print(f" {dim('and injects setup() for those with full coverage.')}\n")
284
+ print(f" Usage: python -m vue3_migration component <path/to/Component.vue>\n")
285
+ return
286
+ component_workflow.run(sys.argv[2], config)
287
+
288
+ elif command == "audit":
289
+ if len(sys.argv) < 3:
290
+ print(f"\n {bold('Audit a mixin')}")
291
+ print(f" {dim('Finds every component using a mixin, shows member usage,')}")
292
+ print(f" {dim('and compares against a composable if provided.')}\n")
293
+ print(f" Usage: python -m vue3_migration audit <mixin_path> [composable_path]\n")
294
+ return
295
+ composable_arg = sys.argv[3] if len(sys.argv) > 3 else None
296
+ mixin_workflow.run(sys.argv[2], composable_arg, config)
297
+
298
+ elif command in ("help", "--help", "-h"):
299
+ print(f"""
300
+ {bold('Vue Mixin Migration Tool')}
301
+
302
+ {bold('Usage:')}
303
+ python -m vue3_migration Interactive menu
304
+ python -m vue3_migration scan Scan project for migration status
305
+ python -m vue3_migration component <path.vue> Migrate one component
306
+ python -m vue3_migration audit <mixin> [composable] Audit one mixin
307
+
308
+ {bold('Workflow:')}
309
+ 1. Run {green('scan')} to see which components still use mixins
310
+ and which composables are already available.
311
+
312
+ 2. Pick a component and run {green('component')} to migrate it.
313
+ The tool will match each mixin to a composable, report what's
314
+ missing, and inject setup() for every ready composable.
315
+
316
+ 3. If you want to focus on a single mixin across the codebase,
317
+ run {green('audit')} to see every component that depends on it
318
+ and what members they use.
319
+ """)
320
+
321
+ else:
322
+ print(f"\n {yellow('Unknown command')}: {command}")
323
+ print(f" Run {bold('python -m vue3_migration help')} to see available commands.\n")
File without changes
@@ -0,0 +1,76 @@
1
+ """
2
+ Component analysis — parse imports, mixins arrays, member usage, and
3
+ component-defined members from Vue component source code.
4
+ """
5
+
6
+ import re
7
+
8
+ from .js_parser import extract_brace_block, extract_property_names
9
+
10
+
11
+ def parse_imports(component_source: str) -> dict[str, str]:
12
+ """Parse import statements. Returns { local_name: import_path }."""
13
+ imports = {}
14
+ for match in re.finditer(r"""import\s+(\w+)\s+from\s+['"]([^'"]+)['"]""", component_source):
15
+ imports[match.group(1)] = match.group(2)
16
+ return imports
17
+
18
+
19
+ def parse_mixins_array(component_source: str) -> list[str]:
20
+ """Extract the local variable names from `mixins: [A, B, C]`."""
21
+ match = re.search(r"mixins\s*:\s*\[([^\]]*)\]", component_source)
22
+ if not match:
23
+ return []
24
+ return [name.strip() for name in match.group(1).split(",") if name.strip()]
25
+
26
+
27
+ def find_used_members(component_source: str, member_names: list[str]) -> list[str]:
28
+ """Find which mixin members are referenced in the component (script + template).
29
+
30
+ For .vue files, searches within <script> and <template> sections.
31
+ Uses word-boundary regex to avoid partial matches.
32
+ """
33
+ sections = []
34
+ for tag in ("script", "template"):
35
+ tag_match = re.search(rf"<{tag}[^>]*>(.*?)</{tag}>", component_source, re.DOTALL)
36
+ if tag_match:
37
+ sections.append(tag_match.group(1))
38
+ search_text = "\n".join(sections) if sections else component_source
39
+
40
+ return [
41
+ member for member in member_names
42
+ if re.search(rf"(?<!\w){re.escape(member)}(?!\w)", search_text)
43
+ ]
44
+
45
+
46
+ def extract_own_members(component_source: str) -> set[str]:
47
+ """Extract member names defined in the component's OWN data/computed/methods/watch.
48
+
49
+ These are members the component defines itself (not inherited from mixins).
50
+ When a component overrides a mixin member, the component's version takes
51
+ precedence, so the composable doesn't need to provide it.
52
+ """
53
+ own_members: set[str] = set()
54
+
55
+ # Extract just the <script> section for .vue files
56
+ script_match = re.search(r"<script[^>]*>(.*?)</script>", component_source, re.DOTALL)
57
+ source = script_match.group(1) if script_match else component_source
58
+
59
+ # data() { return { ... } }
60
+ data_match = re.search(r"\bdata\s*\(\s*\)\s*\{", source)
61
+ if data_match:
62
+ body = source[data_match.end():]
63
+ ret = re.search(r"\breturn\s*\{", body)
64
+ if ret:
65
+ brace_pos = ret.start() + ret.group().index("{")
66
+ own_members.update(extract_property_names(extract_brace_block(body, brace_pos)))
67
+
68
+ # computed, methods, watch sections
69
+ for section in ("computed", "methods", "watch"):
70
+ match = re.search(rf"\b{section}\s*:\s*\{{", source)
71
+ if match:
72
+ own_members.update(
73
+ extract_property_names(extract_brace_block(source, match.end() - 1))
74
+ )
75
+
76
+ return own_members
@@ -0,0 +1,86 @@
1
+ """
2
+ Composable analysis — extract identifiers, return keys, and function names
3
+ from Vue 3 composable source files.
4
+ """
5
+
6
+ import re
7
+ from typing import Optional
8
+
9
+ from .js_parser import extract_brace_block
10
+
11
+
12
+ def extract_all_identifiers(source: str) -> list[str]:
13
+ """Extract ALL identifiers defined anywhere in a composable file.
14
+
15
+ Covers: variable declarations, function declarations, destructuring,
16
+ and return statement keys.
17
+ """
18
+ ids: set[str] = set()
19
+
20
+ # const/let/var name = ...
21
+ ids.update(re.findall(r"\b(?:const|let|var)\s+(\w+)", source))
22
+
23
+ # function name(...)
24
+ ids.update(re.findall(r"\bfunction\s+(\w+)", source))
25
+
26
+ # Destructured: const { a, b: renamed, c = default } = ...
27
+ for match in re.finditer(r"\b(?:const|let|var)\s*\{([^}]+)\}", source):
28
+ for part in match.group(1).split(","):
29
+ part = part.strip()
30
+ if not part:
31
+ continue
32
+ if ":" in part:
33
+ ids.add(part.split(":")[1].strip().split("=")[0].strip())
34
+ else:
35
+ ids.add(part.split("=")[0].strip())
36
+
37
+ # Return keys: return { foo, bar, baz: val }
38
+ ret = re.search(r"\breturn\s*\{", source)
39
+ if ret:
40
+ block = extract_brace_block(source, ret.end() - 1)
41
+ ids.update(re.findall(r"\b(\w+)\s*[,}\n:]", block))
42
+
43
+ noise = {
44
+ "const", "let", "var", "function", "return", "if", "else", "new",
45
+ "true", "false", "null", "undefined", "async", "await", "from", "import",
46
+ }
47
+ return sorted(ids - noise)
48
+
49
+
50
+ def extract_return_keys(source: str) -> list[str]:
51
+ """Extract ONLY the keys from the composable's return { ... } statement.
52
+
53
+ Members must be in the return statement to be accessible by the component.
54
+ """
55
+ ret = re.search(r"\breturn\s*\{", source)
56
+ if not ret:
57
+ return []
58
+
59
+ block = extract_brace_block(source, ret.end() - 1)
60
+ keys = re.findall(r"\b(\w+)\b", block)
61
+
62
+ noise = {
63
+ "const", "let", "var", "function", "return", "true", "false",
64
+ "null", "undefined", "new", "value",
65
+ }
66
+ return list(dict.fromkeys(k for k in keys if k not in noise))
67
+
68
+
69
+ def extract_function_name(source: str) -> Optional[str]:
70
+ """Find the exported function name (e.g. useSelection) in a composable.
71
+
72
+ Checks for:
73
+ - export function useName(
74
+ - export default function useName(
75
+ - export const useName =
76
+ - export default const useName =
77
+ """
78
+ # export function useName( / export default function useName(
79
+ match = re.search(r"\bexport\s+(?:default\s+)?function\s+(\w+)", source)
80
+ if match:
81
+ return match.group(1)
82
+ # export const useName = (
83
+ match = re.search(r"\bexport\s+(?:default\s+)?const\s+(\w+)\s*=", source)
84
+ if match:
85
+ return match.group(1)
86
+ return None