contextl 1.2.14 → 1.2.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -3
- package/bin/contextl.js +8 -0
- package/package.json +5 -2
- package/python/import_parser.py +137 -7
- package/python/scanner.py +1 -1
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ If you prefer to configure it manually, add this to your IDE's MCP config file:
|
|
|
45
45
|
|
|
46
46
|
| IDE | Config path |
|
|
47
47
|
|-----|-------------|
|
|
48
|
-
|
|
|
48
|
+
| Gemini CLI | `~/.gemini/config/mcp_config.json` |
|
|
49
49
|
| Cursor | `~/.cursor/mcp.json` |
|
|
50
50
|
| Windsurf | `~/.codeium/windsurf/mcp_config.json` |
|
|
51
51
|
| Claude Code | `~/.claude.json` |
|
|
@@ -57,18 +57,33 @@ Restart your IDE. Done — no cloning, no Python setup beyond having it installe
|
|
|
57
57
|
|
|
58
58
|
## What it gives your agent
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
Seven tools, automatically available once connected:
|
|
61
61
|
|
|
62
62
|
### `query_repo`
|
|
63
63
|
*"Find the files relevant to this change."*
|
|
64
64
|
|
|
65
65
|
Ranks every file in your repo against a natural-language query using filename matching, content matching, and graph proximity. Returns confidence-scored results with plain-English reasoning.
|
|
66
66
|
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"repo_path": "/path/to/repo",
|
|
70
|
+
"query": "fix the upload error handler",
|
|
71
|
+
"top_n": 5
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
67
75
|
### `analyze_impact`
|
|
68
76
|
*"If I change this file, what breaks?"*
|
|
69
77
|
|
|
70
78
|
Walks the dependency graph upstream from any file to find every direct and transitive dependent. Flags likely test files so your agent knows what to re-run. Essential before touching shared files like `types/`, `utils/`, or config.
|
|
71
79
|
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"repo_path": "/path/to/repo",
|
|
83
|
+
"target_file": "src/types/index.ts"
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
72
87
|
### `scan_repo`
|
|
73
88
|
*"What files exist here?"*
|
|
74
89
|
|
|
@@ -79,11 +94,50 @@ Lists every source file `contextl` can see — useful for the agent to orient it
|
|
|
79
94
|
|
|
80
95
|
Finds unused files and dead code by analyzing the dependency graph for files with an in-degree of 0. Automatically filters out standard entry points (like `page.tsx` or `index.ts`) and test files.
|
|
81
96
|
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"repo_path": "/path/to/repo"
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
82
103
|
### `export_obsidian_vault`
|
|
83
104
|
*"Visualize this codebase in Obsidian."*
|
|
84
105
|
|
|
85
106
|
Takes the exact dependency graph built by the intelligence engine and physically writes it to disk as a directory of interconnected Markdown files. Automatically injects file metadata, JSDoc/Docstring explanations, and uses standard `[[wikilinks]]` to map out dependencies. Open the generated folder as an Obsidian vault for a stunning 3D interactive graph of your architecture!
|
|
86
107
|
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"repo_path": "/path/to/repo",
|
|
111
|
+
"output_dir": "/path/to/save/vault"
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### `review_changes`
|
|
116
|
+
*"I'm reviewing or changing some code. What's the impact?"*
|
|
117
|
+
|
|
118
|
+
Git-aware context builder. Reads the current git diff to find what files you are editing, then automatically runs impact analysis to find every file that depends on your changes. Returns a structured context bundle with the changed files, full blast radius, and suggested tests to re-run.
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"repo_path": "/path/to/repo",
|
|
123
|
+
"staged": true,
|
|
124
|
+
"unstaged": true
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### `get_skeleton`
|
|
129
|
+
*"Show me the API of this huge file."*
|
|
130
|
+
|
|
131
|
+
Extracts the structural skeleton (API surface) of a source file using Tree-sitter — including all class names, method signatures, return types, and docstrings — without pulling in any implementation bodies. Shrinks a 5,000-line file into a ~100-line reference header.
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"file_path": "/path/to/src/api.py"
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
|
|
87
141
|
|
|
88
142
|
|
|
89
143
|
## How the ranking works
|
|
@@ -98,10 +152,12 @@ Takes the exact dependency graph built by the intelligence engine and physically
|
|
|
98
152
|
## Supported languages
|
|
99
153
|
|
|
100
154
|
**JavaScript ecosystem**: TypeScript, TSX, JavaScript, JSX.
|
|
101
|
-
**Backend ecosystem**: Python (`.py`)
|
|
155
|
+
**Backend ecosystem**: Python (`.py`), Java (`.java`), Rust (`.rs`), Go (`.go`), C/C++ (`.cpp`, `.c`, `.h`).
|
|
102
156
|
|
|
103
157
|
The engine natively understands dot-notation module paths (`from X import Y`, `import com.example.X;`) and correctly resolves them to physical file paths to build the architecture graph.
|
|
104
158
|
|
|
159
|
+
> **Note:** Wildcard imports (e.g. `use super::*`, `use crate::*`) are intentionally left unresolved. ContextL maps explicit dependency paths only.
|
|
160
|
+
|
|
105
161
|
---
|
|
106
162
|
|
|
107
163
|
## The Global CLI
|
|
@@ -120,6 +176,9 @@ contextl dead-code ./my-repo
|
|
|
120
176
|
|
|
121
177
|
# 4. Generate an Obsidian vault
|
|
122
178
|
contextl obsidian ./my-repo ./my_vault
|
|
179
|
+
|
|
180
|
+
# 5. Git-aware review / impact of current edits
|
|
181
|
+
contextl review ./my-repo
|
|
123
182
|
```
|
|
124
183
|
|
|
125
184
|
*(Note: If you omit the sub-command, `contextl ./my-repo "query"` will automatically default to `search` for backwards compatibility).*
|
package/bin/contextl.js
CHANGED
|
@@ -107,6 +107,14 @@ function ensureDeps(python) {
|
|
|
107
107
|
const required = [
|
|
108
108
|
{ importName: "networkx", pipName: "networkx" },
|
|
109
109
|
{ importName: "mcp", pipName: "mcp" },
|
|
110
|
+
{ importName: "tree_sitter", pipName: "tree-sitter>=0.22.0" },
|
|
111
|
+
{ importName: "tree_sitter_python", pipName: "tree-sitter-python>=0.22.0" },
|
|
112
|
+
{ importName: "tree_sitter_javascript", pipName: "tree-sitter-javascript>=0.22.0" },
|
|
113
|
+
{ importName: "tree_sitter_typescript", pipName: "tree-sitter-typescript>=0.22.0" },
|
|
114
|
+
{ importName: "tree_sitter_java", pipName: "tree-sitter-java>=0.22.0" },
|
|
115
|
+
{ importName: "tree_sitter_rust", pipName: "tree-sitter-rust>=0.22.0" },
|
|
116
|
+
{ importName: "tree_sitter_go", pipName: "tree-sitter-go>=0.22.0" },
|
|
117
|
+
{ importName: "tree_sitter_cpp", pipName: "tree-sitter-cpp>=0.22.0" },
|
|
110
118
|
];
|
|
111
119
|
|
|
112
120
|
for (const { importName, pipName } of required) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "contextl",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.16",
|
|
4
4
|
"description": "contextl — finds the most relevant files in your codebase for any change request. MCP server for AI coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -16,10 +16,13 @@
|
|
|
16
16
|
"nextjs",
|
|
17
17
|
"python",
|
|
18
18
|
"java",
|
|
19
|
+
"rust",
|
|
20
|
+
"go",
|
|
21
|
+
"cpp",
|
|
19
22
|
"obsidian",
|
|
20
23
|
"repository"
|
|
21
24
|
],
|
|
22
|
-
"homepage": "https://github.com/DS0710-coder",
|
|
25
|
+
"homepage": "https://github.com/DS0710-coder/contextl",
|
|
23
26
|
"license": "MIT",
|
|
24
27
|
"author": "dev7shah",
|
|
25
28
|
"bin": {
|
package/python/import_parser.py
CHANGED
|
@@ -213,7 +213,15 @@ def _ts_imports_rust(root: "Node") -> list[str]:
|
|
|
213
213
|
# Grab the first meaningful child (scoped_identifier, scoped_use_list, identifier)
|
|
214
214
|
for child in node.children:
|
|
215
215
|
if child.type not in ("use", ";"):
|
|
216
|
-
|
|
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)
|
|
217
225
|
break
|
|
218
226
|
elif node.type == "extern_crate_declaration":
|
|
219
227
|
for child in node.children:
|
|
@@ -419,11 +427,24 @@ def _resolve_relative(import_path: str, source_file: str) -> str:
|
|
|
419
427
|
return str(PurePosixPath(*parts)) if parts else "."
|
|
420
428
|
|
|
421
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
|
+
|
|
422
442
|
def _find_file_in_repo(
|
|
423
443
|
candidate: str,
|
|
424
444
|
file_index: dict,
|
|
425
445
|
root: str,
|
|
426
446
|
extension: str = "",
|
|
447
|
+
source_file: str = "",
|
|
427
448
|
) -> str | None:
|
|
428
449
|
root_path = Path(root)
|
|
429
450
|
candidate_path = Path(candidate)
|
|
@@ -452,6 +473,85 @@ def _find_file_in_repo(
|
|
|
452
473
|
return path
|
|
453
474
|
return None
|
|
454
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]
|
|
553
|
+
return None
|
|
554
|
+
|
|
455
555
|
if extension == ".py":
|
|
456
556
|
py_ext = candidate_str + ".py"
|
|
457
557
|
if py_ext in file_index:
|
|
@@ -565,6 +665,28 @@ def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
|
|
|
565
665
|
except Exception:
|
|
566
666
|
pass
|
|
567
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
|
+
|
|
568
690
|
if not alias_map:
|
|
569
691
|
top_dirs = {Path(f.path).parts[0] for f in scan_result.files if Path(f.path).parts}
|
|
570
692
|
if len(top_dirs) == 1:
|
|
@@ -716,11 +838,17 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
716
838
|
else:
|
|
717
839
|
candidate = raw.replace(".", "/")
|
|
718
840
|
|
|
719
|
-
elif scanned_file.extension in (".
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
|
724
852
|
|
|
725
853
|
else:
|
|
726
854
|
# Node.js / TypeScript
|
|
@@ -730,7 +858,7 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
730
858
|
else:
|
|
731
859
|
candidate = _resolve_relative(raw, scanned_file.path)
|
|
732
860
|
|
|
733
|
-
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)
|
|
734
862
|
|
|
735
863
|
if matched:
|
|
736
864
|
result.relationships.append(
|
|
@@ -741,6 +869,8 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
741
869
|
)
|
|
742
870
|
)
|
|
743
871
|
else:
|
|
872
|
+
if scanned_file.extension == ".rs" and locals().get("is_likely_external", False):
|
|
873
|
+
continue
|
|
744
874
|
result.unresolved.append((scanned_file.path, raw))
|
|
745
875
|
|
|
746
876
|
return result
|
package/python/scanner.py
CHANGED
|
@@ -12,7 +12,7 @@ from pathlib import Path
|
|
|
12
12
|
from dataclasses import dataclass, field
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
SUPPORTED_EXTENSIONS = {".tsx", ".ts", ".jsx", ".js", ".py", ".java"}
|
|
15
|
+
SUPPORTED_EXTENSIONS = {".tsx", ".ts", ".jsx", ".js", ".py", ".java", ".go", ".rs", ".cpp", ".cc", ".c", ".h", ".hpp"}
|
|
16
16
|
|
|
17
17
|
ignore_dirs = {
|
|
18
18
|
".git", "node_modules", "__pycache__", "venv", ".venv",
|