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 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
- | Antigravity | `~/.gemini/config/mcp_config.json` |
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
- Three tools, automatically available once connected:
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`) and Java (`.java`).
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.13",
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)