contextl 1.2.13 → 1.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -4
- package/bin/contextl.js +8 -0
- package/package.json +5 -2
- package/python/git_review.py +245 -0
- package/python/import_parser.py +1146 -156
- package/python/main.py +35 -1
- package/python/mcp_server.py +113 -0
- 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,14 +94,51 @@ 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
|
+
|
|
87
140
|
|
|
88
141
|
|
|
89
|
-
## How the ranking works
|
|
90
142
|
|
|
91
143
|
1. **Keyword match** — does the filename contain query terms?
|
|
92
144
|
2. **Content match** — does the file's source code mention the terms?
|
|
@@ -98,10 +150,12 @@ Takes the exact dependency graph built by the intelligence engine and physically
|
|
|
98
150
|
## Supported languages
|
|
99
151
|
|
|
100
152
|
**JavaScript ecosystem**: TypeScript, TSX, JavaScript, JSX.
|
|
101
|
-
**Backend ecosystem**: Python (`.py`)
|
|
153
|
+
**Backend ecosystem**: Python (`.py`), Java (`.java`), Rust (`.rs`), Go (`.go`), C/C++ (`.cpp`, `.c`, `.h`).
|
|
102
154
|
|
|
103
155
|
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
156
|
|
|
157
|
+
> **Note:** Wildcard imports (e.g. `use super::*`, `use crate::*`) are intentionally left unresolved. ContextL maps explicit dependency paths only.
|
|
158
|
+
|
|
105
159
|
---
|
|
106
160
|
|
|
107
161
|
## The Global CLI
|
|
@@ -120,6 +174,9 @@ contextl dead-code ./my-repo
|
|
|
120
174
|
|
|
121
175
|
# 4. Generate an Obsidian vault
|
|
122
176
|
contextl obsidian ./my-repo ./my_vault
|
|
177
|
+
|
|
178
|
+
# 5. Git-aware review / impact of current edits
|
|
179
|
+
contextl review ./my-repo
|
|
123
180
|
```
|
|
124
181
|
|
|
125
182
|
*(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.15",
|
|
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": {
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Repository Intelligence Engine
|
|
3
|
+
Feature #6: Git-Aware Context
|
|
4
|
+
|
|
5
|
+
Reads git diff to find what you've changed, then builds a full blast-radius
|
|
6
|
+
context package showing every file that depends on your changes — ready for
|
|
7
|
+
AI review.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Data structures
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class FileReview:
|
|
22
|
+
path: str
|
|
23
|
+
status: str # "staged", "unstaged", or "staged+unstaged"
|
|
24
|
+
direct_dependents: list[str] = field(default_factory=list)
|
|
25
|
+
transitive_dependents: list[str] = field(default_factory=list)
|
|
26
|
+
test_files: list[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ReviewContext:
|
|
31
|
+
repo: str
|
|
32
|
+
changed_files: list[FileReview]
|
|
33
|
+
all_affected: list[str] # union of all dependents, deduped, sorted
|
|
34
|
+
suggested_tests: list[str] # union of all test files, deduped, sorted
|
|
35
|
+
staged_count: int
|
|
36
|
+
unstaged_count: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Git helpers
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def _run_git(args: list[str], cwd: str) -> list[str]:
|
|
44
|
+
"""Run a git command and return non-empty lines of stdout."""
|
|
45
|
+
try:
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
["git"] + args,
|
|
48
|
+
cwd=cwd,
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
timeout=15,
|
|
52
|
+
)
|
|
53
|
+
if result.returncode != 0:
|
|
54
|
+
return []
|
|
55
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
56
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_git_repo(repo_path: str) -> bool:
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
63
|
+
cwd=repo_path,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
)
|
|
67
|
+
return result.returncode == 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_changed_files(
|
|
71
|
+
repo_path: str,
|
|
72
|
+
include_staged: bool = True,
|
|
73
|
+
include_unstaged: bool = True,
|
|
74
|
+
) -> tuple[set[str], set[str]]:
|
|
75
|
+
"""
|
|
76
|
+
Returns (staged_files, unstaged_files) as sets of relative paths.
|
|
77
|
+
Only includes files that actually exist on disk (deleted files are excluded).
|
|
78
|
+
"""
|
|
79
|
+
staged: set[str] = set()
|
|
80
|
+
unstaged: set[str] = set()
|
|
81
|
+
|
|
82
|
+
if include_staged:
|
|
83
|
+
for f in _run_git(["diff", "--cached", "--name-only", "--diff-filter=ACMRT"], repo_path):
|
|
84
|
+
full = Path(repo_path) / f
|
|
85
|
+
if full.exists():
|
|
86
|
+
staged.add(f)
|
|
87
|
+
|
|
88
|
+
if include_unstaged:
|
|
89
|
+
for f in _run_git(["diff", "--name-only", "--diff-filter=ACMRT"], repo_path):
|
|
90
|
+
full = Path(repo_path) / f
|
|
91
|
+
if full.exists():
|
|
92
|
+
unstaged.add(f)
|
|
93
|
+
|
|
94
|
+
return staged, unstaged
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Core review engine
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def build_review_context(
|
|
102
|
+
repo_path: str,
|
|
103
|
+
staged: set[str],
|
|
104
|
+
unstaged: set[str],
|
|
105
|
+
repo_graph,
|
|
106
|
+
) -> ReviewContext:
|
|
107
|
+
"""
|
|
108
|
+
For each changed file, run impact analysis and aggregate into a ReviewContext.
|
|
109
|
+
"""
|
|
110
|
+
# Avoid circular import — import here
|
|
111
|
+
from impact_analysis import analyze_impact
|
|
112
|
+
|
|
113
|
+
resolved = str(Path(repo_path).resolve())
|
|
114
|
+
all_changed = staged | unstaged
|
|
115
|
+
|
|
116
|
+
all_affected: set[str] = set()
|
|
117
|
+
suggested_tests: set[str] = set()
|
|
118
|
+
file_reviews: list[FileReview] = []
|
|
119
|
+
|
|
120
|
+
for rel_path in sorted(all_changed):
|
|
121
|
+
# Determine status label
|
|
122
|
+
in_staged = rel_path in staged
|
|
123
|
+
in_unstaged = rel_path in unstaged
|
|
124
|
+
if in_staged and in_unstaged:
|
|
125
|
+
status = "staged+unstaged"
|
|
126
|
+
elif in_staged:
|
|
127
|
+
status = "staged"
|
|
128
|
+
else:
|
|
129
|
+
status = "unstaged"
|
|
130
|
+
|
|
131
|
+
# Run impact analysis (may raise ValueError if file not in graph)
|
|
132
|
+
direct: list[str] = []
|
|
133
|
+
transitive: list[str] = []
|
|
134
|
+
tests: list[str] = []
|
|
135
|
+
try:
|
|
136
|
+
report = analyze_impact(rel_path, repo_graph, max_depth=5)
|
|
137
|
+
direct = [f.path for f in report.directly_affected]
|
|
138
|
+
transitive = [f.path for f in report.transitively_affected]
|
|
139
|
+
tests = report.test_files
|
|
140
|
+
except (ValueError, KeyError):
|
|
141
|
+
# File isn't tracked in graph (new file, binary, etc.) — still include it
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
file_reviews.append(FileReview(
|
|
145
|
+
path=rel_path,
|
|
146
|
+
status=status,
|
|
147
|
+
direct_dependents=direct,
|
|
148
|
+
transitive_dependents=transitive,
|
|
149
|
+
test_files=tests,
|
|
150
|
+
))
|
|
151
|
+
|
|
152
|
+
all_affected.update(direct)
|
|
153
|
+
all_affected.update(transitive)
|
|
154
|
+
suggested_tests.update(tests)
|
|
155
|
+
|
|
156
|
+
# Don't list changed files as "affected by themselves"
|
|
157
|
+
all_affected -= all_changed
|
|
158
|
+
suggested_tests -= all_changed
|
|
159
|
+
|
|
160
|
+
return ReviewContext(
|
|
161
|
+
repo=resolved,
|
|
162
|
+
changed_files=file_reviews,
|
|
163
|
+
all_affected=sorted(all_affected),
|
|
164
|
+
suggested_tests=sorted(suggested_tests),
|
|
165
|
+
staged_count=len(staged),
|
|
166
|
+
unstaged_count=len(unstaged),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# Output formatters
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def format_review_human(ctx: ReviewContext) -> str:
|
|
175
|
+
BOLD = "\033[1m"
|
|
176
|
+
DIM = "\033[2m"
|
|
177
|
+
GREEN = "\033[32m"
|
|
178
|
+
YELLOW= "\033[33m"
|
|
179
|
+
CYAN = "\033[36m"
|
|
180
|
+
RED = "\033[31m"
|
|
181
|
+
RESET = "\033[0m"
|
|
182
|
+
|
|
183
|
+
total = ctx.staged_count + ctx.unstaged_count
|
|
184
|
+
lines = [
|
|
185
|
+
"",
|
|
186
|
+
f"{BOLD}Repository Intelligence Engine — Git Review{RESET}",
|
|
187
|
+
f"{DIM}{'─' * 60}{RESET}",
|
|
188
|
+
f" Repo : {ctx.repo}",
|
|
189
|
+
f" Changed : {total} file(s) "
|
|
190
|
+
f"({ctx.staged_count} staged, {ctx.unstaged_count} unstaged)",
|
|
191
|
+
f"{DIM}{'─' * 60}{RESET}",
|
|
192
|
+
"",
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
# Changed files section
|
|
196
|
+
lines.append(f" {BOLD}CHANGED FILES{RESET} {DIM}(what you edited){RESET}")
|
|
197
|
+
if not ctx.changed_files:
|
|
198
|
+
lines.append(f" {DIM}No changed files found.{RESET}")
|
|
199
|
+
for fr in ctx.changed_files:
|
|
200
|
+
label_color = GREEN if fr.status == "staged" else YELLOW if fr.status == "unstaged" else CYAN
|
|
201
|
+
label = f"{label_color}[{fr.status}]{RESET}"
|
|
202
|
+
lines.append(f" {CYAN}{fr.path}{RESET} {label}")
|
|
203
|
+
lines.append("")
|
|
204
|
+
|
|
205
|
+
# Blast radius section
|
|
206
|
+
lines.append(f" {BOLD}BLAST RADIUS{RESET} {DIM}(files that depend on your changes){RESET}")
|
|
207
|
+
if not ctx.all_affected:
|
|
208
|
+
lines.append(f" {DIM}No dependent files found.{RESET}")
|
|
209
|
+
else:
|
|
210
|
+
for path in ctx.all_affected:
|
|
211
|
+
is_test = any(t in path for t in [".test.", ".spec.", "_test.", "test_"])
|
|
212
|
+
test_badge = f" {RED}[TEST]{RESET}" if is_test else ""
|
|
213
|
+
lines.append(f" {DIM}→{RESET} {path}{test_badge}")
|
|
214
|
+
lines.append("")
|
|
215
|
+
|
|
216
|
+
# Suggested tests
|
|
217
|
+
lines.append(f" {BOLD}SUGGESTED TESTS{RESET} ({len(ctx.suggested_tests)} file(s) to re-run)")
|
|
218
|
+
if not ctx.suggested_tests:
|
|
219
|
+
lines.append(f" {DIM}No test files identified.{RESET}")
|
|
220
|
+
for t in ctx.suggested_tests:
|
|
221
|
+
lines.append(f" {RED}→{RESET} {t}")
|
|
222
|
+
lines.append("")
|
|
223
|
+
|
|
224
|
+
return "\n".join(lines)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def format_review_json(ctx: ReviewContext) -> str:
|
|
228
|
+
import json
|
|
229
|
+
return json.dumps({
|
|
230
|
+
"repo": ctx.repo,
|
|
231
|
+
"staged_count": ctx.staged_count,
|
|
232
|
+
"unstaged_count": ctx.unstaged_count,
|
|
233
|
+
"changed_files": [
|
|
234
|
+
{
|
|
235
|
+
"path": fr.path,
|
|
236
|
+
"status": fr.status,
|
|
237
|
+
"direct_dependents": fr.direct_dependents,
|
|
238
|
+
"transitive_dependents": fr.transitive_dependents,
|
|
239
|
+
"test_files": fr.test_files,
|
|
240
|
+
}
|
|
241
|
+
for fr in ctx.changed_files
|
|
242
|
+
],
|
|
243
|
+
"all_affected": ctx.all_affected,
|
|
244
|
+
"suggested_tests": ctx.suggested_tests,
|
|
245
|
+
}, indent=2)
|