rag-your-code 0.4.1__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.
ragyourcode/indexer.py ADDED
@@ -0,0 +1,411 @@
1
+ """Repository walking and index construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import struct
9
+ import sys
10
+ import time
11
+ from array import array
12
+ from dataclasses import dataclass, replace
13
+ from pathlib import Path
14
+
15
+ from . import config as config_module
16
+ from .annotate import comment_for
17
+ from .config import Config
18
+ from .descriptions import DescriptionStore, index_descriptions_fingerprint
19
+ from .embeddings import embed, embedding_metadata
20
+ from .models import CodeUnit
21
+ from .parser import parse_file
22
+
23
+ # These names predate the configuration layer and several tests import them.
24
+ # They are derived from the settings table rather than restated, so there is
25
+ # still exactly one place a default is written down.
26
+ DEFAULT_IGNORES = set(config_module.BY_PATH["index.ignore"].default)
27
+ SOURCE_SUFFIXES = set(config_module.BY_PATH["index.suffixes"].default)
28
+ MAX_SOURCE_BYTES = config_module.BY_PATH["index.max_file_bytes"].default
29
+
30
+
31
+ def _resolve(root: Path, cfg: Config | None) -> Config:
32
+ """Fall back to the repository's own configuration file.
33
+
34
+ Callers that do not pass one get the same settings the CLI would use, so a
35
+ library caller and a command line invocation cannot disagree about which
36
+ files are source.
37
+ """
38
+ return cfg if cfg is not None else config_module.load(root)
39
+
40
+
41
+ class StaleMonitor:
42
+ """Rate-limit repository stat walks while allowing forced checks."""
43
+
44
+ def __init__(
45
+ self,
46
+ root: Path,
47
+ payload: dict,
48
+ interval_seconds: float = 1.0,
49
+ assume_checked: bool = False,
50
+ cfg: Config | None = None,
51
+ descriptions_fingerprint: str | None = None,
52
+ ):
53
+ self.root = root
54
+ self.payload = payload
55
+ self.cfg = _resolve(root, cfg)
56
+ self.interval_seconds = max(0.0, interval_seconds)
57
+ self.last_checked = time.monotonic() if assume_checked else 0.0
58
+ # Neither authored input is an indexed source, so nothing in the
59
+ # file-stat comparison below can see either one change. An index built
60
+ # under different suffixes, ignores or vector width describes a
61
+ # different corpus; an index built from different descriptions serves
62
+ # text nobody wrote any more. Both are stale regardless of the walk.
63
+ self.config_changed = index_config_fingerprint(payload) != self.cfg.build_fingerprint
64
+ self.inputs_changed = self.config_changed or (
65
+ descriptions_fingerprint is not None and index_descriptions_fingerprint(payload) != descriptions_fingerprint
66
+ )
67
+ self.value = self.inputs_changed or bool(payload.get("stale", True))
68
+
69
+ def check(self, force: bool = False) -> bool:
70
+ if self.inputs_changed:
71
+ self.payload["stale"] = True
72
+ return True
73
+ now = time.monotonic()
74
+ if not force and self.last_checked and now - self.last_checked < self.interval_seconds:
75
+ return self.value
76
+ try:
77
+ stored_stats = self.payload.get("file_stats")
78
+ if isinstance(stored_stats, dict):
79
+ self.value = stored_stats != file_stats(self.root, self.cfg)
80
+ else:
81
+ self.value = self.payload.get("fingerprint") != fingerprint(self.root, self.cfg)
82
+ except OSError:
83
+ self.value = True
84
+ self.last_checked = now
85
+ self.payload["stale"] = self.value
86
+ return self.value
87
+
88
+
89
+ def index_config_fingerprint(payload: dict) -> str:
90
+ """The build fingerprint an index was published with.
91
+
92
+ An index written before 0.4.0 carries no such key, and by construction it
93
+ was built with the built-in defaults, so that is what a missing key means.
94
+ Treating it as unknown instead would force one pointless full rebuild on
95
+ every upgrade.
96
+ """
97
+ stored = payload.get("config_fingerprint")
98
+ return stored if isinstance(stored, str) else config_module.defaults().build_fingerprint
99
+
100
+
101
+ def iter_source_files(root: Path, cfg: Config | None = None):
102
+ cfg = _resolve(root, cfg)
103
+ ignored = set(cfg["index.ignore"])
104
+ suffixes = {suffix.lower() for suffix in cfg["index.suffixes"]}
105
+ max_bytes = cfg["index.max_file_bytes"]
106
+ for directory, dirs, files in os.walk(root):
107
+ dirs[:] = sorted(name for name in dirs if name not in ignored and not name.startswith("."))
108
+ for filename in sorted(files):
109
+ path = Path(directory) / filename
110
+ if path.suffix.lower() not in suffixes or path.is_symlink():
111
+ continue
112
+ try:
113
+ if path.stat().st_size > max_bytes:
114
+ continue
115
+ except OSError:
116
+ continue
117
+ yield path
118
+
119
+
120
+ @dataclass(frozen=True, slots=True)
121
+ class RepositorySnapshot:
122
+ """One walk of the repository, shared by parsing and by publication.
123
+
124
+ Parsing from one walk while publishing hashes from a second is what let an
125
+ index record a file's NEW hash beside units parsed from its OLD content: a
126
+ save landing between the two walks was invisible, `fingerprint` then
127
+ reported the index fresh, and every later incremental run reused the stale
128
+ units forever. Taking the snapshot once removes the window rather than
129
+ narrowing it, and drops a run from four tree walks to two.
130
+ """
131
+
132
+ paths: tuple[Path, ...]
133
+ fingerprints: dict[str, str]
134
+ stats: dict[str, list[int]]
135
+
136
+ @property
137
+ def fingerprint(self) -> str:
138
+ return _fingerprint_files(self.fingerprints)
139
+
140
+
141
+ def snapshot_repository(root: Path, cfg: Config | None = None) -> RepositorySnapshot:
142
+ """Hash, stat and collect every source file in a single pass."""
143
+ paths: list[Path] = []
144
+ fingerprints: dict[str, str] = {}
145
+ stats: dict[str, list[int]] = {}
146
+ for path in iter_source_files(root, cfg):
147
+ try:
148
+ stat = path.stat()
149
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
150
+ except OSError:
151
+ # A file that vanished or became unreadable between the walk and the
152
+ # read simply is not in this snapshot. Recording a half-read entry
153
+ # would reintroduce exactly the parse/publish mismatch this type exists
154
+ # to prevent.
155
+ continue
156
+ relative = path.relative_to(root).as_posix()
157
+ paths.append(path)
158
+ fingerprints[relative] = digest
159
+ stats[relative] = [stat.st_size, stat.st_mtime_ns]
160
+ return RepositorySnapshot(tuple(paths), fingerprints, stats)
161
+
162
+
163
+ def file_fingerprints(root: Path, cfg: Config | None = None) -> dict[str, str]:
164
+ return snapshot_repository(root, cfg).fingerprints
165
+
166
+
167
+ def file_stats(root: Path, cfg: Config | None = None) -> dict[str, list[int]]:
168
+ """Size and mtime only -- deliberately NOT routed through the snapshot.
169
+
170
+ StaleMonitor asks "has anything changed?" many times per session and needs
171
+ no content consistency with a parse. Routing it through snapshot_repository
172
+ made every stale check SHA-256 the whole repository, which measured a 2.5x
173
+ regression (69 ms -> 172 ms at 10k units).
174
+ """
175
+ result: dict[str, list[int]] = {}
176
+ for path in iter_source_files(root, cfg):
177
+ try:
178
+ stat = path.stat()
179
+ except OSError:
180
+ # A file that disappeared mid-walk is simply absent from this
181
+ # reading; the next check will see the directory as it then is.
182
+ continue
183
+ result[path.relative_to(root).as_posix()] = [stat.st_size, stat.st_mtime_ns]
184
+ return result
185
+
186
+
187
+ def _assign_global_serials(units: list[CodeUnit], previous: dict[str, int] | None = None) -> None:
188
+ previous = previous or {}
189
+ used: set[int] = set()
190
+ assigned: set[str] = set()
191
+ next_serial = max(previous.values(), default=0) + 1
192
+ for unit in sorted(units, key=lambda item: (item.path, item.start_line, item.qualified_name, item.id)):
193
+ old = previous.get(unit.id)
194
+ if old is not None and old > 0 and old not in used:
195
+ unit.serial = old
196
+ used.add(old)
197
+ assigned.add(unit.id)
198
+ for unit in sorted(units, key=lambda item: (item.path, item.start_line, item.qualified_name, item.id)):
199
+ if unit.id in assigned:
200
+ continue
201
+ while next_serial in used:
202
+ next_serial += 1
203
+ unit.serial = next_serial
204
+ used.add(next_serial)
205
+ next_serial += 1
206
+
207
+
208
+ def build_units(
209
+ root: Path,
210
+ previous_units: list[CodeUnit] | None = None,
211
+ previous_files: dict[str, str] | None = None,
212
+ diagnostics: list[dict] | None = None,
213
+ snapshot: RepositorySnapshot | None = None,
214
+ cfg: Config | None = None,
215
+ previous_config: str | None = None,
216
+ descriptions: "DescriptionStore | None" = None,
217
+ ) -> list[CodeUnit]:
218
+ """Build units, reusing unchanged files and stable serials when possible.
219
+
220
+ Pass the same ``snapshot`` to ``write_index`` so the hashes published
221
+ describe exactly the bytes these units were parsed from.
222
+
223
+ ``previous_config`` is the build fingerprint the previous index was
224
+ published with. When it disagrees with the current one the previous units
225
+ describe a different corpus -- different suffixes, ignores, size cap or
226
+ vector width -- so they are discarded rather than reused. Reuse is keyed on
227
+ file content, which cannot notice that the rules changed.
228
+ """
229
+ cfg = _resolve(root, cfg)
230
+ if previous_config is not None and previous_config != cfg.build_fingerprint:
231
+ previous_units, previous_files = None, None
232
+ dimensions = cfg["embedding.dimensions"]
233
+ snapshot = snapshot or snapshot_repository(root, cfg)
234
+ current_files = snapshot.fingerprints
235
+ old_by_path: dict[str, list[CodeUnit]] = {}
236
+ for unit in previous_units or []:
237
+ old_by_path.setdefault(unit.path, []).append(unit)
238
+ units: list[CodeUnit] = []
239
+ for path in snapshot.paths:
240
+ relative = path.relative_to(root).as_posix()
241
+ if previous_files and previous_files.get(relative) == current_files.get(relative) and relative in old_by_path:
242
+ units.extend(replace(unit) for unit in old_by_path[relative])
243
+ else:
244
+ units.extend(parse_file(path, root, diagnostics))
245
+ previous_serials = {unit.id: unit.serial for unit in previous_units or []}
246
+ _assign_global_serials(units, previous_serials)
247
+ # Authored descriptions are checked against the units they describe, which
248
+ # is why the store is filtered here rather than by the caller: the digest
249
+ # comparison needs the parsed units, and the parsed units do not exist
250
+ # until this point.
251
+ authored = descriptions.applicable(units) if descriptions is not None else {}
252
+ for unit in units:
253
+ # The generated description is replaced before the vector is computed.
254
+ # `description` is part of `searchable_text`, so embedding first would
255
+ # produce a vector for the sentence the agent replaced.
256
+ text = authored.get(unit.id)
257
+ if text and unit.description != text:
258
+ unit.description = text
259
+ unit.vector = []
260
+ if previous_serials.get(unit.id, unit.serial) != unit.serial or len(unit.vector) != dimensions:
261
+ unit.vector = []
262
+ if not unit.vector:
263
+ # Embed the numbered sidecar comment together with source/context so
264
+ # retrieval is grounded in the same records users can review.
265
+ unit.vector = embed(comment_for(unit.description, unit.serial, unit.id) + "\n" + unit.searchable_text, dimensions)
266
+ return sorted(units, key=lambda item: (item.serial, item.id))
267
+
268
+
269
+ def fingerprint(root: Path, cfg: Config | None = None) -> str:
270
+ return _fingerprint_files(file_fingerprints(root, cfg))
271
+
272
+
273
+ def _fingerprint_files(files: dict[str, str]) -> str:
274
+ digest = hashlib.sha256()
275
+ for relative, file_hash in sorted(files.items()):
276
+ digest.update(relative.encode())
277
+ digest.update(file_hash.encode())
278
+ return digest.hexdigest()
279
+
280
+
281
+ def write_index(
282
+ path: Path,
283
+ root: Path,
284
+ units: list[CodeUnit],
285
+ graph: dict | None = None,
286
+ compact: bool = False,
287
+ diagnostics: list[dict] | None = None,
288
+ snapshot: RepositorySnapshot | None = None,
289
+ cfg: Config | None = None,
290
+ descriptions_fingerprint: str | None = None,
291
+ ) -> None:
292
+ cfg = _resolve(root, cfg)
293
+ path.parent.mkdir(parents=True, exist_ok=True)
294
+ snapshot = snapshot or snapshot_repository(root, cfg)
295
+ files = snapshot.fingerprints
296
+ repository_fingerprint = snapshot.fingerprint
297
+ dimensions = len(units[0].vector) if units and units[0].vector else cfg["embedding.dimensions"]
298
+ serialized_units = [unit.to_dict(include_vector=not compact) for unit in units]
299
+ vector_store = None
300
+ if compact and units:
301
+ vector_temp = path.with_name(f"{path.stem}.vectors.{os.getpid()}.tmp")
302
+ vector_digest = hashlib.sha256()
303
+ with vector_temp.open("wb") as stream:
304
+ for unit in units:
305
+ vector = unit.vector or [0.0] * dimensions
306
+ if len(vector) != dimensions:
307
+ raise ValueError("all vectors must have the same dimensions")
308
+ packed = struct.pack(f"<{dimensions}f", *vector)
309
+ vector_digest.update(packed)
310
+ stream.write(packed)
311
+ vector_path = path.with_name(
312
+ f"{path.stem}.{repository_fingerprint[:8]}.{vector_digest.hexdigest()[:16]}.vectors.bin"
313
+ )
314
+ vector_temp.replace(vector_path)
315
+ vector_store = {
316
+ "path": vector_path.name,
317
+ "dimensions": dimensions,
318
+ "dtype": "float32-le",
319
+ "count": len(units),
320
+ }
321
+ payload = {
322
+ "schema": 2,
323
+ "root": str(root.resolve()),
324
+ "fingerprint": repository_fingerprint,
325
+ "config_fingerprint": cfg.build_fingerprint,
326
+ "descriptions_fingerprint": descriptions_fingerprint,
327
+ "files": files,
328
+ "file_stats": snapshot.stats,
329
+ "dimensions": dimensions,
330
+ "embedding": embedding_metadata(dimensions),
331
+ "units": serialized_units,
332
+ "graph": graph or {"edges": []},
333
+ "diagnostics": diagnostics or [],
334
+ }
335
+ if vector_store:
336
+ payload["vector_store"] = vector_store
337
+ index_temp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
338
+ with index_temp.open("w", encoding="utf-8", newline="\n") as stream:
339
+ json.dump(payload, stream, ensure_ascii=False, indent=2)
340
+ index_temp.replace(path)
341
+ _remove_superseded_vectors(path, vector_store["path"] if vector_store else None)
342
+
343
+
344
+ def _remove_superseded_vectors(path: Path, active_name: str | None) -> None:
345
+ """Delete this index's own outdated vector sidecars.
346
+
347
+ The set of files to remove is derived from the naming scheme ``write_index``
348
+ itself uses (``<stem>.<fingerprint>.<digest>.vectors.bin``) and never from a
349
+ path read back out of the index being replaced. That index lives inside the
350
+ repository being scanned, so a repository can ship one; trusting its
351
+ ``vector_store.path`` turned publication into an arbitrary in-tree delete.
352
+ Enumerating instead of trusting also reclaims sidecars orphaned by an
353
+ earlier run whose index.json was unreadable or absent.
354
+ """
355
+ prefix, suffix = f"{path.stem}.", ".vectors.bin"
356
+ try:
357
+ candidates = list(path.parent.iterdir())
358
+ except OSError:
359
+ # The directory was just written to successfully, so an unreadable
360
+ # parent here means a concurrent removal. Cleanup is best-effort by
361
+ # design; the freshly published index is already valid without it.
362
+ return
363
+ for candidate in candidates:
364
+ if candidate.name == active_name or not candidate.name.startswith(prefix) or not candidate.name.endswith(suffix):
365
+ continue
366
+ try:
367
+ candidate.unlink()
368
+ except OSError:
369
+ # Sidecars are content-addressed, so one that cannot be unlinked
370
+ # (a concurrent reader holding it open on Windows) is inert: no
371
+ # index points at it. Leaking a file beats failing the publish.
372
+ continue
373
+
374
+
375
+ def read_index(path: Path) -> tuple[dict, list[CodeUnit]]:
376
+ payload = json.loads(path.read_text(encoding="utf-8"))
377
+ if not isinstance(payload, dict):
378
+ raise ValueError("index root must be a JSON object")
379
+ stored_units = payload.get("units", [])
380
+ if not isinstance(stored_units, list) or any(not isinstance(item, dict) for item in stored_units):
381
+ raise ValueError("index units must be a list of objects")
382
+ raw_units = [dict(item) for item in stored_units]
383
+ vector_store = payload.get("vector_store")
384
+ payload["degraded"] = None
385
+ if isinstance(vector_store, dict):
386
+ vector_path = (path.parent / str(vector_store.get("path", ""))).resolve()
387
+ dimensions = int(vector_store.get("dimensions", payload.get("dimensions", 384)))
388
+ count = len(raw_units)
389
+ try:
390
+ vector_path.relative_to(path.parent.resolve())
391
+ if not 32 <= dimensions <= 4096:
392
+ raise ValueError("invalid vector dimensions")
393
+ raw_vectors = vector_path.read_bytes()
394
+ expected = count * dimensions * 4
395
+ if len(raw_vectors) != expected:
396
+ raise ValueError("vector sidecar length does not match index")
397
+ if sys.byteorder == "little":
398
+ values = memoryview(raw_vectors).cast("f")
399
+ else: # The sidecar contract is explicitly little-endian.
400
+ native_values = array("f")
401
+ native_values.frombytes(raw_vectors)
402
+ native_values.byteswap()
403
+ values = memoryview(native_values)
404
+ for index, item in enumerate(raw_units):
405
+ start = index * dimensions
406
+ item["vector"] = values[start : start + dimensions]
407
+ except (OSError, ValueError, struct.error):
408
+ payload["degraded"] = "vector_store_unavailable"
409
+ for item in raw_units:
410
+ item["vector"] = []
411
+ return payload, [CodeUnit.from_dict(item) for item in raw_units]
ragyourcode/models.py ADDED
@@ -0,0 +1,86 @@
1
+ """Data contracts used by indexing, storage, and agent integrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class CodeUnit:
12
+ """One searchable code unit and its generated, explainable description."""
13
+
14
+ id: str
15
+ path: str
16
+ language: str
17
+ kind: str
18
+ name: str
19
+ qualified_name: str
20
+ signature: str
21
+ start_line: int
22
+ end_line: int
23
+ source: str
24
+ description: str
25
+ serial: int
26
+ parent: str | None = None
27
+ calls: list[str] = field(default_factory=list)
28
+ imports: list[str] = field(default_factory=list)
29
+ vector: Sequence[float] = field(default_factory=list)
30
+
31
+ @property
32
+ def searchable_text(self) -> str:
33
+ return "\n".join(
34
+ (
35
+ f"{self.qualified_name} {self.kind} {self.signature}",
36
+ self.description,
37
+ "calls: " + " ".join(self.calls),
38
+ "imports: " + " ".join(self.imports),
39
+ self.source,
40
+ )
41
+ )
42
+
43
+ def to_dict(self, include_vector: bool = True) -> dict[str, Any]:
44
+ data = {
45
+ "id": self.id,
46
+ "path": self.path,
47
+ "language": self.language,
48
+ "kind": self.kind,
49
+ "name": self.name,
50
+ "qualified_name": self.qualified_name,
51
+ "signature": self.signature,
52
+ "start_line": self.start_line,
53
+ "end_line": self.end_line,
54
+ "source": self.source,
55
+ "description": self.description,
56
+ "serial": self.serial,
57
+ "parent": self.parent,
58
+ "calls": self.calls,
59
+ "imports": self.imports,
60
+ }
61
+ if include_vector:
62
+ data["vector"] = list(self.vector)
63
+ return data
64
+
65
+ @classmethod
66
+ def from_dict(cls, data: dict[str, Any]) -> "CodeUnit":
67
+ return cls(**data)
68
+
69
+
70
+ @dataclass(slots=True)
71
+ class SearchResult:
72
+ unit: CodeUnit
73
+ score: float
74
+ matched_terms: list[str] = field(default_factory=list)
75
+ evidence: list[str] = field(default_factory=list)
76
+
77
+ def to_dict(self) -> dict[str, Any]:
78
+ # Vectors are persisted for ranking but are an internal detail of the
79
+ # index; returning them would needlessly consume an agent's context.
80
+ unit = self.unit.to_dict(include_vector=False)
81
+ return {
82
+ "score": round(self.score, 6),
83
+ "matched_terms": self.matched_terms,
84
+ "evidence": self.evidence,
85
+ "unit": unit,
86
+ }