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.
- dotscope/.scope +63 -0
- dotscope/__init__.py +3 -0
- dotscope/absorber.py +390 -0
- dotscope/assertions.py +128 -0
- dotscope/ast_analyzer.py +2 -0
- dotscope/backtest.py +2 -0
- dotscope/bench.py +141 -0
- dotscope/budget.py +3 -0
- dotscope/cache.py +2 -0
- dotscope/check/__init__.py +1 -0
- dotscope/check/acknowledge.py +2 -0
- dotscope/check/checker.py +3 -0
- dotscope/check/checks/__init__.py +1 -0
- dotscope/check/checks/antipattern.py +2 -0
- dotscope/check/checks/boundary.py +2 -0
- dotscope/check/checks/contracts.py +3 -0
- dotscope/check/checks/direction.py +2 -0
- dotscope/check/checks/intent.py +2 -0
- dotscope/check/checks/stability.py +2 -0
- dotscope/check/constraints.py +2 -0
- dotscope/check/models.py +15 -0
- dotscope/cli.py +1447 -0
- dotscope/composer.py +147 -0
- dotscope/constants.py +45 -0
- dotscope/context.py +60 -0
- dotscope/counterfactual.py +180 -0
- dotscope/debug.py +220 -0
- dotscope/discovery.py +104 -0
- dotscope/formatter.py +157 -0
- dotscope/graph.py +3 -0
- dotscope/health.py +212 -0
- dotscope/help.py +204 -0
- dotscope/history.py +6 -0
- dotscope/hooks.py +2 -0
- dotscope/ingest.py +858 -0
- dotscope/intent.py +618 -0
- dotscope/lessons.py +223 -0
- dotscope/matcher.py +104 -0
- dotscope/mcp_server.py +1081 -0
- dotscope/models/.scope +45 -0
- dotscope/models/__init__.py +7 -0
- dotscope/models/core.py +288 -0
- dotscope/models/history.py +73 -0
- dotscope/models/intent.py +213 -0
- dotscope/models/passes.py +58 -0
- dotscope/models/state.py +250 -0
- dotscope/models.py +9 -0
- dotscope/near_miss.py +3 -0
- dotscope/onboarding.py +2 -0
- dotscope/parser.py +387 -0
- dotscope/passes/.scope +105 -0
- dotscope/passes/__init__.py +1 -0
- dotscope/passes/ast_analyzer.py +508 -0
- dotscope/passes/backtest.py +198 -0
- dotscope/passes/budget_allocator.py +164 -0
- dotscope/passes/convention_compliance.py +40 -0
- dotscope/passes/convention_discovery.py +247 -0
- dotscope/passes/convention_parser.py +223 -0
- dotscope/passes/graph_builder.py +299 -0
- dotscope/passes/history_miner.py +336 -0
- dotscope/passes/incremental.py +149 -0
- dotscope/passes/lang/__init__.py +38 -0
- dotscope/passes/lang/_base.py +20 -0
- dotscope/passes/lang/_treesitter.py +93 -0
- dotscope/passes/lang/go.py +333 -0
- dotscope/passes/lang/javascript.py +348 -0
- dotscope/passes/lazy.py +152 -0
- dotscope/passes/semantic_diff.py +160 -0
- dotscope/passes/sentinel/__init__.py +1 -0
- dotscope/passes/sentinel/acknowledge.py +222 -0
- dotscope/passes/sentinel/checker.py +383 -0
- dotscope/passes/sentinel/checks/__init__.py +1 -0
- dotscope/passes/sentinel/checks/antipattern.py +84 -0
- dotscope/passes/sentinel/checks/boundary.py +46 -0
- dotscope/passes/sentinel/checks/contracts.py +148 -0
- dotscope/passes/sentinel/checks/convention.py +54 -0
- dotscope/passes/sentinel/checks/direction.py +71 -0
- dotscope/passes/sentinel/checks/intent.py +207 -0
- dotscope/passes/sentinel/checks/stability.py +66 -0
- dotscope/passes/sentinel/checks/voice.py +108 -0
- dotscope/passes/sentinel/constraints.py +472 -0
- dotscope/passes/sentinel/line_filter.py +88 -0
- dotscope/passes/sentinel/models.py +15 -0
- dotscope/passes/virtual.py +239 -0
- dotscope/passes/voice.py +162 -0
- dotscope/passes/voice_defaults.py +28 -0
- dotscope/passes/voice_discovery.py +245 -0
- dotscope/paths.py +32 -0
- dotscope/progress.py +44 -0
- dotscope/regression.py +147 -0
- dotscope/resolver.py +203 -0
- dotscope/scanner.py +246 -0
- dotscope/sessions.py +2 -0
- dotscope/storage/.scope +64 -0
- dotscope/storage/__init__.py +1 -0
- dotscope/storage/cache.py +114 -0
- dotscope/storage/claude_hooks.py +119 -0
- dotscope/storage/git_hooks.py +277 -0
- dotscope/storage/incremental_state.py +61 -0
- dotscope/storage/mcp_config.py +98 -0
- dotscope/storage/near_miss.py +183 -0
- dotscope/storage/onboarding.py +150 -0
- dotscope/storage/session_manager.py +195 -0
- dotscope/storage/timing.py +84 -0
- dotscope/timing.py +2 -0
- dotscope/tokens.py +53 -0
- dotscope/utility.py +123 -0
- dotscope/virtual.py +3 -0
- dotscope/visibility.py +664 -0
- dotscope-0.1.0.dist-info/METADATA +50 -0
- dotscope-0.1.0.dist-info/RECORD +114 -0
- dotscope-0.1.0.dist-info/WHEEL +4 -0
- dotscope-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|