contextl 1.2.9 → 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 +1 -1
- package/python/import_parser.py +100 -32
- package/python/query_engine.py +19 -10
- package/python/scanner.py +10 -11
package/package.json
CHANGED
package/python/import_parser.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
227
|
+
def _load_tsconfig_paths(tsconfig_path: Path) -> dict:
|
|
214
228
|
"""
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
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:
|
|
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]
|
package/python/query_engine.py
CHANGED
|
@@ -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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
17
|
-
"node_modules",
|
|
18
|
-
".
|
|
19
|
-
".
|
|
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
|
|
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
|