codegraph-voyage 0.1.0__py3-none-any.whl
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.
- codegraph_voyage/__init__.py +8 -0
- codegraph_voyage/__main__.py +5 -0
- codegraph_voyage/cli.py +691 -0
- codegraph_voyage/document.py +238 -0
- codegraph_voyage/explore.py +148 -0
- codegraph_voyage/mcp_server.py +78 -0
- codegraph_voyage/providers.py +275 -0
- codegraph_voyage/ranking.py +448 -0
- codegraph_voyage/sanitize.py +116 -0
- codegraph_voyage/sidecar.py +325 -0
- codegraph_voyage/tests/__init__.py +1 -0
- codegraph_voyage/tests/benchmark.py +278 -0
- codegraph_voyage/tests/test_all.py +1114 -0
- codegraph_voyage-0.1.0.dist-info/METADATA +196 -0
- codegraph_voyage-0.1.0.dist-info/RECORD +17 -0
- codegraph_voyage-0.1.0.dist-info/WHEEL +4 -0
- codegraph_voyage-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""Ranking: weighted reciprocal-rank fusion (RRF) with pinned candidates.
|
|
2
|
+
|
|
3
|
+
Pinned candidates (exact path matches, exact identifier matches) are always
|
|
4
|
+
included and never displaced by vector or lexical ranking.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RankingResult:
|
|
15
|
+
"""A single ranked result with provenance tracking."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
node_id: str,
|
|
20
|
+
score: float = 0.0,
|
|
21
|
+
*,
|
|
22
|
+
is_pinned: bool = False,
|
|
23
|
+
lexical_score: float | None = None,
|
|
24
|
+
vector_score: float | None = None,
|
|
25
|
+
exact_score: float | None = None,
|
|
26
|
+
path_score: float | None = None,
|
|
27
|
+
name: str = "",
|
|
28
|
+
qualified_name: str = "",
|
|
29
|
+
file_path: str = "",
|
|
30
|
+
node_kind: str = "",
|
|
31
|
+
language: str = "",
|
|
32
|
+
start_line: int | None = None,
|
|
33
|
+
end_line: int | None = None,
|
|
34
|
+
provenance: str | None = None,
|
|
35
|
+
):
|
|
36
|
+
self.node_id = node_id
|
|
37
|
+
self.score = score
|
|
38
|
+
self.is_pinned = is_pinned
|
|
39
|
+
self.lexical_score = lexical_score
|
|
40
|
+
self.vector_score = vector_score
|
|
41
|
+
self.exact_score = exact_score
|
|
42
|
+
self.path_score = path_score
|
|
43
|
+
self.name = name
|
|
44
|
+
self.qualified_name = qualified_name
|
|
45
|
+
self.file_path = file_path
|
|
46
|
+
self.node_kind = node_kind
|
|
47
|
+
self.language = language
|
|
48
|
+
self.start_line = start_line
|
|
49
|
+
self.end_line = end_line
|
|
50
|
+
self.provenance = provenance or ""
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict[str, Any]:
|
|
53
|
+
"""Serialize to a dict for JSON output."""
|
|
54
|
+
return {
|
|
55
|
+
"node_id": self.node_id,
|
|
56
|
+
"score": round(self.score, 6),
|
|
57
|
+
"is_pinned": self.is_pinned,
|
|
58
|
+
"lexical_score": round(self.lexical_score, 6) if self.lexical_score is not None else None,
|
|
59
|
+
"vector_score": round(self.vector_score, 6) if self.vector_score is not None else None,
|
|
60
|
+
"exact_score": round(self.exact_score, 6) if self.exact_score is not None else None,
|
|
61
|
+
"path_score": round(self.path_score, 6) if self.path_score is not None else None,
|
|
62
|
+
"name": self.name,
|
|
63
|
+
"qualified_name": self.qualified_name,
|
|
64
|
+
"file_path": self.file_path,
|
|
65
|
+
"node_kind": self.node_kind,
|
|
66
|
+
"language": self.language,
|
|
67
|
+
"start_line": self.start_line,
|
|
68
|
+
"end_line": self.end_line,
|
|
69
|
+
"provenance": self.provenance,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def reciprocal_rank_fusion(
|
|
74
|
+
ranked_lists: list[list[RankingResult]],
|
|
75
|
+
*,
|
|
76
|
+
weights: list[float] | None = None,
|
|
77
|
+
k: int = 60,
|
|
78
|
+
) -> list[RankingResult]:
|
|
79
|
+
"""Combine multiple ranked lists using weighted reciprocal-rank fusion.
|
|
80
|
+
|
|
81
|
+
Each list contributes its own RRF score per item:
|
|
82
|
+
rrf_score(item, list_i) = weight_i / (k + rank_i(item))
|
|
83
|
+
|
|
84
|
+
where rank_i is 1-based position in list_i. Items not present in a list
|
|
85
|
+
get 0 from that list.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
ranked_lists: List of ranked result lists (each list is of RankingResult).
|
|
89
|
+
weights: Per-list weight. If None, equal weight (1/N).
|
|
90
|
+
k: RRF constant (default 60, the standard value).
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
A single ranked list, sorted by fused score descending.
|
|
94
|
+
"""
|
|
95
|
+
n = len(ranked_lists)
|
|
96
|
+
if n == 0:
|
|
97
|
+
return []
|
|
98
|
+
|
|
99
|
+
if weights is None:
|
|
100
|
+
weights = [1.0 / n] * n
|
|
101
|
+
else:
|
|
102
|
+
# Normalize weights
|
|
103
|
+
total = sum(abs(w) for w in weights)
|
|
104
|
+
if total > 0:
|
|
105
|
+
weights = [w / total for w in weights]
|
|
106
|
+
else:
|
|
107
|
+
weights = [1.0 / n] * n
|
|
108
|
+
|
|
109
|
+
# Accumulate fused scores
|
|
110
|
+
fused_map: dict[str, tuple[RankingResult, float]] = {}
|
|
111
|
+
|
|
112
|
+
for lst_idx, lst in enumerate(ranked_lists):
|
|
113
|
+
w = weights[lst_idx]
|
|
114
|
+
for rank, item in enumerate(lst, start=1):
|
|
115
|
+
node_id = item.node_id
|
|
116
|
+
rrf = w / (k + rank)
|
|
117
|
+
if node_id in fused_map:
|
|
118
|
+
existing, score = fused_map[node_id]
|
|
119
|
+
# Merge provenance
|
|
120
|
+
existing.provenance = _merge_provenance(existing.provenance, item.provenance)
|
|
121
|
+
existing.score = score + rrf
|
|
122
|
+
# Track sub-scores
|
|
123
|
+
if item.lexical_score is not None:
|
|
124
|
+
existing.lexical_score = (existing.lexical_score or 0) + item.lexical_score
|
|
125
|
+
if item.vector_score is not None:
|
|
126
|
+
existing.vector_score = (existing.vector_score or 0) + item.vector_score
|
|
127
|
+
if item.exact_score is not None:
|
|
128
|
+
existing.exact_score = (existing.exact_score or 0) + item.exact_score
|
|
129
|
+
if item.path_score is not None:
|
|
130
|
+
existing.path_score = (existing.path_score or 0) + item.path_score
|
|
131
|
+
if item.is_pinned:
|
|
132
|
+
existing.is_pinned = True
|
|
133
|
+
fused_map[node_id] = (existing, existing.score)
|
|
134
|
+
else:
|
|
135
|
+
item.score = rrf
|
|
136
|
+
fused_map[node_id] = (item, rrf)
|
|
137
|
+
|
|
138
|
+
# Sort by fused score descending
|
|
139
|
+
results = sorted(fused_map.values(), key=lambda x: -x[1])
|
|
140
|
+
return [r for r, _ in results]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _merge_provenance(p1: str, p2: str) -> str:
|
|
144
|
+
"""Merge two provenance strings with dedup."""
|
|
145
|
+
parts = set()
|
|
146
|
+
if p1:
|
|
147
|
+
parts.update(p1.split("+"))
|
|
148
|
+
if p2:
|
|
149
|
+
parts.update(p2.split("+"))
|
|
150
|
+
# Filter empty
|
|
151
|
+
parts = {p.strip() for p in parts if p.strip()}
|
|
152
|
+
if not parts:
|
|
153
|
+
return ""
|
|
154
|
+
return "+".join(sorted(parts))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def find_pinned_candidates(
|
|
158
|
+
query: str,
|
|
159
|
+
candidates: list[dict[str, Any]],
|
|
160
|
+
) -> list[RankingResult]:
|
|
161
|
+
"""Find exactly matching identifiers and paths; return as pinned candidates.
|
|
162
|
+
|
|
163
|
+
Pinned candidates match the query as an exact identifier:
|
|
164
|
+
- query matches name exactly (case-insensitive)
|
|
165
|
+
- query matches qualified_name exactly (case-insensitive)
|
|
166
|
+
- query matches file_path exactly (case-insensitive)
|
|
167
|
+
- query matches the file_path basename exactly (case-insensitive)
|
|
168
|
+
|
|
169
|
+
These are always included in the final result set and never displaced.
|
|
170
|
+
"""
|
|
171
|
+
query_lower = query.lower().strip()
|
|
172
|
+
if not query_lower:
|
|
173
|
+
return []
|
|
174
|
+
pinned: list[RankingResult] = []
|
|
175
|
+
seen_ids: set[str] = set()
|
|
176
|
+
|
|
177
|
+
for c in candidates:
|
|
178
|
+
node_id = c.get("node_id", "")
|
|
179
|
+
if not node_id or node_id in seen_ids:
|
|
180
|
+
continue
|
|
181
|
+
name = (c.get("name") or "").lower()
|
|
182
|
+
qname = (c.get("qualified_name") or "").lower()
|
|
183
|
+
fpath = (c.get("file_path") or "").replace("\\", "/").lower()
|
|
184
|
+
basename = fpath.rsplit("/", 1)[-1]
|
|
185
|
+
score = 0.0
|
|
186
|
+
provenance_parts: list[str] = []
|
|
187
|
+
|
|
188
|
+
# Exact name match
|
|
189
|
+
if name == query_lower:
|
|
190
|
+
score += 10.0
|
|
191
|
+
provenance_parts.append("exact_name")
|
|
192
|
+
|
|
193
|
+
# Exact qualified name match
|
|
194
|
+
if qname == query_lower:
|
|
195
|
+
score += 10.0
|
|
196
|
+
provenance_parts.append("exact_qname")
|
|
197
|
+
|
|
198
|
+
if fpath == query_lower:
|
|
199
|
+
score += 5.0
|
|
200
|
+
provenance_parts.append("exact_path")
|
|
201
|
+
|
|
202
|
+
if basename == query_lower:
|
|
203
|
+
score += 5.0
|
|
204
|
+
provenance_parts.append("exact_basename")
|
|
205
|
+
|
|
206
|
+
if score > 0:
|
|
207
|
+
seen_ids.add(node_id)
|
|
208
|
+
pinned.append(
|
|
209
|
+
RankingResult(
|
|
210
|
+
node_id=node_id,
|
|
211
|
+
score=score,
|
|
212
|
+
is_pinned=True,
|
|
213
|
+
exact_score=score,
|
|
214
|
+
name=c.get("name", ""),
|
|
215
|
+
qualified_name=c.get("qualified_name", ""),
|
|
216
|
+
file_path=c.get("file_path", ""),
|
|
217
|
+
node_kind=c.get("node_kind", ""),
|
|
218
|
+
language=c.get("language", ""),
|
|
219
|
+
start_line=c.get("start_line"),
|
|
220
|
+
end_line=c.get("end_line"),
|
|
221
|
+
provenance="+".join(provenance_parts),
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return pinned
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def merge_pinned_into_results(
|
|
229
|
+
pinned: list[RankingResult],
|
|
230
|
+
fused: list[RankingResult],
|
|
231
|
+
) -> list[RankingResult]:
|
|
232
|
+
"""Merge pinned candidates into the fused result list.
|
|
233
|
+
|
|
234
|
+
Pinned items are placed at the top (sorted by their exact_score within
|
|
235
|
+
the pinned group), and duplicates are removed from the fused list.
|
|
236
|
+
"""
|
|
237
|
+
fused_by_id = {r.node_id: r for r in fused}
|
|
238
|
+
for item in pinned:
|
|
239
|
+
fused_item = fused_by_id.get(item.node_id)
|
|
240
|
+
if fused_item is None:
|
|
241
|
+
continue
|
|
242
|
+
item.provenance = _merge_provenance(item.provenance, fused_item.provenance)
|
|
243
|
+
item.lexical_score = fused_item.lexical_score
|
|
244
|
+
item.vector_score = fused_item.vector_score
|
|
245
|
+
item.path_score = fused_item.path_score if item.path_score is None else item.path_score
|
|
246
|
+
item.score += fused_item.score
|
|
247
|
+
for attr in ("name", "qualified_name", "file_path", "node_kind", "language"):
|
|
248
|
+
if not getattr(item, attr):
|
|
249
|
+
setattr(item, attr, getattr(fused_item, attr))
|
|
250
|
+
if item.start_line is None:
|
|
251
|
+
item.start_line = fused_item.start_line
|
|
252
|
+
if item.end_line is None:
|
|
253
|
+
item.end_line = fused_item.end_line
|
|
254
|
+
pinned_ids = {p.node_id for p in pinned}
|
|
255
|
+
# Deduplicate fused list
|
|
256
|
+
fused_deduped = [r for r in fused if r.node_id not in pinned_ids]
|
|
257
|
+
# Sort pinned by their exact score descending
|
|
258
|
+
pinned_sorted = sorted(pinned, key=lambda p: -(p.exact_score or 0))
|
|
259
|
+
return pinned_sorted + fused_deduped
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def cosine_similarity(a: list[float], b: list[float]) -> float:
|
|
263
|
+
"""Compute cosine similarity between two vectors."""
|
|
264
|
+
if len(a) != len(b):
|
|
265
|
+
return 0.0
|
|
266
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
267
|
+
norm_a = math.sqrt(sum(x * x for x in a))
|
|
268
|
+
norm_b = math.sqrt(sum(y * y for y in b))
|
|
269
|
+
if norm_a == 0 or norm_b == 0:
|
|
270
|
+
return 0.0
|
|
271
|
+
return dot / (norm_a * norm_b)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def rank_by_vector_similarity(
|
|
275
|
+
query_vector: list[float],
|
|
276
|
+
candidates: list[dict[str, Any]],
|
|
277
|
+
*,
|
|
278
|
+
top_k: int = 30,
|
|
279
|
+
) -> list[RankingResult]:
|
|
280
|
+
"""Rank candidates by cosine similarity to the query vector.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
query_vector: The query embedding vector.
|
|
284
|
+
candidates: List of dicts with 'node_id', 'embedding', etc.
|
|
285
|
+
top_k: Max results to return.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Ranked list of RankingResult with vector_score populated.
|
|
289
|
+
"""
|
|
290
|
+
scored: list[tuple[float, dict[str, Any]]] = []
|
|
291
|
+
for c in candidates:
|
|
292
|
+
emb = c.get("embedding")
|
|
293
|
+
if emb is None:
|
|
294
|
+
continue
|
|
295
|
+
sim = cosine_similarity(query_vector, emb)
|
|
296
|
+
scored.append((sim, c))
|
|
297
|
+
|
|
298
|
+
scored.sort(key=lambda x: -x[0])
|
|
299
|
+
|
|
300
|
+
results: list[RankingResult] = []
|
|
301
|
+
for sim, c in scored[:top_k]:
|
|
302
|
+
results.append(
|
|
303
|
+
RankingResult(
|
|
304
|
+
node_id=c.get("node_id", ""),
|
|
305
|
+
score=sim,
|
|
306
|
+
vector_score=sim,
|
|
307
|
+
name=c.get("name", ""),
|
|
308
|
+
qualified_name=c.get("qualified_name", ""),
|
|
309
|
+
file_path=c.get("file_path", ""),
|
|
310
|
+
node_kind=c.get("node_kind", ""),
|
|
311
|
+
language=c.get("language", ""),
|
|
312
|
+
start_line=c.get("start_line"),
|
|
313
|
+
end_line=c.get("end_line"),
|
|
314
|
+
provenance="vector",
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
return results
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def rank_by_lexical_similarity(
|
|
321
|
+
query: str,
|
|
322
|
+
candidates: list[dict[str, Any]],
|
|
323
|
+
*,
|
|
324
|
+
top_k: int = 30,
|
|
325
|
+
) -> list[RankingResult]:
|
|
326
|
+
"""Rank candidates by lexical (BM25-like) text overlap with the query.
|
|
327
|
+
|
|
328
|
+
Uses a simple TF-IDF-like scoring on the document text for a lightweight
|
|
329
|
+
lexical ranking without external dependencies.
|
|
330
|
+
"""
|
|
331
|
+
query_lower = query.lower().strip()
|
|
332
|
+
query_terms = re.findall(r"[a-zA-Z0-9_]+", query_lower)
|
|
333
|
+
if not query_terms:
|
|
334
|
+
# Fall back to per-char matching
|
|
335
|
+
query_terms = [query_lower]
|
|
336
|
+
|
|
337
|
+
scored: list[tuple[float, dict[str, Any]]] = []
|
|
338
|
+
for c in candidates:
|
|
339
|
+
doc_text = (c.get("document_text") or "").lower()
|
|
340
|
+
if not doc_text:
|
|
341
|
+
scored.append((0.0, c))
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
score = 0.0
|
|
345
|
+
for term in query_terms:
|
|
346
|
+
# Count occurrences
|
|
347
|
+
count = doc_text.count(term)
|
|
348
|
+
if count > 0:
|
|
349
|
+
# Simple TF: log(1 + count)
|
|
350
|
+
tf = math.log(1.0 + count)
|
|
351
|
+
# IDF-like: fewer terms in the query = higher weight
|
|
352
|
+
idf = math.log(1.0 + len(query_terms) / len(set(query_terms)))
|
|
353
|
+
score += tf * idf
|
|
354
|
+
|
|
355
|
+
# Bonus for term in name or qualified_name
|
|
356
|
+
name = (c.get("name") or "").lower()
|
|
357
|
+
qname = (c.get("qualified_name") or "").lower()
|
|
358
|
+
for term in query_terms:
|
|
359
|
+
if term in name:
|
|
360
|
+
score += 2.0
|
|
361
|
+
if term in qname:
|
|
362
|
+
score += 1.0
|
|
363
|
+
|
|
364
|
+
scored.append((score, c))
|
|
365
|
+
|
|
366
|
+
scored.sort(key=lambda x: -x[0])
|
|
367
|
+
|
|
368
|
+
results: list[RankingResult] = []
|
|
369
|
+
for score, c in scored[:top_k]:
|
|
370
|
+
results.append(
|
|
371
|
+
RankingResult(
|
|
372
|
+
node_id=c.get("node_id", ""),
|
|
373
|
+
score=score,
|
|
374
|
+
lexical_score=score,
|
|
375
|
+
name=c.get("name", ""),
|
|
376
|
+
qualified_name=c.get("qualified_name", ""),
|
|
377
|
+
file_path=c.get("file_path", ""),
|
|
378
|
+
node_kind=c.get("node_kind", ""),
|
|
379
|
+
language=c.get("language", ""),
|
|
380
|
+
start_line=c.get("start_line"),
|
|
381
|
+
end_line=c.get("end_line"),
|
|
382
|
+
provenance="lexical",
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
return results
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def hybrid_search(
|
|
389
|
+
query: str,
|
|
390
|
+
query_vector: list[float] | None,
|
|
391
|
+
vector_candidates: list[dict[str, Any]],
|
|
392
|
+
lexical_candidates: list[dict[str, Any]],
|
|
393
|
+
pinned_candidates: list[RankingResult] | None = None,
|
|
394
|
+
*,
|
|
395
|
+
top_k: int = 20,
|
|
396
|
+
lexical_weight: float = 0.5,
|
|
397
|
+
vector_weight: float = 0.5,
|
|
398
|
+
rrf_k: int = 60,
|
|
399
|
+
) -> list[RankingResult]:
|
|
400
|
+
"""Perform hybrid search with weighted RRF and pinned candidate merging.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
query: The search query string.
|
|
404
|
+
query_vector: Query embedding vector (None for vector-only search).
|
|
405
|
+
vector_candidates: Full list of candidates with embeddings.
|
|
406
|
+
lexical_candidates: Full list of candidates for lexical scoring.
|
|
407
|
+
pinned_candidates: Pre-computed pinned candidates (or None to compute).
|
|
408
|
+
top_k: Max final results.
|
|
409
|
+
lexical_weight: RRF weight for lexical rank list.
|
|
410
|
+
vector_weight: RRF weight for vector rank list.
|
|
411
|
+
rrf_k: RRF constant.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
Ranked list of RankingResult.
|
|
415
|
+
"""
|
|
416
|
+
# Compute pinned if not provided
|
|
417
|
+
if pinned_candidates is None:
|
|
418
|
+
pinned_candidates = find_pinned_candidates(query, lexical_candidates)
|
|
419
|
+
|
|
420
|
+
# Build ranked lists
|
|
421
|
+
ranked_lists: list[list[RankingResult]] = []
|
|
422
|
+
weights: list[float] = []
|
|
423
|
+
|
|
424
|
+
if query_vector is not None:
|
|
425
|
+
vec_results = rank_by_vector_similarity(
|
|
426
|
+
query_vector, vector_candidates, top_k=top_k * 2
|
|
427
|
+
)
|
|
428
|
+
if vec_results:
|
|
429
|
+
ranked_lists.append(vec_results)
|
|
430
|
+
weights.append(vector_weight)
|
|
431
|
+
|
|
432
|
+
lex_results = rank_by_lexical_similarity(
|
|
433
|
+
query, lexical_candidates, top_k=top_k * 2
|
|
434
|
+
)
|
|
435
|
+
if lex_results:
|
|
436
|
+
ranked_lists.append(lex_results)
|
|
437
|
+
weights.append(lexical_weight)
|
|
438
|
+
|
|
439
|
+
# Fuse
|
|
440
|
+
if ranked_lists:
|
|
441
|
+
fused = reciprocal_rank_fusion(ranked_lists, weights=weights, k=rrf_k)
|
|
442
|
+
else:
|
|
443
|
+
fused = []
|
|
444
|
+
|
|
445
|
+
# Merge pinned
|
|
446
|
+
final_results = merge_pinned_into_results(pinned_candidates, fused)
|
|
447
|
+
|
|
448
|
+
return final_results[:top_k]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Path and content sanitization — exclude sensitive paths before remote transmission."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
# Patterns that match sensitive paths to exclude from embedding content.
|
|
6
|
+
# Each pattern is compiled and checked against the relative file path.
|
|
7
|
+
SENSITIVE_PATH_PATTERNS: list[re.Pattern] = [
|
|
8
|
+
re.compile(r"(?:^|/)\.env(?:\..*)?$", re.IGNORECASE),
|
|
9
|
+
re.compile(r"(?:^|/)\.aws/(?:credentials|config)$", re.IGNORECASE),
|
|
10
|
+
re.compile(r"(?:^|/)\.ssh/", re.IGNORECASE),
|
|
11
|
+
re.compile(r"(?:^|/)\.docker/config\.json$", re.IGNORECASE),
|
|
12
|
+
re.compile(r"(?:^|/)\.(?:kube|gnupg)/", re.IGNORECASE),
|
|
13
|
+
re.compile(r"(?:^|/)(?:credentials|secrets)(?:[^/]*)?(?:/|$)", re.IGNORECASE),
|
|
14
|
+
re.compile(r"(?:^|/)(?:private_key[^/]*|id_[^/]+|[^/]*_(?:rsa|dsa|ecdsa|ed25519))$", re.IGNORECASE),
|
|
15
|
+
re.compile(r"(?:^|/)\.(?:pgpass|netrc|npmrc|pypirc|git-credentials)$", re.IGNORECASE),
|
|
16
|
+
re.compile(r"(?:^|/)(?:tokens\.json|service-account[^/]*\.json|vault-token)$", re.IGNORECASE),
|
|
17
|
+
re.compile(r"(?:^|/)(?:secret|password|private_key|token|api_key|keyring)[^/]*$", re.IGNORECASE),
|
|
18
|
+
re.compile(r"\.(?:pem|key|p12|pfx|crt|cer|jks|keystore)$", re.IGNORECASE),
|
|
19
|
+
re.compile(r"(?:^|/)\.git/"),
|
|
20
|
+
re.compile(r"(?:^|/)__pycache__/"),
|
|
21
|
+
re.compile(r"(?:^|/)node_modules/"),
|
|
22
|
+
re.compile(r"(?:^|/)\.venv/"),
|
|
23
|
+
re.compile(r"(?:^|/)venv/"),
|
|
24
|
+
re.compile(r"(?:^|/)\.codegraph/"),
|
|
25
|
+
re.compile(r"(?:^|/)\.hermes/"),
|
|
26
|
+
re.compile(r"(?:^|/)runtime/"),
|
|
27
|
+
re.compile(r"(?:^|/)target/"),
|
|
28
|
+
re.compile(r"(?:^|/)dist/"),
|
|
29
|
+
re.compile(r"(?:^|/)build/"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# Line-level patterns: skip embedding of lines that match these.
|
|
33
|
+
SENSITIVE_LINE_PATTERNS: list[re.Pattern] = [
|
|
34
|
+
re.compile(
|
|
35
|
+
r"(?:^|[,{\s])['\"]?(?:password|passwd|secret|api_key|apikey|auth_token|"
|
|
36
|
+
r"access_token|refresh_token|private_key|client_secret|aws_secret_access_key|token)"
|
|
37
|
+
r"['\"]?\s*(?:=>|->|=|:)\s*(?P<value>.+?)\s*$",
|
|
38
|
+
re.IGNORECASE,
|
|
39
|
+
),
|
|
40
|
+
re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----", re.IGNORECASE),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
_PRIVATE_KEY_END = re.compile(r"-----END [A-Z0-9 ]*PRIVATE KEY-----", re.IGNORECASE)
|
|
44
|
+
_PLACEHOLDER_VALUES = re.compile(
|
|
45
|
+
r"^(?:['\"])?(?:|placeholder|changeme|change[-_ ]?me|example|dummy|sample|"
|
|
46
|
+
r"redacted|masked|none|null|nil|x+|\*+|your[-_ ].*|<[^>]+>|\$\{[^}]+\})"
|
|
47
|
+
r"(?:['\"])?$",
|
|
48
|
+
re.IGNORECASE,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_obvious_placeholder(value: str) -> bool:
|
|
53
|
+
normalized = value.strip().rstrip(",").rstrip()
|
|
54
|
+
# A JSON object contributes its closing brace after the quoted value.
|
|
55
|
+
if normalized.endswith("}") and normalized[:1] in {"'", '"'}:
|
|
56
|
+
normalized = normalized[:-1].rstrip()
|
|
57
|
+
return bool(_PLACEHOLDER_VALUES.fullmatch(normalized))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_sensitive_path(rel_path: str) -> bool:
|
|
61
|
+
"""Return True if rel_path matches any sensitive-path pattern."""
|
|
62
|
+
str_path = rel_path.replace("\\", "/")
|
|
63
|
+
for pat in SENSITIVE_PATH_PATTERNS:
|
|
64
|
+
if pat.search(str_path):
|
|
65
|
+
return True
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def sanitize_content(content: str, rel_path: str = "") -> str:
|
|
70
|
+
"""Remove lines that match sensitive patterns from content.
|
|
71
|
+
|
|
72
|
+
Returns the sanitized content. Lines are replaced with a comment marker.
|
|
73
|
+
If the entire document is consumed (all lines removed), a placeholder is
|
|
74
|
+
returned so the embedding is not empty.
|
|
75
|
+
"""
|
|
76
|
+
if is_sensitive_path(rel_path):
|
|
77
|
+
return "[content excluded: sensitive path]"
|
|
78
|
+
|
|
79
|
+
lines = content.splitlines(keepends=True)
|
|
80
|
+
kept: list[str] = []
|
|
81
|
+
in_private_key = False
|
|
82
|
+
for line in lines:
|
|
83
|
+
stripped = line.strip()
|
|
84
|
+
if not stripped:
|
|
85
|
+
kept.append(line)
|
|
86
|
+
continue
|
|
87
|
+
if in_private_key:
|
|
88
|
+
kept.append("# [redacted by codegraph-voyage sanitizer]\n")
|
|
89
|
+
if _PRIVATE_KEY_END.search(stripped):
|
|
90
|
+
in_private_key = False
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
is_sensitive = False
|
|
94
|
+
for pat in SENSITIVE_LINE_PATTERNS:
|
|
95
|
+
match = pat.search(stripped)
|
|
96
|
+
if not match:
|
|
97
|
+
continue
|
|
98
|
+
if "BEGIN" in stripped.upper() and "PRIVATE KEY" in stripped.upper():
|
|
99
|
+
in_private_key = True
|
|
100
|
+
is_sensitive = True
|
|
101
|
+
break
|
|
102
|
+
value = match.groupdict().get("value")
|
|
103
|
+
if value is None or not _is_obvious_placeholder(value):
|
|
104
|
+
is_sensitive = True
|
|
105
|
+
break
|
|
106
|
+
if is_sensitive:
|
|
107
|
+
kept.append(f"# [redacted by codegraph-voyage sanitizer]\n")
|
|
108
|
+
else:
|
|
109
|
+
kept.append(line)
|
|
110
|
+
|
|
111
|
+
result = "".join(kept).strip()
|
|
112
|
+
return result if result else "[content excluded: all lines redacted]"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Exported list of path patterns (for documentation / debugging).
|
|
116
|
+
EXCLUDED_PATH_PATTERNS = [p.pattern for p in SENSITIVE_PATH_PATTERNS]
|