contextl 1.2.20 → 1.2.21

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.21",
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):
@@ -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():
@@ -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