contextl 1.2.12 → 1.2.14

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