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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextl",
3
- "version": "1.2.23",
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": {
@@ -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
- py_ext = candidate_str + ".py"
557
- if py_ext in file_index:
558
- return py_ext
559
- py_idx = candidate_str + "/__init__.py"
560
- if py_idx in file_index:
561
- return py_idx
562
- suffix = "/" + py_ext
563
- for path in file_index:
564
- if path.endswith(suffix) or path == py_ext:
565
- return path
566
-
567
- # Fallback for Python cross-language imports (e.g. from tests to .ts/.js core logic)
568
- for ext in [".ts", ".tsx", ".js", ".jsx", ".java", ".go", ".rs", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".c"]:
569
- alt_suffix = "/" + candidate_str + ext
570
- alt_matches = [p for p in file_index if p.endswith(alt_suffix) or p == alt_suffix.lstrip("/")]
571
- if len(alt_matches) == 1:
572
- return alt_matches[0]
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": ext.lstrip("."),
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": ext.lstrip("."),
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": ext.lstrip("."),
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": lang_map.get(ext, ext.lstrip(".")),
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": "unknown",
1582
+ "language": lang,
1547
1583
  "error": str(e),
1548
1584
  "classes": [],
1549
1585
  "functions": [],
@@ -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
 
@@ -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(term in ft for ft in filename_toks) or any(len(ft) >= 2 and ft in term for ft in filename_toks):
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(term in pt for pt in path_toks) or any(len(pt) >= 2 and pt in term for pt in path_toks):
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.35,
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: 50/50 keyword vs content
357
- combined = (kscore * 0.5) + (cscore * 0.5)
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
@@ -0,0 +1,10 @@
1
+ networkx
2
+ mcp>=1.0.0
3
+ tree-sitter>=0.22.0
4
+ tree-sitter-python>=0.22.0
5
+ tree-sitter-javascript>=0.22.0
6
+ tree-sitter-typescript>=0.22.0
7
+ tree-sitter-java>=0.22.0
8
+ tree-sitter-rust>=0.22.0
9
+ tree-sitter-go>=0.22.0
10
+ tree-sitter-cpp>=0.22.0