contextl 1.2.9 → 1.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/python/import_parser.py +185 -36
- package/python/obsidian_export.py +61 -27
- package/python/query_engine.py +19 -10
- package/python/scanner.py +10 -11
package/package.json
CHANGED
package/python/import_parser.py
CHANGED
|
@@ -22,7 +22,7 @@ from scanner import ScannedFile, ScanResult
|
|
|
22
22
|
|
|
23
23
|
# Matches any ES6 import statement and captures the module path
|
|
24
24
|
IMPORT_PATTERN = re.compile(
|
|
25
|
-
r"""import\s+(?:type\s+)? # import or import type
|
|
25
|
+
r"""(?:import|export)\s+(?:type\s+)? # import or import type or export
|
|
26
26
|
(?:
|
|
27
27
|
\{[^}]*\} # named imports: { Foo, Bar }
|
|
28
28
|
|[\w*]+ # default or namespace: Foo or *
|
|
@@ -38,11 +38,12 @@ IMPORT_PATTERN = re.compile(
|
|
|
38
38
|
DYNAMIC_IMPORT_PATTERN = re.compile(r"""import\s*\(\s*['"](.*?)['"]\s*\)""")
|
|
39
39
|
|
|
40
40
|
# Python
|
|
41
|
-
PYTHON_FROM_IMPORT_PATTERN = re.compile(r"^from\s+([.\w]+)\s+import", re.MULTILINE)
|
|
42
|
-
PYTHON_IMPORT_PATTERN = re.compile(r"^import\s+([.\w]+)", re.MULTILINE)
|
|
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
43
|
|
|
44
44
|
# Java
|
|
45
|
-
JAVA_IMPORT_PATTERN = re.compile(r"^import\s+(?:static\s+)?([\w
|
|
45
|
+
JAVA_IMPORT_PATTERN = re.compile(r"^import\s+(?:static\s+)?([\w.*]+);", re.MULTILINE)
|
|
46
|
+
JAVA_PACKAGE_PATTERN = re.compile(r"^package\s+([\w.]+);", re.MULTILINE)
|
|
46
47
|
|
|
47
48
|
|
|
48
49
|
@dataclass
|
|
@@ -83,7 +84,15 @@ def _extract_raw_imports(source_code: str, extension: str) -> list[str]:
|
|
|
83
84
|
paths.append(match.group(1))
|
|
84
85
|
elif extension == ".py":
|
|
85
86
|
for match in PYTHON_FROM_IMPORT_PATTERN.finditer(source_code):
|
|
86
|
-
|
|
87
|
+
base = match.group(1)
|
|
88
|
+
paths.append(base)
|
|
89
|
+
symbols_str = match.group(2) or match.group(3)
|
|
90
|
+
if symbols_str:
|
|
91
|
+
symbols_str = symbols_str.replace('(', '').replace(')', '').replace('\\', '').strip()
|
|
92
|
+
for sym in symbols_str.split(','):
|
|
93
|
+
sym = sym.split(' as ')[0].strip()
|
|
94
|
+
if sym and sym != '*':
|
|
95
|
+
paths.append(f"{base}.{sym}")
|
|
87
96
|
for match in PYTHON_IMPORT_PATTERN.finditer(source_code):
|
|
88
97
|
paths.append(match.group(1))
|
|
89
98
|
elif extension == ".java":
|
|
@@ -134,12 +143,26 @@ def _resolve_alias(import_path: str, alias_map: dict[str, str]) -> str | None:
|
|
|
134
143
|
def _resolve_relative(import_path: str, source_file: str) -> str:
|
|
135
144
|
"""
|
|
136
145
|
Resolve a relative import path against the source file's directory.
|
|
137
|
-
|
|
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"
|
|
138
150
|
"""
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
+
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 ".."
|
|
158
|
+
parts = []
|
|
159
|
+
for part in resolved.parts:
|
|
160
|
+
if part == "..":
|
|
161
|
+
if parts:
|
|
162
|
+
parts.pop()
|
|
163
|
+
elif part != ".":
|
|
164
|
+
parts.append(part)
|
|
165
|
+
return str(PurePosixPath(*parts)) if parts else "."
|
|
143
166
|
|
|
144
167
|
|
|
145
168
|
def _find_file_in_repo(
|
|
@@ -177,6 +200,16 @@ def _find_file_in_repo(
|
|
|
177
200
|
for path in file_index:
|
|
178
201
|
if path.endswith(suffix):
|
|
179
202
|
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
|
+
if "/" in candidate_str:
|
|
207
|
+
parent_candidate = candidate_str.rsplit("/", 1)[0]
|
|
208
|
+
parent_suffix = parent_candidate + ".java"
|
|
209
|
+
for path in file_index:
|
|
210
|
+
if path.endswith(parent_suffix):
|
|
211
|
+
return path
|
|
212
|
+
|
|
180
213
|
return None
|
|
181
214
|
|
|
182
215
|
if extension == ".py":
|
|
@@ -201,50 +234,137 @@ def _find_file_in_repo(
|
|
|
201
234
|
if with_ext in file_index:
|
|
202
235
|
return with_ext
|
|
203
236
|
|
|
204
|
-
# Node.js: Try as a directory index file
|
|
237
|
+
# Node.js / Deno: Try as a directory index/mod file
|
|
205
238
|
for ext in [".tsx", ".ts", ".jsx", ".js"]:
|
|
206
239
|
index_path = candidate_str + "/index" + ext
|
|
207
240
|
if index_path in file_index:
|
|
208
241
|
return index_path
|
|
242
|
+
mod_path = candidate_str + "/mod" + ext
|
|
243
|
+
if mod_path in file_index:
|
|
244
|
+
return mod_path
|
|
245
|
+
|
|
246
|
+
# Fuzzy fallback for dynamic paths (like P4A recipes)
|
|
247
|
+
if extension == ".py":
|
|
248
|
+
candidate_suffix = "/" + candidate_str + ".py"
|
|
249
|
+
matches = [p for p in file_index if p.endswith(candidate_suffix)]
|
|
250
|
+
if len(matches) == 1:
|
|
251
|
+
return matches[0]
|
|
209
252
|
|
|
210
253
|
return None
|
|
211
254
|
|
|
212
255
|
|
|
256
|
+
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
|
+
import json
|
|
262
|
+
visited = set()
|
|
263
|
+
merged_paths = {}
|
|
264
|
+
|
|
265
|
+
def _load(path: Path):
|
|
266
|
+
if path in visited:
|
|
267
|
+
return
|
|
268
|
+
visited.add(path)
|
|
269
|
+
try:
|
|
270
|
+
text = path.read_text(encoding="utf-8")
|
|
271
|
+
pattern = r'(".*?(?<!\\)")|(/\*.*?\*/|//[^\r\n]*)'
|
|
272
|
+
text = re.sub(pattern, lambda m: m.group(1) if m.group(1) else '', text, flags=re.S)
|
|
273
|
+
data = json.loads(text)
|
|
274
|
+
except Exception:
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
# Follow extends first (parent paths are base, child overrides)
|
|
278
|
+
extends = data.get("extends")
|
|
279
|
+
if extends:
|
|
280
|
+
parent = (path.parent / extends).resolve()
|
|
281
|
+
# handle both with and without .json
|
|
282
|
+
if not parent.suffix:
|
|
283
|
+
parent = parent.with_suffix(".json")
|
|
284
|
+
_load(parent)
|
|
285
|
+
|
|
286
|
+
# Then apply this tsconfig's own paths (child wins)
|
|
287
|
+
paths = data.get("compilerOptions", {}).get("paths", {})
|
|
288
|
+
for alias, targets in paths.items():
|
|
289
|
+
if targets:
|
|
290
|
+
merged_paths[alias] = targets[0]
|
|
291
|
+
|
|
292
|
+
_load(tsconfig_path)
|
|
293
|
+
return merged_paths
|
|
294
|
+
|
|
295
|
+
|
|
213
296
|
def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
|
|
214
297
|
"""
|
|
215
|
-
Auto-detect
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
+
}
|
|
219
308
|
"""
|
|
220
309
|
root = Path(scan_result.root)
|
|
221
|
-
alias_map = {}
|
|
310
|
+
alias_map: dict[str, str] = {}
|
|
222
311
|
|
|
223
|
-
#
|
|
224
|
-
|
|
312
|
+
# Walk all tsconfig.json files (skip node_modules / build dirs)
|
|
313
|
+
ignore = {"node_modules", "dist", "build", ".next", "coverage", "out"}
|
|
314
|
+
tsconfigs = []
|
|
315
|
+
for tc in root.rglob("tsconfig.json"):
|
|
316
|
+
if any(p in ignore for p in tc.parts):
|
|
317
|
+
continue
|
|
318
|
+
tsconfigs.append(tc)
|
|
319
|
+
|
|
320
|
+
for tsconfig in tsconfigs:
|
|
321
|
+
tsconfig_dir = tsconfig.parent
|
|
322
|
+
paths = _load_tsconfig_paths(tsconfig)
|
|
323
|
+
|
|
324
|
+
for alias, target in paths.items():
|
|
325
|
+
# Normalise: strip trailing /* or *
|
|
326
|
+
clean_alias = alias.rstrip("/*").rstrip("*")
|
|
327
|
+
clean_target = target.rstrip("/*").rstrip("*").lstrip("./")
|
|
328
|
+
|
|
329
|
+
# Resolve target relative to the tsconfig's own directory
|
|
330
|
+
resolved = (tsconfig_dir / clean_target).resolve()
|
|
331
|
+
try:
|
|
332
|
+
repo_rel = str(resolved.relative_to(root))
|
|
333
|
+
except ValueError:
|
|
334
|
+
continue # outside repo — skip
|
|
335
|
+
|
|
336
|
+
alias_key = clean_alias + "/"
|
|
337
|
+
target_val = repo_rel.replace("\\", "/") + "/"
|
|
338
|
+
|
|
339
|
+
# Only add if not already defined (first match / most specific wins)
|
|
340
|
+
if alias_key not in alias_map:
|
|
341
|
+
alias_map[alias_key] = target_val
|
|
342
|
+
|
|
343
|
+
# Detect NPM Workspaces (monorepos)
|
|
344
|
+
for pkg in root.rglob("package.json"):
|
|
345
|
+
if any(p in ignore for p in pkg.parts):
|
|
346
|
+
continue
|
|
225
347
|
try:
|
|
226
348
|
import json
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
prefix = str(tsconfig_dir / clean_target).lstrip("./")
|
|
242
|
-
alias_map[clean_alias + "/"] = prefix + "/" if prefix else ""
|
|
349
|
+
data = json.loads(pkg.read_text(encoding="utf-8"))
|
|
350
|
+
name = data.get("name")
|
|
351
|
+
if name and isinstance(name, str):
|
|
352
|
+
pkg_dir = pkg.parent
|
|
353
|
+
try:
|
|
354
|
+
repo_rel = str(pkg_dir.relative_to(root)).replace("\\", "/")
|
|
355
|
+
if repo_rel == ".":
|
|
356
|
+
continue # Skip root package.json
|
|
357
|
+
# For absolute imports like "@meshtastic/sdk-react"
|
|
358
|
+
alias_map[name] = repo_rel
|
|
359
|
+
# For sub-path imports like "@meshtastic/sdk-react/src/foo"
|
|
360
|
+
alias_map[name + "/"] = repo_rel + "/"
|
|
361
|
+
except ValueError:
|
|
362
|
+
pass
|
|
243
363
|
except Exception:
|
|
244
|
-
|
|
364
|
+
pass
|
|
245
365
|
|
|
246
366
|
if not alias_map:
|
|
247
|
-
# Heuristic fallback:
|
|
367
|
+
# Heuristic fallback: single top-level source dir
|
|
248
368
|
top_dirs = {Path(f.path).parts[0] for f in scan_result.files if Path(f.path).parts}
|
|
249
369
|
if len(top_dirs) == 1:
|
|
250
370
|
top = list(top_dirs)[0]
|
|
@@ -274,6 +394,27 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
274
394
|
# Auto-detect alias map (e.g. @/ → frontend/)
|
|
275
395
|
alias_map = _detect_alias_map(scan_result)
|
|
276
396
|
|
|
397
|
+
# Pre-pass for Java: build package map and implicit same-package relationships
|
|
398
|
+
java_packages = {}
|
|
399
|
+
for scanned_file in scan_result.files:
|
|
400
|
+
if scanned_file.extension == ".java":
|
|
401
|
+
try:
|
|
402
|
+
source_code = Path(scanned_file.absolute_path).read_text(encoding="utf-8")
|
|
403
|
+
pkg_match = JAVA_PACKAGE_PATTERN.search(source_code)
|
|
404
|
+
if pkg_match:
|
|
405
|
+
pkg = pkg_match.group(1)
|
|
406
|
+
java_packages.setdefault(pkg, []).append(scanned_file.path)
|
|
407
|
+
except Exception:
|
|
408
|
+
pass
|
|
409
|
+
|
|
410
|
+
for pkg, files in java_packages.items():
|
|
411
|
+
for source_file in files:
|
|
412
|
+
for target_file in files:
|
|
413
|
+
if source_file != target_file:
|
|
414
|
+
result.relationships.append(
|
|
415
|
+
ImportRelationship(source=source_file, target=target_file, raw_import=f"implicit_package:{pkg}")
|
|
416
|
+
)
|
|
417
|
+
|
|
277
418
|
for scanned_file in scan_result.files:
|
|
278
419
|
try:
|
|
279
420
|
source_code = Path(scanned_file.absolute_path).read_text(encoding="utf-8")
|
|
@@ -289,6 +430,14 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
289
430
|
|
|
290
431
|
# Resolve to a candidate path string
|
|
291
432
|
if scanned_file.extension == ".java":
|
|
433
|
+
if raw.endswith(".*"):
|
|
434
|
+
pkg = raw[:-2]
|
|
435
|
+
for target_file in java_packages.get(pkg, []):
|
|
436
|
+
if target_file != scanned_file.path:
|
|
437
|
+
result.relationships.append(
|
|
438
|
+
ImportRelationship(source=scanned_file.path, target=target_file, raw_import=raw)
|
|
439
|
+
)
|
|
440
|
+
continue
|
|
292
441
|
candidate = raw.replace(".", "/")
|
|
293
442
|
elif scanned_file.extension == ".py":
|
|
294
443
|
if raw.startswith("."):
|
|
@@ -19,68 +19,95 @@ from graph_builder import build_graph, RepoGraph
|
|
|
19
19
|
|
|
20
20
|
PYTHON_DOCSTRING_PATTERN = re.compile(r'^\s*["\']{3}(.*?)["\']{3}', re.DOTALL)
|
|
21
21
|
JS_DOC_PATTERN = re.compile(r'^\s*/\*\*(.*?)\*/', re.DOTALL)
|
|
22
|
+
OUTLINE_PATTERN = re.compile(r'^\s*(?:export\s+)?(?:class|def|function|interface|type)\s+([a-zA-Z0-9_]+)', re.MULTILINE)
|
|
22
23
|
|
|
23
|
-
def
|
|
24
|
-
"""
|
|
24
|
+
def _extract_file_context(file_path: str, extension: str, root_dir: str) -> tuple[str, list[str], str]:
|
|
25
|
+
"""Returns (explanation, outline_symbols, snippet)."""
|
|
26
|
+
explanation = "*No explanation provided in source code.*"
|
|
27
|
+
outline = []
|
|
28
|
+
snippet = ""
|
|
25
29
|
try:
|
|
26
30
|
content = (Path(root_dir) / file_path).read_text(encoding="utf-8")
|
|
31
|
+
|
|
32
|
+
# 1. Snippet (first 20 lines)
|
|
33
|
+
lines = content.splitlines()
|
|
34
|
+
snippet = "\n".join(lines[:20])
|
|
35
|
+
if len(lines) > 20:
|
|
36
|
+
snippet += "\n..."
|
|
37
|
+
|
|
38
|
+
# 2. Explanation
|
|
27
39
|
if extension == ".py":
|
|
28
40
|
match = PYTHON_DOCSTRING_PATTERN.search(content)
|
|
29
41
|
if match:
|
|
30
|
-
|
|
42
|
+
explanation = match.group(1).strip()
|
|
31
43
|
elif extension in {".ts", ".tsx", ".js", ".jsx", ".java"}:
|
|
32
44
|
match = JS_DOC_PATTERN.search(content)
|
|
33
45
|
if match:
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
46
|
+
clean_lines = [line.strip().lstrip('*').strip() for line in match.group(1).split('\n')]
|
|
47
|
+
explanation = "\n".join(clean_lines).strip()
|
|
48
|
+
|
|
49
|
+
# 3. Outline
|
|
50
|
+
for match in OUTLINE_PATTERN.finditer(content):
|
|
51
|
+
outline.append(match.group(1))
|
|
52
|
+
|
|
38
53
|
except Exception:
|
|
39
54
|
pass
|
|
40
|
-
|
|
55
|
+
|
|
56
|
+
return explanation, outline, snippet
|
|
41
57
|
|
|
42
58
|
|
|
43
59
|
def export_obsidian_vault(repo_graph: RepoGraph, output_dir: str) -> str:
|
|
44
60
|
"""
|
|
45
|
-
Generates a markdown file for every node in the graph,
|
|
46
|
-
Obsidian wikilinks connecting them based on imports.
|
|
61
|
+
Generates a markdown file for every node in the graph in a hierarchical folder structure,
|
|
62
|
+
with Obsidian wikilinks connecting them based on imports.
|
|
47
63
|
"""
|
|
48
64
|
out_path = Path(output_dir).resolve()
|
|
49
65
|
|
|
50
|
-
# Create the output directory (clean it if it exists)
|
|
51
66
|
if out_path.exists():
|
|
52
67
|
shutil.rmtree(out_path)
|
|
53
68
|
out_path.mkdir(parents=True)
|
|
54
69
|
|
|
55
70
|
for path, node in repo_graph.nodes.items():
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
safe_name = path.replace("/", "-").replace("\\", "-")
|
|
59
|
-
md_file = out_path / f"{safe_name}.md"
|
|
71
|
+
md_file = out_path / f"{path}.md"
|
|
72
|
+
md_file.parent.mkdir(parents=True, exist_ok=True)
|
|
60
73
|
|
|
61
74
|
dependencies = repo_graph.get_dependencies(path)
|
|
62
75
|
dependents = repo_graph.get_dependents(path)
|
|
63
76
|
|
|
64
|
-
explanation =
|
|
77
|
+
explanation, outline, snippet = _extract_file_context(path, node.extension, repo_graph.root)
|
|
65
78
|
|
|
66
79
|
content = [
|
|
67
|
-
f"# {path}",
|
|
80
|
+
f"# {Path(path).name}",
|
|
81
|
+
"",
|
|
82
|
+
"## Architecture Metrics",
|
|
83
|
+
f"- **Path:** `{path}`",
|
|
84
|
+
f"- **Extension:** `{node.extension}`",
|
|
85
|
+
f"- **Size:** {node.size_bytes} bytes",
|
|
86
|
+
f"- **Centrality Score:** {node.centrality:.4f}",
|
|
87
|
+
f"- **In-Degree (Imported By):** {len(dependents)}",
|
|
88
|
+
f"- **Out-Degree (Imports):** {len(dependencies)}",
|
|
68
89
|
"",
|
|
69
90
|
"## Explanation",
|
|
70
91
|
explanation,
|
|
71
92
|
"",
|
|
72
|
-
"##
|
|
73
|
-
f"**Extension:** `{node.extension}`",
|
|
74
|
-
f"**Size:** {node.size_bytes} bytes",
|
|
75
|
-
f"**Centrality Score:** {node.centrality:.4f}",
|
|
76
|
-
"",
|
|
77
|
-
"## Imports (Dependencies)",
|
|
93
|
+
"## Structural Outline",
|
|
78
94
|
]
|
|
79
95
|
|
|
96
|
+
if outline:
|
|
97
|
+
for symbol in outline:
|
|
98
|
+
content.append(f"- `{symbol}`")
|
|
99
|
+
else:
|
|
100
|
+
content.append("*No major classes or functions detected.*")
|
|
101
|
+
|
|
102
|
+
content.extend([
|
|
103
|
+
"",
|
|
104
|
+
"## Imports (Dependencies)"
|
|
105
|
+
])
|
|
106
|
+
|
|
80
107
|
if dependencies:
|
|
81
108
|
for dep in sorted(dependencies):
|
|
82
|
-
|
|
83
|
-
content.append(f"- [[{
|
|
109
|
+
# Use full relative path for accurate linking in hierarchical vaults
|
|
110
|
+
content.append(f"- [[{dep}.md|{dep}]]")
|
|
84
111
|
else:
|
|
85
112
|
content.append("*No internal imports*")
|
|
86
113
|
|
|
@@ -91,11 +118,18 @@ def export_obsidian_vault(repo_graph: RepoGraph, output_dir: str) -> str:
|
|
|
91
118
|
|
|
92
119
|
if dependents:
|
|
93
120
|
for dep in sorted(dependents):
|
|
94
|
-
|
|
95
|
-
content.append(f"- [[{dep_safe}]]")
|
|
121
|
+
content.append(f"- [[{dep}.md|{dep}]]")
|
|
96
122
|
else:
|
|
97
123
|
content.append("*Not imported by any file*")
|
|
98
124
|
|
|
125
|
+
content.extend([
|
|
126
|
+
"",
|
|
127
|
+
"## Source Code Snippet",
|
|
128
|
+
f"```{node.extension.lstrip('.')}",
|
|
129
|
+
snippet,
|
|
130
|
+
"```"
|
|
131
|
+
])
|
|
132
|
+
|
|
99
133
|
md_file.write_text("\n".join(content), encoding="utf-8")
|
|
100
134
|
|
|
101
135
|
return str(out_path)
|
package/python/query_engine.py
CHANGED
|
@@ -161,6 +161,13 @@ def _content_score(query_terms: list[str], file_path: str, file_content: str, id
|
|
|
161
161
|
for part in re.findall(r"[a-z0-9]+", sub):
|
|
162
162
|
if len(part) > 3:
|
|
163
163
|
java_methods.add(part)
|
|
164
|
+
|
|
165
|
+
py_defs = set()
|
|
166
|
+
if file_path.endswith(".py"):
|
|
167
|
+
defs = re.findall(r"\b(?:def|async def)\s+([a-zA-Z0-9_]+)\s*\(", file_content)
|
|
168
|
+
for d in defs:
|
|
169
|
+
if d.lower() not in {"main", "init", "get", "set"}:
|
|
170
|
+
py_defs.add(d.lower())
|
|
164
171
|
|
|
165
172
|
if is_ts:
|
|
166
173
|
exports = re.findall(r"\bexport\s+(?:const|let|var|class|interface|function|default)\s+([a-zA-Z0-9_]+)", file_content)
|
|
@@ -213,7 +220,7 @@ def _content_score(query_terms: list[str], file_path: str, file_content: str, id
|
|
|
213
220
|
multiplier = 8.0
|
|
214
221
|
elif t in declared_classes:
|
|
215
222
|
multiplier = class_mult
|
|
216
|
-
elif t in java_methods:
|
|
223
|
+
elif t in java_methods or t in py_defs:
|
|
217
224
|
multiplier = 4.0
|
|
218
225
|
elif t in annotations:
|
|
219
226
|
multiplier = ann_mult
|
|
@@ -340,9 +347,10 @@ def query(
|
|
|
340
347
|
if df == 0:
|
|
341
348
|
idf_weights[term] = 0.0
|
|
342
349
|
else:
|
|
343
|
-
idf_weights[term] = math.log(total_files / (1 + df)) if total_files > 0 else 1.0
|
|
350
|
+
idf_weights[term] = max(0.1, math.log(total_files / (1 + df))) if total_files > 0 else 1.0
|
|
344
351
|
|
|
345
352
|
# --- Pass 1: keyword + content scores per file ---
|
|
353
|
+
base_scores: dict[str, float] = {}
|
|
346
354
|
keyword_scores: dict[str, float] = {}
|
|
347
355
|
content_scores: dict[str, float] = {}
|
|
348
356
|
matched_terms: dict[str, list[str]] = {}
|
|
@@ -355,14 +363,15 @@ def query(
|
|
|
355
363
|
keyword_scores[path] = kscore
|
|
356
364
|
content_scores[path] = cscore
|
|
357
365
|
matched_terms[path] = list(set(kterms + cterms))
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
+
|
|
367
|
+
# Base combination: 50/50 keyword vs content
|
|
368
|
+
combined = (kscore * 0.5) + (cscore * 0.5)
|
|
369
|
+
|
|
370
|
+
# Tiebreaker: Slightly penalize deep paths so src/x.py wins over build/npm/python/x.py
|
|
371
|
+
depth_penalty = 0.0001 * path.count("/")
|
|
372
|
+
combined -= depth_penalty
|
|
373
|
+
|
|
374
|
+
base_scores[path] = combined
|
|
366
375
|
|
|
367
376
|
# --- Pass 3: neighbor bonus ---
|
|
368
377
|
boosted_scores = _apply_neighbor_bonus(base_scores, repo_graph)
|
package/python/scanner.py
CHANGED
|
@@ -7,24 +7,23 @@ Filters by supported extensions for the MVP (Next.js / React / TypeScript).
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import os
|
|
10
|
+
import pathlib
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from dataclasses import dataclass, field
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
SUPPORTED_EXTENSIONS = {".tsx", ".ts", ".jsx", ".js", ".py", ".java"}
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
"node_modules",
|
|
18
|
-
".
|
|
19
|
-
".
|
|
20
|
-
"dist",
|
|
21
|
-
"build",
|
|
22
|
-
".cache",
|
|
23
|
-
"__pycache__",
|
|
24
|
-
".turbo",
|
|
25
|
-
"coverage",
|
|
17
|
+
ignore_dirs = {
|
|
18
|
+
".git", "node_modules", "__pycache__", "venv", ".venv",
|
|
19
|
+
"env", ".env", ".next", ".nuxt", "out", "build", "dist",
|
|
20
|
+
"coverage", ".nyc_output", ".pytest_cache", "npm", "pypi"
|
|
26
21
|
}
|
|
27
22
|
|
|
23
|
+
def _should_ignore(path: str) -> bool:
|
|
24
|
+
parts = pathlib.Path(path).parts
|
|
25
|
+
return any(part in ignore_dirs for part in parts)
|
|
26
|
+
|
|
28
27
|
|
|
29
28
|
@dataclass
|
|
30
29
|
class ScannedFile:
|
|
@@ -87,7 +86,7 @@ def scan_repo(repo_path: str) -> ScanResult:
|
|
|
87
86
|
|
|
88
87
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
89
88
|
# Prune ignored directories in-place so os.walk skips them
|
|
90
|
-
dirnames[:] = [d for d in dirnames if d not in
|
|
89
|
+
dirnames[:] = [d for d in dirnames if d not in ignore_dirs]
|
|
91
90
|
|
|
92
91
|
for filename in filenames:
|
|
93
92
|
filepath = Path(dirpath) / filename
|