contextl 1.2.17 → 1.2.19

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/bin/contextl.js CHANGED
@@ -106,7 +106,7 @@ function pipInstall(python, packageName) {
106
106
  function ensureDeps(python) {
107
107
  const required = [
108
108
  { importName: "networkx", pipName: "networkx" },
109
- { importName: "mcp", pipName: "mcp" },
109
+ { importName: "mcp", pipName: "mcp>=1.0.0" },
110
110
  { importName: "tree_sitter", pipName: "tree-sitter>=0.22.0" },
111
111
  { importName: "tree_sitter_python", pipName: "tree-sitter-python>=0.22.0" },
112
112
  { importName: "tree_sitter_javascript", pipName: "tree-sitter-javascript>=0.22.0" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextl",
3
- "version": "1.2.17",
3
+ "version": "1.2.19",
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",
@@ -33,6 +33,7 @@
33
33
  "python/",
34
34
  "README.md"
35
35
  ],
36
+
36
37
  "engines": {
37
38
  "node": ">=18"
38
39
  },
@@ -58,13 +58,16 @@ def _run_git(args: list[str], cwd: str) -> list[str]:
58
58
 
59
59
 
60
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
61
+ try:
62
+ result = subprocess.run(
63
+ ["git", "rev-parse", "--is-inside-work-tree"],
64
+ cwd=repo_path,
65
+ capture_output=True,
66
+ text=True,
67
+ )
68
+ return result.returncode == 0
69
+ except FileNotFoundError:
70
+ return False
68
71
 
69
72
 
70
73
  def get_changed_files(
@@ -34,6 +34,10 @@ class FileNode:
34
34
  out_degree: int = 0 # files this imports
35
35
  centrality: float = 0.0 # PageRank score
36
36
 
37
+ # Text data (computed once)
38
+ content: str = ""
39
+ doc_length: int = 0
40
+
37
41
 
38
42
  @dataclass
39
43
  class RepoGraph:
@@ -126,16 +130,30 @@ def build_graph(scan_result, parse_result: ParseResult) -> RepoGraph:
126
130
  """
127
131
  G = nx.DiGraph()
128
132
 
133
+ import re
134
+
129
135
  # Add all scanned files as nodes
130
136
  file_nodes: dict[str, FileNode] = {}
131
137
  for f in scan_result.files:
138
+ abs_path = str(Path(scan_result.root) / f.path)
139
+ try:
140
+ with open(abs_path, "r", encoding="utf-8") as f_obj:
141
+ content = f_obj.read()
142
+ dl = len(re.findall(r"[a-z0-9]+", content.lower()))
143
+ except Exception:
144
+ content = ""
145
+ dl = 0
146
+
132
147
  node = FileNode(
133
148
  path=f.path,
134
149
  extension=f.extension,
135
150
  size_bytes=f.size_bytes,
151
+ content=content,
152
+ doc_length=dl,
136
153
  )
137
154
  file_nodes[f.path] = node
138
- G.add_node(f.path, **vars(node))
155
+ # Add basic node properties to nx graph
156
+ G.add_node(f.path, path=node.path, extension=node.extension, size_bytes=node.size_bytes)
139
157
 
140
158
  # Add edges from import relationships
141
159
  for rel in parse_result.relationships:
@@ -373,10 +373,8 @@ async def _handle_query(args: dict) -> list[types.TextContent]:
373
373
  query_str = args.get("query", "")
374
374
  top_n = min(int(args.get("top_n", 5)), 20)
375
375
 
376
- # Run the engine (synchronous — wrap in thread to stay async-safe)
377
- loop = asyncio.get_event_loop()
378
376
  try:
379
- result = await loop.run_in_executor(None, _run_query, repo_path, query_str, top_n)
377
+ result = await asyncio.to_thread(_run_query, repo_path, query_str, top_n)
380
378
  except Exception as e:
381
379
  result = {"error": str(e), "query": query_str, "repo": repo_path, "results": []}
382
380
 
@@ -409,9 +407,8 @@ def _run_query(repo_path: str, query_str: str, top_n: int) -> dict:
409
407
  async def _handle_scan(args: dict) -> list[types.TextContent]:
410
408
  repo_path = args.get("repo_path", "")
411
409
 
412
- loop = asyncio.get_event_loop()
413
410
  try:
414
- result = await loop.run_in_executor(None, _run_scan, repo_path)
411
+ result = await asyncio.to_thread(_run_scan, repo_path)
415
412
  except Exception as e:
416
413
  result = {"error": str(e), "repo": repo_path}
417
414
 
@@ -436,9 +433,8 @@ async def _handle_impact(args: dict) -> list[types.TextContent]:
436
433
  target_file = args.get("target_file", "")
437
434
  max_depth = min(int(args.get("max_depth", 5)), 10)
438
435
 
439
- loop = asyncio.get_event_loop()
440
436
  try:
441
- result = await loop.run_in_executor(None, _run_impact, repo_path, target_file, max_depth)
437
+ result = await asyncio.to_thread(_run_impact, repo_path, target_file, max_depth)
442
438
  except Exception as e:
443
439
  result = {"error": str(e), "repo": repo_path, "target_file": target_file}
444
440
 
@@ -474,9 +470,8 @@ def _run_impact(repo_path: str, target_file: str, max_depth: int) -> dict:
474
470
  async def _handle_dead_files(args: dict) -> list[types.TextContent]:
475
471
  repo_path = args.get("repo_path", "")
476
472
 
477
- loop = asyncio.get_event_loop()
478
473
  try:
479
- result = await loop.run_in_executor(None, _run_dead_files, repo_path)
474
+ result = await asyncio.to_thread(_run_dead_files, repo_path)
480
475
  except Exception as e:
481
476
  result = {"error": str(e), "repo": repo_path}
482
477
 
@@ -500,9 +495,8 @@ async def _handle_export_obsidian(args: dict) -> list[types.TextContent]:
500
495
  repo_path = args.get("repo_path", "")
501
496
  output_dir = args.get("output_dir", "")
502
497
 
503
- loop = asyncio.get_event_loop()
504
498
  try:
505
- result = await loop.run_in_executor(None, _run_export_obsidian, repo_path, output_dir)
499
+ result = await asyncio.to_thread(_run_export_obsidian, repo_path, output_dir)
506
500
  except Exception as e:
507
501
  result = {"error": str(e), "repo": repo_path, "output_dir": output_dir}
508
502
 
@@ -527,10 +521,9 @@ async def _handle_review_changes(args: dict) -> list[types.TextContent]:
527
521
  include_staged = bool(args.get("staged", True))
528
522
  include_unstaged = bool(args.get("unstaged", True))
529
523
 
530
- loop = asyncio.get_event_loop()
531
524
  try:
532
- result = await loop.run_in_executor(
533
- None, _run_review_changes, repo_path, include_staged, include_unstaged
525
+ result = await asyncio.to_thread(
526
+ _run_review_changes, repo_path, include_staged, include_unstaged
534
527
  )
535
528
  except Exception as e:
536
529
  result = {"error": str(e), "repo": repo_path}
@@ -564,9 +557,8 @@ def _run_review_changes(repo_path: str, include_staged: bool, include_unstaged:
564
557
  async def _handle_get_skeleton(args: dict) -> list[types.TextContent]:
565
558
  file_path = args.get("file_path", "")
566
559
 
567
- loop = asyncio.get_event_loop()
568
560
  try:
569
- result = await loop.run_in_executor(None, extract_skeleton, file_path)
561
+ result = await asyncio.to_thread(extract_skeleton, file_path)
570
562
  except Exception as e:
571
563
  result = {"error": str(e), "file": file_path}
572
564
 
@@ -13,21 +13,18 @@ import re
13
13
  from pathlib import Path
14
14
 
15
15
  from scanner import scan_repo
16
- from import_parser import parse_imports
16
+ from import_parser import parse_imports, extract_skeleton
17
17
  from graph_builder import build_graph, RepoGraph
18
18
 
19
-
20
- PYTHON_DOCSTRING_PATTERN = re.compile(r'^\s*["\']{3}(.*?)["\']{3}', re.DOTALL)
21
- JS_DOC_PATTERN = re.compile(r'^\s*/\*\*(.*?)\*/', re.DOTALL)
22
- OUTLINE_PATTERN = re.compile(r'^\s*(?:export\s+)?(?:class|def|function|interface|type)\s+([a-zA-Z0-9_]+)', re.MULTILINE)
23
-
24
19
  def _extract_file_context(file_path: str, extension: str, root_dir: str) -> tuple[str, list[str], str]:
25
20
  """Returns (explanation, outline_symbols, snippet)."""
26
21
  explanation = "*No explanation provided in source code.*"
27
22
  outline = []
28
23
  snippet = ""
24
+ abs_path = str(Path(root_dir) / file_path)
25
+
29
26
  try:
30
- content = (Path(root_dir) / file_path).read_text(encoding="utf-8")
27
+ content = Path(abs_path).read_text(encoding="utf-8")
31
28
 
32
29
  # 1. Snippet (first 20 lines)
33
30
  lines = content.splitlines()
@@ -35,21 +32,20 @@ def _extract_file_context(file_path: str, extension: str, root_dir: str) -> tupl
35
32
  if len(lines) > 20:
36
33
  snippet += "\n..."
37
34
 
38
- # 2. Explanation
39
- if extension == ".py":
40
- match = PYTHON_DOCSTRING_PATTERN.search(content)
41
- if match:
42
- explanation = match.group(1).strip()
43
- elif extension in {".ts", ".tsx", ".js", ".jsx", ".java"}:
44
- match = JS_DOC_PATTERN.search(content)
45
- if match:
46
- clean_lines = [line.strip().lstrip('*').strip() for line in match.group(1).split('\n')]
47
- explanation = "\n".join(clean_lines).strip()
35
+ # 2 & 3. Explanation and Outline from Tree-Sitter
36
+ skeleton = extract_skeleton(abs_path)
37
+
38
+ if "error" not in skeleton:
39
+ # Grab the first docstring if it exists
40
+ if skeleton["docstrings"]:
41
+ explanation = list(skeleton["docstrings"].values())[0]
42
+
43
+ # Collect classes and functions
44
+ for cls in skeleton["classes"]:
45
+ outline.append(cls["name"])
46
+ for fn in skeleton["functions"]:
47
+ outline.append(fn["name"])
48
48
 
49
- # 3. Outline
50
- for match in OUTLINE_PATTERN.finditer(content):
51
- outline.append(match.group(1))
52
-
53
49
  except Exception:
54
50
  pass
55
51
 
@@ -296,48 +296,37 @@ def query(
296
296
  return []
297
297
 
298
298
  # --- Pre-computation: Global TF-IDF & BM25 ---
299
- import pathlib
300
299
  total_files = len(repo_graph.nodes)
301
300
  document_frequency = {term: 0 for term in query_terms}
302
301
 
303
- file_contents = {}
304
- doc_lengths = {}
305
302
  total_tokens_count = 0
306
303
 
307
304
  for path, node in repo_graph.nodes.items():
308
- abs_path = node.path if hasattr(node, "absolute_path") else str(__import__("pathlib").Path(repo_graph.root) / path)
309
- try:
310
- content = open(abs_path, encoding="utf-8").read()
311
- file_contents[path] = content
312
-
313
- raw_tokens = re.findall(r"[a-z0-9]+", content.lower())
314
- dl = len(raw_tokens)
315
- doc_lengths[path] = dl
316
- total_tokens_count += dl
317
-
318
- content_tokens = set(raw_tokens)
305
+ content = node.content
306
+ dl = node.doc_length
307
+ total_tokens_count += dl
308
+
309
+ raw_tokens = re.findall(r"[a-z0-9]+", content.lower())
310
+ content_tokens = set(raw_tokens)
311
+
312
+ if path.endswith(".java"):
313
+ camel_tokens = []
314
+ for word in re.findall(r"[a-zA-Z]+", content):
315
+ if any(c.isupper() for c in word):
316
+ sub = re.sub(r"([A-Z])", r" \1", word).lower()
317
+ camel_tokens.extend(re.findall(r"[a-z0-9]+", sub))
318
+ content_tokens.update([t for t in camel_tokens if len(t) > 1])
319
319
 
320
- if path.endswith(".java"):
321
- camel_tokens = []
322
- for word in re.findall(r"[a-zA-Z]+", content):
323
- if any(c.isupper() for c in word):
324
- sub = re.sub(r"([A-Z])", r" \1", word).lower()
325
- camel_tokens.extend(re.findall(r"[a-z0-9]+", sub))
326
- content_tokens.update([t for t in camel_tokens if len(t) > 1])
327
-
328
- path_tokens = set(_path_tokens(path))
329
- combined_tokens = content_tokens.union(path_tokens)
330
- for term in query_terms:
331
- if term in combined_tokens:
332
- document_frequency[term] += 1
333
- elif len(term) > 3:
334
- for ct in combined_tokens:
335
- if term in ct:
336
- document_frequency[term] += 1
337
- break
338
- except Exception:
339
- file_contents[path] = ""
340
- doc_lengths[path] = 0
320
+ path_tokens = set(_path_tokens(path))
321
+ combined_tokens = content_tokens.union(path_tokens)
322
+ for term in query_terms:
323
+ if term in combined_tokens:
324
+ document_frequency[term] += 1
325
+ elif len(term) > 3:
326
+ for ct in combined_tokens:
327
+ if term in ct:
328
+ document_frequency[term] += 1
329
+ break
341
330
 
342
331
  avgdl = total_tokens_count / total_files if total_files > 0 else 1.0
343
332
 
@@ -357,8 +346,8 @@ def query(
357
346
 
358
347
  for path, node in repo_graph.nodes.items():
359
348
  kscore, kterms = _keyword_score(query_terms, path, idf_weights)
360
- dl = doc_lengths.get(path, 0)
361
- cscore, cterms = _content_score(query_terms, path, file_contents.get(path, ""), idf_weights, dl, avgdl)
349
+ dl = node.doc_length
350
+ cscore, cterms = _content_score(query_terms, path, node.content, idf_weights, dl, avgdl)
362
351
 
363
352
  keyword_scores[path] = kscore
364
353
  content_scores[path] = cscore
@@ -373,10 +362,10 @@ def query(
373
362
 
374
363
  base_scores[path] = combined
375
364
 
376
- # --- Pass 3: neighbor bonus ---
365
+ # --- Pass 2: neighbor bonus ---
377
366
  boosted_scores = _apply_neighbor_bonus(base_scores, repo_graph)
378
367
 
379
- # --- Pass 4: centrality tiebreaker ---
368
+ # --- Pass 3: centrality tiebreaker ---
380
369
  results = []
381
370
  for path, node in repo_graph.nodes.items():
382
371
  neighbor_bonus = boosted_scores[path] - base_scores[path]