dotscope 0.1.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 (114) hide show
  1. dotscope/.scope +63 -0
  2. dotscope/__init__.py +3 -0
  3. dotscope/absorber.py +390 -0
  4. dotscope/assertions.py +128 -0
  5. dotscope/ast_analyzer.py +2 -0
  6. dotscope/backtest.py +2 -0
  7. dotscope/bench.py +141 -0
  8. dotscope/budget.py +3 -0
  9. dotscope/cache.py +2 -0
  10. dotscope/check/__init__.py +1 -0
  11. dotscope/check/acknowledge.py +2 -0
  12. dotscope/check/checker.py +3 -0
  13. dotscope/check/checks/__init__.py +1 -0
  14. dotscope/check/checks/antipattern.py +2 -0
  15. dotscope/check/checks/boundary.py +2 -0
  16. dotscope/check/checks/contracts.py +3 -0
  17. dotscope/check/checks/direction.py +2 -0
  18. dotscope/check/checks/intent.py +2 -0
  19. dotscope/check/checks/stability.py +2 -0
  20. dotscope/check/constraints.py +2 -0
  21. dotscope/check/models.py +15 -0
  22. dotscope/cli.py +1447 -0
  23. dotscope/composer.py +147 -0
  24. dotscope/constants.py +45 -0
  25. dotscope/context.py +60 -0
  26. dotscope/counterfactual.py +180 -0
  27. dotscope/debug.py +220 -0
  28. dotscope/discovery.py +104 -0
  29. dotscope/formatter.py +157 -0
  30. dotscope/graph.py +3 -0
  31. dotscope/health.py +212 -0
  32. dotscope/help.py +204 -0
  33. dotscope/history.py +6 -0
  34. dotscope/hooks.py +2 -0
  35. dotscope/ingest.py +858 -0
  36. dotscope/intent.py +618 -0
  37. dotscope/lessons.py +223 -0
  38. dotscope/matcher.py +104 -0
  39. dotscope/mcp_server.py +1081 -0
  40. dotscope/models/.scope +45 -0
  41. dotscope/models/__init__.py +7 -0
  42. dotscope/models/core.py +288 -0
  43. dotscope/models/history.py +73 -0
  44. dotscope/models/intent.py +213 -0
  45. dotscope/models/passes.py +58 -0
  46. dotscope/models/state.py +250 -0
  47. dotscope/models.py +9 -0
  48. dotscope/near_miss.py +3 -0
  49. dotscope/onboarding.py +2 -0
  50. dotscope/parser.py +387 -0
  51. dotscope/passes/.scope +105 -0
  52. dotscope/passes/__init__.py +1 -0
  53. dotscope/passes/ast_analyzer.py +508 -0
  54. dotscope/passes/backtest.py +198 -0
  55. dotscope/passes/budget_allocator.py +164 -0
  56. dotscope/passes/convention_compliance.py +40 -0
  57. dotscope/passes/convention_discovery.py +247 -0
  58. dotscope/passes/convention_parser.py +223 -0
  59. dotscope/passes/graph_builder.py +299 -0
  60. dotscope/passes/history_miner.py +336 -0
  61. dotscope/passes/incremental.py +149 -0
  62. dotscope/passes/lang/__init__.py +38 -0
  63. dotscope/passes/lang/_base.py +20 -0
  64. dotscope/passes/lang/_treesitter.py +93 -0
  65. dotscope/passes/lang/go.py +333 -0
  66. dotscope/passes/lang/javascript.py +348 -0
  67. dotscope/passes/lazy.py +152 -0
  68. dotscope/passes/semantic_diff.py +160 -0
  69. dotscope/passes/sentinel/__init__.py +1 -0
  70. dotscope/passes/sentinel/acknowledge.py +222 -0
  71. dotscope/passes/sentinel/checker.py +383 -0
  72. dotscope/passes/sentinel/checks/__init__.py +1 -0
  73. dotscope/passes/sentinel/checks/antipattern.py +84 -0
  74. dotscope/passes/sentinel/checks/boundary.py +46 -0
  75. dotscope/passes/sentinel/checks/contracts.py +148 -0
  76. dotscope/passes/sentinel/checks/convention.py +54 -0
  77. dotscope/passes/sentinel/checks/direction.py +71 -0
  78. dotscope/passes/sentinel/checks/intent.py +207 -0
  79. dotscope/passes/sentinel/checks/stability.py +66 -0
  80. dotscope/passes/sentinel/checks/voice.py +108 -0
  81. dotscope/passes/sentinel/constraints.py +472 -0
  82. dotscope/passes/sentinel/line_filter.py +88 -0
  83. dotscope/passes/sentinel/models.py +15 -0
  84. dotscope/passes/virtual.py +239 -0
  85. dotscope/passes/voice.py +162 -0
  86. dotscope/passes/voice_defaults.py +28 -0
  87. dotscope/passes/voice_discovery.py +245 -0
  88. dotscope/paths.py +32 -0
  89. dotscope/progress.py +44 -0
  90. dotscope/regression.py +147 -0
  91. dotscope/resolver.py +203 -0
  92. dotscope/scanner.py +246 -0
  93. dotscope/sessions.py +2 -0
  94. dotscope/storage/.scope +64 -0
  95. dotscope/storage/__init__.py +1 -0
  96. dotscope/storage/cache.py +114 -0
  97. dotscope/storage/claude_hooks.py +119 -0
  98. dotscope/storage/git_hooks.py +277 -0
  99. dotscope/storage/incremental_state.py +61 -0
  100. dotscope/storage/mcp_config.py +98 -0
  101. dotscope/storage/near_miss.py +183 -0
  102. dotscope/storage/onboarding.py +150 -0
  103. dotscope/storage/session_manager.py +195 -0
  104. dotscope/storage/timing.py +84 -0
  105. dotscope/timing.py +2 -0
  106. dotscope/tokens.py +53 -0
  107. dotscope/utility.py +123 -0
  108. dotscope/virtual.py +3 -0
  109. dotscope/visibility.py +664 -0
  110. dotscope-0.1.0.dist-info/METADATA +50 -0
  111. dotscope-0.1.0.dist-info/RECORD +114 -0
  112. dotscope-0.1.0.dist-info/WHEEL +4 -0
  113. dotscope-0.1.0.dist-info/entry_points.txt +3 -0
  114. dotscope-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,333 @@
1
+ """Tree-sitter Go analyzer.
2
+
3
+ Produces the same FileAnalysis shape as the Python stdlib ast analyzer.
4
+ Handles struct methods via receiver linking, interface embedding,
5
+ and go.mod-aware import resolution.
6
+ """
7
+
8
+ import os
9
+ from typing import Dict, List, Optional
10
+
11
+ from ._base import BaseAnalyzer
12
+ from ._treesitter import (
13
+ parse, node_text, find_child, find_children, walk_type
14
+ )
15
+ from ...models.core import (
16
+ FileAnalysis, ResolvedImport, ClassInfo, FunctionInfo, ExportedSymbol,
17
+ )
18
+
19
+
20
+ class GoAnalyzer(BaseAnalyzer):
21
+
22
+ def analyze(self, filepath: str, source: str) -> Optional[FileAnalysis]:
23
+ source_bytes = source.encode("utf-8")
24
+ try:
25
+ root = parse("go", source_bytes)
26
+ except Exception:
27
+ return None
28
+
29
+ imports = self._extract_imports(root, source_bytes)
30
+ structs, interfaces = self._extract_types(root, source_bytes)
31
+ functions, methods = self._extract_functions(root, source_bytes)
32
+
33
+ # Link methods to structs via receiver type
34
+ struct_map: Dict[str, ClassInfo] = {s.name: s for s in structs}
35
+ for receiver_type, method_name in methods:
36
+ clean_type = receiver_type.lstrip("*")
37
+ if clean_type in struct_map:
38
+ struct_map[clean_type].methods.append(method_name)
39
+ struct_map[clean_type].method_count += 1
40
+
41
+ all_classes = structs + interfaces
42
+ exports = self._extract_exports(all_classes, functions)
43
+ docstring = self._extract_package_doc(root, source_bytes)
44
+
45
+ return FileAnalysis(
46
+ path=filepath,
47
+ language="go",
48
+ imports=imports,
49
+ classes=all_classes,
50
+ functions=functions,
51
+ exports=exports,
52
+ docstring=docstring,
53
+ node_count=self._count_nodes(root),
54
+ )
55
+
56
+ def _extract_imports(self, root, source_bytes) -> List[ResolvedImport]:
57
+ imports = []
58
+
59
+ for decl in walk_type(root, "import_declaration"):
60
+ # Single import: import "fmt"
61
+ for spec in walk_type(decl, "import_spec"):
62
+ path_node = find_child(spec, "interpreted_string_literal")
63
+ if not path_node:
64
+ continue
65
+ raw = _strip_quotes(node_text(path_node, source_bytes))
66
+
67
+ # Alias
68
+ name_node = find_child(spec, "package_identifier") or find_child(spec, "dot")
69
+ alias = node_text(name_node, source_bytes) if name_node else ""
70
+ is_star = alias == "."
71
+
72
+ imports.append(ResolvedImport(
73
+ raw=raw,
74
+ module=raw.split("/")[-1] if "/" in raw else raw,
75
+ names=[alias] if alias and alias != "." else [],
76
+ is_star=is_star,
77
+ line=spec.start_point[0] + 1,
78
+ ))
79
+
80
+ return imports
81
+
82
+ def _extract_types(self, root, source_bytes):
83
+ structs = []
84
+ interfaces = []
85
+
86
+ for decl in walk_type(root, "type_declaration"):
87
+ for spec in find_children(decl, "type_spec"):
88
+ name_node = find_child(spec, "type_identifier")
89
+ if not name_node:
90
+ continue
91
+ name = node_text(name_node, source_bytes)
92
+ is_public = name[0].isupper() if name else False
93
+
94
+ struct_type = find_child(spec, "struct_type")
95
+ interface_type = find_child(spec, "interface_type")
96
+
97
+ if struct_type:
98
+ structs.append(ClassInfo(
99
+ name=name,
100
+ bases=[],
101
+ methods=[],
102
+ method_count=0,
103
+ is_public=is_public,
104
+ line=spec.start_point[0] + 1,
105
+ ))
106
+ elif interface_type:
107
+ # Embedded interfaces act like base classes
108
+ bases = []
109
+ for child in interface_type.children:
110
+ if child.type == "type_elem":
111
+ # Embedded type: type_elem > type_identifier
112
+ tid = find_child(child, "type_identifier")
113
+ if tid:
114
+ bases.append(node_text(tid, source_bytes))
115
+ # Qualified: type_elem > qualified_type
116
+ qt = find_child(child, "qualified_type")
117
+ if qt:
118
+ bases.append(node_text(qt, source_bytes))
119
+
120
+ # Interface methods (method_elem nodes)
121
+ methods = []
122
+ for method_elem in walk_type(interface_type, "method_elem"):
123
+ method_name = find_child(method_elem, "field_identifier")
124
+ if method_name:
125
+ methods.append(node_text(method_name, source_bytes))
126
+
127
+ interfaces.append(ClassInfo(
128
+ name=name,
129
+ bases=bases,
130
+ methods=methods,
131
+ method_count=len(methods),
132
+ is_public=is_public,
133
+ line=spec.start_point[0] + 1,
134
+ ))
135
+
136
+ return structs, interfaces
137
+
138
+ def _extract_functions(self, root, source_bytes):
139
+ """Extract functions and methods. Returns (functions, methods).
140
+
141
+ methods is a list of (receiver_type, method_name) for linking to structs.
142
+ """
143
+ functions = []
144
+ methods_to_link = []
145
+
146
+ # Regular functions
147
+ for node in walk_type(root, "function_declaration"):
148
+ name_node = find_child(node, "identifier")
149
+ if not name_node:
150
+ continue
151
+ name = node_text(name_node, source_bytes)
152
+ params = self._extract_params(node, source_bytes)
153
+ return_type = self._extract_return_type(node, source_bytes)
154
+
155
+ functions.append(FunctionInfo(
156
+ name=name,
157
+ params=params,
158
+ arg_count=len(params),
159
+ return_type=return_type,
160
+ is_public=name[0].isupper() if name else False,
161
+ line=node.start_point[0] + 1,
162
+ ))
163
+
164
+ # Methods (with receiver)
165
+ for node in walk_type(root, "method_declaration"):
166
+ name_node = find_child(node, "field_identifier")
167
+ if not name_node:
168
+ continue
169
+ name = node_text(name_node, source_bytes)
170
+ params = self._extract_params(node, source_bytes)
171
+ return_type = self._extract_return_type(node, source_bytes)
172
+
173
+ # Extract receiver type
174
+ receiver = find_child(node, "parameter_list")
175
+ receiver_type = ""
176
+ if receiver:
177
+ for param in receiver.children:
178
+ if param.type == "parameter_declaration":
179
+ type_node = (
180
+ find_child(param, "pointer_type") or
181
+ find_child(param, "type_identifier")
182
+ )
183
+ if type_node:
184
+ receiver_type = node_text(type_node, source_bytes)
185
+
186
+ functions.append(FunctionInfo(
187
+ name=name,
188
+ params=params,
189
+ arg_count=len(params),
190
+ return_type=return_type,
191
+ is_public=name[0].isupper() if name else False,
192
+ line=node.start_point[0] + 1,
193
+ ))
194
+
195
+ if receiver_type:
196
+ methods_to_link.append((receiver_type, name))
197
+
198
+ return functions, methods_to_link
199
+
200
+ def _extract_params(self, node, source_bytes) -> List[str]:
201
+ """Extract parameter names from a function's parameter_list."""
202
+ params = []
203
+ # Skip the first parameter_list (receiver) for methods
204
+ param_lists = find_children(node, "parameter_list")
205
+ if not param_lists:
206
+ return params
207
+
208
+ # For methods, the second parameter_list is the actual params
209
+ # For functions, the first is the params
210
+ target = param_lists[-1] if len(param_lists) > 1 else param_lists[0]
211
+ # But for function_declaration, there's only one
212
+ if node.type == "function_declaration":
213
+ target = param_lists[0]
214
+
215
+ for param in find_children(target, "parameter_declaration"):
216
+ for id_node in find_children(param, "identifier"):
217
+ params.append(node_text(id_node, source_bytes))
218
+
219
+ return params
220
+
221
+ def _extract_return_type(self, node, source_bytes) -> Optional[str]:
222
+ """Extract Go return type (result)."""
223
+ for child in node.children:
224
+ if child.type in ("type_identifier", "pointer_type",
225
+ "parameter_list", "qualified_type"):
226
+ # Check if this is after the params (is the result)
227
+ param_lists = find_children(node, "parameter_list")
228
+ if child.type == "parameter_list" and child in param_lists:
229
+ # Named return values
230
+ if param_lists.index(child) > 0 or node.type == "function_declaration":
231
+ continue
232
+ if child.start_byte > (param_lists[-1].end_byte if param_lists else 0):
233
+ return node_text(child, source_bytes)
234
+ return None
235
+
236
+ def _extract_exports(self, classes, functions) -> List[ExportedSymbol]:
237
+ """In Go, exported = capitalized name."""
238
+ exports = []
239
+ for cls in classes:
240
+ if cls.is_public:
241
+ exports.append(ExportedSymbol(name=cls.name, kind="class"))
242
+ for fn in functions:
243
+ if fn.is_public:
244
+ exports.append(ExportedSymbol(name=fn.name, kind="function"))
245
+ return exports
246
+
247
+ def _extract_package_doc(self, root, source_bytes) -> Optional[str]:
248
+ """Extract package-level GoDoc comment."""
249
+ for child in root.children:
250
+ if child.type == "comment":
251
+ text = node_text(child, source_bytes)
252
+ if text.startswith("//"):
253
+ return text.lstrip("/ ").strip()
254
+ elif child.type == "package_clause":
255
+ # Check comment before package clause
256
+ if child.prev_named_sibling and child.prev_named_sibling.type == "comment":
257
+ text = node_text(child.prev_named_sibling, source_bytes)
258
+ return text.lstrip("/ ").strip()
259
+ break
260
+ else:
261
+ break
262
+ return None
263
+
264
+ def _count_nodes(self, root) -> int:
265
+ count = 0
266
+ stack = [root]
267
+ while stack:
268
+ n = stack.pop()
269
+ count += 1
270
+ stack.extend(n.children)
271
+ return count
272
+
273
+
274
+ def _strip_quotes(s: str) -> str:
275
+ if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
276
+ return s[1:-1]
277
+ return s
278
+
279
+
280
+ def resolve_go_import(
281
+ imp: ResolvedImport,
282
+ source_file: str,
283
+ root: str,
284
+ ) -> Optional[str]:
285
+ """Resolve a Go import to a file path relative to root.
286
+
287
+ Reads go.mod to determine the module path. If the import starts
288
+ with the module path, maps it to the local directory.
289
+ """
290
+ module_path = _read_go_mod(root)
291
+ if not module_path:
292
+ return None
293
+
294
+ import_path = imp.raw
295
+ if not import_path.startswith(module_path):
296
+ return None # External package
297
+
298
+ # Strip module prefix to get relative path
299
+ rel = import_path[len(module_path):].lstrip("/")
300
+ candidate = os.path.join(root, rel)
301
+
302
+ if os.path.isdir(candidate):
303
+ # Find a .go file in the directory
304
+ for f in sorted(os.listdir(candidate)):
305
+ if f.endswith(".go") and not f.endswith("_test.go"):
306
+ return os.path.join(rel, f)
307
+
308
+ return None
309
+
310
+
311
+ _go_mod_cache: Dict[str, Optional[str]] = {}
312
+
313
+
314
+ def _read_go_mod(root: str) -> Optional[str]:
315
+ """Read module path from go.mod."""
316
+ if root in _go_mod_cache:
317
+ return _go_mod_cache[root]
318
+
319
+ go_mod = os.path.join(root, "go.mod")
320
+ result = None
321
+ if os.path.exists(go_mod):
322
+ try:
323
+ with open(go_mod, "r", encoding="utf-8") as f:
324
+ for line in f:
325
+ line = line.strip()
326
+ if line.startswith("module "):
327
+ result = line.split(None, 1)[1].strip()
328
+ break
329
+ except (IOError, IndexError):
330
+ pass
331
+
332
+ _go_mod_cache[root] = result
333
+ return result
@@ -0,0 +1,348 @@
1
+ """Tree-sitter JavaScript/TypeScript analyzer.
2
+
3
+ Produces the same FileAnalysis shape as the Python stdlib ast analyzer.
4
+ A single class handles both JS and TS since TS is a superset — TS-only
5
+ features (decorators, type annotations) simply produce no captures on
6
+ plain JS files.
7
+ """
8
+
9
+ import os
10
+ from typing import List, Optional
11
+
12
+ from ._base import BaseAnalyzer
13
+ from ._treesitter import (
14
+ parse, node_text, find_child, find_children, walk_type
15
+ )
16
+ from ...models.core import (
17
+ FileAnalysis, ResolvedImport, ClassInfo, FunctionInfo, ExportedSymbol,
18
+ )
19
+
20
+
21
+ class JavaScriptAnalyzer(BaseAnalyzer):
22
+
23
+ def analyze(self, filepath: str, source: str) -> Optional[FileAnalysis]:
24
+ ext = os.path.splitext(filepath)[1].lower()
25
+ if ext in (".tsx",):
26
+ lang = "tsx"
27
+ elif ext in (".ts",):
28
+ lang = "typescript"
29
+ else:
30
+ lang = "javascript"
31
+
32
+ source_bytes = source.encode("utf-8")
33
+ try:
34
+ root = parse(lang, source_bytes)
35
+ except Exception:
36
+ return None
37
+
38
+ imports = self._extract_imports(root, source_bytes)
39
+ classes = self._extract_classes(root, source_bytes)
40
+ functions = self._extract_functions(root, source_bytes)
41
+ exports = self._extract_exports(root, source_bytes)
42
+ decorators = self._extract_all_decorators(root, source_bytes)
43
+ docstring = self._extract_module_docstring(root, source_bytes)
44
+
45
+ return FileAnalysis(
46
+ path=filepath,
47
+ language="typescript" if ext in (".ts", ".tsx") else "javascript",
48
+ imports=imports,
49
+ classes=classes,
50
+ functions=functions,
51
+ exports=exports,
52
+ decorators_used=sorted(set(decorators)),
53
+ docstring=docstring,
54
+ node_count=self._count_nodes(root),
55
+ )
56
+
57
+ def _extract_imports(self, root, source_bytes) -> List[ResolvedImport]:
58
+ imports = []
59
+
60
+ # ES imports: import X from '...', import { X } from '...', import * as X from '...'
61
+ for node in find_children(root, "import_statement"):
62
+ source_node = find_child(node, "string")
63
+ if not source_node:
64
+ continue
65
+ raw = _strip_quotes(node_text(source_node, source_bytes))
66
+
67
+ clause = find_child(node, "import_clause")
68
+ names = []
69
+ is_star = False
70
+ if clause:
71
+ for named in walk_type(clause, "import_specifier"):
72
+ name_node = find_child(named, "identifier")
73
+ if name_node:
74
+ names.append(node_text(name_node, source_bytes))
75
+ for ns in walk_type(clause, "namespace_import"):
76
+ is_star = True
77
+ id_node = find_child(ns, "identifier")
78
+ if id_node:
79
+ names.append(node_text(id_node, source_bytes))
80
+ # default import
81
+ default_id = find_child(clause, "identifier")
82
+ if default_id and default_id.parent == clause:
83
+ names.append(node_text(default_id, source_bytes))
84
+
85
+ imports.append(ResolvedImport(
86
+ raw=raw,
87
+ module=raw.split("/")[-1] if "/" in raw else raw,
88
+ names=names,
89
+ is_star=is_star,
90
+ is_relative=raw.startswith("."),
91
+ line=node.start_point[0] + 1,
92
+ ))
93
+
94
+ # require() calls
95
+ for call in walk_type(root, "call_expression"):
96
+ fn = find_child(call, "identifier")
97
+ if fn and node_text(fn, source_bytes) == "require":
98
+ args = find_child(call, "arguments")
99
+ if args:
100
+ str_node = find_child(args, "string")
101
+ if str_node:
102
+ raw = _strip_quotes(node_text(str_node, source_bytes))
103
+ imports.append(ResolvedImport(
104
+ raw=raw,
105
+ module=raw.split("/")[-1] if "/" in raw else raw,
106
+ names=[],
107
+ is_relative=raw.startswith("."),
108
+ line=call.start_point[0] + 1,
109
+ ))
110
+
111
+ # dynamic import()
112
+ for call in walk_type(root, "call_expression"):
113
+ fn = find_child(call, "import")
114
+ if fn:
115
+ args = find_child(call, "arguments")
116
+ if args:
117
+ str_node = find_child(args, "string")
118
+ if str_node:
119
+ raw = _strip_quotes(node_text(str_node, source_bytes))
120
+ imports.append(ResolvedImport(
121
+ raw=raw,
122
+ module=raw.split("/")[-1] if "/" in raw else raw,
123
+ names=[],
124
+ is_conditional=True,
125
+ is_relative=raw.startswith("."),
126
+ line=call.start_point[0] + 1,
127
+ ))
128
+
129
+ return imports
130
+
131
+ def _extract_classes(self, root, source_bytes) -> List[ClassInfo]:
132
+ classes = []
133
+ for node in walk_type(root, "class_declaration"):
134
+ classes.append(self._parse_class(node, source_bytes))
135
+ # class expressions assigned to variables
136
+ for node in walk_type(root, "class"):
137
+ if node.parent and node.parent.type != "class_declaration":
138
+ classes.append(self._parse_class(node, source_bytes))
139
+ return classes
140
+
141
+ def _parse_class(self, node, source_bytes) -> ClassInfo:
142
+ name = ""
143
+ name_node = find_child(node, "identifier") or find_child(node, "type_identifier")
144
+ if name_node:
145
+ name = node_text(name_node, source_bytes)
146
+
147
+ # Base classes (extends)
148
+ bases = []
149
+ heritage = find_child(node, "class_heritage")
150
+ if heritage:
151
+ # member_expression: React.Component (check first, more specific)
152
+ mem = find_child(heritage, "member_expression")
153
+ if mem:
154
+ bases.append(node_text(mem, source_bytes))
155
+ else:
156
+ # Simple identifier: BaseService
157
+ id_node = find_child(heritage, "identifier")
158
+ if id_node:
159
+ bases.append(node_text(id_node, source_bytes))
160
+
161
+ # Methods
162
+ methods = []
163
+ body = find_child(node, "class_body")
164
+ if body:
165
+ for method in find_children(body, "method_definition"):
166
+ method_name = find_child(method, "property_identifier")
167
+ if method_name:
168
+ methods.append(node_text(method_name, source_bytes))
169
+
170
+ # Decorators (TS)
171
+ decorators = []
172
+ for dec in find_children(node, "decorator"):
173
+ dec_text = node_text(dec, source_bytes).lstrip("@").split("(")[0]
174
+ decorators.append(dec_text)
175
+ # Also check preceding siblings (some grammars put decorators before the class)
176
+ if node.prev_named_sibling and node.prev_named_sibling.type == "decorator":
177
+ dec_text = node_text(node.prev_named_sibling, source_bytes).lstrip("@").split("(")[0]
178
+ decorators.append(dec_text)
179
+
180
+ return ClassInfo(
181
+ name=name,
182
+ bases=bases,
183
+ methods=methods,
184
+ method_count=len(methods),
185
+ decorators=decorators,
186
+ is_public=True,
187
+ line=node.start_point[0] + 1,
188
+ )
189
+
190
+ def _extract_functions(self, root, source_bytes) -> List[FunctionInfo]:
191
+ functions = []
192
+
193
+ # Top-level function declarations
194
+ for node in find_children(root, "function_declaration"):
195
+ functions.append(self._parse_function(node, source_bytes))
196
+
197
+ # Exported function declarations
198
+ for export in find_children(root, "export_statement"):
199
+ for fn in find_children(export, "function_declaration"):
200
+ functions.append(self._parse_function(fn, source_bytes))
201
+
202
+ # Arrow functions assigned to top-level const/let/var
203
+ for decl in find_children(root, "lexical_declaration"):
204
+ for declarator in find_children(decl, "variable_declarator"):
205
+ value = find_child(declarator, "arrow_function")
206
+ if value:
207
+ name_node = find_child(declarator, "identifier")
208
+ name = node_text(name_node, source_bytes) if name_node else ""
209
+ functions.append(self._parse_arrow(name, value, source_bytes))
210
+
211
+ return functions
212
+
213
+ def _parse_function(self, node, source_bytes) -> FunctionInfo:
214
+ name = ""
215
+ name_node = find_child(node, "identifier")
216
+ if name_node:
217
+ name = node_text(name_node, source_bytes)
218
+
219
+ params = self._extract_params(node, source_bytes)
220
+ return_type = self._extract_return_type(node, source_bytes)
221
+ is_async = any(
222
+ c.type == "async" for c in node.children
223
+ )
224
+
225
+ decorators = []
226
+ if node.prev_named_sibling and node.prev_named_sibling.type == "decorator":
227
+ dec_text = node_text(node.prev_named_sibling, source_bytes).lstrip("@").split("(")[0]
228
+ decorators.append(dec_text)
229
+
230
+ return FunctionInfo(
231
+ name=name,
232
+ params=params,
233
+ arg_count=len(params),
234
+ return_type=return_type,
235
+ decorators=decorators,
236
+ is_async=is_async,
237
+ is_public=True,
238
+ line=node.start_point[0] + 1,
239
+ )
240
+
241
+ def _parse_arrow(self, name, node, source_bytes) -> FunctionInfo:
242
+ params = self._extract_params(node, source_bytes)
243
+ return_type = self._extract_return_type(node, source_bytes)
244
+ is_async = any(c.type == "async" for c in node.children)
245
+
246
+ return FunctionInfo(
247
+ name=name,
248
+ params=params,
249
+ arg_count=len(params),
250
+ return_type=return_type,
251
+ is_async=is_async,
252
+ is_public=True,
253
+ line=node.start_point[0] + 1,
254
+ )
255
+
256
+ def _extract_params(self, node, source_bytes) -> List[str]:
257
+ params_node = find_child(node, "formal_parameters")
258
+ if not params_node:
259
+ return []
260
+ params = []
261
+ for child in params_node.children:
262
+ if child.type in ("identifier", "required_parameter",
263
+ "optional_parameter", "rest_parameter",
264
+ "assignment_pattern"):
265
+ params.append(node_text(child, source_bytes))
266
+ return params
267
+
268
+ def _extract_return_type(self, node, source_bytes) -> Optional[str]:
269
+ """Extract TS return type annotation."""
270
+ for child in node.children:
271
+ if child.type == "type_annotation":
272
+ return node_text(child, source_bytes).lstrip(": ").strip()
273
+ return None
274
+
275
+ def _extract_exports(self, root, source_bytes) -> List[ExportedSymbol]:
276
+ exports = []
277
+ for node in find_children(root, "export_statement"):
278
+ # export default X
279
+ default_node = find_child(node, "identifier")
280
+ fn = find_child(node, "function_declaration")
281
+ cls = find_child(node, "class_declaration")
282
+
283
+ if fn:
284
+ name_node = find_child(fn, "identifier")
285
+ if name_node:
286
+ exports.append(ExportedSymbol(
287
+ name=node_text(name_node, source_bytes),
288
+ kind="function",
289
+ ))
290
+ elif cls:
291
+ name_node = find_child(cls, "identifier")
292
+ if name_node:
293
+ exports.append(ExportedSymbol(
294
+ name=node_text(name_node, source_bytes),
295
+ kind="class",
296
+ ))
297
+ elif default_node and any(c.type == "default" for c in node.children):
298
+ exports.append(ExportedSymbol(
299
+ name=node_text(default_node, source_bytes),
300
+ kind="variable",
301
+ ))
302
+
303
+ # export const/let/var
304
+ for decl in find_children(node, "lexical_declaration"):
305
+ for declarator in find_children(decl, "variable_declarator"):
306
+ id_node = find_child(declarator, "identifier")
307
+ if id_node:
308
+ exports.append(ExportedSymbol(
309
+ name=node_text(id_node, source_bytes),
310
+ kind="variable",
311
+ ))
312
+
313
+ return exports
314
+
315
+ def _extract_all_decorators(self, root, source_bytes) -> List[str]:
316
+ """Collect all decorator names used in the file."""
317
+ decorators = []
318
+ for dec in walk_type(root, "decorator"):
319
+ text = node_text(dec, source_bytes).lstrip("@").split("(")[0]
320
+ decorators.append(text)
321
+ return decorators
322
+
323
+ def _extract_module_docstring(self, root, source_bytes) -> Optional[str]:
324
+ """Extract leading JSDoc comment as module docstring."""
325
+ if root.child_count == 0:
326
+ return None
327
+ first = root.children[0]
328
+ if first.type == "comment":
329
+ text = node_text(first, source_bytes)
330
+ if text.startswith("/**"):
331
+ return text.strip("/* \n")
332
+ return None
333
+
334
+ def _count_nodes(self, root) -> int:
335
+ count = 0
336
+ stack = [root]
337
+ while stack:
338
+ n = stack.pop()
339
+ count += 1
340
+ stack.extend(n.children)
341
+ return count
342
+
343
+
344
+ def _strip_quotes(s: str) -> str:
345
+ """Remove surrounding quotes from a string literal."""
346
+ if len(s) >= 2 and s[0] in ('"', "'", "`") and s[-1] in ('"', "'", "`"):
347
+ return s[1:-1]
348
+ return s