commiter-cli 0.3.0__py3-none-any.whl

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 (96) hide show
  1. commiter/__init__.py +3 -0
  2. commiter/adapters/__init__.py +0 -0
  3. commiter/adapters/base.py +96 -0
  4. commiter/adapters/django_rest.py +247 -0
  5. commiter/adapters/express.py +204 -0
  6. commiter/adapters/fastapi.py +170 -0
  7. commiter/adapters/flask.py +169 -0
  8. commiter/adapters/nextjs.py +180 -0
  9. commiter/adapters/prisma.py +76 -0
  10. commiter/adapters/raw_sql.py +191 -0
  11. commiter/adapters/react.py +129 -0
  12. commiter/adapters/sqlalchemy.py +99 -0
  13. commiter/adapters/supabase.py +68 -0
  14. commiter/auth.py +130 -0
  15. commiter/cli.py +667 -0
  16. commiter/correlator.py +208 -0
  17. commiter/extractors/__init__.py +0 -0
  18. commiter/extractors/api_calls.py +91 -0
  19. commiter/extractors/api_endpoints.py +354 -0
  20. commiter/extractors/backend_files.py +33 -0
  21. commiter/extractors/base.py +40 -0
  22. commiter/extractors/db_operations.py +69 -0
  23. commiter/extractors/dependencies.py +219 -0
  24. commiter/generic_resolver.py +204 -0
  25. commiter/handler_index.py +97 -0
  26. commiter/lib.py +63 -0
  27. commiter/middleware_index.py +350 -0
  28. commiter/models.py +117 -0
  29. commiter/parser.py +1283 -0
  30. commiter/prefix_index.py +211 -0
  31. commiter/report/__init__.py +0 -0
  32. commiter/report/ai.py +120 -0
  33. commiter/report/api_guide.py +217 -0
  34. commiter/report/architecture.py +930 -0
  35. commiter/report/console.py +254 -0
  36. commiter/report/json_output.py +122 -0
  37. commiter/report/markdown.py +163 -0
  38. commiter/scanner.py +383 -0
  39. commiter/type_index.py +304 -0
  40. commiter/uploader.py +46 -0
  41. commiter/utils/__init__.py +0 -0
  42. commiter/utils/env_reader.py +78 -0
  43. commiter/utils/file_classifier.py +187 -0
  44. commiter/utils/path_helpers.py +73 -0
  45. commiter/utils/tsconfig_resolver.py +281 -0
  46. commiter/wrapper_index.py +288 -0
  47. commiter_cli-0.3.0.dist-info/METADATA +14 -0
  48. commiter_cli-0.3.0.dist-info/RECORD +96 -0
  49. commiter_cli-0.3.0.dist-info/WHEEL +5 -0
  50. commiter_cli-0.3.0.dist-info/entry_points.txt +2 -0
  51. commiter_cli-0.3.0.dist-info/top_level.txt +2 -0
  52. tests/__init__.py +0 -0
  53. tests/fixtures/arch_backend/app.py +22 -0
  54. tests/fixtures/arch_backend/middleware/__init__.py +0 -0
  55. tests/fixtures/arch_backend/middleware/rate_limit.py +4 -0
  56. tests/fixtures/arch_backend/routes/__init__.py +0 -0
  57. tests/fixtures/arch_backend/routes/analytics.py +20 -0
  58. tests/fixtures/arch_backend/routes/auth.py +29 -0
  59. tests/fixtures/arch_backend/routes/projects.py +60 -0
  60. tests/fixtures/arch_backend/routes/users.py +55 -0
  61. tests/fixtures/arch_monorepo/apps/api/app.py +30 -0
  62. tests/fixtures/arch_monorepo/apps/api/middleware/__init__.py +0 -0
  63. tests/fixtures/arch_monorepo/apps/api/middleware/auth.py +17 -0
  64. tests/fixtures/arch_monorepo/apps/api/middleware/rate_limit.py +10 -0
  65. tests/fixtures/arch_monorepo/apps/api/routes/__init__.py +0 -0
  66. tests/fixtures/arch_monorepo/apps/api/routes/auth.py +46 -0
  67. tests/fixtures/arch_monorepo/apps/api/routes/invites.py +30 -0
  68. tests/fixtures/arch_monorepo/apps/api/routes/notifications.py +25 -0
  69. tests/fixtures/arch_monorepo/apps/api/routes/projects.py +80 -0
  70. tests/fixtures/arch_monorepo/apps/api/routes/tasks.py +91 -0
  71. tests/fixtures/arch_monorepo/apps/api/routes/users.py +48 -0
  72. tests/fixtures/arch_monorepo/apps/api/services/__init__.py +0 -0
  73. tests/fixtures/arch_monorepo/apps/api/services/email.py +11 -0
  74. tests/fixtures/backend_b/app.py +17 -0
  75. tests/fixtures/fastapi_app/app.py +48 -0
  76. tests/fixtures/fastapi_crossfile/routes.py +18 -0
  77. tests/fixtures/fastapi_crossfile/schemas.py +21 -0
  78. tests/fixtures/flask_app/app.py +33 -0
  79. tests/fixtures/flask_blueprint/app.py +7 -0
  80. tests/fixtures/flask_blueprint/routes/items.py +13 -0
  81. tests/fixtures/flask_blueprint/routes/users.py +20 -0
  82. tests/fixtures/middleware_test_flask/routes/public.py +8 -0
  83. tests/fixtures/middleware_test_flask/routes/users.py +26 -0
  84. tests/fixtures/python_deep_imports/app/__init__.py +0 -0
  85. tests/fixtures/python_deep_imports/app/api/__init__.py +0 -0
  86. tests/fixtures/python_deep_imports/app/api/health.py +11 -0
  87. tests/fixtures/python_deep_imports/app/api/v1/__init__.py +0 -0
  88. tests/fixtures/python_deep_imports/app/api/v1/items.py +18 -0
  89. tests/fixtures/python_deep_imports/app/api/v1/users.py +27 -0
  90. tests/fixtures/python_deep_imports/app/schemas/__init__.py +0 -0
  91. tests/fixtures/python_deep_imports/app/schemas/item.py +13 -0
  92. tests/fixtures/python_deep_imports/app/schemas/user.py +15 -0
  93. tests/fixtures/python_deep_imports/app/shared/__init__.py +0 -0
  94. tests/fixtures/python_deep_imports/app/shared/models.py +7 -0
  95. tests/fixtures/raw_sql_test/app.py +54 -0
  96. tests/test_architecture.py +757 -0
commiter/parser.py ADDED
@@ -0,0 +1,1283 @@
1
+ """Tree-sitter setup, grammar loading, and AST query helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ import tree_sitter_python as tspython
10
+ import tree_sitter_javascript as tsjavascript
11
+ import tree_sitter_typescript as tstypescript
12
+ from tree_sitter import Language, Parser, Node
13
+
14
+ if TYPE_CHECKING:
15
+ from tree_sitter import Tree
16
+
17
+ # Language singletons — loaded once and cached
18
+ _LANGUAGES: dict[str, Language] = {}
19
+
20
+
21
+ def _get_language(lang: str) -> Language:
22
+ """Get or create a Tree-sitter Language for the given language name."""
23
+ if lang not in _LANGUAGES:
24
+ if lang == "python":
25
+ _LANGUAGES[lang] = Language(tspython.language())
26
+ elif lang == "javascript":
27
+ _LANGUAGES[lang] = Language(tsjavascript.language())
28
+ elif lang == "typescript":
29
+ _LANGUAGES[lang] = Language(tstypescript.language_typescript())
30
+ elif lang == "tsx":
31
+ _LANGUAGES[lang] = Language(tstypescript.language_tsx())
32
+ else:
33
+ raise ValueError(f"Unsupported language: {lang}")
34
+ return _LANGUAGES[lang]
35
+
36
+
37
+ def detect_language(file_path: str) -> str | None:
38
+ """Detect programming language from file extension."""
39
+ ext = Path(file_path).suffix.lower()
40
+ mapping = {
41
+ ".py": "python",
42
+ ".js": "javascript",
43
+ ".mjs": "javascript",
44
+ ".cjs": "javascript",
45
+ ".jsx": "javascript",
46
+ ".ts": "typescript",
47
+ ".tsx": "tsx",
48
+ }
49
+ return mapping.get(ext)
50
+
51
+
52
+ def parse_file(file_path: str, language: str | None = None) -> tuple[Tree, str] | None:
53
+ """Parse a file and return its Tree-sitter tree and detected language.
54
+
55
+ Returns None if the file cannot be parsed (unsupported language, read error, etc.).
56
+ """
57
+ if language is None:
58
+ language = detect_language(file_path)
59
+ if language is None:
60
+ return None
61
+
62
+ try:
63
+ source = Path(file_path).read_bytes()
64
+ except (OSError, IOError):
65
+ return None
66
+
67
+ lang = _get_language(language)
68
+ parser = Parser(lang)
69
+ tree = parser.parse(source)
70
+ return tree, language
71
+
72
+
73
+ def get_source(file_path: str) -> bytes:
74
+ """Read file source as bytes."""
75
+ return Path(file_path).read_bytes()
76
+
77
+
78
+ def node_text(node: Node, source: bytes) -> str:
79
+ """Extract the text content of an AST node."""
80
+ return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
81
+
82
+
83
+ def find_nodes_by_type(node: Node, type_name: str) -> list[Node]:
84
+ """Recursively find all descendant nodes of a given type."""
85
+ results = []
86
+ if node.type == type_name:
87
+ results.append(node)
88
+ for child in node.children:
89
+ results.extend(find_nodes_by_type(child, type_name))
90
+ return results
91
+
92
+
93
+ def find_decorated_functions(node: Node, source: bytes) -> list[tuple[list[str], Node]]:
94
+ """Find all decorated function definitions.
95
+
96
+ Returns a list of (decorator_names, function_node) tuples.
97
+ """
98
+ results = []
99
+ for child in node.children:
100
+ if child.type == "decorated_definition":
101
+ decorators = []
102
+ func_node = None
103
+ for sub in child.children:
104
+ if sub.type == "decorator":
105
+ dec_text = node_text(sub, source).lstrip("@").strip()
106
+ decorators.append(dec_text)
107
+ elif sub.type == "function_definition":
108
+ func_node = sub
109
+ if func_node is not None:
110
+ results.append((decorators, func_node))
111
+ elif child.type == "function_definition":
112
+ results.append(([], child))
113
+ # Recurse into class bodies, modules, etc.
114
+ if child.type in ("module", "class_definition", "block"):
115
+ results.extend(find_decorated_functions(child, source))
116
+ return results
117
+
118
+
119
+ def find_function_calls(node: Node, source: bytes, name: str | None = None) -> list[Node]:
120
+ """Find all function call nodes, optionally filtered by function name."""
121
+ calls = find_nodes_by_type(node, "call")
122
+ if name is None:
123
+ return calls
124
+ results = []
125
+ for call in calls:
126
+ func = call.child_by_field_name("function")
127
+ if func and node_text(func, source) == name:
128
+ results.append(call)
129
+ return results
130
+
131
+
132
+ def find_imports(node: Node, source: bytes) -> list[str]:
133
+ """Extract all import names from a module."""
134
+ imports = []
135
+ for child in node.children:
136
+ if child.type == "import_statement":
137
+ imports.append(node_text(child, source))
138
+ elif child.type == "import_from_statement":
139
+ imports.append(node_text(child, source))
140
+ return imports
141
+
142
+
143
+ def find_string_literals(node: Node, source: bytes) -> list[tuple[str, Node]]:
144
+ """Find all string literal values and their nodes."""
145
+ strings = []
146
+ for str_node in find_nodes_by_type(node, "string"):
147
+ text = node_text(str_node, source)
148
+ # Strip quotes
149
+ if len(text) >= 2:
150
+ if text.startswith(('"""', "'''")):
151
+ val = text[3:-3]
152
+ elif text.startswith(('"', "'")):
153
+ val = text[1:-1]
154
+ else:
155
+ val = text
156
+ strings.append((val, str_node))
157
+ return strings
158
+
159
+
160
+ # ──────────────────────────────────────────────
161
+ # JS/TS import and export helpers
162
+ # ──────────────────────────────────────────────
163
+
164
+ @dataclass
165
+ class JSImport:
166
+ """A parsed JS/TS import statement."""
167
+ names: list[str]
168
+ module_path: str
169
+ is_default: bool
170
+ line: int
171
+
172
+
173
+ @dataclass
174
+ class JSExportedFunction:
175
+ """An exported function or arrow-function const in JS/TS."""
176
+ name: str
177
+ body_node: Node
178
+ file_path: str
179
+ line: int
180
+
181
+
182
+ def find_js_imports(node: Node, source: bytes) -> list[JSImport]:
183
+ """Find all import statements in a JS/TS AST.
184
+
185
+ Handles:
186
+ import { getUser, createPost } from "../lib/api";
187
+ import api from "../lib/api";
188
+ import * as api from "../lib/api";
189
+ """
190
+ imports = []
191
+ for child in node.children:
192
+ if child.type != "import_statement":
193
+ continue
194
+
195
+ text = node_text(child, source)
196
+ line = child.start_point[0] + 1
197
+
198
+ # Extract the module path (the string after "from")
199
+ module_path = None
200
+ for sub in child.children:
201
+ if sub.type == "string":
202
+ module_path = node_text(sub, source).strip("'\"")
203
+ break
204
+
205
+ if not module_path:
206
+ continue
207
+
208
+ # Extract imported names
209
+ names = []
210
+ is_default = False
211
+
212
+ for sub in child.children:
213
+ if sub.type == "import_clause":
214
+ for clause_child in sub.children:
215
+ if clause_child.type == "identifier":
216
+ # default import: import Foo from "..."
217
+ names.append(node_text(clause_child, source))
218
+ is_default = True
219
+ elif clause_child.type == "named_imports":
220
+ # { getUser, createPost } or { getUser as gu }
221
+ for spec in clause_child.children:
222
+ if spec.type == "import_specifier":
223
+ name_node = spec.child_by_field_name("name")
224
+ alias_node = spec.child_by_field_name("alias")
225
+ # Use alias if present, otherwise the original name
226
+ target = alias_node if alias_node else name_node
227
+ if target:
228
+ names.append(node_text(target, source))
229
+ elif clause_child.type == "namespace_import":
230
+ # import * as api from "..."
231
+ for ns_child in clause_child.children:
232
+ if ns_child.type == "identifier":
233
+ names.append(node_text(ns_child, source))
234
+ is_default = True
235
+
236
+ if names:
237
+ imports.append(JSImport(
238
+ names=names,
239
+ module_path=module_path,
240
+ is_default=is_default,
241
+ line=line,
242
+ ))
243
+
244
+ return imports
245
+
246
+
247
+ def find_js_exported_functions(node: Node, source: bytes, file_path: str = "") -> list[JSExportedFunction]:
248
+ """Find all exported function/const declarations in JS/TS.
249
+
250
+ Handles:
251
+ export function getUser(id) { ... }
252
+ export const getUser = (id) => fetch(...);
253
+ export const getUser = async (id) => { ... };
254
+ export default function handler(req, res) { ... }
255
+ """
256
+ results = []
257
+
258
+ for child in node.children:
259
+ if child.type != "export_statement":
260
+ continue
261
+
262
+ for sub in child.children:
263
+ # export function getUser(id) { ... }
264
+ if sub.type == "function_declaration":
265
+ name_node = sub.child_by_field_name("name")
266
+ if name_node:
267
+ body = sub.child_by_field_name("body") or sub
268
+ results.append(JSExportedFunction(
269
+ name=node_text(name_node, source),
270
+ body_node=body,
271
+ file_path=file_path,
272
+ line=sub.start_point[0] + 1,
273
+ ))
274
+
275
+ # export const getUser = (id) => fetch(...)
276
+ elif sub.type == "lexical_declaration":
277
+ for decl in sub.children:
278
+ if decl.type == "variable_declarator":
279
+ name_node = decl.child_by_field_name("name")
280
+ value_node = decl.child_by_field_name("value")
281
+ if name_node and value_node:
282
+ # value_node could be arrow_function, function_expression, or call_expression
283
+ body = value_node
284
+ if value_node.type in ("arrow_function", "function_expression"):
285
+ body = value_node.child_by_field_name("body") or value_node
286
+ results.append(JSExportedFunction(
287
+ name=node_text(name_node, source),
288
+ body_node=body,
289
+ file_path=file_path,
290
+ line=decl.start_point[0] + 1,
291
+ ))
292
+
293
+ return results
294
+
295
+
296
+ # ──────────────────────────────────────────────
297
+ # Type extraction helpers (TS interfaces, Python classes)
298
+ # ──────────────────────────────────────────────
299
+
300
+ @dataclass
301
+ class TypeField:
302
+ """A single field in a type/interface/class/enum definition."""
303
+ name: str
304
+ type_str: str
305
+ optional: bool = False
306
+ value: str | None = None # actual value for enum members and const assertions
307
+
308
+
309
+ def extract_type_annotation(node: Node, source: bytes) -> str | None:
310
+ """Extract type annotation from a TS parameter node.
311
+
312
+ Works with required_parameter, optional_parameter, and property_signature nodes
313
+ that have a type_annotation child.
314
+ """
315
+ for child in node.children:
316
+ if child.type == "type_annotation":
317
+ text = node_text(child, source).strip()
318
+ if text.startswith(":"):
319
+ text = text[1:].strip()
320
+ return text
321
+ return None
322
+
323
+
324
+ def find_ts_interface_fields(node: Node, source: bytes) -> dict[str, list[TypeField]]:
325
+ """Find all interface definitions in a TS/TSX file.
326
+
327
+ Returns a dict mapping type name to its field list.
328
+ Handles both exported and non-exported interfaces.
329
+ """
330
+ result: dict[str, list[TypeField]] = {}
331
+
332
+ for child in node.children:
333
+ target = child
334
+ # Handle exported interfaces: export_statement -> interface_declaration
335
+ if child.type == "export_statement":
336
+ for sub in child.children:
337
+ if sub.type == "interface_declaration":
338
+ target = sub
339
+ break
340
+
341
+ if target.type == "interface_declaration":
342
+ name_node = target.child_by_field_name("name")
343
+ if not name_node:
344
+ continue
345
+ name = node_text(name_node, source)
346
+ fields = _extract_interface_body_fields(target, source)
347
+ if fields:
348
+ result[name] = fields
349
+
350
+ return result
351
+
352
+
353
+ def _extract_interface_body_fields(iface_node: Node, source: bytes) -> list[TypeField]:
354
+ """Extract property signatures from an interface body."""
355
+ fields = []
356
+ body = iface_node.child_by_field_name("body")
357
+ if not body:
358
+ for child in iface_node.children:
359
+ if child.type in ("interface_body", "object_type"):
360
+ body = child
361
+ break
362
+ if not body:
363
+ return fields
364
+
365
+ for child in body.children:
366
+ if child.type == "property_signature":
367
+ prop_name = None
368
+ prop_type = "unknown"
369
+ optional = False
370
+
371
+ name_node = child.child_by_field_name("name")
372
+ if name_node:
373
+ prop_name = node_text(name_node, source)
374
+
375
+ for sub in child.children:
376
+ if node_text(sub, source) == "?":
377
+ optional = True
378
+
379
+ type_ann = extract_type_annotation(child, source)
380
+ if type_ann:
381
+ prop_type = type_ann
382
+
383
+ if prop_name:
384
+ fields.append(TypeField(name=prop_name, type_str=prop_type, optional=optional))
385
+
386
+ return fields
387
+
388
+
389
+ def find_ts_enum_declarations(node: Node, source: bytes) -> dict[str, list[TypeField]]:
390
+ """Find all enum definitions in a TS/TSX file.
391
+
392
+ Returns a dict mapping enum name to its member list.
393
+ Handles: enum Status { ACTIVE = "active", INACTIVE = "inactive" }
394
+ """
395
+ result: dict[str, list[TypeField]] = {}
396
+
397
+ for child in node.children:
398
+ target = child
399
+ if child.type == "export_statement":
400
+ for sub in child.children:
401
+ if sub.type == "enum_declaration":
402
+ target = sub
403
+ break
404
+
405
+ if target.type == "enum_declaration":
406
+ name_node = target.child_by_field_name("name")
407
+ if not name_node:
408
+ continue
409
+ name = node_text(name_node, source)
410
+ members = _extract_enum_members(target, source)
411
+ if members:
412
+ result[name] = members
413
+
414
+ return result
415
+
416
+
417
+ def _extract_enum_members(enum_node: Node, source: bytes) -> list[TypeField]:
418
+ """Extract members from an enum body."""
419
+ members = []
420
+ body = enum_node.child_by_field_name("body")
421
+ if not body:
422
+ # Try finding enum_body child directly
423
+ for child in enum_node.children:
424
+ if child.type == "enum_body":
425
+ body = child
426
+ break
427
+ if not body:
428
+ return members
429
+
430
+ for child in body.children:
431
+ if child.type in ("enum_member", "enum_assignment", "property_identifier"):
432
+ member_name = None
433
+ value_str = None
434
+
435
+ name_node = child.child_by_field_name("name")
436
+ if name_node:
437
+ member_name = node_text(name_node, source)
438
+ elif child.type == "property_identifier":
439
+ member_name = node_text(child, source)
440
+
441
+ value_node = child.child_by_field_name("value")
442
+ if value_node:
443
+ value_str = node_text(value_node, source).strip("'\"")
444
+
445
+ if member_name:
446
+ members.append(TypeField(
447
+ name=member_name,
448
+ type_str=f'"{value_str}"' if value_str else "auto",
449
+ value=value_str,
450
+ ))
451
+
452
+ return members
453
+
454
+
455
+ def find_ts_type_aliases(node: Node, source: bytes) -> dict[str, list[TypeField]]:
456
+ """Find type alias declarations in a TS/TSX file.
457
+
458
+ Handles:
459
+ type User = { id: string; name: string } (object-like)
460
+ type Status = "active" | "inactive" (union literal)
461
+ """
462
+ result: dict[str, list[TypeField]] = {}
463
+
464
+ for child in node.children:
465
+ target = child
466
+ if child.type == "export_statement":
467
+ for sub in child.children:
468
+ if sub.type == "type_alias_declaration":
469
+ target = sub
470
+ break
471
+
472
+ if target.type == "type_alias_declaration":
473
+ name_node = target.child_by_field_name("name")
474
+ if not name_node:
475
+ continue
476
+ name = node_text(name_node, source)
477
+
478
+ value_node = target.child_by_field_name("value")
479
+ if not value_node:
480
+ continue
481
+
482
+ # Object-like type alias: type User = { id: string; name: string }
483
+ if value_node.type == "object_type":
484
+ fields = []
485
+ for prop in value_node.children:
486
+ if prop.type == "property_signature":
487
+ prop_name = None
488
+ prop_type = "unknown"
489
+ optional = False
490
+
491
+ pn = prop.child_by_field_name("name")
492
+ if pn:
493
+ prop_name = node_text(pn, source)
494
+ for sub in prop.children:
495
+ if node_text(sub, source) == "?":
496
+ optional = True
497
+ type_ann = extract_type_annotation(prop, source)
498
+ if type_ann:
499
+ prop_type = type_ann
500
+ if prop_name:
501
+ fields.append(TypeField(name=prop_name, type_str=prop_type, optional=optional))
502
+ if fields:
503
+ result[name] = fields
504
+
505
+ # Union: type Status = "active" | "inactive" OR type Response = Success | Error
506
+ elif value_node.type == "union_type":
507
+ members = _flatten_union_type(value_node, source)
508
+ if members:
509
+ result[name] = members
510
+
511
+ # Intersection: type FullUser = BaseFields & AuthFields & { role: string }
512
+ elif value_node.type == "intersection_type":
513
+ fields = _extract_intersection_parts(value_node, source)
514
+ if fields:
515
+ result[name] = fields
516
+
517
+ # Generic type: type UserSummary = Pick<User, "id" | "name">
518
+ # Store as a marker — resolved lazily by the generic resolver
519
+ elif value_node.type == "generic_type":
520
+ generic_text = node_text(value_node, source).strip()
521
+ result[name] = [TypeField(name=name, type_str="__generic__", value=generic_text)]
522
+
523
+ return result
524
+
525
+
526
+ def _flatten_union_type(union_node: Node, source: bytes) -> list[TypeField]:
527
+ """Recursively flatten a union_type node into a list of TypeField members."""
528
+ members = []
529
+ for child in union_node.children:
530
+ if child.type == "union_type":
531
+ members.extend(_flatten_union_type(child, source))
532
+ elif child.type == "literal_type":
533
+ val = node_text(child, source).strip("'\"")
534
+ if val:
535
+ members.append(TypeField(name=val, type_str="literal", value=val))
536
+ elif child.type == "type_identifier":
537
+ # Type reference in union: SuccessResponse | ErrorResponse
538
+ type_name = node_text(child, source)
539
+ members.append(TypeField(name=type_name, type_str="__type_ref__"))
540
+ elif child.type == "predefined_type":
541
+ type_name = node_text(child, source)
542
+ members.append(TypeField(name=type_name, type_str="predefined"))
543
+ return members
544
+
545
+
546
+ def _extract_intersection_parts(node: Node, source: bytes) -> list[TypeField]:
547
+ """Extract fields from an intersection type: A & B & { inline: string }.
548
+
549
+ For type references (A, B): stores as __type_ref__ markers for later resolution.
550
+ For inline objects ({ inline: string }): extracts fields directly.
551
+ Handles nested intersections recursively.
552
+ """
553
+ fields: list[TypeField] = []
554
+
555
+ for child in node.children:
556
+ if child.type == "intersection_type":
557
+ # Nested intersection — recurse
558
+ fields.extend(_extract_intersection_parts(child, source))
559
+ elif child.type == "type_identifier":
560
+ # Reference to another type — mark for later resolution
561
+ type_name = node_text(child, source)
562
+ fields.append(TypeField(name=type_name, type_str="__type_ref__"))
563
+ elif child.type == "object_type":
564
+ # Inline object: { role: string; active: boolean }
565
+ for prop in child.children:
566
+ if prop.type == "property_signature":
567
+ prop_name = None
568
+ prop_type = "unknown"
569
+ optional = False
570
+
571
+ pn = prop.child_by_field_name("name")
572
+ if pn:
573
+ prop_name = node_text(pn, source)
574
+ for sub in prop.children:
575
+ if node_text(sub, source) == "?":
576
+ optional = True
577
+ type_ann = extract_type_annotation(prop, source)
578
+ if type_ann:
579
+ prop_type = type_ann
580
+ if prop_name:
581
+ fields.append(TypeField(name=prop_name, type_str=prop_type, optional=optional))
582
+
583
+ return fields
584
+
585
+
586
+ def find_ts_const_objects(node: Node, source: bytes) -> dict[str, list[TypeField]]:
587
+ """Find const object declarations with 'as const' or UPPER_CASE names.
588
+
589
+ Handles: const STATUS = { ACTIVE: "active", INACTIVE: "inactive" } as const;
590
+ """
591
+ result: dict[str, list[TypeField]] = {}
592
+
593
+ for child in node.children:
594
+ target = child
595
+ if child.type == "export_statement":
596
+ for sub in child.children:
597
+ if sub.type == "lexical_declaration":
598
+ target = sub
599
+ break
600
+
601
+ if target.type != "lexical_declaration":
602
+ continue
603
+
604
+ for decl in target.children:
605
+ if decl.type != "variable_declarator":
606
+ continue
607
+
608
+ name_node = decl.child_by_field_name("name")
609
+ value_node = decl.child_by_field_name("value")
610
+ if not name_node or not value_node:
611
+ continue
612
+
613
+ var_name = node_text(name_node, source)
614
+
615
+ # Check for "as const" assertion or UPPER_CASE convention
616
+ decl_text = node_text(decl, source)
617
+ is_const_assertion = "as const" in decl_text
618
+ is_upper_case = var_name == var_name.upper() and len(var_name) > 1
619
+
620
+ if not (is_const_assertion or is_upper_case):
621
+ continue
622
+
623
+ # Extract object properties — value might be wrapped in as_expression
624
+ obj_node = value_node
625
+ if value_node.type == "as_expression":
626
+ # Unwrap: { ... } as const → get the object inside
627
+ for sub in value_node.children:
628
+ if sub.type == "object":
629
+ obj_node = sub
630
+ break
631
+
632
+ if obj_node.type != "object":
633
+ continue
634
+
635
+ fields = []
636
+ for prop in obj_node.children:
637
+ if prop.type == "pair":
638
+ key_node = prop.child_by_field_name("key")
639
+ val_node = prop.child_by_field_name("value")
640
+ if key_node and val_node:
641
+ key = node_text(key_node, source).strip("'\"")
642
+ val = node_text(val_node, source).strip("'\"")
643
+ fields.append(TypeField(name=key, type_str=f'"{val}"', value=val))
644
+
645
+ if fields:
646
+ result[var_name] = fields
647
+
648
+ return result
649
+
650
+
651
+ def find_py_class_fields(node: Node, source: bytes, class_name: str) -> list[TypeField]:
652
+ """Extract typed fields from a Python class (Pydantic BaseModel, dataclass, etc.).
653
+
654
+ Looks for annotated assignments: name: str, price: float = 0.0, etc.
655
+ """
656
+ for child in node.children:
657
+ if child.type != "class_definition":
658
+ continue
659
+ name_node = child.child_by_field_name("name")
660
+ if not name_node or node_text(name_node, source) != class_name:
661
+ continue
662
+
663
+ body = child.child_by_field_name("body")
664
+ if not body:
665
+ continue
666
+
667
+ return _extract_py_class_body_fields(body, source)
668
+
669
+ return []
670
+
671
+
672
+ def _extract_py_class_body_fields(body: Node, source: bytes) -> list[TypeField]:
673
+ """Extract annotated assignments from a Python class body."""
674
+ fields = []
675
+ for stmt in body.children:
676
+ if stmt.type != "expression_statement":
677
+ continue
678
+
679
+ text = node_text(stmt, source).strip()
680
+ if ":" not in text:
681
+ continue
682
+
683
+ # Split on first ":" to get name and type
684
+ colon_idx = text.index(":")
685
+ name = text[:colon_idx].strip()
686
+ rest = text[colon_idx + 1:].strip()
687
+
688
+ # Remove default value if present
689
+ type_str = rest.split("=")[0].strip()
690
+ has_default = "=" in rest
691
+ optional = "Optional" in type_str or "| None" in type_str or has_default
692
+
693
+ if name and not name.startswith(("#", "def", "class", "@")):
694
+ fields.append(TypeField(name=name, type_str=type_str, optional=optional))
695
+
696
+ return fields
697
+
698
+
699
+ def find_py_imports_with_names(node: Node, source: bytes) -> list["PyImport"]:
700
+ """Extract Python from...import statements with individual names.
701
+
702
+ Handles: from .schemas import UserCreate, UserResponse
703
+ """
704
+ imports = []
705
+ for child in node.children:
706
+ if child.type != "import_from_statement":
707
+ continue
708
+
709
+ text = node_text(child, source)
710
+ line = child.start_point[0] + 1
711
+
712
+ # Extract module path and names from the text via regex
713
+ import re as _re
714
+ m = _re.match(r"from\s+(\S+)\s+import\s+(.+)", text)
715
+ if not m:
716
+ continue
717
+
718
+ module_path = m.group(1)
719
+ names_str = m.group(2).strip().strip("()")
720
+ names = [n.strip().split(" as ")[-1].strip() for n in names_str.split(",") if n.strip()]
721
+
722
+ if names:
723
+ imports.append(PyImport(names=names, module_path=module_path, line=line))
724
+
725
+ return imports
726
+
727
+
728
+ @dataclass
729
+ class PyImport:
730
+ """A parsed Python from...import statement."""
731
+ names: list[str]
732
+ module_path: str
733
+ line: int
734
+
735
+
736
+ # ──────────────────────────────────────────────
737
+ # Generic type parsing
738
+ # ──────────────────────────────────────────────
739
+
740
+ def parse_generic_type(type_str: str) -> tuple[str, list[str]] | None:
741
+ """Parse a generic type string into its base type and arguments.
742
+
743
+ Examples:
744
+ "User[]" -> ("Array", ["User"])
745
+ "Array<User>" -> ("Array", ["User"])
746
+ "Promise<User>" -> ("Promise", ["User"])
747
+ "Pick<User, \\"id\\" | \\"name\\">" -> ("Pick", ["User", "\\"id\\" | \\"name\\""])
748
+ "Partial<User>" -> ("Partial", ["User"])
749
+ "ApiResponse<User>" -> ("ApiResponse", ["User"])
750
+ "Map<string, User>" -> ("Map", ["string", "User"])
751
+ "string" -> None (not generic)
752
+
753
+ Returns (base_type, [args]) or None if not a generic type.
754
+ """
755
+ type_str = type_str.strip()
756
+
757
+ # Handle array shorthand: User[] -> ("Array", ["User"])
758
+ if type_str.endswith("[]"):
759
+ inner = type_str[:-2].strip()
760
+ if inner:
761
+ return ("Array", [inner])
762
+ return None
763
+
764
+ # Handle generic syntax: Name<Arg1, Arg2, ...>
765
+ lt_idx = type_str.find("<")
766
+ if lt_idx == -1:
767
+ return None
768
+
769
+ base = type_str[:lt_idx].strip()
770
+ if not base:
771
+ return None
772
+
773
+ # Extract the content between < and the matching >
774
+ args_str = _extract_between_angles(type_str, lt_idx)
775
+ if args_str is None:
776
+ return None
777
+
778
+ # Split arguments by top-level commas (respecting nested <> and quotes)
779
+ args = _split_generic_args(args_str)
780
+ return (base, args) if args else None
781
+
782
+
783
+ def _extract_between_angles(text: str, start: int) -> str | None:
784
+ """Extract content between < and matching >, respecting nesting."""
785
+ depth = 0
786
+ i = start
787
+ while i < len(text):
788
+ if text[i] == "<":
789
+ depth += 1
790
+ elif text[i] == ">":
791
+ depth -= 1
792
+ if depth == 0:
793
+ return text[start + 1:i].strip()
794
+ i += 1
795
+ return None
796
+
797
+
798
+ def _split_generic_args(args_str: str) -> list[str]:
799
+ """Split generic arguments by top-level commas, respecting nested <>, quotes, and |."""
800
+ parts = []
801
+ depth = 0
802
+ current = ""
803
+ in_string = None
804
+
805
+ for ch in args_str:
806
+ if in_string:
807
+ current += ch
808
+ if ch == in_string:
809
+ in_string = None
810
+ continue
811
+
812
+ if ch in ("'", '"'):
813
+ in_string = ch
814
+ current += ch
815
+ elif ch == "<":
816
+ depth += 1
817
+ current += ch
818
+ elif ch == ">":
819
+ depth -= 1
820
+ current += ch
821
+ elif ch == "," and depth == 0:
822
+ if current.strip():
823
+ parts.append(current.strip())
824
+ current = ""
825
+ else:
826
+ current += ch
827
+
828
+ if current.strip():
829
+ parts.append(current.strip())
830
+ return parts
831
+
832
+
833
+ def extract_type_parameters(node: Node, source: bytes) -> list[str]:
834
+ """Extract type parameter names from an interface or type alias declaration.
835
+
836
+ For `interface ApiResponse<T>` returns ["T"].
837
+ For `type Result<T, E>` returns ["T", "E"].
838
+ """
839
+ params = []
840
+ for child in node.children:
841
+ if child.type == "type_parameters":
842
+ for param_node in child.children:
843
+ if param_node.type == "type_parameter":
844
+ name_node = param_node.child_by_field_name("name")
845
+ if name_node:
846
+ params.append(node_text(name_node, source))
847
+ return params
848
+
849
+
850
+ # ──────────────────────────────────────────────
851
+ # Class method type extraction (for controller handlers)
852
+ # ──────────────────────────────────────────────
853
+
854
+ @dataclass
855
+ class ClassMethod:
856
+ """A class method with its request body type annotation."""
857
+ class_name: str
858
+ method_name: str
859
+ request_body_type: str | None
860
+ file_path: str
861
+ line: int
862
+
863
+
864
+ @dataclass
865
+ class ClassInstance:
866
+ """A variable instantiated from a class: const x = new ClassName()."""
867
+ var_name: str
868
+ class_name: str
869
+ file_path: str
870
+ line: int
871
+
872
+
873
+ def find_ts_class_methods(node: Node, source: bytes, file_path: str = "") -> list[ClassMethod]:
874
+ """Find class methods with Request type annotations in their parameters.
875
+
876
+ Handles:
877
+ class UserController {
878
+ async create(req: Request<{}, {}, CreateUserBody>, res: Response) { ... }
879
+ }
880
+ """
881
+ import re as _re
882
+ results = []
883
+
884
+ for child in node.children:
885
+ target = child
886
+ if child.type == "export_statement":
887
+ for sub in child.children:
888
+ if sub.type == "class_declaration":
889
+ target = sub
890
+ break
891
+
892
+ if target.type != "class_declaration":
893
+ continue
894
+
895
+ class_name_node = target.child_by_field_name("name")
896
+ if not class_name_node:
897
+ continue
898
+ class_name = node_text(class_name_node, source)
899
+
900
+ # Find the class body
901
+ body = target.child_by_field_name("body")
902
+ if not body:
903
+ continue
904
+
905
+ for member in body.children:
906
+ if member.type != "method_definition":
907
+ continue
908
+
909
+ # Get method name
910
+ method_name = None
911
+ for sub in member.children:
912
+ if sub.type == "property_identifier":
913
+ method_name = node_text(sub, source)
914
+ break
915
+
916
+ if not method_name:
917
+ continue
918
+
919
+ # Extract Request<P, Res, Body> from parameters
920
+ body_type = None
921
+ params_node = member.child_by_field_name("parameters")
922
+ if params_node:
923
+ params_text = node_text(params_node, source)
924
+ match = _re.search(r':\s*Request\s*<[^,]*,[^,]*,\s*(\w+)\s*>', params_text)
925
+ if match:
926
+ body_type = match.group(1)
927
+
928
+ results.append(ClassMethod(
929
+ class_name=class_name,
930
+ method_name=method_name,
931
+ request_body_type=body_type,
932
+ file_path=file_path,
933
+ line=member.start_point[0] + 1,
934
+ ))
935
+
936
+ return results
937
+
938
+
939
+ def find_ts_class_instances(node: Node, source: bytes, file_path: str = "") -> list[ClassInstance]:
940
+ """Find variable declarations that instantiate a class.
941
+
942
+ Handles:
943
+ export const projectController = new ProjectController();
944
+ const controller = new UserController();
945
+ """
946
+ results = []
947
+
948
+ for child in node.children:
949
+ target = child
950
+ if child.type == "export_statement":
951
+ for sub in child.children:
952
+ if sub.type == "lexical_declaration":
953
+ target = sub
954
+ break
955
+
956
+ if target.type != "lexical_declaration":
957
+ continue
958
+
959
+ for decl in target.children:
960
+ if decl.type != "variable_declarator":
961
+ continue
962
+
963
+ name_node = decl.child_by_field_name("name")
964
+ value_node = decl.child_by_field_name("value")
965
+ if not name_node or not value_node:
966
+ continue
967
+
968
+ # Check if value is a new_expression: new ClassName()
969
+ if value_node.type != "new_expression":
970
+ continue
971
+
972
+ var_name = node_text(name_node, source)
973
+
974
+ # Extract class name from new ClassName(...)
975
+ constructor_node = value_node.child_by_field_name("constructor")
976
+ if constructor_node:
977
+ class_name = node_text(constructor_node, source)
978
+ else:
979
+ # Fallback: first identifier child
980
+ class_name = None
981
+ for sub in value_node.children:
982
+ if sub.type == "identifier":
983
+ class_name = node_text(sub, source)
984
+ break
985
+
986
+ if class_name:
987
+ results.append(ClassInstance(
988
+ var_name=var_name,
989
+ class_name=class_name,
990
+ file_path=file_path,
991
+ line=decl.start_point[0] + 1,
992
+ ))
993
+
994
+ return results
995
+
996
+
997
+ # ──────────────────────────────────────────────
998
+ # Re-export (barrel file) detection
999
+ # ──────────────────────────────────────────────
1000
+
1001
+ @dataclass
1002
+ class JSReExport:
1003
+ """A re-export statement: export { X } from "./y" or export * from "./y"."""
1004
+ names: list[str] # ["User", "Post"] or ["*"] for star exports
1005
+ module_path: str # "./user"
1006
+ line: int
1007
+
1008
+
1009
+ def find_js_re_exports(node: Node, source: bytes) -> list[JSReExport]:
1010
+ """Find all re-export statements in a JS/TS file.
1011
+
1012
+ Handles:
1013
+ export { User, Post } from "./types";
1014
+ export { User as AppUser } from "./types";
1015
+ export * from "./helpers";
1016
+ """
1017
+ results = []
1018
+ for child in node.children:
1019
+ if child.type != "export_statement":
1020
+ continue
1021
+
1022
+ # Look for a source module string (the "from" part)
1023
+ module_path = None
1024
+ for sub in child.children:
1025
+ if sub.type == "string":
1026
+ module_path = node_text(sub, source).strip("'\"")
1027
+ break
1028
+
1029
+ if not module_path:
1030
+ continue # Not a re-export (it's a local export)
1031
+
1032
+ line = child.start_point[0] + 1
1033
+ names = []
1034
+
1035
+ # Check for star export: export * from "./y"
1036
+ child_text = node_text(child, source)
1037
+ if "* from" in child_text or "*from" in child_text:
1038
+ results.append(JSReExport(names=["*"], module_path=module_path, line=line))
1039
+ continue
1040
+
1041
+ # Check for named re-exports: export { X, Y } from "./y"
1042
+ for sub in child.children:
1043
+ if sub.type == "export_clause":
1044
+ for spec in sub.children:
1045
+ if spec.type == "export_specifier":
1046
+ name_node = spec.child_by_field_name("name")
1047
+ alias_node = spec.child_by_field_name("alias")
1048
+ if name_node:
1049
+ # Use the original name (not alias) for looking up the definition
1050
+ exported_name = node_text(alias_node, source) if alias_node else node_text(name_node, source)
1051
+ names.append(exported_name)
1052
+
1053
+ if names:
1054
+ results.append(JSReExport(names=names, module_path=module_path, line=line))
1055
+
1056
+ return results
1057
+
1058
+
1059
+ # ──────────────────────────────────────────────
1060
+ # Dynamic URL resolution helpers
1061
+ # ──────────────────────────────────────────────
1062
+
1063
+ def resolve_url_from_node(node: Node, source: bytes, constants: dict[str, str] | None = None) -> str | None:
1064
+ """Attempt to resolve a URL from various AST node types.
1065
+
1066
+ Handles:
1067
+ - String literals: "/api/users"
1068
+ - Template literals: `/api/users/${id}`
1069
+ - String concatenation: BASE + "/users/" + id
1070
+ - Variable references: BASE_URL (looked up in constants dict)
1071
+
1072
+ Returns a URL pattern string with unresolvable parts replaced by :param,
1073
+ or None if no URL can be extracted.
1074
+ """
1075
+ if constants is None:
1076
+ constants = {}
1077
+
1078
+ if node.type in ("string", "string_fragment"):
1079
+ text = node_text(node, source).strip("'\"`")
1080
+ return text if text else None
1081
+
1082
+ if node.type == "template_string":
1083
+ return _resolve_template_literal(node, source, constants)
1084
+
1085
+ if node.type == "binary_expression":
1086
+ return _resolve_concatenation(node, source, constants)
1087
+
1088
+ if node.type == "identifier":
1089
+ name = node_text(node, source)
1090
+ if name in constants:
1091
+ return constants[name]
1092
+ return None
1093
+
1094
+ if node.type == "member_expression":
1095
+ # process.env.API_URL or similar
1096
+ text = node_text(node, source)
1097
+ if text in constants:
1098
+ return constants[text]
1099
+ return None
1100
+
1101
+ return None
1102
+
1103
+
1104
+ def _resolve_template_literal(node: Node, source: bytes, constants: dict[str, str]) -> str | None:
1105
+ """Resolve a template literal like `/api/users/${id}` to a URL pattern.
1106
+
1107
+ Strategy: keep static string fragments, resolve known constants,
1108
+ replace unknown interpolations with :param placeholders.
1109
+ """
1110
+ parts = []
1111
+ for child in node.children:
1112
+ if child.type == "string_fragment":
1113
+ parts.append(node_text(child, source))
1114
+ elif child.type == "template_substitution":
1115
+ # The expression inside ${}
1116
+ expr = None
1117
+ for sub in child.children:
1118
+ if sub.type not in ("${", "}"):
1119
+ expr = sub
1120
+ break
1121
+ if expr:
1122
+ expr_text = node_text(expr, source)
1123
+ # Try to resolve from constants
1124
+ if expr_text in constants:
1125
+ parts.append(constants[expr_text])
1126
+ elif "." in expr_text and expr_text in constants:
1127
+ parts.append(constants[expr_text])
1128
+ else:
1129
+ # Unknown variable — use as param placeholder
1130
+ # Clean up the variable name for a readable param name
1131
+ param_name = expr_text.split(".")[-1].strip()
1132
+ if param_name:
1133
+ parts.append(f":{param_name}")
1134
+ else:
1135
+ parts.append(":param")
1136
+ elif child.type in ("`",):
1137
+ continue # skip the backtick delimiters
1138
+
1139
+ result = "".join(parts)
1140
+ return result if result else None
1141
+
1142
+
1143
+ def _resolve_concatenation(node: Node, source: bytes, constants: dict[str, str]) -> str | None:
1144
+ """Resolve a binary expression (string concatenation) to a URL pattern.
1145
+
1146
+ Handles: BASE + "/users/" + id
1147
+ Strategy: flatten the + chain, resolve what we can, placeholder the rest.
1148
+ """
1149
+ parts = _flatten_binary_expression(node, source, constants)
1150
+ if not parts:
1151
+ return None
1152
+
1153
+ result = "".join(parts)
1154
+ # If we got nothing useful (all placeholders, no path-like content), bail
1155
+ if "/" not in result:
1156
+ return None
1157
+ return result
1158
+
1159
+
1160
+ def _flatten_binary_expression(node: Node, source: bytes, constants: dict[str, str]) -> list[str]:
1161
+ """Recursively flatten a + concatenation into resolved parts."""
1162
+ if node.type == "binary_expression":
1163
+ # Check operator is +
1164
+ op = None
1165
+ left = node.child_by_field_name("left")
1166
+ right = node.child_by_field_name("right")
1167
+ for child in node.children:
1168
+ if node_text(child, source) == "+":
1169
+ op = "+"
1170
+ break
1171
+ if op != "+":
1172
+ return []
1173
+ left_parts = _flatten_binary_expression(left, source, constants) if left else []
1174
+ right_parts = _flatten_binary_expression(right, source, constants) if right else []
1175
+ return left_parts + right_parts
1176
+
1177
+ if node.type in ("string", "string_fragment"):
1178
+ text = node_text(node, source).strip("'\"`")
1179
+ return [text] if text else []
1180
+
1181
+ if node.type == "template_string":
1182
+ resolved = _resolve_template_literal(node, source, constants)
1183
+ return [resolved] if resolved else []
1184
+
1185
+ if node.type == "identifier":
1186
+ name = node_text(node, source)
1187
+ if name in constants:
1188
+ return [constants[name]]
1189
+ # Unknown variable — use as param placeholder
1190
+ return [f":{name}"]
1191
+
1192
+ if node.type == "member_expression":
1193
+ text = node_text(node, source)
1194
+ if text in constants:
1195
+ return [constants[text]]
1196
+ return [f":{text.split('.')[-1]}"]
1197
+
1198
+ # Unknown node type — try raw text as fallback
1199
+ return []
1200
+
1201
+
1202
+ def build_constants_from_file(root_node: Node, source: bytes, env: dict[str, str] | None = None) -> dict[str, str]:
1203
+ """Build a constants dict from top-level const/let assignments in a JS/TS file.
1204
+
1205
+ Resolves:
1206
+ const BASE = "/api" -> {"BASE": "/api"}
1207
+ const API_URL = process.env.API_URL -> {"API_URL": env value or ""}
1208
+ const BASE = process.env.NEXT_PUBLIC_API_URL || "/api" -> {"BASE": env value or "/api"}
1209
+
1210
+ Also includes env vars with their process.env.X full names.
1211
+ """
1212
+ constants: dict[str, str] = {}
1213
+ if env is None:
1214
+ env = {}
1215
+
1216
+ # Add process.env.* entries from .env files
1217
+ for key, value in env.items():
1218
+ constants[f"process.env.{key}"] = value
1219
+ constants[f"import.meta.env.{key}"] = value
1220
+
1221
+ # Scan top-level variable declarations
1222
+ for child in root_node.children:
1223
+ _extract_const_declarations(child, source, constants, env)
1224
+
1225
+ return constants
1226
+
1227
+
1228
+ def _extract_const_declarations(node: Node, source: bytes, constants: dict[str, str], env: dict[str, str]) -> None:
1229
+ """Extract const/let declarations with string literal or env var values."""
1230
+ # Handle: const X = "value" or export const X = "value"
1231
+ target = node
1232
+ if node.type == "export_statement":
1233
+ for sub in node.children:
1234
+ if sub.type == "lexical_declaration":
1235
+ target = sub
1236
+ break
1237
+
1238
+ if target.type != "lexical_declaration":
1239
+ return
1240
+
1241
+ for decl in target.children:
1242
+ if decl.type != "variable_declarator":
1243
+ continue
1244
+
1245
+ name_node = decl.child_by_field_name("name")
1246
+ value_node = decl.child_by_field_name("value")
1247
+
1248
+ if not name_node or not value_node:
1249
+ continue
1250
+
1251
+ name = node_text(name_node, source)
1252
+
1253
+ # Direct string literal: const BASE = "/api"
1254
+ if value_node.type in ("string",):
1255
+ val = node_text(value_node, source).strip("'\"`")
1256
+ if val:
1257
+ constants[name] = val
1258
+ continue
1259
+
1260
+ # process.env.VAR reference
1261
+ if value_node.type == "member_expression":
1262
+ ref = node_text(value_node, source)
1263
+ if ref.startswith(("process.env.", "import.meta.env.")):
1264
+ var_key = ref.split(".")[-1]
1265
+ if var_key in env:
1266
+ constants[name] = env[var_key]
1267
+ constants[ref] = env[var_key]
1268
+ continue
1269
+
1270
+ # Logical OR fallback: process.env.VAR || "/default"
1271
+ if value_node.type == "binary_expression":
1272
+ val_text = node_text(value_node, source)
1273
+ # Pattern: process.env.X || "/fallback"
1274
+ import re as _re
1275
+ m = _re.match(r'(process\.env\.\w+|import\.meta\.env\.\w+)\s*\|\|\s*["\']([^"\']+)["\']', val_text)
1276
+ if m:
1277
+ env_ref = m.group(1)
1278
+ fallback = m.group(2)
1279
+ var_key = env_ref.split(".")[-1]
1280
+ resolved = env.get(var_key, fallback)
1281
+ constants[name] = resolved
1282
+ constants[env_ref] = resolved
1283
+ continue