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.
- rag_your_code-0.4.1.dist-info/METADATA +237 -0
- rag_your_code-0.4.1.dist-info/RECORD +19 -0
- rag_your_code-0.4.1.dist-info/WHEEL +5 -0
- rag_your_code-0.4.1.dist-info/entry_points.txt +2 -0
- rag_your_code-0.4.1.dist-info/licenses/LICENSE +21 -0
- rag_your_code-0.4.1.dist-info/top_level.txt +1 -0
- ragyourcode/__init__.py +6 -0
- ragyourcode/agentic.py +55 -0
- ragyourcode/annotate.py +35 -0
- ragyourcode/cli.py +575 -0
- ragyourcode/config.py +572 -0
- ragyourcode/descriptions.py +248 -0
- ragyourcode/embeddings.py +60 -0
- ragyourcode/graph.py +198 -0
- ragyourcode/indexer.py +411 -0
- ragyourcode/models.py +86 -0
- ragyourcode/parser.py +485 -0
- ragyourcode/py.typed +0 -0
- ragyourcode/search.py +131 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Agent-authored unit descriptions.
|
|
2
|
+
|
|
3
|
+
`annotate.py` says it in its own first line: it describes a unit *without an
|
|
4
|
+
LLM*. It humanises the identifier, lists parameter and callee names, and
|
|
5
|
+
appends the docstring verbatim -- so it introduces no vocabulary that was not
|
|
6
|
+
already in the source. That is precisely why retrieval cannot reach a concept
|
|
7
|
+
the author never wrote down: the embedder is a feature hash, so cosine measures
|
|
8
|
+
token overlap, and a query sharing no token with a unit scores exactly zero
|
|
9
|
+
against it.
|
|
10
|
+
|
|
11
|
+
The agent already consuming this index can write those words. This module
|
|
12
|
+
stores what it writes, keyed by unit id **and a digest of the unit's source**,
|
|
13
|
+
so a description can never be applied to code it does not describe. When the
|
|
14
|
+
source moves, the entry is retained but not used, and the unit reappears in the
|
|
15
|
+
pending queue; incremental indexing means only the units in changed files do.
|
|
16
|
+
|
|
17
|
+
Stored at the repository root as ``rag-your-code.descriptions.json``, beside
|
|
18
|
+
``rag-your-code.toml`` and for the same two reasons: it is authored rather than
|
|
19
|
+
generated, and ``.rag-your-code/`` is both ignored by Git and the directory
|
|
20
|
+
people delete to clear the cache.
|
|
21
|
+
|
|
22
|
+
What this is, and is not: it moves the semantic work from query time to index
|
|
23
|
+
time. Matching stays lexical. A description saying `retry` still cannot answer
|
|
24
|
+
a query saying `resend` unless the description also says `resend`, which is why
|
|
25
|
+
the guidance handed to the agent asks for the words a reader would search by
|
|
26
|
+
rather than a restatement of the code.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from .models import CodeUnit
|
|
37
|
+
|
|
38
|
+
STORE_FILENAME = "rag-your-code.descriptions.json"
|
|
39
|
+
SCHEMA = 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def source_key(unit: CodeUnit) -> str:
|
|
43
|
+
"""Digest of the exact text a description was written about."""
|
|
44
|
+
return hashlib.sha256(unit.source.encode("utf-8")).hexdigest()[:16]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def guidance(languages: tuple[str, ...] | list[str], max_chars: int) -> str:
|
|
48
|
+
"""The instruction handed to the agent with every pending batch.
|
|
49
|
+
|
|
50
|
+
It is returned by the protocol rather than living only in SKILL.md so that
|
|
51
|
+
an agent reaching this index through any host receives the same brief, and
|
|
52
|
+
so the brief cannot drift away from the ``describe.*`` settings that shape
|
|
53
|
+
it.
|
|
54
|
+
"""
|
|
55
|
+
names = {"en": "English", "zh": "Chinese"}
|
|
56
|
+
written = ", ".join(names.get(code, code) for code in languages)
|
|
57
|
+
return (
|
|
58
|
+
f"Write one description per unit, in {written}, at most {max_chars} characters total. "
|
|
59
|
+
"Retrieval over these descriptions is lexical: a query matches a unit only when they "
|
|
60
|
+
"share words. So write the words a person would search by -- what the unit is for in "
|
|
61
|
+
"domain terms, the operation it performs, the failure it handles, and the obvious "
|
|
62
|
+
"synonyms for each. Do not restate the signature, do not name the parameters, and do "
|
|
63
|
+
"not describe behaviour the source does not show; the source is included so you can "
|
|
64
|
+
"check. If a unit is trivial, say so briefly rather than padding it."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class DescriptionStore:
|
|
70
|
+
"""Authored descriptions, addressed by unit id."""
|
|
71
|
+
|
|
72
|
+
path: Path
|
|
73
|
+
entries: dict[str, dict]
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def fingerprint(self) -> str:
|
|
77
|
+
"""Digest of the authored text, so an index can tell when it changed.
|
|
78
|
+
|
|
79
|
+
Importing descriptions touches no source file, so nothing the index
|
|
80
|
+
tracks moves and it would go on serving the previous text until someone
|
|
81
|
+
happened to rebuild. This is the same failure the configuration
|
|
82
|
+
fingerprint exists to prevent, in a second authored input.
|
|
83
|
+
"""
|
|
84
|
+
# An empty store is the empty string rather than the digest of no
|
|
85
|
+
# bytes, so that it equals what `index_descriptions_fingerprint` reads
|
|
86
|
+
# out of an index written before this field existed. Otherwise every
|
|
87
|
+
# index predating 0.4.0 reports itself permanently stale over
|
|
88
|
+
# descriptions that neither it nor the repository has.
|
|
89
|
+
if not self.entries:
|
|
90
|
+
return ""
|
|
91
|
+
digest = hashlib.sha256()
|
|
92
|
+
for uid, entry in sorted(self.entries.items()):
|
|
93
|
+
digest.update(uid.encode("utf-8"))
|
|
94
|
+
digest.update(str(entry.get("hash", "")).encode("utf-8"))
|
|
95
|
+
digest.update(str(entry.get("text", "")).encode("utf-8"))
|
|
96
|
+
return digest.hexdigest()
|
|
97
|
+
|
|
98
|
+
def _relocations(self) -> dict[tuple[str, str], str | None]:
|
|
99
|
+
"""Stored text addressed by file and code digest rather than by unit id.
|
|
100
|
+
|
|
101
|
+
A unit id embeds the line the declaration starts on, so inserting a
|
|
102
|
+
comment or an import near the top of a file changes the id of
|
|
103
|
+
everything below it while changing none of their code. Keyed only by
|
|
104
|
+
id, every description in that file would be orphaned by an edit that
|
|
105
|
+
did not touch a single one of the things they describe -- which is
|
|
106
|
+
what adding a seven-line comment to config.py did to nineteen of them.
|
|
107
|
+
|
|
108
|
+
The digest already answers "is this the same code?"; this uses it to
|
|
109
|
+
answer "where did that code go?" as well. Two units in one file with
|
|
110
|
+
byte-identical source and different stored text are ambiguous, and map
|
|
111
|
+
to nothing rather than to a guess.
|
|
112
|
+
"""
|
|
113
|
+
table: dict[tuple[str, str], str | None] = {}
|
|
114
|
+
for entry in self.entries.values():
|
|
115
|
+
path, digest = entry.get("path"), entry.get("hash")
|
|
116
|
+
if not isinstance(path, str) or not isinstance(digest, str):
|
|
117
|
+
continue
|
|
118
|
+
text = entry.get("text")
|
|
119
|
+
key = (path, digest)
|
|
120
|
+
if key in table and table[key] != text:
|
|
121
|
+
table[key] = None
|
|
122
|
+
else:
|
|
123
|
+
table.setdefault(key, text)
|
|
124
|
+
return table
|
|
125
|
+
|
|
126
|
+
def applicable(self, units: list[CodeUnit]) -> dict[str, str]:
|
|
127
|
+
"""Descriptions whose digest still matches the unit they describe."""
|
|
128
|
+
relocated = self._relocations()
|
|
129
|
+
usable: dict[str, str] = {}
|
|
130
|
+
for unit in units:
|
|
131
|
+
digest = source_key(unit)
|
|
132
|
+
entry = self.entries.get(unit.id)
|
|
133
|
+
text = entry.get("text") if entry and entry.get("hash") == digest else None
|
|
134
|
+
if text is None:
|
|
135
|
+
text = relocated.get((unit.path, digest))
|
|
136
|
+
if isinstance(text, str) and text.strip():
|
|
137
|
+
usable[unit.id] = text
|
|
138
|
+
return usable
|
|
139
|
+
|
|
140
|
+
def classify(self, units: list[CodeUnit]) -> dict[str, list[CodeUnit]]:
|
|
141
|
+
"""Split units into described / superseded / missing.
|
|
142
|
+
|
|
143
|
+
``superseded`` is the interesting one: something was written about this
|
|
144
|
+
declaration and the code has since changed, so it is deliberately not
|
|
145
|
+
applied. It is distinguished from ``missing`` by name rather than by
|
|
146
|
+
id, for the same reason applicability is: an id changes when the lines
|
|
147
|
+
above it do.
|
|
148
|
+
"""
|
|
149
|
+
usable = self.applicable(units)
|
|
150
|
+
written = {(entry.get("path"), entry.get("name")) for entry in self.entries.values()}
|
|
151
|
+
described: list[CodeUnit] = []
|
|
152
|
+
superseded: list[CodeUnit] = []
|
|
153
|
+
missing: list[CodeUnit] = []
|
|
154
|
+
for unit in units:
|
|
155
|
+
if unit.id in usable:
|
|
156
|
+
described.append(unit)
|
|
157
|
+
elif self.entries.get(unit.id) or (unit.path, unit.qualified_name) in written:
|
|
158
|
+
superseded.append(unit)
|
|
159
|
+
else:
|
|
160
|
+
missing.append(unit)
|
|
161
|
+
return {"described": described, "superseded": superseded, "missing": missing}
|
|
162
|
+
|
|
163
|
+
def pending(self, units: list[CodeUnit], limit: int) -> list[CodeUnit]:
|
|
164
|
+
"""Units still needing a description, missing ones before stale ones.
|
|
165
|
+
|
|
166
|
+
A unit with no description at all is worth more than a refresh of one
|
|
167
|
+
that exists, so a budget-limited agent spends its first batches where
|
|
168
|
+
retrieval is currently blind.
|
|
169
|
+
"""
|
|
170
|
+
groups = self.classify(units)
|
|
171
|
+
return (groups["missing"] + groups["superseded"])[: max(0, limit)]
|
|
172
|
+
|
|
173
|
+
def put(self, unit: CodeUnit, text: str) -> None:
|
|
174
|
+
self.entries[unit.id] = {
|
|
175
|
+
"hash": source_key(unit),
|
|
176
|
+
"path": unit.path,
|
|
177
|
+
"name": unit.qualified_name,
|
|
178
|
+
"text": text,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
def save(self, units: list[CodeUnit] | None = None) -> None:
|
|
182
|
+
"""Write the store, dropping entries for units that no longer exist.
|
|
183
|
+
|
|
184
|
+
Pruning needs the full unit list to be safe, so it only happens when a
|
|
185
|
+
caller supplies one; a partial list would silently discard the
|
|
186
|
+
descriptions of everything it omitted.
|
|
187
|
+
|
|
188
|
+
An entry is kept when its id is still live *or* its code is, since a
|
|
189
|
+
declaration that merely moved down the file has a new id and the same
|
|
190
|
+
digest. Pruning on ids alone would have deleted exactly the entries
|
|
191
|
+
the relocation lookup exists to rescue.
|
|
192
|
+
"""
|
|
193
|
+
if units is not None:
|
|
194
|
+
live_ids = {unit.id for unit in units}
|
|
195
|
+
live_code = {(unit.path, source_key(unit)) for unit in units}
|
|
196
|
+
self.entries = {
|
|
197
|
+
uid: entry
|
|
198
|
+
for uid, entry in self.entries.items()
|
|
199
|
+
if uid in live_ids or (entry.get("path"), entry.get("hash")) in live_code
|
|
200
|
+
}
|
|
201
|
+
payload = {
|
|
202
|
+
"schema": SCHEMA,
|
|
203
|
+
"descriptions": dict(sorted(self.entries.items())),
|
|
204
|
+
}
|
|
205
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
temp = self.path.with_name(f"{self.path.name}.tmp")
|
|
207
|
+
with temp.open("w", encoding="utf-8", newline="\n") as stream:
|
|
208
|
+
json.dump(payload, stream, ensure_ascii=False, indent=2, sort_keys=False)
|
|
209
|
+
stream.write("\n")
|
|
210
|
+
temp.replace(self.path)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def index_descriptions_fingerprint(payload: dict) -> str:
|
|
214
|
+
"""The descriptions digest an index was published with.
|
|
215
|
+
|
|
216
|
+
An index written before 0.4.0 has no such key, which means the same thing
|
|
217
|
+
as an empty store: no authored description was applied.
|
|
218
|
+
"""
|
|
219
|
+
stored = payload.get("descriptions_fingerprint")
|
|
220
|
+
return stored if isinstance(stored, str) else ""
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def store_path(root: Path) -> Path:
|
|
224
|
+
return root / STORE_FILENAME
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def load(root: Path) -> DescriptionStore:
|
|
228
|
+
"""Read the store, treating an unusable file as empty rather than fatal.
|
|
229
|
+
|
|
230
|
+
This file is committed, so it will meet merge conflicts and hand-editing. A
|
|
231
|
+
malformed one costs retrieval quality, which is recoverable by describing
|
|
232
|
+
again; refusing to search until it is repaired would not be.
|
|
233
|
+
"""
|
|
234
|
+
path = store_path(root)
|
|
235
|
+
entries: dict[str, dict] = {}
|
|
236
|
+
if path.is_file():
|
|
237
|
+
try:
|
|
238
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
239
|
+
stored = payload.get("descriptions") if isinstance(payload, dict) else None
|
|
240
|
+
if isinstance(stored, dict):
|
|
241
|
+
entries = {
|
|
242
|
+
str(uid): entry
|
|
243
|
+
for uid, entry in stored.items()
|
|
244
|
+
if isinstance(entry, dict) and isinstance(entry.get("text"), str)
|
|
245
|
+
}
|
|
246
|
+
except (OSError, ValueError):
|
|
247
|
+
entries = {}
|
|
248
|
+
return DescriptionStore(path, entries)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Dependency-free deterministic embeddings.
|
|
2
|
+
|
|
3
|
+
The default hasher is deliberately local and reproducible. It is useful for
|
|
4
|
+
small/medium repositories and can later be replaced by an API or sentence
|
|
5
|
+
transformer without changing the index schema.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
|
|
15
|
+
TOKEN_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*|[\u4e00-\u9fff]+|\d+")
|
|
16
|
+
CJK_RE = re.compile(r"^[\u4e00-\u9fff]+$")
|
|
17
|
+
DEFAULT_DIMENSIONS = 384
|
|
18
|
+
EMBEDDING_PROVIDER = "signed-feature-hash"
|
|
19
|
+
EMBEDDING_VERSION = 1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def embedding_metadata(dimensions: int = DEFAULT_DIMENSIONS) -> dict[str, object]:
|
|
23
|
+
return {"provider": EMBEDDING_PROVIDER, "version": EMBEDDING_VERSION, "dimensions": dimensions}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def tokenize(text: str) -> list[str]:
|
|
27
|
+
tokens: list[str] = []
|
|
28
|
+
for raw in TOKEN_RE.findall(text):
|
|
29
|
+
token = raw.lower()
|
|
30
|
+
tokens.append(token)
|
|
31
|
+
if CJK_RE.fullmatch(token) and len(token) > 1:
|
|
32
|
+
tokens.extend(token[index : index + 2] for index in range(len(token) - 1))
|
|
33
|
+
return tokens
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _bucket(token: str, dimensions: int) -> int:
|
|
37
|
+
digest = hashlib.blake2b(token.encode("utf-8"), digest_size=8).digest()
|
|
38
|
+
return int.from_bytes(digest, "big") % dimensions
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def embed(text: str, dimensions: int = DEFAULT_DIMENSIONS) -> list[float]:
|
|
42
|
+
"""Return a normalized signed feature-hash vector."""
|
|
43
|
+
if dimensions < 32:
|
|
44
|
+
raise ValueError("dimensions must be at least 32")
|
|
45
|
+
tokens = tokenize(text)
|
|
46
|
+
vector = [0.0] * dimensions
|
|
47
|
+
for token in tokens:
|
|
48
|
+
index = _bucket(token, dimensions)
|
|
49
|
+
sign = 1.0 if _bucket("sign:" + token, dimensions) % 2 else -1.0
|
|
50
|
+
vector[index] += sign
|
|
51
|
+
norm = math.sqrt(sum(value * value for value in vector))
|
|
52
|
+
if norm:
|
|
53
|
+
vector = [value / norm for value in vector]
|
|
54
|
+
return vector
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cosine(left: Sequence[float], right: Sequence[float]) -> float:
|
|
58
|
+
if not left or not right or len(left) != len(right):
|
|
59
|
+
return 0.0
|
|
60
|
+
return sum(a * b for a, b in zip(left, right))
|
ragyourcode/graph.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Explainable symbol graph used by graph-aware retrieval.
|
|
2
|
+
|
|
3
|
+
The graph is intentionally derived from the parser's stable ``CodeUnit`` IDs.
|
|
4
|
+
Edges are conservative: unresolved calls are omitted rather than guessed. This
|
|
5
|
+
keeps graph expansion useful for navigation without fabricating relationships.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import defaultdict, deque
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Iterable
|
|
13
|
+
|
|
14
|
+
from .models import CodeUnit, SearchResult
|
|
15
|
+
from .search import DEFAULT_VECTOR_WEIGHT, SearchIndex, search
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class CodeEdge:
|
|
20
|
+
source: str
|
|
21
|
+
target: str
|
|
22
|
+
kind: str
|
|
23
|
+
label: str = ""
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, str]:
|
|
26
|
+
return {"source": self.source, "target": self.target, "kind": self.kind, "label": self.label}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CodeGraph:
|
|
30
|
+
"""Directed code relationship graph with bounded neighborhood traversal."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, units: Iterable[CodeUnit], edges: Iterable[CodeEdge] = ()):
|
|
33
|
+
self.units = {unit.id: unit for unit in units}
|
|
34
|
+
self.edges = sorted(set(edges), key=lambda edge: (edge.source, edge.kind, edge.target))
|
|
35
|
+
self._out: dict[str, list[CodeEdge]] = defaultdict(list)
|
|
36
|
+
self._in: dict[str, list[CodeEdge]] = defaultdict(list)
|
|
37
|
+
for edge in self.edges:
|
|
38
|
+
if edge.source in self.units and edge.target in self.units:
|
|
39
|
+
self._out[edge.source].append(edge)
|
|
40
|
+
self._in[edge.target].append(edge)
|
|
41
|
+
|
|
42
|
+
def neighbors(self, unit_id: str, hops: int = 1, direction: str = "both") -> list[tuple[CodeUnit, list[str]]]:
|
|
43
|
+
if unit_id not in self.units or hops <= 0:
|
|
44
|
+
return []
|
|
45
|
+
directions = {"out"} if direction == "out" else {"in"} if direction == "in" else {"out", "in"}
|
|
46
|
+
queue: deque[tuple[str, int, list[str]]] = deque([(unit_id, 0, [unit_id])])
|
|
47
|
+
seen = {unit_id}
|
|
48
|
+
found: list[tuple[CodeUnit, list[str]]] = []
|
|
49
|
+
while queue:
|
|
50
|
+
current, distance, path = queue.popleft()
|
|
51
|
+
if distance >= hops:
|
|
52
|
+
continue
|
|
53
|
+
edges: list[CodeEdge] = []
|
|
54
|
+
if "out" in directions:
|
|
55
|
+
edges.extend(self._out.get(current, []))
|
|
56
|
+
if "in" in directions:
|
|
57
|
+
edges.extend(self._in.get(current, []))
|
|
58
|
+
for edge in edges:
|
|
59
|
+
target = edge.target if edge.source == current else edge.source
|
|
60
|
+
if target in seen:
|
|
61
|
+
continue
|
|
62
|
+
seen.add(target)
|
|
63
|
+
edge_text = f"{edge.kind}:{edge.source}->{edge.target}"
|
|
64
|
+
next_path = path + [edge_text, target]
|
|
65
|
+
found.append((self.units[target], next_path))
|
|
66
|
+
queue.append((target, distance + 1, next_path))
|
|
67
|
+
return found
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict[str, object]:
|
|
70
|
+
return {"edges": [edge.to_dict() for edge in self.edges]}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _name_indexes(units: Iterable[CodeUnit]):
|
|
74
|
+
by_name: dict[str, list[str]] = defaultdict(list)
|
|
75
|
+
by_module: dict[str, list[str]] = defaultdict(list)
|
|
76
|
+
for unit in units:
|
|
77
|
+
by_name[unit.name].append(unit.id)
|
|
78
|
+
if unit.qualified_name != unit.name:
|
|
79
|
+
by_name[unit.qualified_name].append(unit.id)
|
|
80
|
+
module = unit.path.rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
|
81
|
+
by_module[module].append(unit.id)
|
|
82
|
+
return by_name, by_module
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _call_targets(call: str, by_name: dict[str, list[str]], local_modules: set[str], unit: CodeUnit, unit_by_id: dict[str, CodeUnit]) -> list[str]:
|
|
86
|
+
"""Resolve a recorded call to local unit ids, guessing only where allowed.
|
|
87
|
+
|
|
88
|
+
An exact match on the text the parser recorded always wins. Falling back to
|
|
89
|
+
the last dotted segment is a guess, and an unrestricted guess is how
|
|
90
|
+
`os.path.join` acquired a `calls` edge to an unrelated local `join` -- while
|
|
91
|
+
this module's own docstring promises that unresolved calls are omitted
|
|
92
|
+
rather than guessed. The fallback now requires the head of the dotted path
|
|
93
|
+
to be attributable to this repository: `self`/`cls`, or a module some file
|
|
94
|
+
here actually defines. `os`, `json`, `requests` and every other foreign
|
|
95
|
+
prefix resolve to nothing, as documented.
|
|
96
|
+
"""
|
|
97
|
+
exact = by_name.get(call)
|
|
98
|
+
if exact:
|
|
99
|
+
return list(exact)
|
|
100
|
+
if "." not in call:
|
|
101
|
+
return []
|
|
102
|
+
head = call.split(".", 1)[0]
|
|
103
|
+
leaf = call.rsplit(".", 1)[-1]
|
|
104
|
+
if head in {"self", "cls"}:
|
|
105
|
+
# A receiver call names a sibling defined alongside this unit.
|
|
106
|
+
return [target for target in by_name.get(leaf, []) if unit_by_id[target].path == unit.path]
|
|
107
|
+
if head in local_modules:
|
|
108
|
+
return list(by_name.get(leaf, []))
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_graph(units: Iterable[CodeUnit], max_edges_per_unit: int = 64) -> CodeGraph:
|
|
113
|
+
units = list(units)
|
|
114
|
+
unit_by_id = {unit.id: unit for unit in units}
|
|
115
|
+
by_name, by_module = _name_indexes(units)
|
|
116
|
+
local_modules = set(by_module)
|
|
117
|
+
edges: set[CodeEdge] = set()
|
|
118
|
+
for unit in units:
|
|
119
|
+
budget = max(1, max_edges_per_unit)
|
|
120
|
+
if unit.parent:
|
|
121
|
+
parent_ids = by_name.get(unit.parent, [])
|
|
122
|
+
same_file_parents = [target for target in parent_ids if unit_by_id[target].path == unit.path]
|
|
123
|
+
resolved_parents = same_file_parents if len(same_file_parents) == 1 else parent_ids if len(parent_ids) == 1 else []
|
|
124
|
+
for parent_id in resolved_parents:
|
|
125
|
+
edges.add(CodeEdge(parent_id, unit.id, "contains", unit.qualified_name))
|
|
126
|
+
budget -= 1
|
|
127
|
+
for call in unit.calls:
|
|
128
|
+
if budget <= 0:
|
|
129
|
+
break
|
|
130
|
+
targets = _call_targets(call, by_name, local_modules, unit, unit_by_id)
|
|
131
|
+
same_file = [target for target in targets if target != unit.id and unit_by_id[target].path == unit.path]
|
|
132
|
+
resolved = same_file if len(same_file) == 1 else targets if len(targets) == 1 else []
|
|
133
|
+
for target in resolved:
|
|
134
|
+
if target != unit.id:
|
|
135
|
+
edges.add(CodeEdge(unit.id, target, "calls", call))
|
|
136
|
+
budget -= 1
|
|
137
|
+
for imported in unit.imports:
|
|
138
|
+
if budget <= 0:
|
|
139
|
+
break
|
|
140
|
+
module = imported.rsplit(".", 1)[-1]
|
|
141
|
+
module_targets = by_module.get(module, [])
|
|
142
|
+
# A module import is a coarse relationship. Only materialize it
|
|
143
|
+
# when the target module has a small, unambiguous surface.
|
|
144
|
+
module_paths = {unit_by_id[target].path for target in module_targets}
|
|
145
|
+
resolved_module = module_targets if len(module_targets) <= 3 and len(module_paths) == 1 else []
|
|
146
|
+
for target in resolved_module:
|
|
147
|
+
if target != unit.id:
|
|
148
|
+
edges.add(CodeEdge(unit.id, target, "imports", imported))
|
|
149
|
+
budget -= 1
|
|
150
|
+
if budget <= 0:
|
|
151
|
+
break
|
|
152
|
+
return CodeGraph(units, edges)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def graph_from_dict(units: Iterable[CodeUnit], data: dict[str, object] | None) -> CodeGraph:
|
|
156
|
+
raw_edges = (data or {}).get("edges", [])
|
|
157
|
+
edges: list[CodeEdge] = []
|
|
158
|
+
for edge in raw_edges if isinstance(raw_edges, list) else []:
|
|
159
|
+
if not isinstance(edge, dict):
|
|
160
|
+
continue
|
|
161
|
+
try:
|
|
162
|
+
edges.append(CodeEdge(**edge))
|
|
163
|
+
except (TypeError, ValueError):
|
|
164
|
+
continue
|
|
165
|
+
return CodeGraph(units, edges)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def graph_search(
|
|
169
|
+
units: list[CodeUnit],
|
|
170
|
+
query: str,
|
|
171
|
+
limit: int = 8,
|
|
172
|
+
hops: int = 1,
|
|
173
|
+
graph: CodeGraph | None = None,
|
|
174
|
+
search_index: SearchIndex | None = None,
|
|
175
|
+
vector_weight: float = DEFAULT_VECTOR_WEIGHT,
|
|
176
|
+
) -> list[SearchResult]:
|
|
177
|
+
"""Search seeds and add bounded graph neighbors with explicit evidence."""
|
|
178
|
+
if limit <= 0:
|
|
179
|
+
return []
|
|
180
|
+
hops = min(3, max(0, hops))
|
|
181
|
+
graph = graph or build_graph(units)
|
|
182
|
+
seeds = search(units, query, max(limit * 2, 8), search_index=search_index, vector_weight=vector_weight)
|
|
183
|
+
ranked: dict[str, SearchResult] = {seed.unit.id: seed for seed in seeds}
|
|
184
|
+
if hops <= 0:
|
|
185
|
+
return seeds[:limit]
|
|
186
|
+
for seed in seeds:
|
|
187
|
+
for neighbor, path in graph.neighbors(seed.unit.id, hops=hops, direction="both"):
|
|
188
|
+
weights = {"calls": 0.7, "contains": 0.5, "imports": 0.3}
|
|
189
|
+
propagated = seed.score
|
|
190
|
+
for edge_text in path[1::2]:
|
|
191
|
+
propagated *= weights.get(edge_text.split(":", 1)[0], 0.4)
|
|
192
|
+
current = ranked.get(neighbor.id)
|
|
193
|
+
evidence = ["graph:" + " -> ".join(path)]
|
|
194
|
+
if current is None or propagated > current.score:
|
|
195
|
+
ranked[neighbor.id] = SearchResult(neighbor, propagated, [], evidence)
|
|
196
|
+
elif evidence[0] not in current.evidence:
|
|
197
|
+
current.evidence.extend(evidence)
|
|
198
|
+
return sorted(ranked.values(), key=lambda result: (-result.score, result.unit.id))[:limit]
|