contextl 1.2.23 → 1.2.25
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 +2 -2
- package/python/import_parser.py +66 -30
- package/python/mcp_server.py +4 -1
- package/python/query_engine.py +18 -9
- package/requirements.txt +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "contextl",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.25",
|
|
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",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"files": [
|
|
32
32
|
"bin/",
|
|
33
33
|
"python/",
|
|
34
|
-
"README.md"
|
|
34
|
+
"README.md", "requirements.txt"
|
|
35
35
|
],
|
|
36
36
|
|
|
37
37
|
"engines": {
|
package/python/import_parser.py
CHANGED
|
@@ -19,6 +19,18 @@ import sys
|
|
|
19
19
|
from pathlib import Path, PurePosixPath
|
|
20
20
|
from dataclasses import dataclass, field
|
|
21
21
|
|
|
22
|
+
PYTHON_STDLIB_AND_COMMON_PACKAGES = {
|
|
23
|
+
"os", "sys", "re", "math", "json", "datetime", "typing", "collections",
|
|
24
|
+
"itertools", "functools", "pathlib", "logging", "argparse", "subprocess",
|
|
25
|
+
"unittest", "pytest", "numpy", "pandas", "requests", "urllib", "asyncio",
|
|
26
|
+
"time", "random", "hashlib", "base64", "csv", "xml", "html", "http",
|
|
27
|
+
"concurrent", "multiprocessing", "threading", "sqlite3", "flask", "django",
|
|
28
|
+
"fastapi", "sqlalchemy", "pydantic", "dataclasses", "uuid", "socket",
|
|
29
|
+
"struct", "tempfile", "shutil", "glob", "copy", "warnings", "traceback",
|
|
30
|
+
"contextlib", "io", "sysconfig", "importlib", "pkgutil", "runpy",
|
|
31
|
+
"celery", "redis", "psycopg2", "boto3", "jinja2", "pytest_mock",
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
from scanner import ScannedFile, ScanResult
|
|
23
35
|
|
|
24
36
|
|
|
@@ -142,7 +154,7 @@ def _ts_imports_js_ts(root: "Node") -> list[str]:
|
|
|
142
154
|
return child.text.decode("utf-8", errors="replace")
|
|
143
155
|
# Fallback: strip quotes manually
|
|
144
156
|
raw = node.text.decode("utf-8", errors="replace")
|
|
145
|
-
return raw.strip("'
|
|
157
|
+
return raw.strip('"\'`')
|
|
146
158
|
return None
|
|
147
159
|
|
|
148
160
|
def visit(node: "Node"):
|
|
@@ -553,24 +565,35 @@ def _find_file_in_repo(
|
|
|
553
565
|
return None
|
|
554
566
|
|
|
555
567
|
if extension == ".py":
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
568
|
+
def _resolve_python(cand: str) -> str | None:
|
|
569
|
+
py_ext = cand + ".py"
|
|
570
|
+
if py_ext in file_index:
|
|
571
|
+
return py_ext
|
|
572
|
+
py_idx = cand + "/__init__.py"
|
|
573
|
+
if py_idx in file_index:
|
|
574
|
+
return py_idx
|
|
575
|
+
suffix = "/" + py_ext
|
|
576
|
+
for path in file_index:
|
|
577
|
+
if path.endswith(suffix) or path == py_ext:
|
|
578
|
+
return path
|
|
579
|
+
|
|
580
|
+
# Fallback for Python cross-language imports (e.g. from tests to .ts/.js core logic)
|
|
581
|
+
for ext in [".ts", ".tsx", ".js", ".jsx", ".java", ".go", ".rs", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".c"]:
|
|
582
|
+
alt_suffix = "/" + cand + ext
|
|
583
|
+
alt_matches = [p for p in file_index if p.endswith(alt_suffix) or p == alt_suffix.lstrip("/")]
|
|
584
|
+
if len(alt_matches) == 1:
|
|
585
|
+
return alt_matches[0]
|
|
586
|
+
return None
|
|
587
|
+
|
|
588
|
+
res = _resolve_python(candidate_str)
|
|
589
|
+
if res is not None:
|
|
590
|
+
return res
|
|
591
|
+
|
|
592
|
+
# Try stripping the last component (in case it's a symbol-level import like `from a.b import C`)
|
|
593
|
+
if "/" in candidate_str:
|
|
594
|
+
stripped = candidate_str.rsplit("/", 1)[0]
|
|
595
|
+
return _resolve_python(stripped)
|
|
596
|
+
|
|
574
597
|
return None
|
|
575
598
|
for ext in [".tsx", ".ts", ".jsx", ".js"]:
|
|
576
599
|
with_ext = candidate_str + ext
|
|
@@ -872,6 +895,10 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
872
895
|
else:
|
|
873
896
|
if scanned_file.extension == ".rs" and locals().get("is_likely_external", False):
|
|
874
897
|
continue
|
|
898
|
+
if scanned_file.extension == ".py":
|
|
899
|
+
base_pkg = raw.split(".")[0]
|
|
900
|
+
if base_pkg in PYTHON_STDLIB_AND_COMMON_PACKAGES:
|
|
901
|
+
continue
|
|
875
902
|
result.unresolved.append((scanned_file.path, raw))
|
|
876
903
|
|
|
877
904
|
return result
|
|
@@ -1484,11 +1511,18 @@ def extract_skeleton(file_path: str) -> dict:
|
|
|
1484
1511
|
try:
|
|
1485
1512
|
path = Path(file_path)
|
|
1486
1513
|
ext = path.suffix.lower()
|
|
1514
|
+
|
|
1515
|
+
lang_map = {
|
|
1516
|
+
".py": "python", ".ts": "typescript", ".tsx": "tsx",
|
|
1517
|
+
".js": "javascript", ".jsx": "jsx", ".java": "java",
|
|
1518
|
+
".rs": "rust", ".go": "go", ".cpp": "cpp",
|
|
1519
|
+
}
|
|
1520
|
+
derived_language = lang_map.get(ext, ext.lstrip("."))
|
|
1487
1521
|
|
|
1488
1522
|
if not _TS_AVAILABLE:
|
|
1489
1523
|
return {
|
|
1490
1524
|
"file": str(path),
|
|
1491
|
-
"language":
|
|
1525
|
+
"language": derived_language,
|
|
1492
1526
|
"error": "tree-sitter not installed — run: pip install tree-sitter tree-sitter-python tree-sitter-typescript ...",
|
|
1493
1527
|
"classes": [],
|
|
1494
1528
|
"functions": [],
|
|
@@ -1500,7 +1534,7 @@ def extract_skeleton(file_path: str) -> dict:
|
|
|
1500
1534
|
if parser is None:
|
|
1501
1535
|
return {
|
|
1502
1536
|
"file": str(path),
|
|
1503
|
-
"language":
|
|
1537
|
+
"language": derived_language,
|
|
1504
1538
|
"error": f"Unsupported file type: {ext}",
|
|
1505
1539
|
"classes": [],
|
|
1506
1540
|
"functions": [],
|
|
@@ -1515,7 +1549,7 @@ def extract_skeleton(file_path: str) -> dict:
|
|
|
1515
1549
|
if handler is None:
|
|
1516
1550
|
return {
|
|
1517
1551
|
"file": str(path),
|
|
1518
|
-
"language":
|
|
1552
|
+
"language": derived_language,
|
|
1519
1553
|
"error": f"No skeleton handler for {ext}",
|
|
1520
1554
|
"classes": [],
|
|
1521
1555
|
"functions": [],
|
|
@@ -1523,12 +1557,6 @@ def extract_skeleton(file_path: str) -> dict:
|
|
|
1523
1557
|
"docstrings": {},
|
|
1524
1558
|
}
|
|
1525
1559
|
|
|
1526
|
-
lang_map = {
|
|
1527
|
-
".py": "python", ".ts": "typescript", ".tsx": "tsx",
|
|
1528
|
-
".js": "javascript", ".jsx": "jsx", ".java": "java",
|
|
1529
|
-
".rs": "rust", ".go": "go", ".cpp": "cpp",
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
1560
|
if ext == ".py":
|
|
1533
1561
|
body = handler(tree.root_node, source_bytes)
|
|
1534
1562
|
else:
|
|
@@ -1536,14 +1564,22 @@ def extract_skeleton(file_path: str) -> dict:
|
|
|
1536
1564
|
|
|
1537
1565
|
return {
|
|
1538
1566
|
"file": str(path),
|
|
1539
|
-
"language":
|
|
1567
|
+
"language": derived_language,
|
|
1540
1568
|
**body,
|
|
1541
1569
|
}
|
|
1542
1570
|
|
|
1543
1571
|
except Exception as e:
|
|
1572
|
+
# Fallback if path is not yet defined
|
|
1573
|
+
lang = "unknown"
|
|
1574
|
+
if 'derived_language' in locals():
|
|
1575
|
+
lang = derived_language
|
|
1576
|
+
elif 'file_path' in locals():
|
|
1577
|
+
ext = Path(file_path).suffix.lower()
|
|
1578
|
+
lang = lang_map.get(ext, ext.lstrip(".")) if 'lang_map' in locals() else ext.lstrip(".")
|
|
1579
|
+
|
|
1544
1580
|
return {
|
|
1545
1581
|
"file": str(file_path),
|
|
1546
|
-
"language":
|
|
1582
|
+
"language": lang,
|
|
1547
1583
|
"error": str(e),
|
|
1548
1584
|
"classes": [],
|
|
1549
1585
|
"functions": [],
|
package/python/mcp_server.py
CHANGED
|
@@ -106,7 +106,10 @@ def _get_graph(repo_path: str):
|
|
|
106
106
|
now = time.time()
|
|
107
107
|
entry = _cache.get(resolved)
|
|
108
108
|
|
|
109
|
-
# Fast path: bypass filesystem walk if checked within last 5 seconds
|
|
109
|
+
# Fast path: bypass filesystem walk if checked within last 5 seconds.
|
|
110
|
+
# This intentionally creates a 5s "blind window" where new/deleted/modified
|
|
111
|
+
# files are not instantly detected, drastically reducing disk I/O on large repos
|
|
112
|
+
# when the MCP client rapid-fires multiple queries concurrently.
|
|
110
113
|
if entry and (now - entry.last_checked_time < 5.0):
|
|
111
114
|
return entry.scan, entry.repo_graph
|
|
112
115
|
|
package/python/query_engine.py
CHANGED
|
@@ -17,6 +17,7 @@ No LLM. No embeddings. Pure text + graph.
|
|
|
17
17
|
import math
|
|
18
18
|
import re
|
|
19
19
|
from dataclasses import dataclass, field
|
|
20
|
+
from impact_analysis import _is_test_file
|
|
20
21
|
|
|
21
22
|
from scanner import scan_repo
|
|
22
23
|
from import_parser import parse_imports
|
|
@@ -93,12 +94,16 @@ def _keyword_score(query_terms: list[str], file_path: str, idf_weights: dict[str
|
|
|
93
94
|
score = 0.0
|
|
94
95
|
|
|
95
96
|
for term in query_terms:
|
|
96
|
-
# Exact match
|
|
97
|
+
# Exact match on filename
|
|
97
98
|
if term in filename_toks:
|
|
98
99
|
score += 2.0 * idf_weights.get(term, 1.0) # Massive signal
|
|
99
100
|
matched.append(term)
|
|
100
|
-
# Substring match on filename
|
|
101
|
-
elif len(term) >= 2 and any(
|
|
101
|
+
# Substring or abbreviation match on filename
|
|
102
|
+
elif len(term) >= 2 and any(
|
|
103
|
+
term in ft or ft in term or ft.startswith(term) or term.startswith(ft) or
|
|
104
|
+
all(c in iter(ft) for c in term)
|
|
105
|
+
for ft in filename_toks
|
|
106
|
+
):
|
|
102
107
|
score += 1.5 * idf_weights.get(term, 1.0)
|
|
103
108
|
matched.append(term)
|
|
104
109
|
# Exact match on path
|
|
@@ -106,8 +111,12 @@ def _keyword_score(query_terms: list[str], file_path: str, idf_weights: dict[str
|
|
|
106
111
|
score += 0.8 * idf_weights.get(term, 1.0) # Strong signal
|
|
107
112
|
if term not in matched:
|
|
108
113
|
matched.append(term)
|
|
109
|
-
# Substring match on path
|
|
110
|
-
elif len(term) >= 2 and any(
|
|
114
|
+
# Substring or abbreviation match on path
|
|
115
|
+
elif len(term) >= 2 and any(
|
|
116
|
+
term in pt or pt in term or pt.startswith(term) or term.startswith(pt) or
|
|
117
|
+
all(c in iter(pt) for c in term)
|
|
118
|
+
for pt in path_toks
|
|
119
|
+
):
|
|
111
120
|
score += 0.4 * idf_weights.get(term, 1.0)
|
|
112
121
|
if term not in matched:
|
|
113
122
|
matched.append(term)
|
|
@@ -247,7 +256,7 @@ def _content_score(query_terms: list[str], file_path: str, file_content: str, id
|
|
|
247
256
|
def _apply_neighbor_bonus(
|
|
248
257
|
scores: dict[str, float],
|
|
249
258
|
repo_graph: RepoGraph,
|
|
250
|
-
boost: float = 0.
|
|
259
|
+
boost: float = 0.2,
|
|
251
260
|
depth: int = 1,
|
|
252
261
|
) -> dict[str, float]:
|
|
253
262
|
"""
|
|
@@ -353,8 +362,9 @@ def query(
|
|
|
353
362
|
content_scores[path] = cscore
|
|
354
363
|
matched_terms[path] = list(set(kterms + cterms))
|
|
355
364
|
|
|
356
|
-
# Base combination:
|
|
357
|
-
|
|
365
|
+
# Base combination: heavily weight filename/path matches (70%) over content matches (30%)
|
|
366
|
+
# This prevents transitive consumers of a file from out-ranking the file itself
|
|
367
|
+
combined = (kscore * 0.7) + (cscore * 0.3)
|
|
358
368
|
|
|
359
369
|
# Tiebreaker: Slightly penalize deep paths so src/x.py wins over build/npm/python/x.py
|
|
360
370
|
depth_penalty = 0.0001 * path.count("/")
|
|
@@ -383,7 +393,6 @@ def query(
|
|
|
383
393
|
|
|
384
394
|
# Down-rank test files unless the user specifically searched for tests
|
|
385
395
|
is_test_query = "test" in q.lower() or "spec" in q.lower()
|
|
386
|
-
from impact_analysis import _is_test_file
|
|
387
396
|
for r in results:
|
|
388
397
|
if _is_test_file(r.path) and not is_test_query:
|
|
389
398
|
r.total_score *= 0.5
|
package/requirements.txt
ADDED