contextl 1.2.19 → 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/README.md CHANGED
@@ -90,9 +90,11 @@ Walks the dependency graph upstream from any file to find every direct and trans
90
90
  Lists every source file `contextl` can see — useful for the agent to orient itself before doing anything else.
91
91
 
92
92
  ### `find_dead_files`
93
- *"Which files are never imported by anything?"*
93
+ Find files in the repository that are never imported by any other file (in-degree of 0 in the dependency graph).
94
94
 
95
- Finds unused files and dead code by analyzing the dependency graph for files with an in-degree of 0. Automatically filters out standard entry points (like `page.tsx` or `index.ts`) and test files.
95
+ *Note: The engine automatically detects and excludes standard entry points (e.g. `src/index.ts`, `main.py`, `app.tsx`) and test files from these results, as they are intentionally not imported by other files.*
96
+
97
+ **Arguments:**
96
98
 
97
99
  ```json
98
100
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextl",
3
- "version": "1.2.19",
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",
@@ -163,6 +163,15 @@ def build_graph(scan_result, parse_result: ParseResult) -> RepoGraph:
163
163
  # Compute PageRank (importance score)
164
164
  try:
165
165
  pagerank = nx.pagerank(G, alpha=0.85)
166
+
167
+ # Penalize cycle-locked nodes to prevent inflation
168
+ sccs = [scc for scc in nx.strongly_connected_components(G) if len(scc) > 1]
169
+ for scc in sccs:
170
+ for node in scc:
171
+ # If a node only has incoming edges from within its own cycle, dampen it
172
+ predecessors = set(G.predecessors(node))
173
+ if predecessors.issubset(scc):
174
+ pagerank[node] *= 0.1
166
175
  except nx.PowerIterationFailedConvergence:
167
176
  pagerank = {n: 1.0 / len(G.nodes) for n in G.nodes}
168
177
 
@@ -40,14 +40,24 @@ class ImpactReport:
40
40
  transitively_affected: list[ImpactedFile] = field(default_factory=list)
41
41
  test_files: list[str] = field(default_factory=list)
42
42
  total_affected: int = 0
43
+ cycle_detected: bool = False
44
+ cycle_members: list[str] = field(default_factory=list)
45
+ risk_score: float = 0.0
43
46
 
44
47
  def summary(self) -> str:
45
48
  lines = [
46
49
  f"Impact analysis for: {self.target}",
47
50
  f"Total affected files: {self.total_affected}",
51
+ f"Risk score: {self.risk_score:.1f}",
48
52
  "",
49
53
  ]
50
54
 
55
+ if self.cycle_detected:
56
+ lines.append("⚠️ WARNING: Circular dependency detected ⚠️")
57
+ lines.append(f"This file is part of a cycle containing {len(self.cycle_members)} files.")
58
+ lines.append("Members: " + ", ".join(self.cycle_members))
59
+ lines.append("")
60
+
51
61
  if self.directly_affected:
52
62
  lines.append("Directly affected (import this file):")
53
63
  for f in self.directly_affected:
@@ -117,6 +127,9 @@ def analyze_impact(
117
127
  Raises:
118
128
  ValueError: If target_file is not found in the graph.
119
129
  """
130
+ if not target_file:
131
+ raise ValueError("target_file cannot be empty")
132
+
120
133
  if target_file not in repo_graph.nodes:
121
134
  raise ValueError(
122
135
  f"File not found in repository graph: {target_file}. "
@@ -170,6 +183,20 @@ def analyze_impact(
170
183
 
171
184
  report.total_affected = len(report.directly_affected) + len(report.transitively_affected)
172
185
 
186
+ import networkx as nx
187
+ sccs = [scc for scc in nx.strongly_connected_components(repo_graph.graph) if len(scc) > 1]
188
+ for scc in sccs:
189
+ if target_file in scc:
190
+ report.cycle_detected = True
191
+ report.cycle_members = list(scc)
192
+ break
193
+
194
+ target_node = repo_graph.nodes.get(target_file)
195
+ in_degree = target_node.in_degree if target_node else 0
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
199
+
173
200
  return report
174
201
 
175
202
 
@@ -563,8 +563,15 @@ def _find_file_in_repo(
563
563
  for path in file_index:
564
564
  if path.endswith(suffix) or path == py_ext:
565
565
  return path
566
+
567
+ # Fallback for Python cross-language imports (e.g. from tests to .ts/.js core logic)
568
+ for ext in [".ts", ".tsx", ".js", ".jsx", ".java", ".go", ".rs", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".c"]:
569
+ alt_suffix = "/" + candidate_str + ext
570
+ alt_matches = [p for p in file_index if p.endswith(alt_suffix) or p == alt_suffix.lstrip("/")]
571
+ if len(alt_matches) == 1:
572
+ return alt_matches[0]
573
+
566
574
  return None
567
-
568
575
  for ext in [".tsx", ".ts", ".jsx", ".js"]:
569
576
  with_ext = candidate_str + ext
570
577
  if with_ext in file_index:
@@ -578,13 +585,7 @@ def _find_file_in_repo(
578
585
  if mod_path in file_index:
579
586
  return mod_path
580
587
 
581
- if extension == ".py":
582
- candidate_suffix = "/" + candidate_str + ".py"
583
- matches = [p for p in file_index if p.endswith(candidate_suffix)]
584
- if len(matches) == 1:
585
- return matches[0]
586
588
 
587
- return None
588
589
 
589
590
 
590
591
  def _load_tsconfig_paths(tsconfig_path: Path) -> dict:
@@ -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):
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):
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:
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:
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