contextl 1.2.16 → 1.2.18

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.16",
3
+ "version": "1.2.18",
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",
@@ -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:
@@ -1367,6 +1367,85 @@ def _skeleton_rust(root: "Node") -> dict:
1367
1367
  return {"classes": classes, "functions": functions, "exports": exports, "docstrings": docstrings}
1368
1368
 
1369
1369
 
1370
+
1371
+ def _skeleton_go(root: "Node") -> dict:
1372
+ classes = []
1373
+ functions = []
1374
+ exports = []
1375
+ docstrings = {}
1376
+
1377
+ def visit_fn(node: "Node") -> dict:
1378
+ name = ""
1379
+ params = ""
1380
+ ret_type = ""
1381
+ is_method = (node.type == "method_declaration")
1382
+ receiver = ""
1383
+
1384
+ for child in node.children:
1385
+ if child.type == "identifier" or child.type == "field_identifier":
1386
+ if not name:
1387
+ name = _node_text(child)
1388
+ elif child.type == "parameter_list":
1389
+ if is_method and not receiver:
1390
+ receiver = _node_text(child)
1391
+ else:
1392
+ params = _node_text(child)
1393
+ elif child.type == "type_identifier":
1394
+ ret_type = _node_text(child)
1395
+
1396
+ return {"name": name, "params": params, "return_type": ret_type, "visibility": "pub" if name and name[0].isupper() else "private", "is_async": False, "decorators": [], "receiver": receiver}
1397
+
1398
+ def visit(node: "Node"):
1399
+ if node.type in ("function_declaration", "method_declaration"):
1400
+ functions.append(visit_fn(node))
1401
+ elif node.type == "type_spec":
1402
+ name = ""
1403
+ for child in node.children:
1404
+ if child.type == "type_identifier":
1405
+ name = _node_text(child)
1406
+ elif child.type == "struct_type" or child.type == "interface_type":
1407
+ if name:
1408
+ classes.append({"name": name, "fields": [], "methods": [], "extends": "", "implements": [], "properties": []})
1409
+ for child in node.children:
1410
+ visit(child)
1411
+
1412
+ visit(root)
1413
+ return {"classes": classes, "functions": functions, "exports": exports, "docstrings": docstrings}
1414
+
1415
+ def _skeleton_cpp(root: "Node") -> dict:
1416
+ classes = []
1417
+ functions = []
1418
+ exports = []
1419
+ docstrings = {}
1420
+
1421
+ def visit_fn(node: "Node") -> dict:
1422
+ name = ""
1423
+ params = ""
1424
+ for child in node.children:
1425
+ if child.type == "function_declarator":
1426
+ for sub in child.children:
1427
+ if sub.type == "identifier" or sub.type == "field_identifier":
1428
+ name = _node_text(sub)
1429
+ elif sub.type == "parameter_list":
1430
+ params = _node_text(sub)
1431
+ return {"name": name, "params": params, "return_type": "", "visibility": "pub", "is_async": False, "decorators": []}
1432
+
1433
+ def visit(node: "Node"):
1434
+ if node.type == "function_definition":
1435
+ functions.append(visit_fn(node))
1436
+ elif node.type in ("class_specifier", "struct_specifier"):
1437
+ name = ""
1438
+ for child in node.children:
1439
+ if child.type == "type_identifier":
1440
+ name = _node_text(child)
1441
+ if name:
1442
+ classes.append({"name": name, "fields": [], "methods": [], "extends": "", "implements": [], "properties": []})
1443
+ for child in node.children:
1444
+ visit(child)
1445
+
1446
+ visit(root)
1447
+ return {"classes": classes, "functions": functions, "exports": exports, "docstrings": docstrings}
1448
+
1370
1449
  _SKELETON_HANDLERS = {
1371
1450
  ".py": _skeleton_python,
1372
1451
  ".ts": _skeleton_ts_js,
@@ -1375,6 +1454,12 @@ _SKELETON_HANDLERS = {
1375
1454
  ".jsx": _skeleton_ts_js,
1376
1455
  ".java": _skeleton_java,
1377
1456
  ".rs": _skeleton_rust,
1457
+ ".go": _skeleton_go,
1458
+ ".cpp": _skeleton_cpp,
1459
+ ".h": _skeleton_cpp,
1460
+ ".hpp": _skeleton_cpp,
1461
+ ".cc": _skeleton_cpp,
1462
+ ".c": _skeleton_cpp,
1378
1463
  }
1379
1464
 
1380
1465
 
@@ -14,7 +14,7 @@ Tools exposed:
14
14
  query_repo — rank files by relevance to a natural-language query
15
15
  scan_repo — list all source files the engine can see in a repo
16
16
  analyze_impact — find every file affected by changing a target file
17
- find_standalone_files — find unimported files with in-degree 0
17
+ find_dead_files — find unimported files with in-degree 0
18
18
  export_obsidian_vault — export repo graph to an Obsidian markdown vault
19
19
 
20
20
  Performance:
@@ -61,10 +61,11 @@ class _CacheEntry:
61
61
  mtime_hash: float # max mtime across all source files at build time
62
62
  scan: object # ScanResult
63
63
  repo_graph: object # RepoGraph
64
+ last_checked_time: float = 0.0
64
65
 
65
66
 
66
67
  _cache_lock = threading.Lock()
67
- _cache: Optional[_CacheEntry] = None
68
+ _cache: dict[str, _CacheEntry] = {}
68
69
 
69
70
 
70
71
  def _compute_mtime_hash(repo_path: str) -> float:
@@ -95,16 +96,24 @@ def _get_graph(repo_path: str):
95
96
  On subsequent calls with unchanged files: returns instantly from cache.
96
97
  """
97
98
  global _cache
99
+ import time
98
100
 
99
101
  resolved = str(Path(repo_path).resolve())
100
- mtime_hash = _compute_mtime_hash(resolved)
101
102
 
102
103
  with _cache_lock:
103
- if (_cache is not None
104
- and _cache.repo_path == resolved
105
- and _cache.mtime_hash == mtime_hash):
104
+ now = time.time()
105
+ entry = _cache.get(resolved)
106
+
107
+ # Fast path: bypass filesystem walk if checked within last 5 seconds
108
+ if entry and (now - entry.last_checked_time < 5.0):
109
+ return entry.scan, entry.repo_graph
110
+
111
+ mtime_hash = _compute_mtime_hash(resolved)
112
+
113
+ if entry and entry.mtime_hash == mtime_hash:
114
+ entry.last_checked_time = now
106
115
  print("[contextl] graph cache hit", file=sys.stderr, flush=True)
107
- return _cache.scan, _cache.repo_graph
116
+ return entry.scan, entry.repo_graph
108
117
 
109
118
  # Cache miss — build the pipeline
110
119
  print("[contextl] building graph...", file=sys.stderr, flush=True)
@@ -112,11 +121,12 @@ def _get_graph(repo_path: str):
112
121
  parse = parse_imports(scan)
113
122
  repo_graph = build_graph(scan, parse)
114
123
 
115
- _cache = _CacheEntry(
124
+ _cache[resolved] = _CacheEntry(
116
125
  repo_path=resolved,
117
126
  mtime_hash=mtime_hash,
118
127
  scan=scan,
119
128
  repo_graph=repo_graph,
129
+ last_checked_time=now,
120
130
  )
121
131
  print(f"[contextl] graph built: {scan.total_files} files", file=sys.stderr, flush=True)
122
132
  return scan, repo_graph
@@ -228,7 +238,7 @@ async def list_tools() -> list[types.Tool]:
228
238
  },
229
239
  ),
230
240
  types.Tool(
231
- name="find_standalone_files",
241
+ name="find_dead_files",
232
242
  description=(
233
243
  "Find files in the repository that are never imported by any other file "
234
244
  "(in-degree of 0 in the dependency graph). Automatically filters out "
@@ -340,8 +350,8 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
340
350
  if name == "analyze_impact":
341
351
  return await _handle_impact(arguments)
342
352
 
343
- if name == "find_standalone_files":
344
- return await _handle_standalone_files(arguments)
353
+ if name == "find_dead_files":
354
+ return await _handle_dead_files(arguments)
345
355
 
346
356
  if name == "export_obsidian_vault":
347
357
  return await _handle_export_obsidian(arguments)
@@ -363,10 +373,8 @@ async def _handle_query(args: dict) -> list[types.TextContent]:
363
373
  query_str = args.get("query", "")
364
374
  top_n = min(int(args.get("top_n", 5)), 20)
365
375
 
366
- # Run the engine (synchronous — wrap in thread to stay async-safe)
367
- loop = asyncio.get_event_loop()
368
376
  try:
369
- 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)
370
378
  except Exception as e:
371
379
  result = {"error": str(e), "query": query_str, "repo": repo_path, "results": []}
372
380
 
@@ -399,9 +407,8 @@ def _run_query(repo_path: str, query_str: str, top_n: int) -> dict:
399
407
  async def _handle_scan(args: dict) -> list[types.TextContent]:
400
408
  repo_path = args.get("repo_path", "")
401
409
 
402
- loop = asyncio.get_event_loop()
403
410
  try:
404
- result = await loop.run_in_executor(None, _run_scan, repo_path)
411
+ result = await asyncio.to_thread(_run_scan, repo_path)
405
412
  except Exception as e:
406
413
  result = {"error": str(e), "repo": repo_path}
407
414
 
@@ -426,9 +433,8 @@ async def _handle_impact(args: dict) -> list[types.TextContent]:
426
433
  target_file = args.get("target_file", "")
427
434
  max_depth = min(int(args.get("max_depth", 5)), 10)
428
435
 
429
- loop = asyncio.get_event_loop()
430
436
  try:
431
- 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)
432
438
  except Exception as e:
433
439
  result = {"error": str(e), "repo": repo_path, "target_file": target_file}
434
440
 
@@ -461,19 +467,18 @@ def _run_impact(repo_path: str, target_file: str, max_depth: int) -> dict:
461
467
  }
462
468
 
463
469
 
464
- async def _handle_standalone_files(args: dict) -> list[types.TextContent]:
470
+ async def _handle_dead_files(args: dict) -> list[types.TextContent]:
465
471
  repo_path = args.get("repo_path", "")
466
472
 
467
- loop = asyncio.get_event_loop()
468
473
  try:
469
- result = await loop.run_in_executor(None, _run_standalone_files, repo_path)
474
+ result = await asyncio.to_thread(_run_dead_files, repo_path)
470
475
  except Exception as e:
471
476
  result = {"error": str(e), "repo": repo_path}
472
477
 
473
478
  return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
474
479
 
475
480
 
476
- def _run_standalone_files(repo_path: str) -> dict:
481
+ def _run_dead_files(repo_path: str) -> dict:
477
482
  """Uses cached graph — pipeline runs only on first call or after file changes."""
478
483
  scan, repo_graph = _get_graph(repo_path)
479
484
  standalone_files = find_standalone_files(repo_graph)
@@ -490,9 +495,8 @@ async def _handle_export_obsidian(args: dict) -> list[types.TextContent]:
490
495
  repo_path = args.get("repo_path", "")
491
496
  output_dir = args.get("output_dir", "")
492
497
 
493
- loop = asyncio.get_event_loop()
494
498
  try:
495
- 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)
496
500
  except Exception as e:
497
501
  result = {"error": str(e), "repo": repo_path, "output_dir": output_dir}
498
502
 
@@ -517,10 +521,9 @@ async def _handle_review_changes(args: dict) -> list[types.TextContent]:
517
521
  include_staged = bool(args.get("staged", True))
518
522
  include_unstaged = bool(args.get("unstaged", True))
519
523
 
520
- loop = asyncio.get_event_loop()
521
524
  try:
522
- result = await loop.run_in_executor(
523
- 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
524
527
  )
525
528
  except Exception as e:
526
529
  result = {"error": str(e), "repo": repo_path}
@@ -554,9 +557,8 @@ def _run_review_changes(repo_path: str, include_staged: bool, include_unstaged:
554
557
  async def _handle_get_skeleton(args: dict) -> list[types.TextContent]:
555
558
  file_path = args.get("file_path", "")
556
559
 
557
- loop = asyncio.get_event_loop()
558
560
  try:
559
- result = await loop.run_in_executor(None, extract_skeleton, file_path)
561
+ result = await asyncio.to_thread(extract_skeleton, file_path)
560
562
  except Exception as e:
561
563
  result = {"error": str(e), "file": file_path}
562
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]
package/python/scanner.py CHANGED
@@ -3,7 +3,7 @@ Repository Intelligence Engine
3
3
  Step 1: Repository Scanner
4
4
 
5
5
  Walks a repository and discovers all relevant source files.
6
- Filters by supported extensions for the MVP (Next.js / React / TypeScript).
6
+ Filters by supported extensions across 9 language families.
7
7
  """
8
8
 
9
9
  import os
@@ -17,7 +17,8 @@ SUPPORTED_EXTENSIONS = {".tsx", ".ts", ".jsx", ".js", ".py", ".java", ".go", ".r
17
17
  ignore_dirs = {
18
18
  ".git", "node_modules", "__pycache__", "venv", ".venv",
19
19
  "env", ".env", ".next", ".nuxt", "out", "build", "dist",
20
- "coverage", ".nyc_output", ".pytest_cache", "npm", "pypi"
20
+ "coverage", ".nyc_output", ".pytest_cache", "npm", "pypi",
21
+ "vendor", "target", ".cargo", "Pods", "third_party"
21
22
  }
22
23
 
23
24
  def _should_ignore(path: str) -> bool: