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 +1 -1
- package/python/import_parser.py +26 -21
- package/python/query_engine.py +46 -18
package/package.json
CHANGED
package/python/import_parser.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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)
|
package/python/query_engine.py
CHANGED
|
@@ -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
|
|
99
|
-
|
|
100
|
-
|
|
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],
|
|
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
|
|
112
|
+
Uses TF-IDF weighting, capped to avoid huge files dominating.
|
|
109
113
|
"""
|
|
110
|
-
|
|
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 =
|
|
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,
|
|
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
|