contextl 1.2.16 → 1.2.17

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.16",
3
+ "version": "1.2.17",
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",
@@ -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)
@@ -461,19 +471,19 @@ def _run_impact(repo_path: str, target_file: str, max_depth: int) -> dict:
461
471
  }
462
472
 
463
473
 
464
- async def _handle_standalone_files(args: dict) -> list[types.TextContent]:
474
+ async def _handle_dead_files(args: dict) -> list[types.TextContent]:
465
475
  repo_path = args.get("repo_path", "")
466
476
 
467
477
  loop = asyncio.get_event_loop()
468
478
  try:
469
- result = await loop.run_in_executor(None, _run_standalone_files, repo_path)
479
+ result = await loop.run_in_executor(None, _run_dead_files, repo_path)
470
480
  except Exception as e:
471
481
  result = {"error": str(e), "repo": repo_path}
472
482
 
473
483
  return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
474
484
 
475
485
 
476
- def _run_standalone_files(repo_path: str) -> dict:
486
+ def _run_dead_files(repo_path: str) -> dict:
477
487
  """Uses cached graph — pipeline runs only on first call or after file changes."""
478
488
  scan, repo_graph = _get_graph(repo_path)
479
489
  standalone_files = find_standalone_files(repo_graph)
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: