contextl 1.2.4 → 1.2.6

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
@@ -86,6 +86,43 @@ Takes the exact dependency graph built by the intelligence engine and physically
86
86
 
87
87
  ---
88
88
 
89
+ ## AI System Prompt (Query Optimizer)
90
+
91
+ ContextL is a deterministic keyword and graph engine, not a semantic AI. For maximum accuracy, inject this system prompt into your AI agent's rules (e.g., `.cursorrules` or custom instructions). It forces the AI to translate your natural language questions into highly optimized, lexical keyword strings before hitting the `query_repo` tool.
92
+
93
+ ```text
94
+ System Instruction: You are a translation layer designed to convert human requests into highly optimized queries for the contextl codebase search engine. 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.
95
+
96
+ Your goal is to extract the core technical keywords from the user's request and discard all conversational filler or vague concepts.
97
+
98
+ Rules for generating the query string:
99
+
100
+ 1. Remove Conversational Filler: Drop words like "where is", "how does", "find the", "show me".
101
+ 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.
102
+ Example: "routing" ➡️ createRouter Route routeTree
103
+ Example: "entry point" ➡️ main SpringBootApplication bootstrap web.xml init
104
+ Example: "database schema" ➡️ Entity Column Table model schema
105
+ 3. Include Known Framework Terms: If you know the language or framework of the codebase, append its standard terminology.
106
+ 4. Target File Types/Structures: If looking for configurations, use terms like config properties yaml xml json.
107
+ 5. Keep it space-separated: Output a single string of space-separated keywords without punctuation.
108
+
109
+ Examples:
110
+
111
+ User: "Where are the main react components and routing defined?"
112
+ Optimized Query: createRouter Route routeTree components layout
113
+
114
+ User: "Find the application entry point and main method."
115
+ Optimized Query: public static void main bootstrap init ApplicationContext configuration
116
+
117
+ User: "How is the user authentication handled?"
118
+ Optimized Query: login auth authenticate jwt token session passport
119
+
120
+ Input: [Insert human request here]
121
+ Output: [Return ONLY the optimized keyword string]
122
+ ```
123
+
124
+ ---
125
+
89
126
  ## How the ranking works
90
127
 
91
128
  1. **Keyword match** — does the filename contain query terms?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextl",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
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
 
@@ -57,6 +60,38 @@ class RankedFile:
57
60
  )
58
61
 
59
62
 
63
+ @dataclass
64
+ class FileFeatures:
65
+ tokens: set[str]
66
+ annotations: set[str]
67
+ declarations: set[str]
68
+
69
+
70
+ def _extract_file_features(content: str, extension: str) -> FileFeatures:
71
+ """Extract structural heuristics and lowercase tokens from a file."""
72
+ # 1. Declarations: class X, interface Y, enum Z
73
+ decl_pattern = r"\b(?:class|interface|enum)\s+([A-Za-z0-9_]+)"
74
+ declarations = {m.group(1).lower() for m in re.finditer(decl_pattern, content)}
75
+
76
+ # 2. Annotations: @Controller, @Injectable
77
+ annotations = {m.group(1).lower() for m in re.finditer(r"@([A-Za-z0-9_]+)", content)}
78
+
79
+ # 3. Base tokens
80
+ tokens = set(re.findall(r"[a-z0-9]+", content.lower()))
81
+
82
+ # 4. CamelCase / PascalCase Splitting for structural languages
83
+ if extension in {".java", ".ts", ".tsx"}:
84
+ for word in re.findall(r"[A-Za-z]+", content):
85
+ if not word.islower() and not word.isupper() and len(word) > 2:
86
+ # Split CamelCase
87
+ sub = re.sub(r"([a-z])([A-Z])", r"\1 \2", word).lower()
88
+ for part in re.findall(r"[a-z0-9]+", sub):
89
+ if len(part) > 1:
90
+ tokens.add(part)
91
+
92
+ return FileFeatures(tokens=tokens, annotations=annotations, declarations=declarations)
93
+
94
+
60
95
  def _tokenize(text: str) -> list[str]:
61
96
  """Lowercase, split on non-alphanumeric, remove stop words."""
62
97
  tokens = re.findall(r"[a-z0-9]+", text.lower())
@@ -75,7 +110,7 @@ def _path_tokens(path: str) -> list[str]:
75
110
  return [t for t in tokens if t not in STOP_WORDS and len(t) > 1]
76
111
 
77
112
 
78
- def _keyword_score(query_terms: list[str], file_path: str) -> tuple[float, list[str]]:
113
+ def _keyword_score(query_terms: list[str], file_path: str, idf_weights: dict[str, float]) -> tuple[float, list[str]]:
79
114
  """
80
115
  Score how well query terms match the file path / name.
81
116
  Filename matches score higher than directory matches.
@@ -88,37 +123,47 @@ def _keyword_score(query_terms: list[str], file_path: str) -> tuple[float, list[
88
123
 
89
124
  for term in query_terms:
90
125
  if term in filename_toks:
91
- score += 1.0 # Strong signal: term in filename
126
+ score += idf_weights.get(term, 1.0) # Strong signal: term in filename
92
127
  matched.append(term)
93
128
  elif term in path_toks:
94
- score += 0.4 # Weaker signal: term in directory path
129
+ score += 0.4 * idf_weights.get(term, 1.0) # Weaker signal: term in directory path
95
130
  if term not in matched:
96
131
  matched.append(term)
97
132
 
98
- # Normalize by number of query terms
99
- if query_terms:
100
- score = score / len(query_terms)
133
+ # Normalize by max possible score (sum of all IDFs)
134
+ max_score = sum(idf_weights.get(t, 1.0) for t in query_terms)
135
+ if max_score > 0:
136
+ score = score / max_score
101
137
 
102
138
  return score, matched
103
139
 
104
140
 
105
- def _content_score(query_terms: list[str], file_path: str) -> tuple[float, list[str]]:
141
+ def _content_score(query_terms: list[str], features: "FileFeatures", idf_weights: dict[str, float]) -> tuple[float, list[str]]:
106
142
  """
107
143
  Score how often query terms appear in the file's source code.
108
- Uses term frequency, capped to avoid huge files dominating.
144
+ Applies massive multipliers for architectural declarations and annotations.
109
145
  """
110
- try:
111
- content = open(file_path, encoding="utf-8").read().lower()
112
- except Exception:
146
+ if not features or not query_terms:
113
147
  return 0.0, []
114
148
 
115
- content_tokens = set(re.findall(r"[a-z0-9]+", content))
116
- matched = [t for t in query_terms if t in content_tokens]
149
+ matched = [t for t in query_terms if t in features.tokens]
117
150
 
118
- if not query_terms:
119
- return 0.0, []
151
+ score = 0.0
152
+ for t in matched:
153
+ base_weight = idf_weights.get(t, 1.0)
154
+ multiplier = 1.0
155
+ if t in features.declarations:
156
+ multiplier = 10.0
157
+ elif t in features.annotations:
158
+ multiplier = 5.0
159
+ score += base_weight * multiplier
160
+
161
+ max_score = sum(idf_weights.get(t, 1.0) for t in query_terms)
162
+ if max_score > 0:
163
+ score = score / max_score
164
+ else:
165
+ score = 0.0
120
166
 
121
- score = len(matched) / len(query_terms)
122
167
  return score, matched
123
168
 
124
169
 
@@ -173,15 +218,42 @@ def query(
173
218
  if not query_terms:
174
219
  return []
175
220
 
221
+ # --- Pre-computation: Global TF-IDF ---
222
+ import pathlib
223
+ total_files = len(repo_graph.nodes)
224
+ document_frequency = {term: 0 for term in query_terms}
225
+
226
+ file_features_cache = {}
227
+ for path, node in repo_graph.nodes.items():
228
+ abs_path = node.path if hasattr(node, "absolute_path") else str(__import__("pathlib").Path(repo_graph.root) / path)
229
+ extension = pathlib.Path(path).suffix.lower()
230
+
231
+ try:
232
+ content = open(abs_path, encoding="utf-8").read()
233
+ features = _extract_file_features(content, extension)
234
+ file_features_cache[path] = features
235
+
236
+ path_tokens = set(_path_tokens(path))
237
+ combined_tokens = features.tokens.union(path_tokens)
238
+ for term in query_terms:
239
+ if term in combined_tokens:
240
+ document_frequency[term] += 1
241
+ except Exception:
242
+ file_features_cache[path] = None
243
+
244
+ idf_weights = {}
245
+ for term in query_terms:
246
+ df = document_frequency[term]
247
+ idf_weights[term] = math.log(total_files / (1 + df)) if total_files > 0 else 1.0
248
+
176
249
  # --- Pass 1: keyword + content scores per file ---
177
250
  keyword_scores: dict[str, float] = {}
178
251
  content_scores: dict[str, float] = {}
179
252
  matched_terms: dict[str, list[str]] = {}
180
253
 
181
254
  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))
255
+ kscore, kterms = _keyword_score(query_terms, path, idf_weights)
256
+ cscore, cterms = _content_score(query_terms, file_features_cache.get(path), idf_weights)
185
257
 
186
258
  keyword_scores[path] = kscore
187
259
  content_scores[path] = cscore