contextl 1.2.10 → 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 +87 -6
- package/python/obsidian_export.py +61 -27
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":
|
|
@@ -191,6 +200,16 @@ def _find_file_in_repo(
|
|
|
191
200
|
for path in file_index:
|
|
192
201
|
if path.endswith(suffix):
|
|
193
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
|
+
|
|
194
213
|
return None
|
|
195
214
|
|
|
196
215
|
if extension == ".py":
|
|
@@ -215,11 +234,21 @@ def _find_file_in_repo(
|
|
|
215
234
|
if with_ext in file_index:
|
|
216
235
|
return with_ext
|
|
217
236
|
|
|
218
|
-
# Node.js: Try as a directory index file
|
|
237
|
+
# Node.js / Deno: Try as a directory index/mod file
|
|
219
238
|
for ext in [".tsx", ".ts", ".jsx", ".js"]:
|
|
220
239
|
index_path = candidate_str + "/index" + ext
|
|
221
240
|
if index_path in file_index:
|
|
222
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]
|
|
223
252
|
|
|
224
253
|
return None
|
|
225
254
|
|
|
@@ -311,6 +340,29 @@ def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
|
|
|
311
340
|
if alias_key not in alias_map:
|
|
312
341
|
alias_map[alias_key] = target_val
|
|
313
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
|
|
347
|
+
try:
|
|
348
|
+
import json
|
|
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
|
|
363
|
+
except Exception:
|
|
364
|
+
pass
|
|
365
|
+
|
|
314
366
|
if not alias_map:
|
|
315
367
|
# Heuristic fallback: single top-level source dir
|
|
316
368
|
top_dirs = {Path(f.path).parts[0] for f in scan_result.files if Path(f.path).parts}
|
|
@@ -342,6 +394,27 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
342
394
|
# Auto-detect alias map (e.g. @/ → frontend/)
|
|
343
395
|
alias_map = _detect_alias_map(scan_result)
|
|
344
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
|
+
|
|
345
418
|
for scanned_file in scan_result.files:
|
|
346
419
|
try:
|
|
347
420
|
source_code = Path(scanned_file.absolute_path).read_text(encoding="utf-8")
|
|
@@ -357,6 +430,14 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
357
430
|
|
|
358
431
|
# Resolve to a candidate path string
|
|
359
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
|
|
360
441
|
candidate = raw.replace(".", "/")
|
|
361
442
|
elif scanned_file.extension == ".py":
|
|
362
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)
|