contextl 1.2.14 → 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.14",
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": {
@@ -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
- results.append(child.text.decode("utf-8", errors="replace"))
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 (".rs", ".go", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".c"):
720
- # Rust/Go/C++ raw import strings don't map directly to file paths
721
- # without more context; add to unresolved so graph ignores them gracefully
722
- result.unresolved.append((scanned_file.path, raw))
723
- continue
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",