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.
@@ -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
- Handles:
7
- - Relative imports: import X from "./foo" or "../../lib/bar"
8
- - Alias imports: import X from "@/components/Button"
9
- - Type imports: import type { X } from "@/types"
10
- - Named imports: import { X, Y } from "./utils"
11
-
12
- Returns only imports that resolve to other files in the repository.
13
- External packages (react, next, etc.) are ignored.
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
- from pathlib import Path
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
- # Matches any ES6 import statement and captures the module path
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
- # Python
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
- @dataclass
50
- class ImportRelationship:
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
- # Normalise away any .. and . components without touching the filesystem
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[str, str],
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'(".*?(?<!\\)")|(/\*.*?\*/|//[^\r\n]*)'
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 # outside repo — skip
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 # Skip root package.json
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
- # Pre-pass for Java: build package map and implicit same-package relationships
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
- source_code = Path(scanned_file.absolute_path).read_text(encoding="utf-8")
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(source=source_file, target=target_file, raw_import=f"implicit_package:{pkg}")
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
- source_code = Path(scanned_file.absolute_path).read_text(encoding="utf-8")
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 a candidate path string
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
- # Find the actual file in the repo
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())