contextl 1.2.13 → 1.2.15
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/README.md +61 -4
- package/bin/contextl.js +8 -0
- package/package.json +5 -2
- package/python/git_review.py +245 -0
- package/python/import_parser.py +1146 -156
- package/python/main.py +35 -1
- package/python/mcp_server.py +113 -0
- package/python/scanner.py +1 -1
package/python/import_parser.py
CHANGED
|
@@ -2,25 +2,331 @@
|
|
|
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
|
+
raw = child.text.decode("utf-8", errors="replace")
|
|
217
|
+
import re
|
|
218
|
+
match = re.match(r'^(.*?)::\{([^}]+)\}$', raw)
|
|
219
|
+
if match:
|
|
220
|
+
base = match.group(1)
|
|
221
|
+
for item in match.group(2).split(','):
|
|
222
|
+
results.append(f"{base}::{item.strip()}")
|
|
223
|
+
else:
|
|
224
|
+
results.append(raw)
|
|
225
|
+
break
|
|
226
|
+
elif node.type == "extern_crate_declaration":
|
|
227
|
+
for child in node.children:
|
|
228
|
+
if child.type == "identifier":
|
|
229
|
+
results.append(child.text.decode("utf-8", errors="replace"))
|
|
230
|
+
break
|
|
231
|
+
for child in node.children:
|
|
232
|
+
visit(child)
|
|
233
|
+
|
|
234
|
+
visit(root)
|
|
235
|
+
return results
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _ts_imports_go(root: "Node") -> list[str]:
|
|
239
|
+
"""Extract import paths from a Go AST root."""
|
|
240
|
+
results: list[str] = []
|
|
241
|
+
|
|
242
|
+
def visit(node: "Node"):
|
|
243
|
+
if node.type == "import_spec":
|
|
244
|
+
for child in node.children:
|
|
245
|
+
if child.type == "interpreted_string_literal":
|
|
246
|
+
raw = child.text.decode("utf-8", errors="replace")
|
|
247
|
+
results.append(raw.strip('"'))
|
|
248
|
+
break
|
|
249
|
+
for child in node.children:
|
|
250
|
+
visit(child)
|
|
251
|
+
|
|
252
|
+
visit(root)
|
|
253
|
+
return results
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _ts_imports_cpp(root: "Node") -> list[str]:
|
|
257
|
+
"""Extract #include paths from a C/C++ AST root."""
|
|
258
|
+
results: list[str] = []
|
|
259
|
+
|
|
260
|
+
def visit(node: "Node"):
|
|
261
|
+
if node.type == "preproc_include":
|
|
262
|
+
for child in node.children:
|
|
263
|
+
if child.type in ("string_literal", "system_lib_string"):
|
|
264
|
+
raw = child.text.decode("utf-8", errors="replace")
|
|
265
|
+
results.append(raw.strip('"<>'))
|
|
266
|
+
break
|
|
267
|
+
for child in node.children:
|
|
268
|
+
visit(child)
|
|
269
|
+
|
|
270
|
+
visit(root)
|
|
271
|
+
return results
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# Map extension → walker function
|
|
275
|
+
_TS_WALKERS = {
|
|
276
|
+
".py": _ts_imports_python,
|
|
277
|
+
".js": _ts_imports_js_ts,
|
|
278
|
+
".mjs": _ts_imports_js_ts,
|
|
279
|
+
".cjs": _ts_imports_js_ts,
|
|
280
|
+
".jsx": _ts_imports_js_ts,
|
|
281
|
+
".ts": _ts_imports_js_ts,
|
|
282
|
+
".tsx": _ts_imports_js_ts,
|
|
283
|
+
".java": _ts_imports_java,
|
|
284
|
+
".rs": _ts_imports_rust,
|
|
285
|
+
".go": _ts_imports_go,
|
|
286
|
+
".cpp": _ts_imports_cpp,
|
|
287
|
+
".cc": _ts_imports_cpp,
|
|
288
|
+
".cxx": _ts_imports_cpp,
|
|
289
|
+
".h": _ts_imports_cpp,
|
|
290
|
+
".hpp": _ts_imports_cpp,
|
|
291
|
+
".c": _ts_imports_cpp,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _extract_raw_imports_ts(source_bytes: bytes, ext: str) -> list[str]:
|
|
296
|
+
"""
|
|
297
|
+
Parse imports from source bytes using tree-sitter.
|
|
298
|
+
Returns a deduplicated list of raw import strings.
|
|
299
|
+
Never raises — returns [] on any error.
|
|
300
|
+
"""
|
|
301
|
+
try:
|
|
302
|
+
parser, lang = _get_parser(ext)
|
|
303
|
+
if parser is None:
|
|
304
|
+
return []
|
|
305
|
+
|
|
306
|
+
tree = parser.parse(source_bytes)
|
|
307
|
+
walker = _TS_WALKERS.get(ext)
|
|
308
|
+
if walker is None:
|
|
309
|
+
return []
|
|
310
|
+
|
|
311
|
+
raw = walker(tree.root_node)
|
|
312
|
+
# Deduplicate while preserving first-seen order
|
|
313
|
+
seen: set[str] = set()
|
|
314
|
+
result: list[str] = []
|
|
315
|
+
for s in raw:
|
|
316
|
+
s = s.strip()
|
|
317
|
+
if s and s not in seen:
|
|
318
|
+
seen.add(s)
|
|
319
|
+
result.append(s)
|
|
320
|
+
return result
|
|
321
|
+
except Exception as e:
|
|
322
|
+
print(f"[contextl] tree-sitter parse warning ({ext}): {e}", file=sys.stderr)
|
|
323
|
+
return []
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
# Regex fallback (kept verbatim from original for non-tree-sitter envs)
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
|
|
24
330
|
IMPORT_PATTERN = re.compile(
|
|
25
331
|
r"""(?:import|export)\s+(?:type\s+)? # import or import type or export
|
|
26
332
|
(?:
|
|
@@ -29,53 +335,19 @@ IMPORT_PATTERN = re.compile(
|
|
|
29
335
|
|[\w*]+\s*,\s*\{[^}]*\} # mixed: Foo, { Bar }
|
|
30
336
|
)?
|
|
31
337
|
\s*(?:from\s+)? # optional "from"
|
|
32
|
-
['"](.*?)['"] # the module path in quotes
|
|
338
|
+
['\"](.*?)['\"] # the module path in quotes
|
|
33
339
|
""",
|
|
34
340
|
re.VERBOSE,
|
|
35
341
|
)
|
|
36
|
-
|
|
37
|
-
# Also catch: import("./foo") — dynamic imports
|
|
38
342
|
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
|
|
343
|
+
PYTHON_FROM_IMPORT_PATTERN = re.compile(r"^[ \t]*from\s+([\.\w]+)\s+import\s+(?:\(([^)]+)\)|([^#\n]+))", re.MULTILINE)
|
|
344
|
+
PYTHON_IMPORT_PATTERN = re.compile(r"^[ \t]*import\s+([\.\w]+)", re.MULTILINE)
|
|
45
345
|
JAVA_IMPORT_PATTERN = re.compile(r"^import\s+(?:static\s+)?([\w.*]+);", re.MULTILINE)
|
|
46
346
|
JAVA_PACKAGE_PATTERN = re.compile(r"^package\s+([\w.]+);", re.MULTILINE)
|
|
47
347
|
|
|
48
348
|
|
|
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."""
|
|
349
|
+
def _extract_raw_imports_regex(source_code: str, extension: str) -> list[str]:
|
|
350
|
+
"""Legacy regex-based import extractor (fallback when tree-sitter is unavailable)."""
|
|
79
351
|
paths = []
|
|
80
352
|
if extension in {".ts", ".tsx", ".js", ".jsx"}:
|
|
81
353
|
for match in IMPORT_PATTERN.finditer(source_code):
|
|
@@ -101,33 +373,36 @@ def _extract_raw_imports(source_code: str, extension: str) -> list[str]:
|
|
|
101
373
|
return list(set(paths))
|
|
102
374
|
|
|
103
375
|
|
|
376
|
+
def _extract_raw_imports(source_bytes: bytes, source_code: str, extension: str) -> list[str]:
|
|
377
|
+
"""
|
|
378
|
+
Route to tree-sitter (preferred) or regex (fallback).
|
|
379
|
+
Returns deduplicated raw import strings.
|
|
380
|
+
"""
|
|
381
|
+
if _TS_AVAILABLE and extension in _TS_WALKERS:
|
|
382
|
+
return _extract_raw_imports_ts(source_bytes, extension)
|
|
383
|
+
return _extract_raw_imports_regex(source_code, extension)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
# Resolution helpers (unchanged from original — already correct)
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
|
|
104
390
|
def _is_external(import_path: str, extension: str, alias_map: dict[str, str] = None) -> bool:
|
|
105
391
|
"""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.
|
|
392
|
+
if extension in {".py", ".java", ".rs", ".go", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".c"}:
|
|
109
393
|
return False
|
|
110
|
-
|
|
111
|
-
# Node.js: Local files start with . (relative) or match an alias
|
|
112
394
|
if import_path.startswith("./") or import_path.startswith("../"):
|
|
113
395
|
return False
|
|
114
|
-
|
|
115
396
|
if alias_map:
|
|
116
397
|
for alias in alias_map:
|
|
117
398
|
if import_path.startswith(alias):
|
|
118
399
|
return False
|
|
119
|
-
|
|
120
400
|
if import_path.startswith("@/"):
|
|
121
401
|
return False
|
|
122
|
-
|
|
123
|
-
return True # Everything else is an external package
|
|
402
|
+
return True
|
|
124
403
|
|
|
125
404
|
|
|
126
405
|
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
406
|
for alias, real_prefix in alias_map.items():
|
|
132
407
|
if import_path.startswith(alias):
|
|
133
408
|
suffix = import_path[len(alias):].lstrip("/")
|
|
@@ -135,26 +410,13 @@ def _resolve_alias(import_path: str, alias_map: dict[str, str]) -> str | None:
|
|
|
135
410
|
if prefix:
|
|
136
411
|
return prefix + "/" + suffix
|
|
137
412
|
else:
|
|
138
|
-
# alias maps directly to the repo root — no leading slash
|
|
139
413
|
return suffix
|
|
140
414
|
return None
|
|
141
415
|
|
|
142
416
|
|
|
143
417
|
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
418
|
source_dir = PurePosixPath(source_file).parent
|
|
155
|
-
|
|
156
|
-
resolved = (source_dir / import_path)
|
|
157
|
-
# PurePosixPath doesn't have .resolve(), so manually normalise ".."
|
|
419
|
+
resolved = source_dir / import_path
|
|
158
420
|
parts = []
|
|
159
421
|
for part in resolved.parts:
|
|
160
422
|
if part == "..":
|
|
@@ -165,22 +427,28 @@ def _resolve_relative(import_path: str, source_file: str) -> str:
|
|
|
165
427
|
return str(PurePosixPath(*parts)) if parts else "."
|
|
166
428
|
|
|
167
429
|
|
|
430
|
+
def _path_distance(source: str, target: str) -> int:
|
|
431
|
+
s_parts = source.split('/')[:-1]
|
|
432
|
+
t_parts = target.split('/')[:-1]
|
|
433
|
+
common = 0
|
|
434
|
+
for s, t in zip(s_parts, t_parts):
|
|
435
|
+
if s == t:
|
|
436
|
+
common += 1
|
|
437
|
+
else:
|
|
438
|
+
break
|
|
439
|
+
return (len(s_parts) - common) + (len(t_parts) - common)
|
|
440
|
+
|
|
441
|
+
|
|
168
442
|
def _find_file_in_repo(
|
|
169
443
|
candidate: str,
|
|
170
|
-
file_index: dict
|
|
444
|
+
file_index: dict,
|
|
171
445
|
root: str,
|
|
172
446
|
extension: str = "",
|
|
447
|
+
source_file: str = "",
|
|
173
448
|
) -> 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
449
|
root_path = Path(root)
|
|
181
450
|
candidate_path = Path(candidate)
|
|
182
451
|
|
|
183
|
-
# If candidate is absolute, make it relative to root
|
|
184
452
|
if candidate_path.is_absolute():
|
|
185
453
|
try:
|
|
186
454
|
candidate_path = candidate_path.relative_to(root_path)
|
|
@@ -189,52 +457,119 @@ def _find_file_in_repo(
|
|
|
189
457
|
|
|
190
458
|
candidate_str = str(candidate_path)
|
|
191
459
|
|
|
192
|
-
# Try exact match first (already has extension)
|
|
193
460
|
if candidate_str in file_index:
|
|
194
461
|
return candidate_str
|
|
195
462
|
|
|
196
463
|
if extension == ".java":
|
|
197
|
-
# Java candidate is like 'com/example/Config'
|
|
198
|
-
# We search for any file ending in this candidate + .java
|
|
199
464
|
suffix = candidate_str + ".java"
|
|
200
465
|
for path in file_index:
|
|
201
466
|
if path.endswith(suffix):
|
|
202
467
|
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
468
|
if "/" in candidate_str:
|
|
207
469
|
parent_candidate = candidate_str.rsplit("/", 1)[0]
|
|
208
470
|
parent_suffix = parent_candidate + ".java"
|
|
209
471
|
for path in file_index:
|
|
210
472
|
if path.endswith(parent_suffix):
|
|
211
473
|
return path
|
|
212
|
-
|
|
474
|
+
return None
|
|
475
|
+
|
|
476
|
+
if extension in (".cpp", ".cc", ".cxx", ".h", ".hpp", ".c"):
|
|
477
|
+
suffix = "/" + candidate_str
|
|
478
|
+
matches = []
|
|
479
|
+
for path in file_index:
|
|
480
|
+
if path.endswith(suffix) or path == candidate_str:
|
|
481
|
+
matches.append(path)
|
|
482
|
+
if not matches and "/" in candidate_str:
|
|
483
|
+
base_name = "/" + candidate_str.split("/")[-1]
|
|
484
|
+
for path in file_index:
|
|
485
|
+
if path.endswith(base_name) or path == base_name.lstrip("/"):
|
|
486
|
+
matches.append(path)
|
|
487
|
+
if matches:
|
|
488
|
+
if source_file:
|
|
489
|
+
matches.sort(key=lambda p: _path_distance(source_file, p))
|
|
490
|
+
return matches[0]
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
if extension == ".rs":
|
|
494
|
+
if candidate_str.startswith("crate/"):
|
|
495
|
+
match_str = candidate_str[6:]
|
|
496
|
+
else:
|
|
497
|
+
match_str = candidate_str
|
|
498
|
+
|
|
499
|
+
rs_suffix = "/" + match_str + ".rs"
|
|
500
|
+
mod_suffix = "/" + match_str + "/mod.rs"
|
|
501
|
+
matches = []
|
|
502
|
+
for path in file_index:
|
|
503
|
+
if path.endswith(rs_suffix) or path == match_str + ".rs":
|
|
504
|
+
matches.append(path)
|
|
505
|
+
if path.endswith(mod_suffix) or path == match_str + "/mod.rs":
|
|
506
|
+
matches.append(path)
|
|
507
|
+
|
|
508
|
+
if not matches and "/" in match_str:
|
|
509
|
+
parts = match_str.split("/")
|
|
510
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
511
|
+
sub_path = "/" + "/".join(parts[:i])
|
|
512
|
+
b_rs = sub_path + ".rs"
|
|
513
|
+
b_mod = sub_path + "/mod.rs"
|
|
514
|
+
for path in file_index:
|
|
515
|
+
if path.endswith(b_rs) or path.endswith(b_mod) or path == b_rs.lstrip("/") or path == b_mod.lstrip("/"):
|
|
516
|
+
matches.append(path)
|
|
517
|
+
if matches:
|
|
518
|
+
break
|
|
519
|
+
|
|
520
|
+
if matches:
|
|
521
|
+
if source_file:
|
|
522
|
+
matches.sort(key=lambda p: _path_distance(source_file, p))
|
|
523
|
+
return matches[0]
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
if extension == ".go":
|
|
527
|
+
matches = []
|
|
528
|
+
for path in file_index:
|
|
529
|
+
parent_dir = path.rsplit("/", 1)[0] if "/" in path else ""
|
|
530
|
+
if parent_dir and candidate_str.endswith(parent_dir):
|
|
531
|
+
matches.append(path)
|
|
532
|
+
elif candidate_str.endswith(path.replace(".go", "")):
|
|
533
|
+
matches.append(path)
|
|
534
|
+
|
|
535
|
+
if not matches:
|
|
536
|
+
last_part = candidate_str.split("/")[-1]
|
|
537
|
+
fallback_suffix = "/" + last_part + "/"
|
|
538
|
+
for path in file_index:
|
|
539
|
+
if fallback_suffix in path or path.startswith(last_part + "/"):
|
|
540
|
+
matches.append(path)
|
|
541
|
+
|
|
542
|
+
if matches:
|
|
543
|
+
matches.sort()
|
|
544
|
+
last_part = candidate_str.split("/")[-1]
|
|
545
|
+
preferred = "/" + last_part + ".go"
|
|
546
|
+
for m in matches:
|
|
547
|
+
if m.endswith(preferred) or m == preferred.lstrip("/"):
|
|
548
|
+
return m
|
|
549
|
+
for m in matches:
|
|
550
|
+
if m.endswith("/mod.go") or m == "mod.go":
|
|
551
|
+
return m
|
|
552
|
+
return matches[0]
|
|
213
553
|
return None
|
|
214
554
|
|
|
215
555
|
if extension == ".py":
|
|
216
|
-
# Python: try exact candidate + .py
|
|
217
556
|
py_ext = candidate_str + ".py"
|
|
218
557
|
if py_ext in file_index:
|
|
219
558
|
return py_ext
|
|
220
|
-
# Try python package index
|
|
221
559
|
py_idx = candidate_str + "/__init__.py"
|
|
222
560
|
if py_idx in file_index:
|
|
223
561
|
return py_idx
|
|
224
|
-
# Fallback: search for any file ending in candidate + .py (handles src/ prefix omit)
|
|
225
562
|
suffix = "/" + py_ext
|
|
226
563
|
for path in file_index:
|
|
227
564
|
if path.endswith(suffix) or path == py_ext:
|
|
228
565
|
return path
|
|
229
566
|
return None
|
|
230
567
|
|
|
231
|
-
# Node.js: Try adding each supported extension
|
|
232
568
|
for ext in [".tsx", ".ts", ".jsx", ".js"]:
|
|
233
569
|
with_ext = candidate_str + ext
|
|
234
570
|
if with_ext in file_index:
|
|
235
571
|
return with_ext
|
|
236
572
|
|
|
237
|
-
# Node.js / Deno: Try as a directory index/mod file
|
|
238
573
|
for ext in [".tsx", ".ts", ".jsx", ".js"]:
|
|
239
574
|
index_path = candidate_str + "/index" + ext
|
|
240
575
|
if index_path in file_index:
|
|
@@ -243,7 +578,6 @@ def _find_file_in_repo(
|
|
|
243
578
|
if mod_path in file_index:
|
|
244
579
|
return mod_path
|
|
245
580
|
|
|
246
|
-
# Fuzzy fallback for dynamic paths (like P4A recipes)
|
|
247
581
|
if extension == ".py":
|
|
248
582
|
candidate_suffix = "/" + candidate_str + ".py"
|
|
249
583
|
matches = [p for p in file_index if p.endswith(candidate_suffix)]
|
|
@@ -254,13 +588,9 @@ def _find_file_in_repo(
|
|
|
254
588
|
|
|
255
589
|
|
|
256
590
|
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
591
|
import json
|
|
262
|
-
visited = set()
|
|
263
|
-
merged_paths = {}
|
|
592
|
+
visited: set = set()
|
|
593
|
+
merged_paths: dict = {}
|
|
264
594
|
|
|
265
595
|
def _load(path: Path):
|
|
266
596
|
if path in visited:
|
|
@@ -268,22 +598,17 @@ def _load_tsconfig_paths(tsconfig_path: Path) -> dict:
|
|
|
268
598
|
visited.add(path)
|
|
269
599
|
try:
|
|
270
600
|
text = path.read_text(encoding="utf-8")
|
|
271
|
-
pattern = r'(".*?(?<!\\)")|(
|
|
601
|
+
pattern = r'(".*?"(?<!\\)")|(\/\*.*?\*\/|\/\/[^\r\n]*)'
|
|
272
602
|
text = re.sub(pattern, lambda m: m.group(1) if m.group(1) else '', text, flags=re.S)
|
|
273
603
|
data = json.loads(text)
|
|
274
604
|
except Exception:
|
|
275
605
|
return
|
|
276
|
-
|
|
277
|
-
# Follow extends first (parent paths are base, child overrides)
|
|
278
606
|
extends = data.get("extends")
|
|
279
607
|
if extends:
|
|
280
608
|
parent = (path.parent / extends).resolve()
|
|
281
|
-
# handle both with and without .json
|
|
282
609
|
if not parent.suffix:
|
|
283
610
|
parent = parent.with_suffix(".json")
|
|
284
611
|
_load(parent)
|
|
285
|
-
|
|
286
|
-
# Then apply this tsconfig's own paths (child wins)
|
|
287
612
|
paths = data.get("compilerOptions", {}).get("paths", {})
|
|
288
613
|
for alias, targets in paths.items():
|
|
289
614
|
if targets:
|
|
@@ -294,23 +619,10 @@ def _load_tsconfig_paths(tsconfig_path: Path) -> dict:
|
|
|
294
619
|
|
|
295
620
|
|
|
296
621
|
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
622
|
root = Path(scan_result.root)
|
|
310
623
|
alias_map: dict[str, str] = {}
|
|
311
|
-
|
|
312
|
-
# Walk all tsconfig.json files (skip node_modules / build dirs)
|
|
313
624
|
ignore = {"node_modules", "dist", "build", ".next", "coverage", "out"}
|
|
625
|
+
|
|
314
626
|
tsconfigs = []
|
|
315
627
|
for tc in root.rglob("tsconfig.json"):
|
|
316
628
|
if any(p in ignore for p in tc.parts):
|
|
@@ -320,27 +632,19 @@ def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
|
|
|
320
632
|
for tsconfig in tsconfigs:
|
|
321
633
|
tsconfig_dir = tsconfig.parent
|
|
322
634
|
paths = _load_tsconfig_paths(tsconfig)
|
|
323
|
-
|
|
324
635
|
for alias, target in paths.items():
|
|
325
|
-
# Normalise: strip trailing /* or *
|
|
326
636
|
clean_alias = alias.rstrip("/*").rstrip("*")
|
|
327
637
|
clean_target = target.rstrip("/*").rstrip("*").lstrip("./")
|
|
328
|
-
|
|
329
|
-
# Resolve target relative to the tsconfig's own directory
|
|
330
638
|
resolved = (tsconfig_dir / clean_target).resolve()
|
|
331
639
|
try:
|
|
332
640
|
repo_rel = str(resolved.relative_to(root))
|
|
333
641
|
except ValueError:
|
|
334
|
-
continue
|
|
335
|
-
|
|
642
|
+
continue
|
|
336
643
|
alias_key = clean_alias + "/"
|
|
337
644
|
target_val = repo_rel.replace("\\", "/") + "/"
|
|
338
|
-
|
|
339
|
-
# Only add if not already defined (first match / most specific wins)
|
|
340
645
|
if alias_key not in alias_map:
|
|
341
646
|
alias_map[alias_key] = target_val
|
|
342
647
|
|
|
343
|
-
# Detect NPM Workspaces (monorepos)
|
|
344
648
|
for pkg in root.rglob("package.json"):
|
|
345
649
|
if any(p in ignore for p in pkg.parts):
|
|
346
650
|
continue
|
|
@@ -353,18 +657,37 @@ def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
|
|
|
353
657
|
try:
|
|
354
658
|
repo_rel = str(pkg_dir.relative_to(root)).replace("\\", "/")
|
|
355
659
|
if repo_rel == ".":
|
|
356
|
-
continue
|
|
357
|
-
# For absolute imports like "@meshtastic/sdk-react"
|
|
660
|
+
continue
|
|
358
661
|
alias_map[name] = repo_rel
|
|
359
|
-
# For sub-path imports like "@meshtastic/sdk-react/src/foo"
|
|
360
662
|
alias_map[name + "/"] = repo_rel + "/"
|
|
361
663
|
except ValueError:
|
|
362
664
|
pass
|
|
363
665
|
except Exception:
|
|
364
666
|
pass
|
|
365
667
|
|
|
668
|
+
for gomod in root.rglob("go.mod"):
|
|
669
|
+
if any(p in ignore for p in gomod.parts):
|
|
670
|
+
continue
|
|
671
|
+
try:
|
|
672
|
+
text = gomod.read_text(encoding="utf-8")
|
|
673
|
+
for line in text.splitlines():
|
|
674
|
+
if line.startswith("module "):
|
|
675
|
+
mod_name = line.split(" ")[1].strip()
|
|
676
|
+
try:
|
|
677
|
+
repo_rel = str(gomod.parent.relative_to(root)).replace("\\", "/")
|
|
678
|
+
if repo_rel == ".":
|
|
679
|
+
alias_map[mod_name] = ""
|
|
680
|
+
alias_map[mod_name + "/"] = ""
|
|
681
|
+
else:
|
|
682
|
+
alias_map[mod_name] = repo_rel
|
|
683
|
+
alias_map[mod_name + "/"] = repo_rel + "/"
|
|
684
|
+
except ValueError:
|
|
685
|
+
pass
|
|
686
|
+
break
|
|
687
|
+
except Exception:
|
|
688
|
+
pass
|
|
689
|
+
|
|
366
690
|
if not alias_map:
|
|
367
|
-
# Heuristic fallback: single top-level source dir
|
|
368
691
|
top_dirs = {Path(f.path).parts[0] for f in scan_result.files if Path(f.path).parts}
|
|
369
692
|
if len(top_dirs) == 1:
|
|
370
693
|
top = list(top_dirs)[0]
|
|
@@ -375,11 +698,49 @@ def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
|
|
|
375
698
|
return alias_map
|
|
376
699
|
|
|
377
700
|
|
|
701
|
+
# ---------------------------------------------------------------------------
|
|
702
|
+
# Data structures (unchanged)
|
|
703
|
+
# ---------------------------------------------------------------------------
|
|
704
|
+
|
|
705
|
+
@dataclass
|
|
706
|
+
class ImportRelationship:
|
|
707
|
+
"""A resolved import from one file to another."""
|
|
708
|
+
source: str
|
|
709
|
+
target: str
|
|
710
|
+
raw_import: str
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
@dataclass
|
|
714
|
+
class ParseResult:
|
|
715
|
+
"""All import relationships discovered across the repository."""
|
|
716
|
+
relationships: list[ImportRelationship] = field(default_factory=list)
|
|
717
|
+
unresolved: list[tuple[str, str]] = field(default_factory=list)
|
|
718
|
+
|
|
719
|
+
def summary(self) -> str:
|
|
720
|
+
lines = [
|
|
721
|
+
f"Import relationships found: {len(self.relationships)}",
|
|
722
|
+
f"Unresolved (external/missing): {len(self.unresolved)}",
|
|
723
|
+
"",
|
|
724
|
+
"Resolved imports:",
|
|
725
|
+
]
|
|
726
|
+
for rel in self.relationships:
|
|
727
|
+
lines.append(f" {rel.source}")
|
|
728
|
+
lines.append(f" → {rel.target} (from '{rel.raw_import}')")
|
|
729
|
+
return "\n".join(lines)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
# ---------------------------------------------------------------------------
|
|
733
|
+
# Main entry point (public API — signature unchanged)
|
|
734
|
+
# ---------------------------------------------------------------------------
|
|
735
|
+
|
|
378
736
|
def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
379
737
|
"""
|
|
380
738
|
Parse all import statements from scanned files and resolve them
|
|
381
739
|
to concrete file paths within the repository.
|
|
382
740
|
|
|
741
|
+
Uses tree-sitter for accurate AST-level parsing when available,
|
|
742
|
+
falling back to the original regex engine transparently.
|
|
743
|
+
|
|
383
744
|
Args:
|
|
384
745
|
scan_result: Output from scan_repo().
|
|
385
746
|
|
|
@@ -387,19 +748,16 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
387
748
|
ParseResult containing all resolved ImportRelationships.
|
|
388
749
|
"""
|
|
389
750
|
result = ParseResult()
|
|
390
|
-
|
|
391
|
-
# Build lookup: relative_path → ScannedFile
|
|
392
751
|
file_index: dict[str, ScannedFile] = {f.path: f for f in scan_result.files}
|
|
393
|
-
|
|
394
|
-
# Auto-detect alias map (e.g. @/ → frontend/)
|
|
395
752
|
alias_map = _detect_alias_map(scan_result)
|
|
396
753
|
|
|
397
|
-
#
|
|
398
|
-
java_packages = {}
|
|
754
|
+
# Java pre-pass: build package map + implicit same-package edges
|
|
755
|
+
java_packages: dict[str, list[str]] = {}
|
|
399
756
|
for scanned_file in scan_result.files:
|
|
400
757
|
if scanned_file.extension == ".java":
|
|
401
758
|
try:
|
|
402
|
-
|
|
759
|
+
source_bytes = Path(scanned_file.absolute_path).read_bytes()
|
|
760
|
+
source_code = source_bytes.decode("utf-8", errors="replace")
|
|
403
761
|
pkg_match = JAVA_PACKAGE_PATTERN.search(source_code)
|
|
404
762
|
if pkg_match:
|
|
405
763
|
pkg = pkg_match.group(1)
|
|
@@ -412,23 +770,52 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
412
770
|
for target_file in files:
|
|
413
771
|
if source_file != target_file:
|
|
414
772
|
result.relationships.append(
|
|
415
|
-
ImportRelationship(
|
|
773
|
+
ImportRelationship(
|
|
774
|
+
source=source_file,
|
|
775
|
+
target=target_file,
|
|
776
|
+
raw_import=f"implicit_package:{pkg}",
|
|
777
|
+
)
|
|
416
778
|
)
|
|
417
779
|
|
|
780
|
+
# Main pass: parse each file
|
|
418
781
|
for scanned_file in scan_result.files:
|
|
419
782
|
try:
|
|
420
|
-
|
|
783
|
+
source_bytes = Path(scanned_file.absolute_path).read_bytes()
|
|
784
|
+
source_code = source_bytes.decode("utf-8", errors="replace")
|
|
421
785
|
except Exception:
|
|
422
786
|
continue
|
|
423
787
|
|
|
424
|
-
raw_imports = _extract_raw_imports(source_code, scanned_file.extension)
|
|
788
|
+
raw_imports = _extract_raw_imports(source_bytes, source_code, scanned_file.extension)
|
|
789
|
+
|
|
790
|
+
# For Python: also add base.Symbol candidates (for fine-grained resolution)
|
|
791
|
+
if scanned_file.extension == ".py" and not _TS_AVAILABLE:
|
|
792
|
+
pass # regex already handles this above
|
|
793
|
+
elif scanned_file.extension == ".py" and _TS_AVAILABLE:
|
|
794
|
+
# tree-sitter gives us just the module name; add symbol-level candidates
|
|
795
|
+
# to match the legacy behaviour that resolves "scanner.ScannedFile" → scanner.py
|
|
796
|
+
extra: list[str] = []
|
|
797
|
+
for match in PYTHON_FROM_IMPORT_PATTERN.finditer(source_code):
|
|
798
|
+
base = match.group(1)
|
|
799
|
+
symbols_str = match.group(2) or match.group(3)
|
|
800
|
+
if symbols_str:
|
|
801
|
+
symbols_str = symbols_str.replace('(', '').replace(')', '').replace('\\', '').strip()
|
|
802
|
+
for sym in symbols_str.split(','):
|
|
803
|
+
sym = sym.split(' as ')[0].strip()
|
|
804
|
+
if sym and sym != '*':
|
|
805
|
+
extra.append(f"{base}.{sym}")
|
|
806
|
+
# Merge without duplicates
|
|
807
|
+
seen = set(raw_imports)
|
|
808
|
+
for e in extra:
|
|
809
|
+
if e not in seen:
|
|
810
|
+
raw_imports.append(e)
|
|
811
|
+
seen.add(e)
|
|
425
812
|
|
|
426
813
|
for raw in raw_imports:
|
|
427
814
|
if _is_external(raw, scanned_file.extension, alias_map):
|
|
428
815
|
result.unresolved.append((scanned_file.path, raw))
|
|
429
816
|
continue
|
|
430
817
|
|
|
431
|
-
# Resolve to
|
|
818
|
+
# Resolve to candidate path
|
|
432
819
|
if scanned_file.extension == ".java":
|
|
433
820
|
if raw.endswith(".*"):
|
|
434
821
|
pkg = raw[:-2]
|
|
@@ -439,30 +826,39 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
439
826
|
)
|
|
440
827
|
continue
|
|
441
828
|
candidate = raw.replace(".", "/")
|
|
829
|
+
|
|
442
830
|
elif scanned_file.extension == ".py":
|
|
443
831
|
if raw.startswith("."):
|
|
444
|
-
# Relative python import: from .utils import foo
|
|
445
|
-
# Count leading dots
|
|
446
832
|
dots = len(raw) - len(raw.lstrip("."))
|
|
447
833
|
base_dir = Path(scanned_file.path).parent
|
|
448
|
-
# For every dot beyond the first, go up one directory
|
|
449
834
|
for _ in range(dots - 1):
|
|
450
835
|
base_dir = base_dir.parent
|
|
451
836
|
mod_path = raw.lstrip(".").replace(".", "/")
|
|
452
837
|
candidate = str(base_dir / mod_path) if mod_path else str(base_dir)
|
|
453
838
|
else:
|
|
454
|
-
# Absolute python import from root
|
|
455
839
|
candidate = raw.replace(".", "/")
|
|
840
|
+
|
|
841
|
+
elif scanned_file.extension in (".cpp", ".cc", ".cxx", ".h", ".hpp", ".c"):
|
|
842
|
+
candidate = raw
|
|
843
|
+
elif scanned_file.extension == ".rs":
|
|
844
|
+
is_likely_external = not (raw.startswith("crate::") or raw.startswith("super::") or raw.startswith("self::"))
|
|
845
|
+
candidate = raw.replace("::", "/")
|
|
846
|
+
elif scanned_file.extension == ".go":
|
|
847
|
+
resolved = _resolve_alias(raw, alias_map)
|
|
848
|
+
if resolved is not None:
|
|
849
|
+
candidate = resolved
|
|
850
|
+
else:
|
|
851
|
+
candidate = raw
|
|
852
|
+
|
|
456
853
|
else:
|
|
457
|
-
# Node.js
|
|
854
|
+
# Node.js / TypeScript
|
|
458
855
|
resolved_alias = _resolve_alias(raw, alias_map)
|
|
459
856
|
if resolved_alias is not None:
|
|
460
857
|
candidate = resolved_alias
|
|
461
858
|
else:
|
|
462
859
|
candidate = _resolve_relative(raw, scanned_file.path)
|
|
463
860
|
|
|
464
|
-
|
|
465
|
-
matched = _find_file_in_repo(candidate, file_index, scan_result.root, scanned_file.extension)
|
|
861
|
+
matched = _find_file_in_repo(candidate, file_index, scan_result.root, scanned_file.extension, scanned_file.path)
|
|
466
862
|
|
|
467
863
|
if matched:
|
|
468
864
|
result.relationships.append(
|
|
@@ -473,11 +869,606 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
473
869
|
)
|
|
474
870
|
)
|
|
475
871
|
else:
|
|
872
|
+
if scanned_file.extension == ".rs" and locals().get("is_likely_external", False):
|
|
873
|
+
continue
|
|
476
874
|
result.unresolved.append((scanned_file.path, raw))
|
|
477
875
|
|
|
478
876
|
return result
|
|
479
877
|
|
|
480
878
|
|
|
879
|
+
# ---------------------------------------------------------------------------
|
|
880
|
+
# SKELETON MODE — extract_skeleton(file_path: str) -> dict
|
|
881
|
+
# ---------------------------------------------------------------------------
|
|
882
|
+
|
|
883
|
+
def _node_text(node: "Node") -> str:
|
|
884
|
+
return node.text.decode("utf-8", errors="replace") if node.text else ""
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _skeleton_python(root: "Node", source_bytes: bytes) -> dict:
|
|
888
|
+
"""Extract Python skeleton: classes, functions, docstrings."""
|
|
889
|
+
classes: list[dict] = []
|
|
890
|
+
functions: list[dict] = []
|
|
891
|
+
exports: list[str] = []
|
|
892
|
+
docstrings: dict[str, str] = {}
|
|
893
|
+
|
|
894
|
+
def _get_docstring(body_node: "Node") -> str | None:
|
|
895
|
+
"""Check if the first statement in a body is a string literal (docstring)."""
|
|
896
|
+
for child in body_node.children:
|
|
897
|
+
if child.type == "expression_statement":
|
|
898
|
+
for sub in child.children:
|
|
899
|
+
if sub.type == "string":
|
|
900
|
+
raw = _node_text(sub).strip()
|
|
901
|
+
# Strip triple or single quotes
|
|
902
|
+
for q in ('"""', "'''", '"', "'"):
|
|
903
|
+
if raw.startswith(q) and raw.endswith(q) and len(raw) > 2 * len(q):
|
|
904
|
+
return raw[len(q):-len(q)].strip()
|
|
905
|
+
return raw
|
|
906
|
+
break # Only check first statement
|
|
907
|
+
return None
|
|
908
|
+
|
|
909
|
+
def _get_decorators(node: "Node") -> list[str]:
|
|
910
|
+
"""Collect decorator nodes that appear before a function/class definition."""
|
|
911
|
+
decorators: list[str] = []
|
|
912
|
+
if not node.parent:
|
|
913
|
+
return decorators
|
|
914
|
+
siblings = node.parent.children
|
|
915
|
+
try:
|
|
916
|
+
idx = list(siblings).index(node)
|
|
917
|
+
except ValueError:
|
|
918
|
+
return decorators
|
|
919
|
+
for i in range(idx - 1, -1, -1):
|
|
920
|
+
sib = siblings[i]
|
|
921
|
+
if sib.type == "decorator":
|
|
922
|
+
decorators.insert(0, _node_text(sib).strip())
|
|
923
|
+
else:
|
|
924
|
+
break
|
|
925
|
+
return decorators
|
|
926
|
+
|
|
927
|
+
def visit_class(node: "Node") -> dict:
|
|
928
|
+
name = ""
|
|
929
|
+
bases: list[str] = []
|
|
930
|
+
methods: list[dict] = []
|
|
931
|
+
props: list[dict] = []
|
|
932
|
+
|
|
933
|
+
for child in node.children:
|
|
934
|
+
if child.type == "identifier" and not name:
|
|
935
|
+
name = _node_text(child)
|
|
936
|
+
elif child.type == "argument_list":
|
|
937
|
+
for arg in child.children:
|
|
938
|
+
if arg.type not in ("(", ")", ","):
|
|
939
|
+
bases.append(_node_text(arg).strip())
|
|
940
|
+
elif child.type == "block":
|
|
941
|
+
ds = _get_docstring(child)
|
|
942
|
+
if ds:
|
|
943
|
+
docstrings[name] = ds
|
|
944
|
+
for stmt in child.children:
|
|
945
|
+
if stmt.type in ("function_definition", "decorated_definition"):
|
|
946
|
+
fn_node = stmt
|
|
947
|
+
if stmt.type == "decorated_definition":
|
|
948
|
+
for sub in stmt.children:
|
|
949
|
+
if sub.type == "function_definition":
|
|
950
|
+
fn_node = sub
|
|
951
|
+
break
|
|
952
|
+
methods.append(visit_function(fn_node, is_method=True))
|
|
953
|
+
|
|
954
|
+
entry: dict = {"name": name, "bases": bases, "methods": methods, "properties": props}
|
|
955
|
+
return entry
|
|
956
|
+
|
|
957
|
+
def visit_function(node: "Node", is_method: bool = False) -> dict:
|
|
958
|
+
name = ""
|
|
959
|
+
params = ""
|
|
960
|
+
decorators = _get_decorators(node)
|
|
961
|
+
is_async = False
|
|
962
|
+
|
|
963
|
+
for child in node.children:
|
|
964
|
+
if child.type == "async":
|
|
965
|
+
is_async = True
|
|
966
|
+
elif child.type == "identifier" and not name:
|
|
967
|
+
name = _node_text(child)
|
|
968
|
+
elif child.type == "parameters":
|
|
969
|
+
params = _node_text(child)
|
|
970
|
+
elif child.type == "block":
|
|
971
|
+
ds = _get_docstring(child)
|
|
972
|
+
if ds and name:
|
|
973
|
+
docstrings[name] = ds
|
|
974
|
+
break # Stop before body
|
|
975
|
+
|
|
976
|
+
return {
|
|
977
|
+
"name": name,
|
|
978
|
+
"params": params,
|
|
979
|
+
"is_async": is_async,
|
|
980
|
+
"decorators": decorators,
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
def visit(node: "Node"):
|
|
984
|
+
if node.type == "class_definition":
|
|
985
|
+
classes.append(visit_class(node))
|
|
986
|
+
return # Don't recurse into classes (handled inside visit_class)
|
|
987
|
+
if node.type == "function_definition":
|
|
988
|
+
functions.append(visit_function(node))
|
|
989
|
+
return
|
|
990
|
+
if node.type == "decorated_definition":
|
|
991
|
+
for child in node.children:
|
|
992
|
+
if child.type == "class_definition":
|
|
993
|
+
classes.append(visit_class(child))
|
|
994
|
+
return
|
|
995
|
+
if child.type == "function_definition":
|
|
996
|
+
functions.append(visit_function(child))
|
|
997
|
+
return
|
|
998
|
+
for child in node.children:
|
|
999
|
+
visit(child)
|
|
1000
|
+
|
|
1001
|
+
visit(root)
|
|
1002
|
+
|
|
1003
|
+
return {
|
|
1004
|
+
"classes": classes,
|
|
1005
|
+
"functions": functions,
|
|
1006
|
+
"exports": exports,
|
|
1007
|
+
"docstrings": docstrings,
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _skeleton_ts_js(root: "Node") -> dict:
|
|
1012
|
+
"""Extract TypeScript/JavaScript skeleton: classes, methods, functions, exports."""
|
|
1013
|
+
classes: list[dict] = []
|
|
1014
|
+
functions: list[dict] = []
|
|
1015
|
+
exports: list[str] = []
|
|
1016
|
+
docstrings: dict[str, str] = {}
|
|
1017
|
+
|
|
1018
|
+
def _visibility(node: "Node") -> str:
|
|
1019
|
+
for child in node.children:
|
|
1020
|
+
if child.type == "accessibility_modifier":
|
|
1021
|
+
return _node_text(child)
|
|
1022
|
+
return "public"
|
|
1023
|
+
|
|
1024
|
+
def _return_type(node: "Node") -> str:
|
|
1025
|
+
for child in node.children:
|
|
1026
|
+
if child.type == "type_annotation":
|
|
1027
|
+
raw = _node_text(child)
|
|
1028
|
+
return raw.lstrip(":").strip()
|
|
1029
|
+
return ""
|
|
1030
|
+
|
|
1031
|
+
def _is_async(node: "Node") -> bool:
|
|
1032
|
+
for child in node.children:
|
|
1033
|
+
if child.type == "async":
|
|
1034
|
+
return True
|
|
1035
|
+
return False
|
|
1036
|
+
|
|
1037
|
+
def _decorators_before(node: "Node") -> list[str]:
|
|
1038
|
+
decs: list[str] = []
|
|
1039
|
+
if not node.parent:
|
|
1040
|
+
return decs
|
|
1041
|
+
siblings = node.parent.children
|
|
1042
|
+
idx = siblings.index(node)
|
|
1043
|
+
for i in range(idx - 1, -1, -1):
|
|
1044
|
+
s = siblings[i]
|
|
1045
|
+
if s.type == "decorator":
|
|
1046
|
+
decs.insert(0, _node_text(s).strip())
|
|
1047
|
+
else:
|
|
1048
|
+
break
|
|
1049
|
+
return decs
|
|
1050
|
+
|
|
1051
|
+
def visit_class(node: "Node") -> dict:
|
|
1052
|
+
name = ""
|
|
1053
|
+
extends_cls = ""
|
|
1054
|
+
implements_list: list[str] = []
|
|
1055
|
+
methods: list[dict] = []
|
|
1056
|
+
properties: list[dict] = []
|
|
1057
|
+
|
|
1058
|
+
for child in node.children:
|
|
1059
|
+
if child.type == "type_identifier" and not name:
|
|
1060
|
+
name = _node_text(child)
|
|
1061
|
+
elif child.type == "class_heritage":
|
|
1062
|
+
for hc in child.children:
|
|
1063
|
+
if hc.type == "extends_clause":
|
|
1064
|
+
for sub in hc.children:
|
|
1065
|
+
if sub.type in ("type_identifier", "identifier"):
|
|
1066
|
+
extends_cls = _node_text(sub)
|
|
1067
|
+
break
|
|
1068
|
+
elif hc.type == "implements_clause":
|
|
1069
|
+
for sub in hc.children:
|
|
1070
|
+
if sub.type not in ("implements",):
|
|
1071
|
+
implements_list.append(_node_text(sub).strip().rstrip(","))
|
|
1072
|
+
elif child.type == "class_body":
|
|
1073
|
+
for member in child.children:
|
|
1074
|
+
if member.type in ("method_definition", "method_signature"):
|
|
1075
|
+
methods.append(visit_method(member))
|
|
1076
|
+
elif member.type in ("public_field_definition", "field_definition"):
|
|
1077
|
+
vis = _visibility(member)
|
|
1078
|
+
prop_name = ""
|
|
1079
|
+
prop_type = ""
|
|
1080
|
+
for sub in member.children:
|
|
1081
|
+
if sub.type in ("property_identifier", "identifier") and not prop_name:
|
|
1082
|
+
prop_name = _node_text(sub)
|
|
1083
|
+
elif sub.type == "type_annotation":
|
|
1084
|
+
prop_type = _node_text(sub).lstrip(":").strip()
|
|
1085
|
+
if prop_name:
|
|
1086
|
+
properties.append({
|
|
1087
|
+
"name": prop_name,
|
|
1088
|
+
"type": prop_type,
|
|
1089
|
+
"visibility": vis,
|
|
1090
|
+
})
|
|
1091
|
+
|
|
1092
|
+
return {
|
|
1093
|
+
"name": name,
|
|
1094
|
+
"extends": extends_cls,
|
|
1095
|
+
"implements": implements_list,
|
|
1096
|
+
"methods": methods,
|
|
1097
|
+
"properties": properties,
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
def visit_method(node: "Node") -> dict:
|
|
1101
|
+
name = ""
|
|
1102
|
+
params = ""
|
|
1103
|
+
vis = _visibility(node)
|
|
1104
|
+
ret_type = _return_type(node)
|
|
1105
|
+
async_ = _is_async(node)
|
|
1106
|
+
decs = _decorators_before(node)
|
|
1107
|
+
|
|
1108
|
+
for child in node.children:
|
|
1109
|
+
if child.type in ("property_identifier", "identifier") and not name:
|
|
1110
|
+
name = _node_text(child)
|
|
1111
|
+
elif child.type == "formal_parameters":
|
|
1112
|
+
params = _node_text(child)
|
|
1113
|
+
elif child.type == "statement_block":
|
|
1114
|
+
break # Stop before body
|
|
1115
|
+
|
|
1116
|
+
return {
|
|
1117
|
+
"name": name,
|
|
1118
|
+
"params": params,
|
|
1119
|
+
"return_type": ret_type,
|
|
1120
|
+
"visibility": vis,
|
|
1121
|
+
"is_async": async_,
|
|
1122
|
+
"decorators": decs,
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
def visit_function(node: "Node") -> dict:
|
|
1126
|
+
name = ""
|
|
1127
|
+
params = ""
|
|
1128
|
+
ret_type = _return_type(node)
|
|
1129
|
+
async_ = _is_async(node)
|
|
1130
|
+
is_exported = False
|
|
1131
|
+
|
|
1132
|
+
for child in node.children:
|
|
1133
|
+
if child.type in ("identifier",) and not name:
|
|
1134
|
+
name = _node_text(child)
|
|
1135
|
+
elif child.type == "formal_parameters":
|
|
1136
|
+
params = _node_text(child)
|
|
1137
|
+
elif child.type == "statement_block":
|
|
1138
|
+
break
|
|
1139
|
+
|
|
1140
|
+
return {
|
|
1141
|
+
"name": name,
|
|
1142
|
+
"params": params,
|
|
1143
|
+
"return_type": ret_type,
|
|
1144
|
+
"is_async": async_,
|
|
1145
|
+
"is_exported": is_exported,
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
def visit(node: "Node"):
|
|
1149
|
+
if node.type == "class_declaration":
|
|
1150
|
+
c = visit_class(node)
|
|
1151
|
+
classes.append(c)
|
|
1152
|
+
return
|
|
1153
|
+
if node.type in ("function_declaration", "function"):
|
|
1154
|
+
f = visit_function(node)
|
|
1155
|
+
if f["name"]:
|
|
1156
|
+
functions.append(f)
|
|
1157
|
+
return
|
|
1158
|
+
if node.type == "export_statement":
|
|
1159
|
+
# Track what is exported
|
|
1160
|
+
for child in node.children:
|
|
1161
|
+
if child.type == "class_declaration":
|
|
1162
|
+
c = visit_class(child)
|
|
1163
|
+
c_copy = dict(c)
|
|
1164
|
+
classes.append(c_copy)
|
|
1165
|
+
if c["name"]:
|
|
1166
|
+
exports.append(c["name"])
|
|
1167
|
+
return
|
|
1168
|
+
if child.type in ("function_declaration", "function"):
|
|
1169
|
+
f = visit_function(child)
|
|
1170
|
+
if f["name"]:
|
|
1171
|
+
f["is_exported"] = True
|
|
1172
|
+
functions.append(f)
|
|
1173
|
+
exports.append(f["name"])
|
|
1174
|
+
return
|
|
1175
|
+
if child.type == "export_clause":
|
|
1176
|
+
for spec in child.children:
|
|
1177
|
+
if spec.type == "export_specifier":
|
|
1178
|
+
for sub in spec.children:
|
|
1179
|
+
if sub.type == "identifier":
|
|
1180
|
+
exports.append(_node_text(sub))
|
|
1181
|
+
break
|
|
1182
|
+
if child.type in ("identifier", "type_identifier"):
|
|
1183
|
+
exports.append(_node_text(child))
|
|
1184
|
+
for child in node.children:
|
|
1185
|
+
visit(child)
|
|
1186
|
+
|
|
1187
|
+
visit(root)
|
|
1188
|
+
|
|
1189
|
+
return {
|
|
1190
|
+
"classes": classes,
|
|
1191
|
+
"functions": functions,
|
|
1192
|
+
"exports": list(dict.fromkeys(exports)), # dedupe, preserve order
|
|
1193
|
+
"docstrings": docstrings,
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def _skeleton_java(root: "Node") -> dict:
|
|
1198
|
+
"""Extract Java skeleton: classes and methods."""
|
|
1199
|
+
classes: list[dict] = []
|
|
1200
|
+
functions: list[dict] = []
|
|
1201
|
+
exports: list[str] = []
|
|
1202
|
+
docstrings: dict[str, str] = {}
|
|
1203
|
+
|
|
1204
|
+
def _modifiers(node: "Node") -> list[str]:
|
|
1205
|
+
mods = []
|
|
1206
|
+
for child in node.children:
|
|
1207
|
+
if child.type == "modifiers":
|
|
1208
|
+
for m in child.children:
|
|
1209
|
+
mods.append(_node_text(m))
|
|
1210
|
+
return mods
|
|
1211
|
+
|
|
1212
|
+
def visit_class(node: "Node") -> dict:
|
|
1213
|
+
name = ""
|
|
1214
|
+
superclass = ""
|
|
1215
|
+
interfaces: list[str] = []
|
|
1216
|
+
methods: list[dict] = []
|
|
1217
|
+
|
|
1218
|
+
for child in node.children:
|
|
1219
|
+
if child.type == "identifier" and not name:
|
|
1220
|
+
name = _node_text(child)
|
|
1221
|
+
elif child.type == "superclass":
|
|
1222
|
+
for sub in child.children:
|
|
1223
|
+
if sub.type in ("type_identifier", "identifier"):
|
|
1224
|
+
superclass = _node_text(sub)
|
|
1225
|
+
break
|
|
1226
|
+
elif child.type == "super_interfaces":
|
|
1227
|
+
for sub in child.children:
|
|
1228
|
+
if sub.type not in ("implements",):
|
|
1229
|
+
interfaces.append(_node_text(sub).strip().rstrip(","))
|
|
1230
|
+
elif child.type == "class_body":
|
|
1231
|
+
for member in child.children:
|
|
1232
|
+
if member.type == "method_declaration":
|
|
1233
|
+
methods.append(visit_method(member))
|
|
1234
|
+
return {
|
|
1235
|
+
"name": name,
|
|
1236
|
+
"extends": superclass,
|
|
1237
|
+
"implements": interfaces,
|
|
1238
|
+
"methods": methods,
|
|
1239
|
+
"properties": [],
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
def visit_method(node: "Node") -> dict:
|
|
1243
|
+
name = ""
|
|
1244
|
+
params = ""
|
|
1245
|
+
ret_type = ""
|
|
1246
|
+
mods = _modifiers(node)
|
|
1247
|
+
|
|
1248
|
+
for child in node.children:
|
|
1249
|
+
if child.type == "identifier" and not name:
|
|
1250
|
+
name = _node_text(child)
|
|
1251
|
+
elif child.type == "formal_parameters":
|
|
1252
|
+
params = _node_text(child)
|
|
1253
|
+
elif child.type in ("type_identifier", "void_type", "generic_type", "array_type", "integral_type", "boolean_type", "floating_point_type"):
|
|
1254
|
+
if not ret_type:
|
|
1255
|
+
ret_type = _node_text(child)
|
|
1256
|
+
elif child.type == "block":
|
|
1257
|
+
break
|
|
1258
|
+
|
|
1259
|
+
return {
|
|
1260
|
+
"name": name,
|
|
1261
|
+
"params": params,
|
|
1262
|
+
"return_type": ret_type,
|
|
1263
|
+
"visibility": next((m for m in mods if m in ("public", "private", "protected")), "package"),
|
|
1264
|
+
"is_async": False,
|
|
1265
|
+
"decorators": [],
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
def visit(node: "Node"):
|
|
1269
|
+
if node.type == "class_declaration":
|
|
1270
|
+
classes.append(visit_class(node))
|
|
1271
|
+
return
|
|
1272
|
+
for child in node.children:
|
|
1273
|
+
visit(child)
|
|
1274
|
+
|
|
1275
|
+
visit(root)
|
|
1276
|
+
return {"classes": classes, "functions": functions, "exports": exports, "docstrings": docstrings}
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def _skeleton_rust(root: "Node") -> dict:
|
|
1280
|
+
"""Extract Rust skeleton: structs, impl blocks, traits."""
|
|
1281
|
+
classes: list[dict] = []
|
|
1282
|
+
functions: list[dict] = []
|
|
1283
|
+
exports: list[str] = []
|
|
1284
|
+
docstrings: dict[str, str] = {}
|
|
1285
|
+
|
|
1286
|
+
def visit_struct(node: "Node") -> dict:
|
|
1287
|
+
name = ""
|
|
1288
|
+
fields: list[dict] = []
|
|
1289
|
+
for child in node.children:
|
|
1290
|
+
if child.type == "type_identifier" and not name:
|
|
1291
|
+
name = _node_text(child)
|
|
1292
|
+
elif child.type == "field_declaration_list":
|
|
1293
|
+
for f in child.children:
|
|
1294
|
+
if f.type == "field_declaration":
|
|
1295
|
+
fname = ""
|
|
1296
|
+
ftype = ""
|
|
1297
|
+
for sub in f.children:
|
|
1298
|
+
if sub.type == "field_identifier" and not fname:
|
|
1299
|
+
fname = _node_text(sub)
|
|
1300
|
+
elif sub.type in ("type_identifier", "generic_type", "reference_type", "primitive_type"):
|
|
1301
|
+
if not ftype:
|
|
1302
|
+
ftype = _node_text(sub)
|
|
1303
|
+
if fname:
|
|
1304
|
+
fields.append({"name": fname, "type": ftype})
|
|
1305
|
+
return {"name": name, "fields": fields, "methods": [], "extends": "", "implements": [], "properties": []}
|
|
1306
|
+
|
|
1307
|
+
def visit_fn(node: "Node") -> dict:
|
|
1308
|
+
name = ""
|
|
1309
|
+
params = ""
|
|
1310
|
+
ret_type = ""
|
|
1311
|
+
is_pub = False
|
|
1312
|
+
for child in node.children:
|
|
1313
|
+
if child.type == "visibility_modifier":
|
|
1314
|
+
is_pub = True
|
|
1315
|
+
elif child.type == "identifier" and not name:
|
|
1316
|
+
name = _node_text(child)
|
|
1317
|
+
elif child.type == "parameters":
|
|
1318
|
+
params = _node_text(child)
|
|
1319
|
+
elif child.type == "type_identifier" and not ret_type:
|
|
1320
|
+
ret_type = _node_text(child)
|
|
1321
|
+
elif child.type == "block":
|
|
1322
|
+
break
|
|
1323
|
+
return {"name": name, "params": params, "return_type": ret_type, "visibility": "pub" if is_pub else "private", "is_async": False, "decorators": []}
|
|
1324
|
+
|
|
1325
|
+
def visit_impl(node: "Node") -> dict | None:
|
|
1326
|
+
impl_type = ""
|
|
1327
|
+
trait = ""
|
|
1328
|
+
methods: list[dict] = []
|
|
1329
|
+
for child in node.children:
|
|
1330
|
+
if child.type == "type_identifier":
|
|
1331
|
+
if not impl_type:
|
|
1332
|
+
impl_type = _node_text(child)
|
|
1333
|
+
elif not trait:
|
|
1334
|
+
trait = impl_type
|
|
1335
|
+
impl_type = _node_text(child)
|
|
1336
|
+
elif child.type == "declaration_list":
|
|
1337
|
+
for fn_node in child.children:
|
|
1338
|
+
if fn_node.type == "function_item":
|
|
1339
|
+
methods.append(visit_fn(fn_node))
|
|
1340
|
+
return {"name": impl_type, "trait": trait, "methods": methods}
|
|
1341
|
+
|
|
1342
|
+
def visit(node: "Node"):
|
|
1343
|
+
if node.type == "struct_item":
|
|
1344
|
+
classes.append(visit_struct(node))
|
|
1345
|
+
elif node.type == "impl_item":
|
|
1346
|
+
impl = visit_impl(node)
|
|
1347
|
+
if impl and impl["name"]:
|
|
1348
|
+
# Find or create the class entry
|
|
1349
|
+
existing = next((c for c in classes if c["name"] == impl["name"]), None)
|
|
1350
|
+
if existing:
|
|
1351
|
+
existing["methods"].extend(impl["methods"])
|
|
1352
|
+
else:
|
|
1353
|
+
classes.append({
|
|
1354
|
+
"name": impl["name"],
|
|
1355
|
+
"fields": [],
|
|
1356
|
+
"methods": impl["methods"],
|
|
1357
|
+
"extends": impl.get("trait", ""),
|
|
1358
|
+
"implements": [],
|
|
1359
|
+
"properties": [],
|
|
1360
|
+
})
|
|
1361
|
+
elif node.type == "function_item":
|
|
1362
|
+
functions.append(visit_fn(node))
|
|
1363
|
+
for child in node.children:
|
|
1364
|
+
visit(child)
|
|
1365
|
+
|
|
1366
|
+
visit(root)
|
|
1367
|
+
return {"classes": classes, "functions": functions, "exports": exports, "docstrings": docstrings}
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
_SKELETON_HANDLERS = {
|
|
1371
|
+
".py": _skeleton_python,
|
|
1372
|
+
".ts": _skeleton_ts_js,
|
|
1373
|
+
".tsx": _skeleton_ts_js,
|
|
1374
|
+
".js": _skeleton_ts_js,
|
|
1375
|
+
".jsx": _skeleton_ts_js,
|
|
1376
|
+
".java": _skeleton_java,
|
|
1377
|
+
".rs": _skeleton_rust,
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def extract_skeleton(file_path: str) -> dict:
|
|
1382
|
+
"""
|
|
1383
|
+
Extract the structural skeleton (API surface) of a file.
|
|
1384
|
+
|
|
1385
|
+
Uses tree-sitter to parse without executing or importing the code.
|
|
1386
|
+
Returns class names, method signatures, function signatures, and
|
|
1387
|
+
docstrings — without any implementation bodies.
|
|
1388
|
+
|
|
1389
|
+
A 5,000-line file becomes a ~100-line reference header.
|
|
1390
|
+
|
|
1391
|
+
Args:
|
|
1392
|
+
file_path: Absolute or relative path to the source file.
|
|
1393
|
+
|
|
1394
|
+
Returns:
|
|
1395
|
+
dict with keys: file, language, classes, functions, exports, docstrings.
|
|
1396
|
+
Returns {"error": ...} on failure — never raises.
|
|
1397
|
+
"""
|
|
1398
|
+
try:
|
|
1399
|
+
path = Path(file_path)
|
|
1400
|
+
ext = path.suffix.lower()
|
|
1401
|
+
|
|
1402
|
+
if not _TS_AVAILABLE:
|
|
1403
|
+
return {
|
|
1404
|
+
"file": str(path),
|
|
1405
|
+
"language": ext.lstrip("."),
|
|
1406
|
+
"error": "tree-sitter not installed — run: pip install tree-sitter tree-sitter-python tree-sitter-typescript ...",
|
|
1407
|
+
"classes": [],
|
|
1408
|
+
"functions": [],
|
|
1409
|
+
"exports": [],
|
|
1410
|
+
"docstrings": {},
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
parser, lang = _get_parser(ext)
|
|
1414
|
+
if parser is None:
|
|
1415
|
+
return {
|
|
1416
|
+
"file": str(path),
|
|
1417
|
+
"language": ext.lstrip("."),
|
|
1418
|
+
"error": f"Unsupported file type: {ext}",
|
|
1419
|
+
"classes": [],
|
|
1420
|
+
"functions": [],
|
|
1421
|
+
"exports": [],
|
|
1422
|
+
"docstrings": {},
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
source_bytes = path.read_bytes()
|
|
1426
|
+
tree = parser.parse(source_bytes)
|
|
1427
|
+
|
|
1428
|
+
handler = _SKELETON_HANDLERS.get(ext)
|
|
1429
|
+
if handler is None:
|
|
1430
|
+
return {
|
|
1431
|
+
"file": str(path),
|
|
1432
|
+
"language": ext.lstrip("."),
|
|
1433
|
+
"error": f"No skeleton handler for {ext}",
|
|
1434
|
+
"classes": [],
|
|
1435
|
+
"functions": [],
|
|
1436
|
+
"exports": [],
|
|
1437
|
+
"docstrings": {},
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
lang_map = {
|
|
1441
|
+
".py": "python", ".ts": "typescript", ".tsx": "tsx",
|
|
1442
|
+
".js": "javascript", ".jsx": "jsx", ".java": "java",
|
|
1443
|
+
".rs": "rust", ".go": "go", ".cpp": "cpp",
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
if ext == ".py":
|
|
1447
|
+
body = handler(tree.root_node, source_bytes)
|
|
1448
|
+
else:
|
|
1449
|
+
body = handler(tree.root_node)
|
|
1450
|
+
|
|
1451
|
+
return {
|
|
1452
|
+
"file": str(path),
|
|
1453
|
+
"language": lang_map.get(ext, ext.lstrip(".")),
|
|
1454
|
+
**body,
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
except Exception as e:
|
|
1458
|
+
return {
|
|
1459
|
+
"file": str(file_path),
|
|
1460
|
+
"language": "unknown",
|
|
1461
|
+
"error": str(e),
|
|
1462
|
+
"classes": [],
|
|
1463
|
+
"functions": [],
|
|
1464
|
+
"exports": [],
|
|
1465
|
+
"docstrings": {},
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
# ---------------------------------------------------------------------------
|
|
1470
|
+
# CLI entrypoint
|
|
1471
|
+
# ---------------------------------------------------------------------------
|
|
481
1472
|
if __name__ == "__main__":
|
|
482
1473
|
import sys
|
|
483
1474
|
from scanner import scan_repo
|
|
@@ -485,5 +1476,4 @@ if __name__ == "__main__":
|
|
|
485
1476
|
target = sys.argv[1] if len(sys.argv) > 1 else "."
|
|
486
1477
|
scan = scan_repo(target)
|
|
487
1478
|
parse = parse_imports(scan)
|
|
488
|
-
|
|
489
1479
|
print(parse.summary())
|