contextl 1.2.13 → 1.2.14
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.
- package/package.json +1 -1
- package/python/git_review.py +245 -0
- package/python/import_parser.py +1015 -155
- package/python/main.py +35 -1
- package/python/mcp_server.py +113 -0
package/python/import_parser.py
CHANGED
|
@@ -2,25 +2,323 @@
|
|
|
2
2
|
Repository Intelligence Engine
|
|
3
3
|
Step 2: Import Parser
|
|
4
4
|
|
|
5
|
-
Reads each scanned file and extracts import relationships
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
5
|
+
Reads each scanned file and extracts import relationships using tree-sitter
|
|
6
|
+
for accurate, AST-level parsing across Python, TypeScript, JavaScript, Java,
|
|
7
|
+
Rust, Go, and C++.
|
|
8
|
+
|
|
9
|
+
Falls back to the previous regex engine if tree-sitter is not installed
|
|
10
|
+
(zero regression for existing users).
|
|
11
|
+
|
|
12
|
+
Public API (unchanged):
|
|
13
|
+
parse_imports(scan_result: ScanResult) -> ParseResult
|
|
14
|
+
extract_skeleton(file_path: str) -> dict [NEW — Skeleton Mode]
|
|
14
15
|
"""
|
|
15
16
|
|
|
16
17
|
import re
|
|
17
|
-
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path, PurePosixPath
|
|
18
20
|
from dataclasses import dataclass, field
|
|
19
21
|
|
|
20
22
|
from scanner import ScannedFile, ScanResult
|
|
21
23
|
|
|
22
24
|
|
|
23
|
-
#
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# tree-sitter availability check — graceful fallback to regex if not installed
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
try:
|
|
29
|
+
from tree_sitter import Language, Parser as TSParser, Node
|
|
30
|
+
import tree_sitter_python as _tspy
|
|
31
|
+
import tree_sitter_javascript as _tsjs
|
|
32
|
+
import tree_sitter_typescript as _tsts
|
|
33
|
+
import tree_sitter_java as _tsjava
|
|
34
|
+
import tree_sitter_rust as _tsrs
|
|
35
|
+
import tree_sitter_go as _tsgo
|
|
36
|
+
import tree_sitter_cpp as _tscpp
|
|
37
|
+
|
|
38
|
+
_TS_AVAILABLE = True
|
|
39
|
+
except ImportError:
|
|
40
|
+
_TS_AVAILABLE = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Module-level caches (Parser + Language objects are expensive to create)
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
_PARSERS: dict[str, "TSParser"] = {} # ext -> Parser
|
|
47
|
+
_LANGUAGES: dict[str, "Language"] = {} # ext -> Language
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_language(ext: str):
|
|
51
|
+
"""Return the tree-sitter Language for a file extension, or None."""
|
|
52
|
+
if not _TS_AVAILABLE:
|
|
53
|
+
return None
|
|
54
|
+
if ext in _LANGUAGES:
|
|
55
|
+
return _LANGUAGES[ext]
|
|
56
|
+
|
|
57
|
+
lang = None
|
|
58
|
+
if ext == ".py":
|
|
59
|
+
lang = Language(_tspy.language())
|
|
60
|
+
elif ext in (".js", ".mjs", ".cjs", ".jsx"):
|
|
61
|
+
lang = Language(_tsjs.language())
|
|
62
|
+
elif ext == ".tsx":
|
|
63
|
+
lang = Language(_tsts.language_tsx())
|
|
64
|
+
elif ext in (".ts",):
|
|
65
|
+
lang = Language(_tsts.language_typescript())
|
|
66
|
+
elif ext == ".java":
|
|
67
|
+
lang = Language(_tsjava.language())
|
|
68
|
+
elif ext == ".rs":
|
|
69
|
+
lang = Language(_tsrs.language())
|
|
70
|
+
elif ext == ".go":
|
|
71
|
+
lang = Language(_tsgo.language())
|
|
72
|
+
elif ext in (".cpp", ".cc", ".cxx", ".h", ".hpp", ".c"):
|
|
73
|
+
lang = Language(_tscpp.language())
|
|
74
|
+
|
|
75
|
+
if lang is not None:
|
|
76
|
+
_LANGUAGES[ext] = lang
|
|
77
|
+
return lang
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _get_parser(ext: str):
|
|
81
|
+
"""Return a cached Parser for the given extension, or None."""
|
|
82
|
+
if not _TS_AVAILABLE:
|
|
83
|
+
return None, None
|
|
84
|
+
if ext in _PARSERS:
|
|
85
|
+
return _PARSERS[ext], _LANGUAGES[ext]
|
|
86
|
+
|
|
87
|
+
lang = _get_language(ext)
|
|
88
|
+
if lang is None:
|
|
89
|
+
return None, None
|
|
90
|
+
|
|
91
|
+
parser = TSParser(lang)
|
|
92
|
+
_PARSERS[ext] = parser
|
|
93
|
+
return parser, lang
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# tree-sitter import extraction — per-language node walkers
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def _ts_imports_python(root: "Node") -> list[str]:
|
|
101
|
+
"""Extract import module names from a Python AST root."""
|
|
102
|
+
results: list[str] = []
|
|
103
|
+
|
|
104
|
+
def visit(node: "Node"):
|
|
105
|
+
if node.type == "import_statement":
|
|
106
|
+
# import os, sys → dotted_name children
|
|
107
|
+
for child in node.children:
|
|
108
|
+
if child.type == "dotted_name":
|
|
109
|
+
results.append(child.text.decode("utf-8", errors="replace"))
|
|
110
|
+
elif child.type == "aliased_import":
|
|
111
|
+
# import os as operating_system → first dotted_name
|
|
112
|
+
for sub in child.children:
|
|
113
|
+
if sub.type == "dotted_name":
|
|
114
|
+
results.append(sub.text.decode("utf-8", errors="replace"))
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
elif node.type == "import_from_statement":
|
|
118
|
+
# from pathlib import Path
|
|
119
|
+
# from . import utils
|
|
120
|
+
# from ..models import User
|
|
121
|
+
for child in node.children:
|
|
122
|
+
if child.type in ("dotted_name", "relative_import"):
|
|
123
|
+
results.append(child.text.decode("utf-8", errors="replace"))
|
|
124
|
+
break # only the module name, not the imported symbols
|
|
125
|
+
|
|
126
|
+
for child in node.children:
|
|
127
|
+
visit(child)
|
|
128
|
+
|
|
129
|
+
visit(root)
|
|
130
|
+
return results
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _ts_imports_js_ts(root: "Node") -> list[str]:
|
|
134
|
+
"""Extract import paths from a JS/TS/JSX/TSX AST root."""
|
|
135
|
+
results: list[str] = []
|
|
136
|
+
|
|
137
|
+
def _string_value(node: "Node") -> str | None:
|
|
138
|
+
"""Extract the inner string from a string node (strips quotes)."""
|
|
139
|
+
if node.type == "string":
|
|
140
|
+
for child in node.children:
|
|
141
|
+
if child.type == "string_fragment":
|
|
142
|
+
return child.text.decode("utf-8", errors="replace")
|
|
143
|
+
# Fallback: strip quotes manually
|
|
144
|
+
raw = node.text.decode("utf-8", errors="replace")
|
|
145
|
+
return raw.strip("'\"`")
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
def visit(node: "Node"):
|
|
149
|
+
# import X from 'module' / import { X } from 'module' / import type ...
|
|
150
|
+
if node.type == "import_statement":
|
|
151
|
+
for child in node.children:
|
|
152
|
+
if child.type == "string":
|
|
153
|
+
val = _string_value(child)
|
|
154
|
+
if val:
|
|
155
|
+
results.append(val)
|
|
156
|
+
|
|
157
|
+
# export { X } from 'module' / export * from 'module'
|
|
158
|
+
elif node.type == "export_statement":
|
|
159
|
+
for child in node.children:
|
|
160
|
+
if child.type == "string":
|
|
161
|
+
val = _string_value(child)
|
|
162
|
+
if val:
|
|
163
|
+
results.append(val)
|
|
164
|
+
|
|
165
|
+
# require('./foo') / import('./lazy')
|
|
166
|
+
elif node.type == "call_expression":
|
|
167
|
+
func = None
|
|
168
|
+
args_node = None
|
|
169
|
+
for child in node.children:
|
|
170
|
+
if child.type in ("identifier", "import"):
|
|
171
|
+
func = child.text.decode("utf-8", errors="replace")
|
|
172
|
+
elif child.type == "arguments":
|
|
173
|
+
args_node = child
|
|
174
|
+
|
|
175
|
+
if func in ("require", "import") and args_node:
|
|
176
|
+
for arg in args_node.children:
|
|
177
|
+
if arg.type == "string":
|
|
178
|
+
val = _string_value(arg)
|
|
179
|
+
if val:
|
|
180
|
+
results.append(val)
|
|
181
|
+
|
|
182
|
+
for child in node.children:
|
|
183
|
+
visit(child)
|
|
184
|
+
|
|
185
|
+
visit(root)
|
|
186
|
+
return results
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _ts_imports_java(root: "Node") -> list[str]:
|
|
190
|
+
"""Extract import class names from a Java AST root."""
|
|
191
|
+
results: list[str] = []
|
|
192
|
+
|
|
193
|
+
def visit(node: "Node"):
|
|
194
|
+
if node.type == "import_declaration":
|
|
195
|
+
# Find the top-level scoped_identifier or identifier
|
|
196
|
+
for child in node.children:
|
|
197
|
+
if child.type in ("scoped_identifier", "identifier", "asterisk"):
|
|
198
|
+
results.append(child.text.decode("utf-8", errors="replace").replace("::", "."))
|
|
199
|
+
break
|
|
200
|
+
for child in node.children:
|
|
201
|
+
visit(child)
|
|
202
|
+
|
|
203
|
+
visit(root)
|
|
204
|
+
return results
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _ts_imports_rust(root: "Node") -> list[str]:
|
|
208
|
+
"""Extract use paths from a Rust AST root."""
|
|
209
|
+
results: list[str] = []
|
|
210
|
+
|
|
211
|
+
def visit(node: "Node"):
|
|
212
|
+
if node.type == "use_declaration":
|
|
213
|
+
# Grab the first meaningful child (scoped_identifier, scoped_use_list, identifier)
|
|
214
|
+
for child in node.children:
|
|
215
|
+
if child.type not in ("use", ";"):
|
|
216
|
+
results.append(child.text.decode("utf-8", errors="replace"))
|
|
217
|
+
break
|
|
218
|
+
elif node.type == "extern_crate_declaration":
|
|
219
|
+
for child in node.children:
|
|
220
|
+
if child.type == "identifier":
|
|
221
|
+
results.append(child.text.decode("utf-8", errors="replace"))
|
|
222
|
+
break
|
|
223
|
+
for child in node.children:
|
|
224
|
+
visit(child)
|
|
225
|
+
|
|
226
|
+
visit(root)
|
|
227
|
+
return results
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _ts_imports_go(root: "Node") -> list[str]:
|
|
231
|
+
"""Extract import paths from a Go AST root."""
|
|
232
|
+
results: list[str] = []
|
|
233
|
+
|
|
234
|
+
def visit(node: "Node"):
|
|
235
|
+
if node.type == "import_spec":
|
|
236
|
+
for child in node.children:
|
|
237
|
+
if child.type == "interpreted_string_literal":
|
|
238
|
+
raw = child.text.decode("utf-8", errors="replace")
|
|
239
|
+
results.append(raw.strip('"'))
|
|
240
|
+
break
|
|
241
|
+
for child in node.children:
|
|
242
|
+
visit(child)
|
|
243
|
+
|
|
244
|
+
visit(root)
|
|
245
|
+
return results
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _ts_imports_cpp(root: "Node") -> list[str]:
|
|
249
|
+
"""Extract #include paths from a C/C++ AST root."""
|
|
250
|
+
results: list[str] = []
|
|
251
|
+
|
|
252
|
+
def visit(node: "Node"):
|
|
253
|
+
if node.type == "preproc_include":
|
|
254
|
+
for child in node.children:
|
|
255
|
+
if child.type in ("string_literal", "system_lib_string"):
|
|
256
|
+
raw = child.text.decode("utf-8", errors="replace")
|
|
257
|
+
results.append(raw.strip('"<>'))
|
|
258
|
+
break
|
|
259
|
+
for child in node.children:
|
|
260
|
+
visit(child)
|
|
261
|
+
|
|
262
|
+
visit(root)
|
|
263
|
+
return results
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# Map extension → walker function
|
|
267
|
+
_TS_WALKERS = {
|
|
268
|
+
".py": _ts_imports_python,
|
|
269
|
+
".js": _ts_imports_js_ts,
|
|
270
|
+
".mjs": _ts_imports_js_ts,
|
|
271
|
+
".cjs": _ts_imports_js_ts,
|
|
272
|
+
".jsx": _ts_imports_js_ts,
|
|
273
|
+
".ts": _ts_imports_js_ts,
|
|
274
|
+
".tsx": _ts_imports_js_ts,
|
|
275
|
+
".java": _ts_imports_java,
|
|
276
|
+
".rs": _ts_imports_rust,
|
|
277
|
+
".go": _ts_imports_go,
|
|
278
|
+
".cpp": _ts_imports_cpp,
|
|
279
|
+
".cc": _ts_imports_cpp,
|
|
280
|
+
".cxx": _ts_imports_cpp,
|
|
281
|
+
".h": _ts_imports_cpp,
|
|
282
|
+
".hpp": _ts_imports_cpp,
|
|
283
|
+
".c": _ts_imports_cpp,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _extract_raw_imports_ts(source_bytes: bytes, ext: str) -> list[str]:
|
|
288
|
+
"""
|
|
289
|
+
Parse imports from source bytes using tree-sitter.
|
|
290
|
+
Returns a deduplicated list of raw import strings.
|
|
291
|
+
Never raises — returns [] on any error.
|
|
292
|
+
"""
|
|
293
|
+
try:
|
|
294
|
+
parser, lang = _get_parser(ext)
|
|
295
|
+
if parser is None:
|
|
296
|
+
return []
|
|
297
|
+
|
|
298
|
+
tree = parser.parse(source_bytes)
|
|
299
|
+
walker = _TS_WALKERS.get(ext)
|
|
300
|
+
if walker is None:
|
|
301
|
+
return []
|
|
302
|
+
|
|
303
|
+
raw = walker(tree.root_node)
|
|
304
|
+
# Deduplicate while preserving first-seen order
|
|
305
|
+
seen: set[str] = set()
|
|
306
|
+
result: list[str] = []
|
|
307
|
+
for s in raw:
|
|
308
|
+
s = s.strip()
|
|
309
|
+
if s and s not in seen:
|
|
310
|
+
seen.add(s)
|
|
311
|
+
result.append(s)
|
|
312
|
+
return result
|
|
313
|
+
except Exception as e:
|
|
314
|
+
print(f"[contextl] tree-sitter parse warning ({ext}): {e}", file=sys.stderr)
|
|
315
|
+
return []
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
# Regex fallback (kept verbatim from original for non-tree-sitter envs)
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
|
|
24
322
|
IMPORT_PATTERN = re.compile(
|
|
25
323
|
r"""(?:import|export)\s+(?:type\s+)? # import or import type or export
|
|
26
324
|
(?:
|
|
@@ -29,53 +327,19 @@ IMPORT_PATTERN = re.compile(
|
|
|
29
327
|
|[\w*]+\s*,\s*\{[^}]*\} # mixed: Foo, { Bar }
|
|
30
328
|
)?
|
|
31
329
|
\s*(?:from\s+)? # optional "from"
|
|
32
|
-
['"](.*?)['"] # the module path in quotes
|
|
330
|
+
['\"](.*?)['\"] # the module path in quotes
|
|
33
331
|
""",
|
|
34
332
|
re.VERBOSE,
|
|
35
333
|
)
|
|
36
|
-
|
|
37
|
-
# Also catch: import("./foo") — dynamic imports
|
|
38
334
|
DYNAMIC_IMPORT_PATTERN = re.compile(r"""import\s*\(\s*['"](.*?)['"]\s*\)""")
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
PYTHON_FROM_IMPORT_PATTERN = re.compile(r"^[ \t]*from\s+([.\w]+)\s+import\s+(?:\(([^)]+)\)|([^#\n]+))", re.MULTILINE)
|
|
42
|
-
PYTHON_IMPORT_PATTERN = re.compile(r"^[ \t]*import\s+([.\w]+)", re.MULTILINE)
|
|
43
|
-
|
|
44
|
-
# Java
|
|
335
|
+
PYTHON_FROM_IMPORT_PATTERN = re.compile(r"^[ \t]*from\s+([\.\w]+)\s+import\s+(?:\(([^)]+)\)|([^#\n]+))", re.MULTILINE)
|
|
336
|
+
PYTHON_IMPORT_PATTERN = re.compile(r"^[ \t]*import\s+([\.\w]+)", re.MULTILINE)
|
|
45
337
|
JAVA_IMPORT_PATTERN = re.compile(r"^import\s+(?:static\s+)?([\w.*]+);", re.MULTILINE)
|
|
46
338
|
JAVA_PACKAGE_PATTERN = re.compile(r"^package\s+([\w.]+);", re.MULTILINE)
|
|
47
339
|
|
|
48
340
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"""A resolved import from one file to another."""
|
|
52
|
-
source: str # Relative path of the file doing the importing
|
|
53
|
-
target: str # Relative path of the file being imported
|
|
54
|
-
raw_import: str # The original import string (e.g. "@/components/Button")
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@dataclass
|
|
58
|
-
class ParseResult:
|
|
59
|
-
"""All import relationships discovered across the repository."""
|
|
60
|
-
relationships: list[ImportRelationship] = field(default_factory=list)
|
|
61
|
-
unresolved: list[tuple[str, str]] = field(default_factory=list)
|
|
62
|
-
# unresolved = [(source_file, raw_import), ...] — external packages or not found
|
|
63
|
-
|
|
64
|
-
def summary(self) -> str:
|
|
65
|
-
lines = [
|
|
66
|
-
f"Import relationships found: {len(self.relationships)}",
|
|
67
|
-
f"Unresolved (external/missing): {len(self.unresolved)}",
|
|
68
|
-
"",
|
|
69
|
-
"Resolved imports:",
|
|
70
|
-
]
|
|
71
|
-
for rel in self.relationships:
|
|
72
|
-
lines.append(f" {rel.source}")
|
|
73
|
-
lines.append(f" → {rel.target} (from '{rel.raw_import}')")
|
|
74
|
-
return "\n".join(lines)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def _extract_raw_imports(source_code: str, extension: str) -> list[str]:
|
|
78
|
-
"""Pull all import paths out of a source file based on its extension."""
|
|
341
|
+
def _extract_raw_imports_regex(source_code: str, extension: str) -> list[str]:
|
|
342
|
+
"""Legacy regex-based import extractor (fallback when tree-sitter is unavailable)."""
|
|
79
343
|
paths = []
|
|
80
344
|
if extension in {".ts", ".tsx", ".js", ".jsx"}:
|
|
81
345
|
for match in IMPORT_PATTERN.finditer(source_code):
|
|
@@ -101,33 +365,36 @@ def _extract_raw_imports(source_code: str, extension: str) -> list[str]:
|
|
|
101
365
|
return list(set(paths))
|
|
102
366
|
|
|
103
367
|
|
|
368
|
+
def _extract_raw_imports(source_bytes: bytes, source_code: str, extension: str) -> list[str]:
|
|
369
|
+
"""
|
|
370
|
+
Route to tree-sitter (preferred) or regex (fallback).
|
|
371
|
+
Returns deduplicated raw import strings.
|
|
372
|
+
"""
|
|
373
|
+
if _TS_AVAILABLE and extension in _TS_WALKERS:
|
|
374
|
+
return _extract_raw_imports_ts(source_bytes, extension)
|
|
375
|
+
return _extract_raw_imports_regex(source_code, extension)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# Resolution helpers (unchanged from original — already correct)
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
|
|
104
382
|
def _is_external(import_path: str, extension: str, alias_map: dict[str, str] = None) -> bool:
|
|
105
383
|
"""Return True if this is an external package rather than a local file."""
|
|
106
|
-
if extension in {".py", ".java"}:
|
|
107
|
-
# For Python and Java, it's hard to tell without resolving,
|
|
108
|
-
# so assume it's local and let the resolver fail if it's external.
|
|
384
|
+
if extension in {".py", ".java", ".rs", ".go", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".c"}:
|
|
109
385
|
return False
|
|
110
|
-
|
|
111
|
-
# Node.js: Local files start with . (relative) or match an alias
|
|
112
386
|
if import_path.startswith("./") or import_path.startswith("../"):
|
|
113
387
|
return False
|
|
114
|
-
|
|
115
388
|
if alias_map:
|
|
116
389
|
for alias in alias_map:
|
|
117
390
|
if import_path.startswith(alias):
|
|
118
391
|
return False
|
|
119
|
-
|
|
120
392
|
if import_path.startswith("@/"):
|
|
121
393
|
return False
|
|
122
|
-
|
|
123
|
-
return True # Everything else is an external package
|
|
394
|
+
return True
|
|
124
395
|
|
|
125
396
|
|
|
126
397
|
def _resolve_alias(import_path: str, alias_map: dict[str, str]) -> str | None:
|
|
127
|
-
"""
|
|
128
|
-
Convert an aliased import path to a repo-relative path.
|
|
129
|
-
Example: "@/components/Button" → "frontend/components/Button"
|
|
130
|
-
"""
|
|
131
398
|
for alias, real_prefix in alias_map.items():
|
|
132
399
|
if import_path.startswith(alias):
|
|
133
400
|
suffix = import_path[len(alias):].lstrip("/")
|
|
@@ -135,26 +402,13 @@ def _resolve_alias(import_path: str, alias_map: dict[str, str]) -> str | None:
|
|
|
135
402
|
if prefix:
|
|
136
403
|
return prefix + "/" + suffix
|
|
137
404
|
else:
|
|
138
|
-
# alias maps directly to the repo root — no leading slash
|
|
139
405
|
return suffix
|
|
140
406
|
return None
|
|
141
407
|
|
|
142
408
|
|
|
143
409
|
def _resolve_relative(import_path: str, source_file: str) -> str:
|
|
144
|
-
"""
|
|
145
|
-
Resolve a relative import path against the source file's directory.
|
|
146
|
-
Returns a repo-relative path string (NOT absolute).
|
|
147
|
-
|
|
148
|
-
Example: source="apps/web/src/routes.tsx", import="./App.tsx"
|
|
149
|
-
→ "apps/web/src/App.tsx"
|
|
150
|
-
"""
|
|
151
|
-
# Use pure PurePosixPath arithmetic — never call .resolve() which
|
|
152
|
-
# anchors to the OS cwd and produces a wrong absolute path.
|
|
153
|
-
from pathlib import PurePosixPath
|
|
154
410
|
source_dir = PurePosixPath(source_file).parent
|
|
155
|
-
|
|
156
|
-
resolved = (source_dir / import_path)
|
|
157
|
-
# PurePosixPath doesn't have .resolve(), so manually normalise ".."
|
|
411
|
+
resolved = source_dir / import_path
|
|
158
412
|
parts = []
|
|
159
413
|
for part in resolved.parts:
|
|
160
414
|
if part == "..":
|
|
@@ -167,20 +421,13 @@ def _resolve_relative(import_path: str, source_file: str) -> str:
|
|
|
167
421
|
|
|
168
422
|
def _find_file_in_repo(
|
|
169
423
|
candidate: str,
|
|
170
|
-
file_index: dict
|
|
424
|
+
file_index: dict,
|
|
171
425
|
root: str,
|
|
172
426
|
extension: str = "",
|
|
173
427
|
) -> str | None:
|
|
174
|
-
"""
|
|
175
|
-
Given a candidate path (without extension), find the matching file
|
|
176
|
-
in the repository. Tries adding common extensions if missing.
|
|
177
|
-
|
|
178
|
-
file_index maps absolute_path → relative_path.
|
|
179
|
-
"""
|
|
180
428
|
root_path = Path(root)
|
|
181
429
|
candidate_path = Path(candidate)
|
|
182
430
|
|
|
183
|
-
# If candidate is absolute, make it relative to root
|
|
184
431
|
if candidate_path.is_absolute():
|
|
185
432
|
try:
|
|
186
433
|
candidate_path = candidate_path.relative_to(root_path)
|
|
@@ -189,52 +436,40 @@ def _find_file_in_repo(
|
|
|
189
436
|
|
|
190
437
|
candidate_str = str(candidate_path)
|
|
191
438
|
|
|
192
|
-
# Try exact match first (already has extension)
|
|
193
439
|
if candidate_str in file_index:
|
|
194
440
|
return candidate_str
|
|
195
441
|
|
|
196
442
|
if extension == ".java":
|
|
197
|
-
# Java candidate is like 'com/example/Config'
|
|
198
|
-
# We search for any file ending in this candidate + .java
|
|
199
443
|
suffix = candidate_str + ".java"
|
|
200
444
|
for path in file_index:
|
|
201
445
|
if path.endswith(suffix):
|
|
202
446
|
return path
|
|
203
|
-
|
|
204
|
-
# Fallback for static method/field imports: import static com.example.MyClass.myMethod;
|
|
205
|
-
# In this case, candidate_str is 'com/example/MyClass/myMethod'
|
|
206
447
|
if "/" in candidate_str:
|
|
207
448
|
parent_candidate = candidate_str.rsplit("/", 1)[0]
|
|
208
449
|
parent_suffix = parent_candidate + ".java"
|
|
209
450
|
for path in file_index:
|
|
210
451
|
if path.endswith(parent_suffix):
|
|
211
452
|
return path
|
|
212
|
-
|
|
213
453
|
return None
|
|
214
454
|
|
|
215
455
|
if extension == ".py":
|
|
216
|
-
# Python: try exact candidate + .py
|
|
217
456
|
py_ext = candidate_str + ".py"
|
|
218
457
|
if py_ext in file_index:
|
|
219
458
|
return py_ext
|
|
220
|
-
# Try python package index
|
|
221
459
|
py_idx = candidate_str + "/__init__.py"
|
|
222
460
|
if py_idx in file_index:
|
|
223
461
|
return py_idx
|
|
224
|
-
# Fallback: search for any file ending in candidate + .py (handles src/ prefix omit)
|
|
225
462
|
suffix = "/" + py_ext
|
|
226
463
|
for path in file_index:
|
|
227
464
|
if path.endswith(suffix) or path == py_ext:
|
|
228
465
|
return path
|
|
229
466
|
return None
|
|
230
467
|
|
|
231
|
-
# Node.js: Try adding each supported extension
|
|
232
468
|
for ext in [".tsx", ".ts", ".jsx", ".js"]:
|
|
233
469
|
with_ext = candidate_str + ext
|
|
234
470
|
if with_ext in file_index:
|
|
235
471
|
return with_ext
|
|
236
472
|
|
|
237
|
-
# Node.js / Deno: Try as a directory index/mod file
|
|
238
473
|
for ext in [".tsx", ".ts", ".jsx", ".js"]:
|
|
239
474
|
index_path = candidate_str + "/index" + ext
|
|
240
475
|
if index_path in file_index:
|
|
@@ -243,7 +478,6 @@ def _find_file_in_repo(
|
|
|
243
478
|
if mod_path in file_index:
|
|
244
479
|
return mod_path
|
|
245
480
|
|
|
246
|
-
# Fuzzy fallback for dynamic paths (like P4A recipes)
|
|
247
481
|
if extension == ".py":
|
|
248
482
|
candidate_suffix = "/" + candidate_str + ".py"
|
|
249
483
|
matches = [p for p in file_index if p.endswith(candidate_suffix)]
|
|
@@ -254,13 +488,9 @@ def _find_file_in_repo(
|
|
|
254
488
|
|
|
255
489
|
|
|
256
490
|
def _load_tsconfig_paths(tsconfig_path: Path) -> dict:
|
|
257
|
-
"""
|
|
258
|
-
Load compilerOptions.paths from a tsconfig.json, following `extends` chains.
|
|
259
|
-
Returns merged paths dict (child overrides parent).
|
|
260
|
-
"""
|
|
261
491
|
import json
|
|
262
|
-
visited = set()
|
|
263
|
-
merged_paths = {}
|
|
492
|
+
visited: set = set()
|
|
493
|
+
merged_paths: dict = {}
|
|
264
494
|
|
|
265
495
|
def _load(path: Path):
|
|
266
496
|
if path in visited:
|
|
@@ -268,22 +498,17 @@ def _load_tsconfig_paths(tsconfig_path: Path) -> dict:
|
|
|
268
498
|
visited.add(path)
|
|
269
499
|
try:
|
|
270
500
|
text = path.read_text(encoding="utf-8")
|
|
271
|
-
pattern = r'(".*?(?<!\\)")|(
|
|
501
|
+
pattern = r'(".*?"(?<!\\)")|(\/\*.*?\*\/|\/\/[^\r\n]*)'
|
|
272
502
|
text = re.sub(pattern, lambda m: m.group(1) if m.group(1) else '', text, flags=re.S)
|
|
273
503
|
data = json.loads(text)
|
|
274
504
|
except Exception:
|
|
275
505
|
return
|
|
276
|
-
|
|
277
|
-
# Follow extends first (parent paths are base, child overrides)
|
|
278
506
|
extends = data.get("extends")
|
|
279
507
|
if extends:
|
|
280
508
|
parent = (path.parent / extends).resolve()
|
|
281
|
-
# handle both with and without .json
|
|
282
509
|
if not parent.suffix:
|
|
283
510
|
parent = parent.with_suffix(".json")
|
|
284
511
|
_load(parent)
|
|
285
|
-
|
|
286
|
-
# Then apply this tsconfig's own paths (child wins)
|
|
287
512
|
paths = data.get("compilerOptions", {}).get("paths", {})
|
|
288
513
|
for alias, targets in paths.items():
|
|
289
514
|
if targets:
|
|
@@ -294,23 +519,10 @@ def _load_tsconfig_paths(tsconfig_path: Path) -> dict:
|
|
|
294
519
|
|
|
295
520
|
|
|
296
521
|
def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
|
|
297
|
-
"""
|
|
298
|
-
Auto-detect TypeScript path aliases by reading every tsconfig.json in the repo.
|
|
299
|
-
Each tsconfig's aliases are scoped relative to its own directory so that
|
|
300
|
-
multiple packages with conflicting @/* aliases don't clobber each other.
|
|
301
|
-
|
|
302
|
-
Returns a flat map: alias_prefix -> resolved_repo_relative_prefix
|
|
303
|
-
e.g. {
|
|
304
|
-
'@/' : 'packages/ui/src/',
|
|
305
|
-
'@components/' : 'apps/web/src/components/',
|
|
306
|
-
'@core/' : 'apps/web/src/core/',
|
|
307
|
-
}
|
|
308
|
-
"""
|
|
309
522
|
root = Path(scan_result.root)
|
|
310
523
|
alias_map: dict[str, str] = {}
|
|
311
|
-
|
|
312
|
-
# Walk all tsconfig.json files (skip node_modules / build dirs)
|
|
313
524
|
ignore = {"node_modules", "dist", "build", ".next", "coverage", "out"}
|
|
525
|
+
|
|
314
526
|
tsconfigs = []
|
|
315
527
|
for tc in root.rglob("tsconfig.json"):
|
|
316
528
|
if any(p in ignore for p in tc.parts):
|
|
@@ -320,27 +532,19 @@ def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
|
|
|
320
532
|
for tsconfig in tsconfigs:
|
|
321
533
|
tsconfig_dir = tsconfig.parent
|
|
322
534
|
paths = _load_tsconfig_paths(tsconfig)
|
|
323
|
-
|
|
324
535
|
for alias, target in paths.items():
|
|
325
|
-
# Normalise: strip trailing /* or *
|
|
326
536
|
clean_alias = alias.rstrip("/*").rstrip("*")
|
|
327
537
|
clean_target = target.rstrip("/*").rstrip("*").lstrip("./")
|
|
328
|
-
|
|
329
|
-
# Resolve target relative to the tsconfig's own directory
|
|
330
538
|
resolved = (tsconfig_dir / clean_target).resolve()
|
|
331
539
|
try:
|
|
332
540
|
repo_rel = str(resolved.relative_to(root))
|
|
333
541
|
except ValueError:
|
|
334
|
-
continue
|
|
335
|
-
|
|
542
|
+
continue
|
|
336
543
|
alias_key = clean_alias + "/"
|
|
337
544
|
target_val = repo_rel.replace("\\", "/") + "/"
|
|
338
|
-
|
|
339
|
-
# Only add if not already defined (first match / most specific wins)
|
|
340
545
|
if alias_key not in alias_map:
|
|
341
546
|
alias_map[alias_key] = target_val
|
|
342
547
|
|
|
343
|
-
# Detect NPM Workspaces (monorepos)
|
|
344
548
|
for pkg in root.rglob("package.json"):
|
|
345
549
|
if any(p in ignore for p in pkg.parts):
|
|
346
550
|
continue
|
|
@@ -353,10 +557,8 @@ def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
|
|
|
353
557
|
try:
|
|
354
558
|
repo_rel = str(pkg_dir.relative_to(root)).replace("\\", "/")
|
|
355
559
|
if repo_rel == ".":
|
|
356
|
-
continue
|
|
357
|
-
# For absolute imports like "@meshtastic/sdk-react"
|
|
560
|
+
continue
|
|
358
561
|
alias_map[name] = repo_rel
|
|
359
|
-
# For sub-path imports like "@meshtastic/sdk-react/src/foo"
|
|
360
562
|
alias_map[name + "/"] = repo_rel + "/"
|
|
361
563
|
except ValueError:
|
|
362
564
|
pass
|
|
@@ -364,7 +566,6 @@ def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
|
|
|
364
566
|
pass
|
|
365
567
|
|
|
366
568
|
if not alias_map:
|
|
367
|
-
# Heuristic fallback: single top-level source dir
|
|
368
569
|
top_dirs = {Path(f.path).parts[0] for f in scan_result.files if Path(f.path).parts}
|
|
369
570
|
if len(top_dirs) == 1:
|
|
370
571
|
top = list(top_dirs)[0]
|
|
@@ -375,11 +576,49 @@ def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
|
|
|
375
576
|
return alias_map
|
|
376
577
|
|
|
377
578
|
|
|
579
|
+
# ---------------------------------------------------------------------------
|
|
580
|
+
# Data structures (unchanged)
|
|
581
|
+
# ---------------------------------------------------------------------------
|
|
582
|
+
|
|
583
|
+
@dataclass
|
|
584
|
+
class ImportRelationship:
|
|
585
|
+
"""A resolved import from one file to another."""
|
|
586
|
+
source: str
|
|
587
|
+
target: str
|
|
588
|
+
raw_import: str
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
@dataclass
|
|
592
|
+
class ParseResult:
|
|
593
|
+
"""All import relationships discovered across the repository."""
|
|
594
|
+
relationships: list[ImportRelationship] = field(default_factory=list)
|
|
595
|
+
unresolved: list[tuple[str, str]] = field(default_factory=list)
|
|
596
|
+
|
|
597
|
+
def summary(self) -> str:
|
|
598
|
+
lines = [
|
|
599
|
+
f"Import relationships found: {len(self.relationships)}",
|
|
600
|
+
f"Unresolved (external/missing): {len(self.unresolved)}",
|
|
601
|
+
"",
|
|
602
|
+
"Resolved imports:",
|
|
603
|
+
]
|
|
604
|
+
for rel in self.relationships:
|
|
605
|
+
lines.append(f" {rel.source}")
|
|
606
|
+
lines.append(f" → {rel.target} (from '{rel.raw_import}')")
|
|
607
|
+
return "\n".join(lines)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
# ---------------------------------------------------------------------------
|
|
611
|
+
# Main entry point (public API — signature unchanged)
|
|
612
|
+
# ---------------------------------------------------------------------------
|
|
613
|
+
|
|
378
614
|
def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
379
615
|
"""
|
|
380
616
|
Parse all import statements from scanned files and resolve them
|
|
381
617
|
to concrete file paths within the repository.
|
|
382
618
|
|
|
619
|
+
Uses tree-sitter for accurate AST-level parsing when available,
|
|
620
|
+
falling back to the original regex engine transparently.
|
|
621
|
+
|
|
383
622
|
Args:
|
|
384
623
|
scan_result: Output from scan_repo().
|
|
385
624
|
|
|
@@ -387,19 +626,16 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
387
626
|
ParseResult containing all resolved ImportRelationships.
|
|
388
627
|
"""
|
|
389
628
|
result = ParseResult()
|
|
390
|
-
|
|
391
|
-
# Build lookup: relative_path → ScannedFile
|
|
392
629
|
file_index: dict[str, ScannedFile] = {f.path: f for f in scan_result.files}
|
|
393
|
-
|
|
394
|
-
# Auto-detect alias map (e.g. @/ → frontend/)
|
|
395
630
|
alias_map = _detect_alias_map(scan_result)
|
|
396
631
|
|
|
397
|
-
#
|
|
398
|
-
java_packages = {}
|
|
632
|
+
# Java pre-pass: build package map + implicit same-package edges
|
|
633
|
+
java_packages: dict[str, list[str]] = {}
|
|
399
634
|
for scanned_file in scan_result.files:
|
|
400
635
|
if scanned_file.extension == ".java":
|
|
401
636
|
try:
|
|
402
|
-
|
|
637
|
+
source_bytes = Path(scanned_file.absolute_path).read_bytes()
|
|
638
|
+
source_code = source_bytes.decode("utf-8", errors="replace")
|
|
403
639
|
pkg_match = JAVA_PACKAGE_PATTERN.search(source_code)
|
|
404
640
|
if pkg_match:
|
|
405
641
|
pkg = pkg_match.group(1)
|
|
@@ -412,23 +648,52 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
412
648
|
for target_file in files:
|
|
413
649
|
if source_file != target_file:
|
|
414
650
|
result.relationships.append(
|
|
415
|
-
ImportRelationship(
|
|
651
|
+
ImportRelationship(
|
|
652
|
+
source=source_file,
|
|
653
|
+
target=target_file,
|
|
654
|
+
raw_import=f"implicit_package:{pkg}",
|
|
655
|
+
)
|
|
416
656
|
)
|
|
417
657
|
|
|
658
|
+
# Main pass: parse each file
|
|
418
659
|
for scanned_file in scan_result.files:
|
|
419
660
|
try:
|
|
420
|
-
|
|
661
|
+
source_bytes = Path(scanned_file.absolute_path).read_bytes()
|
|
662
|
+
source_code = source_bytes.decode("utf-8", errors="replace")
|
|
421
663
|
except Exception:
|
|
422
664
|
continue
|
|
423
665
|
|
|
424
|
-
raw_imports = _extract_raw_imports(source_code, scanned_file.extension)
|
|
666
|
+
raw_imports = _extract_raw_imports(source_bytes, source_code, scanned_file.extension)
|
|
667
|
+
|
|
668
|
+
# For Python: also add base.Symbol candidates (for fine-grained resolution)
|
|
669
|
+
if scanned_file.extension == ".py" and not _TS_AVAILABLE:
|
|
670
|
+
pass # regex already handles this above
|
|
671
|
+
elif scanned_file.extension == ".py" and _TS_AVAILABLE:
|
|
672
|
+
# tree-sitter gives us just the module name; add symbol-level candidates
|
|
673
|
+
# to match the legacy behaviour that resolves "scanner.ScannedFile" → scanner.py
|
|
674
|
+
extra: list[str] = []
|
|
675
|
+
for match in PYTHON_FROM_IMPORT_PATTERN.finditer(source_code):
|
|
676
|
+
base = match.group(1)
|
|
677
|
+
symbols_str = match.group(2) or match.group(3)
|
|
678
|
+
if symbols_str:
|
|
679
|
+
symbols_str = symbols_str.replace('(', '').replace(')', '').replace('\\', '').strip()
|
|
680
|
+
for sym in symbols_str.split(','):
|
|
681
|
+
sym = sym.split(' as ')[0].strip()
|
|
682
|
+
if sym and sym != '*':
|
|
683
|
+
extra.append(f"{base}.{sym}")
|
|
684
|
+
# Merge without duplicates
|
|
685
|
+
seen = set(raw_imports)
|
|
686
|
+
for e in extra:
|
|
687
|
+
if e not in seen:
|
|
688
|
+
raw_imports.append(e)
|
|
689
|
+
seen.add(e)
|
|
425
690
|
|
|
426
691
|
for raw in raw_imports:
|
|
427
692
|
if _is_external(raw, scanned_file.extension, alias_map):
|
|
428
693
|
result.unresolved.append((scanned_file.path, raw))
|
|
429
694
|
continue
|
|
430
695
|
|
|
431
|
-
# Resolve to
|
|
696
|
+
# Resolve to candidate path
|
|
432
697
|
if scanned_file.extension == ".java":
|
|
433
698
|
if raw.endswith(".*"):
|
|
434
699
|
pkg = raw[:-2]
|
|
@@ -439,29 +704,32 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
439
704
|
)
|
|
440
705
|
continue
|
|
441
706
|
candidate = raw.replace(".", "/")
|
|
707
|
+
|
|
442
708
|
elif scanned_file.extension == ".py":
|
|
443
709
|
if raw.startswith("."):
|
|
444
|
-
# Relative python import: from .utils import foo
|
|
445
|
-
# Count leading dots
|
|
446
710
|
dots = len(raw) - len(raw.lstrip("."))
|
|
447
711
|
base_dir = Path(scanned_file.path).parent
|
|
448
|
-
# For every dot beyond the first, go up one directory
|
|
449
712
|
for _ in range(dots - 1):
|
|
450
713
|
base_dir = base_dir.parent
|
|
451
714
|
mod_path = raw.lstrip(".").replace(".", "/")
|
|
452
715
|
candidate = str(base_dir / mod_path) if mod_path else str(base_dir)
|
|
453
716
|
else:
|
|
454
|
-
# Absolute python import from root
|
|
455
717
|
candidate = raw.replace(".", "/")
|
|
718
|
+
|
|
719
|
+
elif scanned_file.extension in (".rs", ".go", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".c"):
|
|
720
|
+
# Rust/Go/C++ — raw import strings don't map directly to file paths
|
|
721
|
+
# without more context; add to unresolved so graph ignores them gracefully
|
|
722
|
+
result.unresolved.append((scanned_file.path, raw))
|
|
723
|
+
continue
|
|
724
|
+
|
|
456
725
|
else:
|
|
457
|
-
# Node.js
|
|
726
|
+
# Node.js / TypeScript
|
|
458
727
|
resolved_alias = _resolve_alias(raw, alias_map)
|
|
459
728
|
if resolved_alias is not None:
|
|
460
729
|
candidate = resolved_alias
|
|
461
730
|
else:
|
|
462
731
|
candidate = _resolve_relative(raw, scanned_file.path)
|
|
463
732
|
|
|
464
|
-
# Find the actual file in the repo
|
|
465
733
|
matched = _find_file_in_repo(candidate, file_index, scan_result.root, scanned_file.extension)
|
|
466
734
|
|
|
467
735
|
if matched:
|
|
@@ -478,6 +746,599 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
478
746
|
return result
|
|
479
747
|
|
|
480
748
|
|
|
749
|
+
# ---------------------------------------------------------------------------
|
|
750
|
+
# SKELETON MODE — extract_skeleton(file_path: str) -> dict
|
|
751
|
+
# ---------------------------------------------------------------------------
|
|
752
|
+
|
|
753
|
+
def _node_text(node: "Node") -> str:
|
|
754
|
+
return node.text.decode("utf-8", errors="replace") if node.text else ""
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _skeleton_python(root: "Node", source_bytes: bytes) -> dict:
|
|
758
|
+
"""Extract Python skeleton: classes, functions, docstrings."""
|
|
759
|
+
classes: list[dict] = []
|
|
760
|
+
functions: list[dict] = []
|
|
761
|
+
exports: list[str] = []
|
|
762
|
+
docstrings: dict[str, str] = {}
|
|
763
|
+
|
|
764
|
+
def _get_docstring(body_node: "Node") -> str | None:
|
|
765
|
+
"""Check if the first statement in a body is a string literal (docstring)."""
|
|
766
|
+
for child in body_node.children:
|
|
767
|
+
if child.type == "expression_statement":
|
|
768
|
+
for sub in child.children:
|
|
769
|
+
if sub.type == "string":
|
|
770
|
+
raw = _node_text(sub).strip()
|
|
771
|
+
# Strip triple or single quotes
|
|
772
|
+
for q in ('"""', "'''", '"', "'"):
|
|
773
|
+
if raw.startswith(q) and raw.endswith(q) and len(raw) > 2 * len(q):
|
|
774
|
+
return raw[len(q):-len(q)].strip()
|
|
775
|
+
return raw
|
|
776
|
+
break # Only check first statement
|
|
777
|
+
return None
|
|
778
|
+
|
|
779
|
+
def _get_decorators(node: "Node") -> list[str]:
|
|
780
|
+
"""Collect decorator nodes that appear before a function/class definition."""
|
|
781
|
+
decorators: list[str] = []
|
|
782
|
+
if not node.parent:
|
|
783
|
+
return decorators
|
|
784
|
+
siblings = node.parent.children
|
|
785
|
+
try:
|
|
786
|
+
idx = list(siblings).index(node)
|
|
787
|
+
except ValueError:
|
|
788
|
+
return decorators
|
|
789
|
+
for i in range(idx - 1, -1, -1):
|
|
790
|
+
sib = siblings[i]
|
|
791
|
+
if sib.type == "decorator":
|
|
792
|
+
decorators.insert(0, _node_text(sib).strip())
|
|
793
|
+
else:
|
|
794
|
+
break
|
|
795
|
+
return decorators
|
|
796
|
+
|
|
797
|
+
def visit_class(node: "Node") -> dict:
|
|
798
|
+
name = ""
|
|
799
|
+
bases: list[str] = []
|
|
800
|
+
methods: list[dict] = []
|
|
801
|
+
props: list[dict] = []
|
|
802
|
+
|
|
803
|
+
for child in node.children:
|
|
804
|
+
if child.type == "identifier" and not name:
|
|
805
|
+
name = _node_text(child)
|
|
806
|
+
elif child.type == "argument_list":
|
|
807
|
+
for arg in child.children:
|
|
808
|
+
if arg.type not in ("(", ")", ","):
|
|
809
|
+
bases.append(_node_text(arg).strip())
|
|
810
|
+
elif child.type == "block":
|
|
811
|
+
ds = _get_docstring(child)
|
|
812
|
+
if ds:
|
|
813
|
+
docstrings[name] = ds
|
|
814
|
+
for stmt in child.children:
|
|
815
|
+
if stmt.type in ("function_definition", "decorated_definition"):
|
|
816
|
+
fn_node = stmt
|
|
817
|
+
if stmt.type == "decorated_definition":
|
|
818
|
+
for sub in stmt.children:
|
|
819
|
+
if sub.type == "function_definition":
|
|
820
|
+
fn_node = sub
|
|
821
|
+
break
|
|
822
|
+
methods.append(visit_function(fn_node, is_method=True))
|
|
823
|
+
|
|
824
|
+
entry: dict = {"name": name, "bases": bases, "methods": methods, "properties": props}
|
|
825
|
+
return entry
|
|
826
|
+
|
|
827
|
+
def visit_function(node: "Node", is_method: bool = False) -> dict:
|
|
828
|
+
name = ""
|
|
829
|
+
params = ""
|
|
830
|
+
decorators = _get_decorators(node)
|
|
831
|
+
is_async = False
|
|
832
|
+
|
|
833
|
+
for child in node.children:
|
|
834
|
+
if child.type == "async":
|
|
835
|
+
is_async = True
|
|
836
|
+
elif child.type == "identifier" and not name:
|
|
837
|
+
name = _node_text(child)
|
|
838
|
+
elif child.type == "parameters":
|
|
839
|
+
params = _node_text(child)
|
|
840
|
+
elif child.type == "block":
|
|
841
|
+
ds = _get_docstring(child)
|
|
842
|
+
if ds and name:
|
|
843
|
+
docstrings[name] = ds
|
|
844
|
+
break # Stop before body
|
|
845
|
+
|
|
846
|
+
return {
|
|
847
|
+
"name": name,
|
|
848
|
+
"params": params,
|
|
849
|
+
"is_async": is_async,
|
|
850
|
+
"decorators": decorators,
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
def visit(node: "Node"):
|
|
854
|
+
if node.type == "class_definition":
|
|
855
|
+
classes.append(visit_class(node))
|
|
856
|
+
return # Don't recurse into classes (handled inside visit_class)
|
|
857
|
+
if node.type == "function_definition":
|
|
858
|
+
functions.append(visit_function(node))
|
|
859
|
+
return
|
|
860
|
+
if node.type == "decorated_definition":
|
|
861
|
+
for child in node.children:
|
|
862
|
+
if child.type == "class_definition":
|
|
863
|
+
classes.append(visit_class(child))
|
|
864
|
+
return
|
|
865
|
+
if child.type == "function_definition":
|
|
866
|
+
functions.append(visit_function(child))
|
|
867
|
+
return
|
|
868
|
+
for child in node.children:
|
|
869
|
+
visit(child)
|
|
870
|
+
|
|
871
|
+
visit(root)
|
|
872
|
+
|
|
873
|
+
return {
|
|
874
|
+
"classes": classes,
|
|
875
|
+
"functions": functions,
|
|
876
|
+
"exports": exports,
|
|
877
|
+
"docstrings": docstrings,
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _skeleton_ts_js(root: "Node") -> dict:
|
|
882
|
+
"""Extract TypeScript/JavaScript skeleton: classes, methods, functions, exports."""
|
|
883
|
+
classes: list[dict] = []
|
|
884
|
+
functions: list[dict] = []
|
|
885
|
+
exports: list[str] = []
|
|
886
|
+
docstrings: dict[str, str] = {}
|
|
887
|
+
|
|
888
|
+
def _visibility(node: "Node") -> str:
|
|
889
|
+
for child in node.children:
|
|
890
|
+
if child.type == "accessibility_modifier":
|
|
891
|
+
return _node_text(child)
|
|
892
|
+
return "public"
|
|
893
|
+
|
|
894
|
+
def _return_type(node: "Node") -> str:
|
|
895
|
+
for child in node.children:
|
|
896
|
+
if child.type == "type_annotation":
|
|
897
|
+
raw = _node_text(child)
|
|
898
|
+
return raw.lstrip(":").strip()
|
|
899
|
+
return ""
|
|
900
|
+
|
|
901
|
+
def _is_async(node: "Node") -> bool:
|
|
902
|
+
for child in node.children:
|
|
903
|
+
if child.type == "async":
|
|
904
|
+
return True
|
|
905
|
+
return False
|
|
906
|
+
|
|
907
|
+
def _decorators_before(node: "Node") -> list[str]:
|
|
908
|
+
decs: list[str] = []
|
|
909
|
+
if not node.parent:
|
|
910
|
+
return decs
|
|
911
|
+
siblings = node.parent.children
|
|
912
|
+
idx = siblings.index(node)
|
|
913
|
+
for i in range(idx - 1, -1, -1):
|
|
914
|
+
s = siblings[i]
|
|
915
|
+
if s.type == "decorator":
|
|
916
|
+
decs.insert(0, _node_text(s).strip())
|
|
917
|
+
else:
|
|
918
|
+
break
|
|
919
|
+
return decs
|
|
920
|
+
|
|
921
|
+
def visit_class(node: "Node") -> dict:
|
|
922
|
+
name = ""
|
|
923
|
+
extends_cls = ""
|
|
924
|
+
implements_list: list[str] = []
|
|
925
|
+
methods: list[dict] = []
|
|
926
|
+
properties: list[dict] = []
|
|
927
|
+
|
|
928
|
+
for child in node.children:
|
|
929
|
+
if child.type == "type_identifier" and not name:
|
|
930
|
+
name = _node_text(child)
|
|
931
|
+
elif child.type == "class_heritage":
|
|
932
|
+
for hc in child.children:
|
|
933
|
+
if hc.type == "extends_clause":
|
|
934
|
+
for sub in hc.children:
|
|
935
|
+
if sub.type in ("type_identifier", "identifier"):
|
|
936
|
+
extends_cls = _node_text(sub)
|
|
937
|
+
break
|
|
938
|
+
elif hc.type == "implements_clause":
|
|
939
|
+
for sub in hc.children:
|
|
940
|
+
if sub.type not in ("implements",):
|
|
941
|
+
implements_list.append(_node_text(sub).strip().rstrip(","))
|
|
942
|
+
elif child.type == "class_body":
|
|
943
|
+
for member in child.children:
|
|
944
|
+
if member.type in ("method_definition", "method_signature"):
|
|
945
|
+
methods.append(visit_method(member))
|
|
946
|
+
elif member.type in ("public_field_definition", "field_definition"):
|
|
947
|
+
vis = _visibility(member)
|
|
948
|
+
prop_name = ""
|
|
949
|
+
prop_type = ""
|
|
950
|
+
for sub in member.children:
|
|
951
|
+
if sub.type in ("property_identifier", "identifier") and not prop_name:
|
|
952
|
+
prop_name = _node_text(sub)
|
|
953
|
+
elif sub.type == "type_annotation":
|
|
954
|
+
prop_type = _node_text(sub).lstrip(":").strip()
|
|
955
|
+
if prop_name:
|
|
956
|
+
properties.append({
|
|
957
|
+
"name": prop_name,
|
|
958
|
+
"type": prop_type,
|
|
959
|
+
"visibility": vis,
|
|
960
|
+
})
|
|
961
|
+
|
|
962
|
+
return {
|
|
963
|
+
"name": name,
|
|
964
|
+
"extends": extends_cls,
|
|
965
|
+
"implements": implements_list,
|
|
966
|
+
"methods": methods,
|
|
967
|
+
"properties": properties,
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
def visit_method(node: "Node") -> dict:
|
|
971
|
+
name = ""
|
|
972
|
+
params = ""
|
|
973
|
+
vis = _visibility(node)
|
|
974
|
+
ret_type = _return_type(node)
|
|
975
|
+
async_ = _is_async(node)
|
|
976
|
+
decs = _decorators_before(node)
|
|
977
|
+
|
|
978
|
+
for child in node.children:
|
|
979
|
+
if child.type in ("property_identifier", "identifier") and not name:
|
|
980
|
+
name = _node_text(child)
|
|
981
|
+
elif child.type == "formal_parameters":
|
|
982
|
+
params = _node_text(child)
|
|
983
|
+
elif child.type == "statement_block":
|
|
984
|
+
break # Stop before body
|
|
985
|
+
|
|
986
|
+
return {
|
|
987
|
+
"name": name,
|
|
988
|
+
"params": params,
|
|
989
|
+
"return_type": ret_type,
|
|
990
|
+
"visibility": vis,
|
|
991
|
+
"is_async": async_,
|
|
992
|
+
"decorators": decs,
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
def visit_function(node: "Node") -> dict:
|
|
996
|
+
name = ""
|
|
997
|
+
params = ""
|
|
998
|
+
ret_type = _return_type(node)
|
|
999
|
+
async_ = _is_async(node)
|
|
1000
|
+
is_exported = False
|
|
1001
|
+
|
|
1002
|
+
for child in node.children:
|
|
1003
|
+
if child.type in ("identifier",) and not name:
|
|
1004
|
+
name = _node_text(child)
|
|
1005
|
+
elif child.type == "formal_parameters":
|
|
1006
|
+
params = _node_text(child)
|
|
1007
|
+
elif child.type == "statement_block":
|
|
1008
|
+
break
|
|
1009
|
+
|
|
1010
|
+
return {
|
|
1011
|
+
"name": name,
|
|
1012
|
+
"params": params,
|
|
1013
|
+
"return_type": ret_type,
|
|
1014
|
+
"is_async": async_,
|
|
1015
|
+
"is_exported": is_exported,
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
def visit(node: "Node"):
|
|
1019
|
+
if node.type == "class_declaration":
|
|
1020
|
+
c = visit_class(node)
|
|
1021
|
+
classes.append(c)
|
|
1022
|
+
return
|
|
1023
|
+
if node.type in ("function_declaration", "function"):
|
|
1024
|
+
f = visit_function(node)
|
|
1025
|
+
if f["name"]:
|
|
1026
|
+
functions.append(f)
|
|
1027
|
+
return
|
|
1028
|
+
if node.type == "export_statement":
|
|
1029
|
+
# Track what is exported
|
|
1030
|
+
for child in node.children:
|
|
1031
|
+
if child.type == "class_declaration":
|
|
1032
|
+
c = visit_class(child)
|
|
1033
|
+
c_copy = dict(c)
|
|
1034
|
+
classes.append(c_copy)
|
|
1035
|
+
if c["name"]:
|
|
1036
|
+
exports.append(c["name"])
|
|
1037
|
+
return
|
|
1038
|
+
if child.type in ("function_declaration", "function"):
|
|
1039
|
+
f = visit_function(child)
|
|
1040
|
+
if f["name"]:
|
|
1041
|
+
f["is_exported"] = True
|
|
1042
|
+
functions.append(f)
|
|
1043
|
+
exports.append(f["name"])
|
|
1044
|
+
return
|
|
1045
|
+
if child.type == "export_clause":
|
|
1046
|
+
for spec in child.children:
|
|
1047
|
+
if spec.type == "export_specifier":
|
|
1048
|
+
for sub in spec.children:
|
|
1049
|
+
if sub.type == "identifier":
|
|
1050
|
+
exports.append(_node_text(sub))
|
|
1051
|
+
break
|
|
1052
|
+
if child.type in ("identifier", "type_identifier"):
|
|
1053
|
+
exports.append(_node_text(child))
|
|
1054
|
+
for child in node.children:
|
|
1055
|
+
visit(child)
|
|
1056
|
+
|
|
1057
|
+
visit(root)
|
|
1058
|
+
|
|
1059
|
+
return {
|
|
1060
|
+
"classes": classes,
|
|
1061
|
+
"functions": functions,
|
|
1062
|
+
"exports": list(dict.fromkeys(exports)), # dedupe, preserve order
|
|
1063
|
+
"docstrings": docstrings,
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _skeleton_java(root: "Node") -> dict:
|
|
1068
|
+
"""Extract Java skeleton: classes and methods."""
|
|
1069
|
+
classes: list[dict] = []
|
|
1070
|
+
functions: list[dict] = []
|
|
1071
|
+
exports: list[str] = []
|
|
1072
|
+
docstrings: dict[str, str] = {}
|
|
1073
|
+
|
|
1074
|
+
def _modifiers(node: "Node") -> list[str]:
|
|
1075
|
+
mods = []
|
|
1076
|
+
for child in node.children:
|
|
1077
|
+
if child.type == "modifiers":
|
|
1078
|
+
for m in child.children:
|
|
1079
|
+
mods.append(_node_text(m))
|
|
1080
|
+
return mods
|
|
1081
|
+
|
|
1082
|
+
def visit_class(node: "Node") -> dict:
|
|
1083
|
+
name = ""
|
|
1084
|
+
superclass = ""
|
|
1085
|
+
interfaces: list[str] = []
|
|
1086
|
+
methods: list[dict] = []
|
|
1087
|
+
|
|
1088
|
+
for child in node.children:
|
|
1089
|
+
if child.type == "identifier" and not name:
|
|
1090
|
+
name = _node_text(child)
|
|
1091
|
+
elif child.type == "superclass":
|
|
1092
|
+
for sub in child.children:
|
|
1093
|
+
if sub.type in ("type_identifier", "identifier"):
|
|
1094
|
+
superclass = _node_text(sub)
|
|
1095
|
+
break
|
|
1096
|
+
elif child.type == "super_interfaces":
|
|
1097
|
+
for sub in child.children:
|
|
1098
|
+
if sub.type not in ("implements",):
|
|
1099
|
+
interfaces.append(_node_text(sub).strip().rstrip(","))
|
|
1100
|
+
elif child.type == "class_body":
|
|
1101
|
+
for member in child.children:
|
|
1102
|
+
if member.type == "method_declaration":
|
|
1103
|
+
methods.append(visit_method(member))
|
|
1104
|
+
return {
|
|
1105
|
+
"name": name,
|
|
1106
|
+
"extends": superclass,
|
|
1107
|
+
"implements": interfaces,
|
|
1108
|
+
"methods": methods,
|
|
1109
|
+
"properties": [],
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
def visit_method(node: "Node") -> dict:
|
|
1113
|
+
name = ""
|
|
1114
|
+
params = ""
|
|
1115
|
+
ret_type = ""
|
|
1116
|
+
mods = _modifiers(node)
|
|
1117
|
+
|
|
1118
|
+
for child in node.children:
|
|
1119
|
+
if child.type == "identifier" and not name:
|
|
1120
|
+
name = _node_text(child)
|
|
1121
|
+
elif child.type == "formal_parameters":
|
|
1122
|
+
params = _node_text(child)
|
|
1123
|
+
elif child.type in ("type_identifier", "void_type", "generic_type", "array_type", "integral_type", "boolean_type", "floating_point_type"):
|
|
1124
|
+
if not ret_type:
|
|
1125
|
+
ret_type = _node_text(child)
|
|
1126
|
+
elif child.type == "block":
|
|
1127
|
+
break
|
|
1128
|
+
|
|
1129
|
+
return {
|
|
1130
|
+
"name": name,
|
|
1131
|
+
"params": params,
|
|
1132
|
+
"return_type": ret_type,
|
|
1133
|
+
"visibility": next((m for m in mods if m in ("public", "private", "protected")), "package"),
|
|
1134
|
+
"is_async": False,
|
|
1135
|
+
"decorators": [],
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
def visit(node: "Node"):
|
|
1139
|
+
if node.type == "class_declaration":
|
|
1140
|
+
classes.append(visit_class(node))
|
|
1141
|
+
return
|
|
1142
|
+
for child in node.children:
|
|
1143
|
+
visit(child)
|
|
1144
|
+
|
|
1145
|
+
visit(root)
|
|
1146
|
+
return {"classes": classes, "functions": functions, "exports": exports, "docstrings": docstrings}
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def _skeleton_rust(root: "Node") -> dict:
|
|
1150
|
+
"""Extract Rust skeleton: structs, impl blocks, traits."""
|
|
1151
|
+
classes: list[dict] = []
|
|
1152
|
+
functions: list[dict] = []
|
|
1153
|
+
exports: list[str] = []
|
|
1154
|
+
docstrings: dict[str, str] = {}
|
|
1155
|
+
|
|
1156
|
+
def visit_struct(node: "Node") -> dict:
|
|
1157
|
+
name = ""
|
|
1158
|
+
fields: list[dict] = []
|
|
1159
|
+
for child in node.children:
|
|
1160
|
+
if child.type == "type_identifier" and not name:
|
|
1161
|
+
name = _node_text(child)
|
|
1162
|
+
elif child.type == "field_declaration_list":
|
|
1163
|
+
for f in child.children:
|
|
1164
|
+
if f.type == "field_declaration":
|
|
1165
|
+
fname = ""
|
|
1166
|
+
ftype = ""
|
|
1167
|
+
for sub in f.children:
|
|
1168
|
+
if sub.type == "field_identifier" and not fname:
|
|
1169
|
+
fname = _node_text(sub)
|
|
1170
|
+
elif sub.type in ("type_identifier", "generic_type", "reference_type", "primitive_type"):
|
|
1171
|
+
if not ftype:
|
|
1172
|
+
ftype = _node_text(sub)
|
|
1173
|
+
if fname:
|
|
1174
|
+
fields.append({"name": fname, "type": ftype})
|
|
1175
|
+
return {"name": name, "fields": fields, "methods": [], "extends": "", "implements": [], "properties": []}
|
|
1176
|
+
|
|
1177
|
+
def visit_fn(node: "Node") -> dict:
|
|
1178
|
+
name = ""
|
|
1179
|
+
params = ""
|
|
1180
|
+
ret_type = ""
|
|
1181
|
+
is_pub = False
|
|
1182
|
+
for child in node.children:
|
|
1183
|
+
if child.type == "visibility_modifier":
|
|
1184
|
+
is_pub = True
|
|
1185
|
+
elif child.type == "identifier" and not name:
|
|
1186
|
+
name = _node_text(child)
|
|
1187
|
+
elif child.type == "parameters":
|
|
1188
|
+
params = _node_text(child)
|
|
1189
|
+
elif child.type == "type_identifier" and not ret_type:
|
|
1190
|
+
ret_type = _node_text(child)
|
|
1191
|
+
elif child.type == "block":
|
|
1192
|
+
break
|
|
1193
|
+
return {"name": name, "params": params, "return_type": ret_type, "visibility": "pub" if is_pub else "private", "is_async": False, "decorators": []}
|
|
1194
|
+
|
|
1195
|
+
def visit_impl(node: "Node") -> dict | None:
|
|
1196
|
+
impl_type = ""
|
|
1197
|
+
trait = ""
|
|
1198
|
+
methods: list[dict] = []
|
|
1199
|
+
for child in node.children:
|
|
1200
|
+
if child.type == "type_identifier":
|
|
1201
|
+
if not impl_type:
|
|
1202
|
+
impl_type = _node_text(child)
|
|
1203
|
+
elif not trait:
|
|
1204
|
+
trait = impl_type
|
|
1205
|
+
impl_type = _node_text(child)
|
|
1206
|
+
elif child.type == "declaration_list":
|
|
1207
|
+
for fn_node in child.children:
|
|
1208
|
+
if fn_node.type == "function_item":
|
|
1209
|
+
methods.append(visit_fn(fn_node))
|
|
1210
|
+
return {"name": impl_type, "trait": trait, "methods": methods}
|
|
1211
|
+
|
|
1212
|
+
def visit(node: "Node"):
|
|
1213
|
+
if node.type == "struct_item":
|
|
1214
|
+
classes.append(visit_struct(node))
|
|
1215
|
+
elif node.type == "impl_item":
|
|
1216
|
+
impl = visit_impl(node)
|
|
1217
|
+
if impl and impl["name"]:
|
|
1218
|
+
# Find or create the class entry
|
|
1219
|
+
existing = next((c for c in classes if c["name"] == impl["name"]), None)
|
|
1220
|
+
if existing:
|
|
1221
|
+
existing["methods"].extend(impl["methods"])
|
|
1222
|
+
else:
|
|
1223
|
+
classes.append({
|
|
1224
|
+
"name": impl["name"],
|
|
1225
|
+
"fields": [],
|
|
1226
|
+
"methods": impl["methods"],
|
|
1227
|
+
"extends": impl.get("trait", ""),
|
|
1228
|
+
"implements": [],
|
|
1229
|
+
"properties": [],
|
|
1230
|
+
})
|
|
1231
|
+
elif node.type == "function_item":
|
|
1232
|
+
functions.append(visit_fn(node))
|
|
1233
|
+
for child in node.children:
|
|
1234
|
+
visit(child)
|
|
1235
|
+
|
|
1236
|
+
visit(root)
|
|
1237
|
+
return {"classes": classes, "functions": functions, "exports": exports, "docstrings": docstrings}
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
_SKELETON_HANDLERS = {
|
|
1241
|
+
".py": _skeleton_python,
|
|
1242
|
+
".ts": _skeleton_ts_js,
|
|
1243
|
+
".tsx": _skeleton_ts_js,
|
|
1244
|
+
".js": _skeleton_ts_js,
|
|
1245
|
+
".jsx": _skeleton_ts_js,
|
|
1246
|
+
".java": _skeleton_java,
|
|
1247
|
+
".rs": _skeleton_rust,
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def extract_skeleton(file_path: str) -> dict:
|
|
1252
|
+
"""
|
|
1253
|
+
Extract the structural skeleton (API surface) of a file.
|
|
1254
|
+
|
|
1255
|
+
Uses tree-sitter to parse without executing or importing the code.
|
|
1256
|
+
Returns class names, method signatures, function signatures, and
|
|
1257
|
+
docstrings — without any implementation bodies.
|
|
1258
|
+
|
|
1259
|
+
A 5,000-line file becomes a ~100-line reference header.
|
|
1260
|
+
|
|
1261
|
+
Args:
|
|
1262
|
+
file_path: Absolute or relative path to the source file.
|
|
1263
|
+
|
|
1264
|
+
Returns:
|
|
1265
|
+
dict with keys: file, language, classes, functions, exports, docstrings.
|
|
1266
|
+
Returns {"error": ...} on failure — never raises.
|
|
1267
|
+
"""
|
|
1268
|
+
try:
|
|
1269
|
+
path = Path(file_path)
|
|
1270
|
+
ext = path.suffix.lower()
|
|
1271
|
+
|
|
1272
|
+
if not _TS_AVAILABLE:
|
|
1273
|
+
return {
|
|
1274
|
+
"file": str(path),
|
|
1275
|
+
"language": ext.lstrip("."),
|
|
1276
|
+
"error": "tree-sitter not installed — run: pip install tree-sitter tree-sitter-python tree-sitter-typescript ...",
|
|
1277
|
+
"classes": [],
|
|
1278
|
+
"functions": [],
|
|
1279
|
+
"exports": [],
|
|
1280
|
+
"docstrings": {},
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
parser, lang = _get_parser(ext)
|
|
1284
|
+
if parser is None:
|
|
1285
|
+
return {
|
|
1286
|
+
"file": str(path),
|
|
1287
|
+
"language": ext.lstrip("."),
|
|
1288
|
+
"error": f"Unsupported file type: {ext}",
|
|
1289
|
+
"classes": [],
|
|
1290
|
+
"functions": [],
|
|
1291
|
+
"exports": [],
|
|
1292
|
+
"docstrings": {},
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
source_bytes = path.read_bytes()
|
|
1296
|
+
tree = parser.parse(source_bytes)
|
|
1297
|
+
|
|
1298
|
+
handler = _SKELETON_HANDLERS.get(ext)
|
|
1299
|
+
if handler is None:
|
|
1300
|
+
return {
|
|
1301
|
+
"file": str(path),
|
|
1302
|
+
"language": ext.lstrip("."),
|
|
1303
|
+
"error": f"No skeleton handler for {ext}",
|
|
1304
|
+
"classes": [],
|
|
1305
|
+
"functions": [],
|
|
1306
|
+
"exports": [],
|
|
1307
|
+
"docstrings": {},
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
lang_map = {
|
|
1311
|
+
".py": "python", ".ts": "typescript", ".tsx": "tsx",
|
|
1312
|
+
".js": "javascript", ".jsx": "jsx", ".java": "java",
|
|
1313
|
+
".rs": "rust", ".go": "go", ".cpp": "cpp",
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
if ext == ".py":
|
|
1317
|
+
body = handler(tree.root_node, source_bytes)
|
|
1318
|
+
else:
|
|
1319
|
+
body = handler(tree.root_node)
|
|
1320
|
+
|
|
1321
|
+
return {
|
|
1322
|
+
"file": str(path),
|
|
1323
|
+
"language": lang_map.get(ext, ext.lstrip(".")),
|
|
1324
|
+
**body,
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
except Exception as e:
|
|
1328
|
+
return {
|
|
1329
|
+
"file": str(file_path),
|
|
1330
|
+
"language": "unknown",
|
|
1331
|
+
"error": str(e),
|
|
1332
|
+
"classes": [],
|
|
1333
|
+
"functions": [],
|
|
1334
|
+
"exports": [],
|
|
1335
|
+
"docstrings": {},
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
# ---------------------------------------------------------------------------
|
|
1340
|
+
# CLI entrypoint
|
|
1341
|
+
# ---------------------------------------------------------------------------
|
|
481
1342
|
if __name__ == "__main__":
|
|
482
1343
|
import sys
|
|
483
1344
|
from scanner import scan_repo
|
|
@@ -485,5 +1346,4 @@ if __name__ == "__main__":
|
|
|
485
1346
|
target = sys.argv[1] if len(sys.argv) > 1 else "."
|
|
486
1347
|
scan = scan_repo(target)
|
|
487
1348
|
parse = parse_imports(scan)
|
|
488
|
-
|
|
489
1349
|
print(parse.summary())
|