contextl 1.2.20 → 1.2.22

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.20",
3
+ "version": "1.2.22",
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",
@@ -127,6 +127,9 @@ def analyze_impact(
127
127
  Raises:
128
128
  ValueError: If target_file is not found in the graph.
129
129
  """
130
+ if not target_file:
131
+ raise ValueError("target_file cannot be empty")
132
+
130
133
  if target_file not in repo_graph.nodes:
131
134
  raise ValueError(
132
135
  f"File not found in repository graph: {target_file}. "
@@ -191,6 +194,8 @@ def analyze_impact(
191
194
  target_node = repo_graph.nodes.get(target_file)
192
195
  in_degree = target_node.in_degree if target_node else 0
193
196
  report.risk_score = (in_degree * 0.5) + (len(report.transitively_affected) * 0.2)
197
+ if report.cycle_detected:
198
+ report.risk_score += len(report.cycle_members) * 0.8
194
199
 
195
200
  return report
196
201
 
@@ -567,7 +567,7 @@ def _find_file_in_repo(
567
567
  # Fallback for Python cross-language imports (e.g. from tests to .ts/.js core logic)
568
568
  for ext in [".ts", ".tsx", ".js", ".jsx", ".java", ".go", ".rs", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".c"]:
569
569
  alt_suffix = "/" + candidate_str + ext
570
- alt_matches = [p for p in file_index if p.endswith(alt_suffix)]
570
+ alt_matches = [p for p in file_index if p.endswith(alt_suffix) or p == alt_suffix.lstrip("/")]
571
571
  if len(alt_matches) == 1:
572
572
  return alt_matches[0]
573
573
 
@@ -58,7 +58,7 @@ from import_parser import extract_skeleton
58
58
  @dataclass
59
59
  class _CacheEntry:
60
60
  repo_path: str # resolved absolute path
61
- mtime_hash: float # max mtime across all source files at build time
61
+ mtime_hash: tuple[float, int] # max mtime across all source files at build time, and total file count
62
62
  scan: object # ScanResult
63
63
  repo_graph: object # RepoGraph
64
64
  last_checked_time: float = 0.0
@@ -68,9 +68,10 @@ _cache_lock = threading.Lock()
68
68
  _cache: dict[str, _CacheEntry] = {}
69
69
 
70
70
 
71
- def _compute_mtime_hash(repo_path: str) -> float:
72
- """Walk repo_path and return the maximum file modification time."""
71
+ def _compute_mtime_hash(repo_path: str) -> tuple[float, int]:
72
+ """Walk repo_path and return (maximum file modification time, file count)."""
73
73
  max_mtime = 0.0
74
+ file_count = 0
74
75
  for dirpath, _, filenames in os.walk(repo_path):
75
76
  # Skip hidden dirs and common noise dirs
76
77
  dirpath_obj = Path(dirpath)
@@ -82,9 +83,10 @@ def _compute_mtime_hash(repo_path: str) -> float:
82
83
  mtime = os.path.getmtime(os.path.join(dirpath, fname))
83
84
  if mtime > max_mtime:
84
85
  max_mtime = mtime
86
+ file_count += 1
85
87
  except OSError:
86
88
  pass
87
- return max_mtime
89
+ return (max_mtime, file_count)
88
90
 
89
91
 
90
92
  def _get_graph(repo_path: str):
@@ -506,7 +508,7 @@ async def _handle_export_obsidian(args: dict) -> list[types.TextContent]:
506
508
  def _run_export_obsidian(repo_path: str, output_dir: str) -> dict:
507
509
  """Uses cached graph — pipeline runs only on first call or after file changes."""
508
510
  scan, repo_graph = _get_graph(repo_path)
509
- vault_path = export_obsidian_vault(repo_graph, output_dir)
511
+ vault_path = export_obsidian_vault(repo_path, output_dir)
510
512
 
511
513
  return {
512
514
  "repo": scan.root,
@@ -52,11 +52,15 @@ def _extract_file_context(file_path: str, extension: str, root_dir: str) -> tupl
52
52
  return explanation, outline, snippet
53
53
 
54
54
 
55
- def export_obsidian_vault(repo_graph: RepoGraph, output_dir: str) -> str:
55
+ def export_obsidian_vault(repo_path: str, output_dir: str) -> str:
56
56
  """
57
57
  Generates a markdown file for every node in the graph in a hierarchical folder structure,
58
58
  with Obsidian wikilinks connecting them based on imports.
59
59
  """
60
+ scan = scan_repo(repo_path)
61
+ parse = parse_imports(scan)
62
+ repo_graph = build_graph(scan, parse)
63
+
60
64
  out_path = Path(output_dir).resolve()
61
65
 
62
66
  if out_path.exists():
@@ -141,10 +145,6 @@ if __name__ == "__main__":
141
145
  repo_path = sys.argv[1]
142
146
  output_dir = sys.argv[2]
143
147
 
144
- scan = scan_repo(repo_path)
145
- parse = parse_imports(scan)
146
- repo_graph = build_graph(scan, parse)
147
-
148
- vault_path = export_obsidian_vault(repo_graph, output_dir)
148
+ vault_path = export_obsidian_vault(repo_path, output_dir)
149
149
  print(f"Obsidian Vault successfully generated at: {vault_path}")
150
150
  print("Open this folder in Obsidian to view your codebase graph!")
@@ -98,7 +98,7 @@ def _keyword_score(query_terms: list[str], file_path: str, idf_weights: dict[str
98
98
  score += 2.0 * idf_weights.get(term, 1.0) # Massive signal
99
99
  matched.append(term)
100
100
  # Substring match on filename
101
- elif len(term) >= 3 and any(term in ft for ft in filename_toks) or any(len(ft) >= 3 and ft in term for ft in filename_toks):
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):
102
102
  score += 1.5 * idf_weights.get(term, 1.0)
103
103
  matched.append(term)
104
104
  # Exact match on path
@@ -107,7 +107,7 @@ def _keyword_score(query_terms: list[str], file_path: str, idf_weights: dict[str
107
107
  if term not in matched:
108
108
  matched.append(term)
109
109
  # Substring match on path
110
- elif len(term) >= 3 and any(term in pt for pt in path_toks) or any(len(pt) >= 3 and pt in term for pt in path_toks):
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):
111
111
  score += 0.4 * idf_weights.get(term, 1.0)
112
112
  if term not in matched:
113
113
  matched.append(term)
@@ -186,7 +186,7 @@ def _content_score(query_terms: list[str], file_path: str, file_content: str, id
186
186
  for ct in content_tokens_list:
187
187
  if t == ct:
188
188
  count += 1
189
- elif len(t) >= 3 and (t in ct or (len(ct) >= 3 and ct in t)):
189
+ elif len(t) >= 2 and (t in ct or (len(ct) >= 2 and ct in t)):
190
190
  count += 1
191
191
  if count > 0:
192
192
  term_counts[t] = count
@@ -322,9 +322,9 @@ def query(
322
322
  for term in query_terms:
323
323
  if term in combined_tokens:
324
324
  document_frequency[term] += 1
325
- elif len(term) >= 3:
325
+ elif len(term) >= 2:
326
326
  for ct in combined_tokens:
327
- if term in ct or (len(ct) >= 3 and ct in term):
327
+ if term in ct or (len(ct) >= 2 and ct in term):
328
328
  document_frequency[term] += 1
329
329
  break
330
330
 
@@ -381,6 +381,13 @@ def query(
381
381
  matched_terms=matched_terms.get(path, []),
382
382
  ))
383
383
 
384
+ # Down-rank test files unless the user specifically searched for tests
385
+ is_test_query = "test" in query_str.lower() or "spec" in query_str.lower()
386
+ from impact_analysis import _is_test_file
387
+ for r in results:
388
+ if _is_test_file(r.path) and not is_test_query:
389
+ r.total_score *= 0.5
390
+
384
391
  # Sort by total score descending
385
392
  results.sort(key=lambda r: r.total_score, reverse=True)
386
393