contextl 1.2.8 → 1.2.10

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.8",
3
+ "version": "1.2.10",
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",
@@ -134,12 +134,26 @@ def _resolve_alias(import_path: str, alias_map: dict[str, str]) -> str | None:
134
134
  def _resolve_relative(import_path: str, source_file: str) -> str:
135
135
  """
136
136
  Resolve a relative import path against the source file's directory.
137
- Example: source="frontend/app/page.tsx", import="../lib/api" "frontend/lib/api"
137
+ Returns a repo-relative path string (NOT absolute).
138
+
139
+ Example: source="apps/web/src/routes.tsx", import="./App.tsx"
140
+ → "apps/web/src/App.tsx"
138
141
  """
139
- source_dir = Path(source_file).parent
140
- resolved = (source_dir / import_path).resolve()
141
- # Make it relative again (we'll strip the absolute prefix below)
142
- return str(resolved)
142
+ # Use pure PurePosixPath arithmetic — never call .resolve() which
143
+ # anchors to the OS cwd and produces a wrong absolute path.
144
+ from pathlib import PurePosixPath
145
+ source_dir = PurePosixPath(source_file).parent
146
+ # Normalise away any .. and . components without touching the filesystem
147
+ resolved = (source_dir / import_path)
148
+ # PurePosixPath doesn't have .resolve(), so manually normalise ".."
149
+ parts = []
150
+ for part in resolved.parts:
151
+ if part == "..":
152
+ if parts:
153
+ parts.pop()
154
+ elif part != ".":
155
+ parts.append(part)
156
+ return str(PurePosixPath(*parts)) if parts else "."
143
157
 
144
158
 
145
159
  def _find_file_in_repo(
@@ -210,41 +224,95 @@ def _find_file_in_repo(
210
224
  return None
211
225
 
212
226
 
213
- def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
227
+ def _load_tsconfig_paths(tsconfig_path: Path) -> dict:
214
228
  """
215
- Auto-detect the @/ alias by finding tsconfig.json or next.config files
216
- and inferring the project root. Falls back to a heuristic.
217
-
218
- Returns a map like {"@/": "frontend/"} or {"@/": ""}
229
+ Load compilerOptions.paths from a tsconfig.json, following `extends` chains.
230
+ Returns merged paths dict (child overrides parent).
219
231
  """
220
- root = Path(scan_result.root)
221
- alias_map = {}
222
-
223
- # Look for tsconfig.json to find paths config
224
- for tsconfig in root.rglob("tsconfig.json"):
232
+ import json
233
+ visited = set()
234
+ merged_paths = {}
235
+
236
+ def _load(path: Path):
237
+ if path in visited:
238
+ return
239
+ visited.add(path)
225
240
  try:
226
- import json
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)
241
+ text = path.read_text(encoding="utf-8")
242
+ pattern = r'(".*?(?<!\\)")|(/\*.*?\*/|//[^\r\n]*)'
243
+ text = re.sub(pattern, lambda m: m.group(1) if m.group(1) else '', text, flags=re.S)
230
244
  data = json.loads(text)
231
- paths = data.get("compilerOptions", {}).get("paths", {})
232
-
233
- for alias, targets in paths.items():
234
- if not targets:
235
- continue
236
- # "@/*": ["./src/*"] → strip trailing /* from both
237
- clean_alias = alias.rstrip("/*").rstrip("*")
238
- clean_target = targets[0].rstrip("/*").rstrip("*").lstrip("./")
239
-
240
- tsconfig_dir = tsconfig.parent.relative_to(root)
241
- prefix = str(tsconfig_dir / clean_target).lstrip("./")
242
- alias_map[clean_alias + "/"] = prefix + "/" if prefix else ""
243
245
  except Exception:
246
+ return
247
+
248
+ # Follow extends first (parent paths are base, child overrides)
249
+ extends = data.get("extends")
250
+ if extends:
251
+ parent = (path.parent / extends).resolve()
252
+ # handle both with and without .json
253
+ if not parent.suffix:
254
+ parent = parent.with_suffix(".json")
255
+ _load(parent)
256
+
257
+ # Then apply this tsconfig's own paths (child wins)
258
+ paths = data.get("compilerOptions", {}).get("paths", {})
259
+ for alias, targets in paths.items():
260
+ if targets:
261
+ merged_paths[alias] = targets[0]
262
+
263
+ _load(tsconfig_path)
264
+ return merged_paths
265
+
266
+
267
+ def _detect_alias_map(scan_result: ScanResult) -> dict[str, str]:
268
+ """
269
+ Auto-detect TypeScript path aliases by reading every tsconfig.json in the repo.
270
+ Each tsconfig's aliases are scoped relative to its own directory so that
271
+ multiple packages with conflicting @/* aliases don't clobber each other.
272
+
273
+ Returns a flat map: alias_prefix -> resolved_repo_relative_prefix
274
+ e.g. {
275
+ '@/' : 'packages/ui/src/',
276
+ '@components/' : 'apps/web/src/components/',
277
+ '@core/' : 'apps/web/src/core/',
278
+ }
279
+ """
280
+ root = Path(scan_result.root)
281
+ alias_map: dict[str, str] = {}
282
+
283
+ # Walk all tsconfig.json files (skip node_modules / build dirs)
284
+ ignore = {"node_modules", "dist", "build", ".next", "coverage", "out"}
285
+ tsconfigs = []
286
+ for tc in root.rglob("tsconfig.json"):
287
+ if any(p in ignore for p in tc.parts):
244
288
  continue
289
+ tsconfigs.append(tc)
290
+
291
+ for tsconfig in tsconfigs:
292
+ tsconfig_dir = tsconfig.parent
293
+ paths = _load_tsconfig_paths(tsconfig)
294
+
295
+ for alias, target in paths.items():
296
+ # Normalise: strip trailing /* or *
297
+ clean_alias = alias.rstrip("/*").rstrip("*")
298
+ clean_target = target.rstrip("/*").rstrip("*").lstrip("./")
299
+
300
+ # Resolve target relative to the tsconfig's own directory
301
+ resolved = (tsconfig_dir / clean_target).resolve()
302
+ try:
303
+ repo_rel = str(resolved.relative_to(root))
304
+ except ValueError:
305
+ continue # outside repo — skip
306
+
307
+ alias_key = clean_alias + "/"
308
+ target_val = repo_rel.replace("\\", "/") + "/"
309
+
310
+ # Only add if not already defined (first match / most specific wins)
311
+ if alias_key not in alias_map:
312
+ alias_map[alias_key] = target_val
245
313
 
246
314
  if not alias_map:
247
- # Heuristic fallback: if all files share a common top-level dir, use that
315
+ # Heuristic fallback: single top-level source dir
248
316
  top_dirs = {Path(f.path).parts[0] for f in scan_result.files if Path(f.path).parts}
249
317
  if len(top_dirs) == 1:
250
318
  top = list(top_dirs)[0]
@@ -71,19 +71,12 @@ async def list_tools() -> list[types.Tool]:
71
71
  "query": {
72
72
  "type": "string",
73
73
  "description": (
74
- "SYSTEM INSTRUCTION: You are a translation layer designed to convert human requests into highly optimized queries for the contextl codebase search engine. "
75
- "The contextl engine is NOT a semantic AI; it is an advanced, dependency-aware fuzzy keyword searcher. It relies on exact token matching and import-graph ranking.\n\n"
76
- "Your goal is to extract the core technical keywords from the user's request and discard all conversational filler or vague concepts.\n\n"
77
- "Rules for generating the query string:\n"
78
- "1. Remove Conversational Filler: Drop words like 'where is', 'how does', 'find the', 'show me'.\n"
79
- "2. Translate Concepts to Code: If the user asks for a concept, translate it into the exact keywords, classes, or library syntax a developer would type. (e.g., 'routing' -> 'createRouter Route routeTree', 'entry point' -> 'main bootstrap init').\n"
80
- "3. Include Known Framework Terms: Append standard terminology of the framework.\n"
81
- "4. Target File Types/Structures: Use terms like 'config', 'yaml', 'json' if looking for configurations.\n"
82
- "5. Keep it space-separated: Output a single string of space-separated keywords without punctuation.\n\n"
83
- "Examples:\n"
84
- "- 'Where are the main react components and routing defined?' -> 'createRouter Route routeTree components layout'\n"
85
- "- 'Find the application entry point and main method.' -> 'public static void main bootstrap init ApplicationContext configuration'\n"
86
- "- 'How is the user authentication handled?' -> 'login auth authenticate jwt token session passport'"
74
+ "SYSTEM INSTRUCTION: The contextl search engine is highly optimized for both raw conversational queries AND exact keyword searches. "
75
+ "It uses dynamic structural multipliers to instantly rank classes for short/conversational queries, and switches to BM25 frequency math for longer keyword queries.\n\n"
76
+ "STRATEGY INSTRUCTION:\n"
77
+ "1. INITIAL SEARCHES: Pass the user's raw natural-language query directly. Do NOT convert it to keywords. The engine is smart enough to drop stop-words and find the primary class anchors. Example: 'where is the auth logic?' -> 'where is the auth logic?'\n"
78
+ "2. TARGETED DEBUG SEARCHES: If the initial search fails, or if you are deep into debugging and need to cross-reference very specific variables across the codebase, then convert the query into highly optimized, space-separated keywords. Example: 'auth failure bug' -> 'login jwt AuthController retry_count token'\n"
79
+ "Never append generic filler words to targeted searches."
87
80
  ),
88
81
  },
89
82
  "top_n": {
@@ -161,6 +161,13 @@ def _content_score(query_terms: list[str], file_path: str, file_content: str, id
161
161
  for part in re.findall(r"[a-z0-9]+", sub):
162
162
  if len(part) > 3:
163
163
  java_methods.add(part)
164
+
165
+ py_defs = set()
166
+ if file_path.endswith(".py"):
167
+ defs = re.findall(r"\b(?:def|async def)\s+([a-zA-Z0-9_]+)\s*\(", file_content)
168
+ for d in defs:
169
+ if d.lower() not in {"main", "init", "get", "set"}:
170
+ py_defs.add(d.lower())
164
171
 
165
172
  if is_ts:
166
173
  exports = re.findall(r"\bexport\s+(?:const|let|var|class|interface|function|default)\s+([a-zA-Z0-9_]+)", file_content)
@@ -213,7 +220,7 @@ def _content_score(query_terms: list[str], file_path: str, file_content: str, id
213
220
  multiplier = 8.0
214
221
  elif t in declared_classes:
215
222
  multiplier = class_mult
216
- elif t in java_methods:
223
+ elif t in java_methods or t in py_defs:
217
224
  multiplier = 4.0
218
225
  elif t in annotations:
219
226
  multiplier = ann_mult
@@ -340,9 +347,10 @@ def query(
340
347
  if df == 0:
341
348
  idf_weights[term] = 0.0
342
349
  else:
343
- idf_weights[term] = math.log(total_files / (1 + df)) if total_files > 0 else 1.0
350
+ idf_weights[term] = max(0.1, math.log(total_files / (1 + df))) if total_files > 0 else 1.0
344
351
 
345
352
  # --- Pass 1: keyword + content scores per file ---
353
+ base_scores: dict[str, float] = {}
346
354
  keyword_scores: dict[str, float] = {}
347
355
  content_scores: dict[str, float] = {}
348
356
  matched_terms: dict[str, list[str]] = {}
@@ -355,14 +363,15 @@ def query(
355
363
  keyword_scores[path] = kscore
356
364
  content_scores[path] = cscore
357
365
  matched_terms[path] = list(set(kterms + cterms))
358
-
359
- # --- Pass 2: combine into base score ---
360
- base_scores: dict[str, float] = {}
361
- for path in repo_graph.nodes:
362
- base_scores[path] = (
363
- keyword_scores.get(path, 0.0) * 0.5 +
364
- content_scores.get(path, 0.0) * 0.5
365
- )
366
+
367
+ # Base combination: 50/50 keyword vs content
368
+ combined = (kscore * 0.5) + (cscore * 0.5)
369
+
370
+ # Tiebreaker: Slightly penalize deep paths so src/x.py wins over build/npm/python/x.py
371
+ depth_penalty = 0.0001 * path.count("/")
372
+ combined -= depth_penalty
373
+
374
+ base_scores[path] = combined
366
375
 
367
376
  # --- Pass 3: neighbor bonus ---
368
377
  boosted_scores = _apply_neighbor_bonus(base_scores, repo_graph)
package/python/scanner.py CHANGED
@@ -7,24 +7,23 @@ Filters by supported extensions for the MVP (Next.js / React / TypeScript).
7
7
  """
8
8
 
9
9
  import os
10
+ import pathlib
10
11
  from pathlib import Path
11
12
  from dataclasses import dataclass, field
12
13
 
13
14
 
14
15
  SUPPORTED_EXTENSIONS = {".tsx", ".ts", ".jsx", ".js", ".py", ".java"}
15
16
 
16
- IGNORED_DIRS = {
17
- "node_modules",
18
- ".git",
19
- ".next",
20
- "dist",
21
- "build",
22
- ".cache",
23
- "__pycache__",
24
- ".turbo",
25
- "coverage",
17
+ ignore_dirs = {
18
+ ".git", "node_modules", "__pycache__", "venv", ".venv",
19
+ "env", ".env", ".next", ".nuxt", "out", "build", "dist",
20
+ "coverage", ".nyc_output", ".pytest_cache", "npm", "pypi"
26
21
  }
27
22
 
23
+ def _should_ignore(path: str) -> bool:
24
+ parts = pathlib.Path(path).parts
25
+ return any(part in ignore_dirs for part in parts)
26
+
28
27
 
29
28
  @dataclass
30
29
  class ScannedFile:
@@ -87,7 +86,7 @@ def scan_repo(repo_path: str) -> ScanResult:
87
86
 
88
87
  for dirpath, dirnames, filenames in os.walk(root):
89
88
  # Prune ignored directories in-place so os.walk skips them
90
- dirnames[:] = [d for d in dirnames if d not in IGNORED_DIRS]
89
+ dirnames[:] = [d for d in dirnames if d not in ignore_dirs]
91
90
 
92
91
  for filename in filenames:
93
92
  filepath = Path(dirpath) / filename