contextl 1.2.4 → 1.2.5

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.4",
3
+ "version": "1.2.5",
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",
@@ -92,19 +92,25 @@ def _extract_raw_imports(source_code: str, extension: str) -> list[str]:
92
92
  return list(set(paths))
93
93
 
94
94
 
95
- def _is_external(import_path: str, extension: str) -> bool:
95
+ def _is_external(import_path: str, extension: str, alias_map: dict[str, str] = None) -> bool:
96
96
  """Return True if this is an external package rather than a local file."""
97
97
  if extension in {".py", ".java"}:
98
98
  # For Python and Java, it's hard to tell without resolving,
99
99
  # so assume it's local and let the resolver fail if it's external.
100
100
  return False
101
101
 
102
- # Node.js: Local files start with . (relative) or @ followed by / (alias like @/)
103
- # but NOT @scope/package style from npm — those don't contain our alias prefix
102
+ # Node.js: Local files start with . (relative) or match an alias
104
103
  if import_path.startswith("./") or import_path.startswith("../"):
105
104
  return False
105
+
106
+ if alias_map:
107
+ for alias in alias_map:
108
+ if import_path.startswith(alias):
109
+ return False
110
+
106
111
  if import_path.startswith("@/"):
107
112
  return False
113
+
108
114
  return True # Everything else is an external package
109
115
 
110
116
 
@@ -212,16 +218,18 @@ def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
212
218
  Returns a map like {"@/": "frontend/"} or {"@/": ""}
213
219
  """
214
220
  root = Path(scan_result.root)
221
+ alias_map = {}
215
222
 
216
223
  # Look for tsconfig.json to find paths config
217
224
  for tsconfig in root.rglob("tsconfig.json"):
218
225
  try:
219
226
  import json
220
- data = json.loads(tsconfig.read_text())
227
+ # naive regex to strip comments from tsconfig before parsing
228
+ text = tsconfig.read_text()
229
+ text = re.sub(r'//.*?\n|/\*.*?\*/', '', text, flags=re.S)
230
+ data = json.loads(text)
221
231
  paths = data.get("compilerOptions", {}).get("paths", {})
222
- base_url = data.get("compilerOptions", {}).get("baseUrl", ".")
223
232
 
224
- alias_map = {}
225
233
  for alias, targets in paths.items():
226
234
  if not targets:
227
235
  continue
@@ -232,19 +240,19 @@ def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
232
240
  tsconfig_dir = tsconfig.parent.relative_to(root)
233
241
  prefix = str(tsconfig_dir / clean_target).lstrip("./")
234
242
  alias_map[clean_alias + "/"] = prefix + "/" if prefix else ""
235
-
236
- if alias_map:
237
- return alias_map
238
243
  except Exception:
239
244
  continue
240
245
 
241
- # Heuristic fallback: if all files share a common top-level dir, use that
242
- top_dirs = {Path(f.path).parts[0] for f in scan_result.files if Path(f.path).parts}
243
- if len(top_dirs) == 1:
244
- top = list(top_dirs)[0]
245
- return {"@/": top + "/"}
246
+ if not alias_map:
247
+ # Heuristic fallback: if all files share a common top-level dir, use that
248
+ top_dirs = {Path(f.path).parts[0] for f in scan_result.files if Path(f.path).parts}
249
+ if len(top_dirs) == 1:
250
+ top = list(top_dirs)[0]
251
+ alias_map["@/"] = top + "/"
252
+ else:
253
+ alias_map["@/"] = ""
246
254
 
247
- return {"@/": ""}
255
+ return alias_map
248
256
 
249
257
 
250
258
  def parse_imports(scan_result: ScanResult) -> ParseResult:
@@ -275,7 +283,7 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
275
283
  raw_imports = _extract_raw_imports(source_code, scanned_file.extension)
276
284
 
277
285
  for raw in raw_imports:
278
- if _is_external(raw, scanned_file.extension):
286
+ if _is_external(raw, scanned_file.extension, alias_map):
279
287
  result.unresolved.append((scanned_file.path, raw))
280
288
  continue
281
289
 
@@ -298,11 +306,8 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
298
306
  candidate = raw.replace(".", "/")
299
307
  else:
300
308
  # Node.js
301
- if raw.startswith("@/"):
302
- resolved_alias = _resolve_alias(raw, alias_map)
303
- if resolved_alias is None:
304
- result.unresolved.append((scanned_file.path, raw))
305
- continue
309
+ resolved_alias = _resolve_alias(raw, alias_map)
310
+ if resolved_alias is not None:
306
311
  candidate = resolved_alias
307
312
  else:
308
313
  candidate = _resolve_relative(raw, scanned_file.path)
@@ -14,6 +14,7 @@ Accepts a natural-language query and ranks files by relevance using:
14
14
  No LLM. No embeddings. Pure text + graph.
15
15
  """
16
16
 
17
+ import math
17
18
  import re
18
19
  from dataclasses import dataclass, field
19
20
 
@@ -30,6 +31,8 @@ STOP_WORDS = {
30
31
  "or", "is", "it", "this", "that", "with", "from", "how", "what",
31
32
  "where", "when", "which", "who", "make", "change", "update", "fix",
32
33
  "add", "remove", "edit", "modify", "the", "my", "our", "i", "we",
34
+ "string", "int", "boolean", "class", "function", "return", "import",
35
+ "public", "private", "logic", "handler", "data", "value"
33
36
  }
34
37
 
35
38
 
@@ -75,7 +78,7 @@ def _path_tokens(path: str) -> list[str]:
75
78
  return [t for t in tokens if t not in STOP_WORDS and len(t) > 1]
76
79
 
77
80
 
78
- def _keyword_score(query_terms: list[str], file_path: str) -> tuple[float, list[str]]:
81
+ def _keyword_score(query_terms: list[str], file_path: str, idf_weights: dict[str, float]) -> tuple[float, list[str]]:
79
82
  """
80
83
  Score how well query terms match the file path / name.
81
84
  Filename matches score higher than directory matches.
@@ -88,37 +91,39 @@ def _keyword_score(query_terms: list[str], file_path: str) -> tuple[float, list[
88
91
 
89
92
  for term in query_terms:
90
93
  if term in filename_toks:
91
- score += 1.0 # Strong signal: term in filename
94
+ score += idf_weights.get(term, 1.0) # Strong signal: term in filename
92
95
  matched.append(term)
93
96
  elif term in path_toks:
94
- score += 0.4 # Weaker signal: term in directory path
97
+ score += 0.4 * idf_weights.get(term, 1.0) # Weaker signal: term in directory path
95
98
  if term not in matched:
96
99
  matched.append(term)
97
100
 
98
- # Normalize by number of query terms
99
- if query_terms:
100
- score = score / len(query_terms)
101
+ # Normalize by max possible score (sum of all IDFs)
102
+ max_score = sum(idf_weights.get(t, 1.0) for t in query_terms)
103
+ if max_score > 0:
104
+ score = score / max_score
101
105
 
102
106
  return score, matched
103
107
 
104
108
 
105
- def _content_score(query_terms: list[str], file_path: str) -> tuple[float, list[str]]:
109
+ def _content_score(query_terms: list[str], file_content: str, idf_weights: dict[str, float]) -> tuple[float, list[str]]:
106
110
  """
107
111
  Score how often query terms appear in the file's source code.
108
- Uses term frequency, capped to avoid huge files dominating.
112
+ Uses TF-IDF weighting, capped to avoid huge files dominating.
109
113
  """
110
- try:
111
- content = open(file_path, encoding="utf-8").read().lower()
112
- except Exception:
113
- return 0.0, []
114
-
115
- content_tokens = set(re.findall(r"[a-z0-9]+", content))
114
+ content_tokens = set(re.findall(r"[a-z0-9]+", file_content))
116
115
  matched = [t for t in query_terms if t in content_tokens]
117
116
 
118
117
  if not query_terms:
119
118
  return 0.0, []
120
119
 
121
- score = len(matched) / len(query_terms)
120
+ score = sum(idf_weights.get(t, 1.0) for t in matched)
121
+ max_score = sum(idf_weights.get(t, 1.0) for t in query_terms)
122
+ if max_score > 0:
123
+ score = score / max_score
124
+ else:
125
+ score = 0.0
126
+
122
127
  return score, matched
123
128
 
124
129
 
@@ -173,15 +178,38 @@ def query(
173
178
  if not query_terms:
174
179
  return []
175
180
 
181
+ # --- Pre-computation: Global TF-IDF ---
182
+ total_files = len(repo_graph.nodes)
183
+ document_frequency = {term: 0 for term in query_terms}
184
+
185
+ file_contents = {}
186
+ for path, node in repo_graph.nodes.items():
187
+ abs_path = node.path if hasattr(node, "absolute_path") else str(__import__("pathlib").Path(repo_graph.root) / path)
188
+ try:
189
+ content = open(abs_path, encoding="utf-8").read().lower()
190
+ file_contents[path] = content
191
+ content_tokens = set(re.findall(r"[a-z0-9]+", content))
192
+ path_tokens = set(_path_tokens(path))
193
+ combined_tokens = content_tokens.union(path_tokens)
194
+ for term in query_terms:
195
+ if term in combined_tokens:
196
+ document_frequency[term] += 1
197
+ except Exception:
198
+ file_contents[path] = ""
199
+
200
+ idf_weights = {}
201
+ for term in query_terms:
202
+ df = document_frequency[term]
203
+ idf_weights[term] = math.log(total_files / (1 + df)) if total_files > 0 else 1.0
204
+
176
205
  # --- Pass 1: keyword + content scores per file ---
177
206
  keyword_scores: dict[str, float] = {}
178
207
  content_scores: dict[str, float] = {}
179
208
  matched_terms: dict[str, list[str]] = {}
180
209
 
181
210
  for path, node in repo_graph.nodes.items():
182
- kscore, kterms = _keyword_score(query_terms, path)
183
- cscore, cterms = _content_score(query_terms, node.path if hasattr(node, "absolute_path") else
184
- str(__import__("pathlib").Path(repo_graph.root) / path))
211
+ kscore, kterms = _keyword_score(query_terms, path, idf_weights)
212
+ cscore, cterms = _content_score(query_terms, file_contents.get(path, ""), idf_weights)
185
213
 
186
214
  keyword_scores[path] = kscore
187
215
  content_scores[path] = cscore