codepulse-engine 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.
- codepulse/__init__.py +5 -0
- codepulse/api/app.py +1336 -0
- codepulse/api/errors.py +172 -0
- codepulse/api/security.py +189 -0
- codepulse/auth.py +406 -0
- codepulse/cli.py +1052 -0
- codepulse/config.py +475 -0
- codepulse/data_location.py +78 -0
- codepulse/embeddings/chunker.py +138 -0
- codepulse/embeddings/embedder.py +120 -0
- codepulse/git_diff.py +201 -0
- codepulse/indexer/engine.py +2655 -0
- codepulse/indexer/ignore.py +120 -0
- codepulse/ingestion_safety.py +169 -0
- codepulse/jobs.py +257 -0
- codepulse/mcp_server.py +269 -0
- codepulse/models.py +140 -0
- codepulse/observability.py +130 -0
- codepulse/parser/__init__.py +8 -0
- codepulse/parser/base.py +58 -0
- codepulse/parser/factory.py +37 -0
- codepulse/parser/graph.py +502 -0
- codepulse/parser/javascript.py +428 -0
- codepulse/parser/module_graph.py +232 -0
- codepulse/parser/python.py +331 -0
- codepulse/rate_limit.py +142 -0
- codepulse/repository_identity.py +83 -0
- codepulse/search/bm25.py +342 -0
- codepulse/search/ranker.py +382 -0
- codepulse/search/tokenizer.py +80 -0
- codepulse/search/vector.py +357 -0
- codepulse/storage/db.py +526 -0
- codepulse/storage/migrations.py +347 -0
- codepulse/storage/schema_migrations/README +1 -0
- codepulse/storage/schema_migrations/env.py +70 -0
- codepulse/storage/schema_migrations/script.py.mako +28 -0
- codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
- codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
- codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
- codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
- codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
- codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
- codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
- codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
- codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
- codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
- codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
- codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
- codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
- codepulse/symbol_identity.py +29 -0
- codepulse_engine-0.1.0.dist-info/METADATA +191 -0
- codepulse_engine-0.1.0.dist-info/RECORD +56 -0
- codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
- codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
- codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- codepulse_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections import defaultdict, deque
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from codepulse.models import CallGraphEdge, FileParseResult, ImportStatement, Symbol, SymbolType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ImportResolver:
|
|
11
|
+
"""Resolves import statements to target repository file paths (Phase 2A)."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, parse_results: dict[str, FileParseResult] | list[FileParseResult]) -> None:
|
|
14
|
+
if isinstance(parse_results, list):
|
|
15
|
+
self.files = {res.file_path.replace("\\", "/"): res for res in parse_results}
|
|
16
|
+
else:
|
|
17
|
+
self.files = {path.replace("\\", "/"): res for path, res in parse_results.items()}
|
|
18
|
+
|
|
19
|
+
# Build symbol lookup index: (file_path, symbol_name) -> Symbol.
|
|
20
|
+
# Phase 3.4: this was built here from this class's earliest
|
|
21
|
+
# version but never actually read by anything -- now used by
|
|
22
|
+
# `CallGraphBuilder._build_graph` to confirm an import-resolved
|
|
23
|
+
# callee is a genuine known symbol in its target file (§11's
|
|
24
|
+
# "high" confidence tier requires a known file+SYMBOL, not just
|
|
25
|
+
# a known file).
|
|
26
|
+
self.symbol_index: dict[tuple[str, str], Symbol] = {}
|
|
27
|
+
for file_path, res in self.files.items():
|
|
28
|
+
for sym in res.symbols:
|
|
29
|
+
self.symbol_index[(file_path, sym.name)] = sym
|
|
30
|
+
|
|
31
|
+
def resolve_import_path(self, current_file: str, imp: ImportStatement) -> Optional[str]:
|
|
32
|
+
"""Resolves an ImportStatement in current_file to a target file path in the repo."""
|
|
33
|
+
current_file = current_file.replace("\\", "/")
|
|
34
|
+
current_dir = os.path.dirname(current_file)
|
|
35
|
+
|
|
36
|
+
if imp.is_relative:
|
|
37
|
+
# Handle relative imports: . or ..
|
|
38
|
+
rel_dir = current_dir
|
|
39
|
+
for _ in range(max(0, imp.level - 1)):
|
|
40
|
+
rel_dir = os.path.dirname(rel_dir)
|
|
41
|
+
|
|
42
|
+
mod_parts = imp.module.split(".") if imp.module else []
|
|
43
|
+
target_path = os.path.normpath(os.path.join(rel_dir, *mod_parts)).replace("\\", "/")
|
|
44
|
+
else:
|
|
45
|
+
# Absolute project import (e.g., 'app.models.user')
|
|
46
|
+
mod_parts = imp.module.split(".")
|
|
47
|
+
target_path = os.path.normpath(os.path.join(*mod_parts)).replace("\\", "/")
|
|
48
|
+
|
|
49
|
+
# Candidates to check
|
|
50
|
+
candidates = [
|
|
51
|
+
f"{target_path}.py",
|
|
52
|
+
f"{target_path}/__init__.py",
|
|
53
|
+
f"{target_path}.js",
|
|
54
|
+
f"{target_path}.ts",
|
|
55
|
+
f"{target_path}/index.js",
|
|
56
|
+
f"{target_path}/index.ts",
|
|
57
|
+
target_path,
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
for cand in candidates:
|
|
61
|
+
cand_norm = os.path.normpath(cand).replace("\\", "/")
|
|
62
|
+
if cand_norm in self.files:
|
|
63
|
+
return cand_norm
|
|
64
|
+
|
|
65
|
+
# Also check suffix matches (e.g. if candidate is 'models/user.py' and file is 'src/models/user.py')
|
|
66
|
+
for existing_path in self.files:
|
|
67
|
+
if existing_path.endswith(cand_norm) or cand_norm.endswith(existing_path):
|
|
68
|
+
return existing_path
|
|
69
|
+
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CallGraphBuilder:
|
|
74
|
+
"""
|
|
75
|
+
Builds and queries the symbol call graph (Phase 2B - Acyclic &
|
|
76
|
+
Cycle-Safe).
|
|
77
|
+
|
|
78
|
+
Phase 4.1 (roadmap §37 item 4.1: "Base-class/inheritance extraction
|
|
79
|
+
(Python + JS/TS)"): also resolves inheritance relationships (a
|
|
80
|
+
class's `Symbol.base_classes`, raw text, into confidence-scored
|
|
81
|
+
edges), reusing this class's own existing import-resolution
|
|
82
|
+
philosophy verbatim (`_resolve_target`, factored out of the
|
|
83
|
+
call-edge logic this milestone found already implementing it
|
|
84
|
+
inline) rather than inventing a second resolution algorithm.
|
|
85
|
+
|
|
86
|
+
CRITICAL invariant, enforced by construction, not by convention:
|
|
87
|
+
inheritance edges are kept in `self.inheritance_edges`/
|
|
88
|
+
`self.inheritance_adjacency`/`self.reverse_inheritance_adjacency` --
|
|
89
|
+
entirely SEPARATE from `self.edges`/`self.adjacency`/
|
|
90
|
+
`self.reverse_adjacency`, which remain calls-only, exactly as before
|
|
91
|
+
this milestone. `get_callers`/`get_callees` traverse ONLY the
|
|
92
|
+
calls-only structures and are therefore semantically unchanged by
|
|
93
|
+
this milestone -- verified by this project's full pre-existing
|
|
94
|
+
call-graph test suite continuing to pass unmodified. A future
|
|
95
|
+
milestone wanting inheritance-aware traversal gets new, dedicated
|
|
96
|
+
methods (not built here -- out of this milestone's exact scope,
|
|
97
|
+
which is extraction + resolution, not a new query surface); it must
|
|
98
|
+
NOT be achieved by merging the two adjacency structures.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
# Phase 4.2 (roadmap §37 item 4.2; §11): deterministic ordering rule
|
|
102
|
+
# for traversal results, reusing §12's own stated convention for the
|
|
103
|
+
# adjacent `/v1/agent/context` bundle ("callers/callees... sorted by
|
|
104
|
+
# confidence then call-count") rather than inventing a new one.
|
|
105
|
+
# `None` (a legacy pre-Phase-3.4 row with no computed confidence,
|
|
106
|
+
# see `CallEdge`'s own docstring) sorts last -- known-good
|
|
107
|
+
# information before honestly-absent information, never fabricated.
|
|
108
|
+
_CONFIDENCE_SORT_RANK = {"high": 0, "low": 1, "unresolved": 2, None: 3}
|
|
109
|
+
|
|
110
|
+
def __init__(self, parse_results: dict[str, FileParseResult] | list[FileParseResult]) -> None:
|
|
111
|
+
self.resolver = ImportResolver(parse_results)
|
|
112
|
+
self.edges: list[CallGraphEdge] = []
|
|
113
|
+
# Adjacency lists for fast bidirectional traversal -- CALLS ONLY.
|
|
114
|
+
self.adjacency: dict[tuple[str, str], set[tuple[str, str]]] = defaultdict(set)
|
|
115
|
+
self.reverse_adjacency: dict[tuple[str, str], set[tuple[str, str]]] = defaultdict(set)
|
|
116
|
+
# Phase 4.2: per-edge (confidence, call_count) index, keyed by the
|
|
117
|
+
# SAME (src_key, tgt_key) pairs used in `self.adjacency` -- CALLS
|
|
118
|
+
# ONLY, built in the same loop that populates `self.adjacency`
|
|
119
|
+
# (no second pass, no new traversal). `_build_graph`'s own
|
|
120
|
+
# `call_counts`/`call_confidence` dicts already guarantee exactly
|
|
121
|
+
# one edge per (src_key, tgt_key) pair, so this lookup is never
|
|
122
|
+
# ambiguous. Lets `get_callers`/`get_callees` attach each result's
|
|
123
|
+
# confidence and sort deterministically without a second graph
|
|
124
|
+
# traversal or a new confidence vocabulary.
|
|
125
|
+
self.edge_meta: dict[tuple[tuple[str, str], tuple[str, str]], tuple[Optional[str], int]] = {}
|
|
126
|
+
# Phase 4.1: INHERITANCE ONLY -- deliberately separate structures,
|
|
127
|
+
# never merged with the calls-only ones above. See this class's
|
|
128
|
+
# own docstring for why.
|
|
129
|
+
self.inheritance_edges: list[CallGraphEdge] = []
|
|
130
|
+
self.inheritance_adjacency: dict[tuple[str, str], set[tuple[str, str]]] = defaultdict(set)
|
|
131
|
+
self.reverse_inheritance_adjacency: dict[tuple[str, str], set[tuple[str, str]]] = defaultdict(set)
|
|
132
|
+
# Phase 4.4 (roadmap §37 item 4.4 "References endpoint"): the
|
|
133
|
+
# inheritance-side counterpart to `edge_meta`, same (src_key,
|
|
134
|
+
# tgt_key) -> confidence lookup shape, built in the SAME loop
|
|
135
|
+
# that populates `inheritance_adjacency` -- CALLS ONLY structures
|
|
136
|
+
# above are never touched by this. Lets `get_direct_subclasses`
|
|
137
|
+
# attach confidence without a second traversal or a new index.
|
|
138
|
+
self.inheritance_edge_meta: dict[tuple[tuple[str, str], tuple[str, str]], Optional[str]] = {}
|
|
139
|
+
self._build_graph()
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_edges(
|
|
143
|
+
cls,
|
|
144
|
+
edges: list[CallGraphEdge],
|
|
145
|
+
inheritance_edges: list[CallGraphEdge],
|
|
146
|
+
) -> "CallGraphBuilder":
|
|
147
|
+
"""
|
|
148
|
+
Rebuilds a graph from ALREADY-RESOLVED edges (the durable
|
|
149
|
+
`call_edges` rows) instead of from parse results, so a restarted
|
|
150
|
+
process can answer callers/callees/subclasses without re-parsing
|
|
151
|
+
the repository.
|
|
152
|
+
|
|
153
|
+
Populates exactly the structures `_build_graph` populates, with the
|
|
154
|
+
same keys (`(file, symbol)` pairs; an unresolved target file is
|
|
155
|
+
`""`), so every query method behaves identically on a restored
|
|
156
|
+
graph and a freshly built one. Edges are taken as stored -- no
|
|
157
|
+
resolution is re-run, so the confidence on each edge is exactly
|
|
158
|
+
what was computed (and persisted) at index time, including a
|
|
159
|
+
legacy `None`.
|
|
160
|
+
|
|
161
|
+
The one thing a restored graph does NOT have is `resolver`
|
|
162
|
+
(parsed files / imports): it is empty. Anything needing the parse
|
|
163
|
+
data -- notably an incremental update, which merges cached parse
|
|
164
|
+
results for unchanged files -- must not treat a restored graph as
|
|
165
|
+
its base; `IndexingEngine` tracks that.
|
|
166
|
+
"""
|
|
167
|
+
graph = cls([])
|
|
168
|
+
for edge in edges:
|
|
169
|
+
graph.edges.append(edge)
|
|
170
|
+
src_key = (edge.source_file, edge.source_symbol_name)
|
|
171
|
+
tgt_key = (edge.target_file or "", edge.target_symbol_name)
|
|
172
|
+
graph.adjacency[src_key].add(tgt_key)
|
|
173
|
+
graph.reverse_adjacency[tgt_key].add(src_key)
|
|
174
|
+
graph.edge_meta[(src_key, tgt_key)] = (edge.confidence, edge.call_count)
|
|
175
|
+
for edge in inheritance_edges:
|
|
176
|
+
graph.inheritance_edges.append(edge)
|
|
177
|
+
src_key = (edge.source_file, edge.source_symbol_name)
|
|
178
|
+
tgt_key = (edge.target_file or "", edge.target_symbol_name)
|
|
179
|
+
graph.inheritance_adjacency[src_key].add(tgt_key)
|
|
180
|
+
graph.reverse_inheritance_adjacency[tgt_key].add(src_key)
|
|
181
|
+
graph.inheritance_edge_meta[(src_key, tgt_key)] = edge.confidence
|
|
182
|
+
return graph
|
|
183
|
+
|
|
184
|
+
def _resolve_target(
|
|
185
|
+
self, name: str, file_path: str, local_symbols: dict[str, Symbol], import_map: dict[str, str]
|
|
186
|
+
) -> tuple[Optional[str], str]:
|
|
187
|
+
"""
|
|
188
|
+
Shared resolution philosophy (roadmap §11), used identically by
|
|
189
|
+
BOTH call-edge and (Phase 4.1) inheritance-edge resolution --
|
|
190
|
+
factored out of what was previously call-edge-only inline logic,
|
|
191
|
+
with IDENTICAL behavior (verified by this project's existing
|
|
192
|
+
call-graph/confidence test suite continuing to pass unmodified
|
|
193
|
+
after this refactor) so the two relationship kinds can never
|
|
194
|
+
silently drift onto different resolution rules. Returns
|
|
195
|
+
`(target_file_or_None, confidence)`.
|
|
196
|
+
|
|
197
|
+
"high" -- resolved through an explicit import mapping to a known
|
|
198
|
+
file+SYMBOL (not just a known file -- cross-checked against
|
|
199
|
+
`self.resolver.symbol_index`). "low" -- resolved only by
|
|
200
|
+
bare-name match against same-file symbols (the ambiguity §3.5's
|
|
201
|
+
audit finding names: two unrelated classes/functions both
|
|
202
|
+
defining a same-named symbol in this file would be conflated
|
|
203
|
+
here, so this can never be "high" no matter how the match was
|
|
204
|
+
made). "unresolved" -- everything else, including a resolved
|
|
205
|
+
import file whose exact symbol couldn't be confirmed in it (a
|
|
206
|
+
cross-file case, so it deliberately does NOT fall back to "low",
|
|
207
|
+
which is defined as a same-file match only) and a true external
|
|
208
|
+
reference with no local or import match at all. Never a fourth
|
|
209
|
+
"ambiguous" tier -- deliberately reusing this exact 3-tier
|
|
210
|
+
vocabulary for inheritance too, not inventing a parallel one.
|
|
211
|
+
"""
|
|
212
|
+
if name in local_symbols:
|
|
213
|
+
return file_path, "low"
|
|
214
|
+
elif name in import_map:
|
|
215
|
+
target_file = import_map[name]
|
|
216
|
+
if (target_file, name) in self.resolver.symbol_index:
|
|
217
|
+
return target_file, "high"
|
|
218
|
+
return target_file, "unresolved"
|
|
219
|
+
else:
|
|
220
|
+
return None, "unresolved"
|
|
221
|
+
|
|
222
|
+
def _build_graph(self) -> None:
|
|
223
|
+
for file_path, res in self.resolver.files.items():
|
|
224
|
+
# Build quick map of imported item names in this file -> resolved target file
|
|
225
|
+
import_map: dict[str, str] = {}
|
|
226
|
+
for imp in res.imports:
|
|
227
|
+
resolved_file = self.resolver.resolve_import_path(file_path, imp)
|
|
228
|
+
if resolved_file:
|
|
229
|
+
for item in imp.items:
|
|
230
|
+
lookup_name = item.alias if item.alias else item.name
|
|
231
|
+
import_map[lookup_name] = resolved_file
|
|
232
|
+
|
|
233
|
+
local_symbols = {s.name: s for s in res.symbols}
|
|
234
|
+
|
|
235
|
+
# Map calls to target symbols. Phase 3.4 (roadmap §37 item
|
|
236
|
+
# 3.4; §11): every resolution decision below already existed
|
|
237
|
+
# -- this milestone's job is to SURFACE which one applied,
|
|
238
|
+
# not to change which edges get created.
|
|
239
|
+
call_counts: dict[tuple[str, str, str, Optional[str]], int] = defaultdict(int)
|
|
240
|
+
call_confidence: dict[tuple[str, str, str, Optional[str]], str] = {}
|
|
241
|
+
|
|
242
|
+
for call in res.calls:
|
|
243
|
+
caller = call.caller_symbol
|
|
244
|
+
callee = call.callee_name
|
|
245
|
+
target_file, confidence = self._resolve_target(callee, file_path, local_symbols, import_map)
|
|
246
|
+
key = (caller, file_path, callee, target_file)
|
|
247
|
+
|
|
248
|
+
call_counts[key] += 1
|
|
249
|
+
# Every call to this exact (caller, file, callee) triple
|
|
250
|
+
# resolves through the identical branch above every time
|
|
251
|
+
# (the decision depends only on `callee`/`file_path`, not
|
|
252
|
+
# on the specific call site), so this is always a no-op
|
|
253
|
+
# re-assignment of the same value, never a real conflict.
|
|
254
|
+
call_confidence[key] = confidence
|
|
255
|
+
|
|
256
|
+
for key, count in call_counts.items():
|
|
257
|
+
src_sym, src_file, tgt_sym, tgt_file = key
|
|
258
|
+
edge = CallGraphEdge(
|
|
259
|
+
source_symbol_name=src_sym,
|
|
260
|
+
source_file=src_file,
|
|
261
|
+
target_symbol_name=tgt_sym,
|
|
262
|
+
target_file=tgt_file,
|
|
263
|
+
call_count=count,
|
|
264
|
+
confidence=call_confidence[key],
|
|
265
|
+
relationship_type="calls",
|
|
266
|
+
)
|
|
267
|
+
self.edges.append(edge)
|
|
268
|
+
|
|
269
|
+
src_key = (src_file, src_sym)
|
|
270
|
+
tgt_key = (tgt_file or "", tgt_sym)
|
|
271
|
+
self.adjacency[src_key].add(tgt_key)
|
|
272
|
+
self.reverse_adjacency[tgt_key].add(src_key)
|
|
273
|
+
self.edge_meta[(src_key, tgt_key)] = (call_confidence[key], count)
|
|
274
|
+
|
|
275
|
+
# Phase 4.1: resolve inheritance relationships for every
|
|
276
|
+
# CLASS symbol in this file, reusing the SAME `import_map`/
|
|
277
|
+
# `local_symbols` already built above for calls (no second
|
|
278
|
+
# pass over `self.resolver.files`) and the SAME
|
|
279
|
+
# `_resolve_target` resolution philosophy. Each entry in a
|
|
280
|
+
# class's `base_classes` (raw text, from `Symbol.
|
|
281
|
+
# base_classes` -- see that field's own docstring for why
|
|
282
|
+
# raw and resolved are kept separate) becomes exactly one
|
|
283
|
+
# inheritance edge, stored ONLY in the inheritance-specific
|
|
284
|
+
# structures below -- never in `self.edges`/`self.adjacency`/
|
|
285
|
+
# `self.reverse_adjacency`.
|
|
286
|
+
for sym in res.symbols:
|
|
287
|
+
if sym.type != SymbolType.CLASS or not sym.base_classes:
|
|
288
|
+
continue
|
|
289
|
+
for base_name in sym.base_classes:
|
|
290
|
+
target_file, confidence = self._resolve_target(
|
|
291
|
+
base_name, file_path, local_symbols, import_map
|
|
292
|
+
)
|
|
293
|
+
edge = CallGraphEdge(
|
|
294
|
+
source_symbol_name=sym.name,
|
|
295
|
+
source_file=file_path,
|
|
296
|
+
target_symbol_name=base_name,
|
|
297
|
+
target_file=target_file,
|
|
298
|
+
call_count=1,
|
|
299
|
+
confidence=confidence,
|
|
300
|
+
relationship_type="inherits",
|
|
301
|
+
)
|
|
302
|
+
self.inheritance_edges.append(edge)
|
|
303
|
+
|
|
304
|
+
src_key = (file_path, sym.name)
|
|
305
|
+
tgt_key = (target_file or "", base_name)
|
|
306
|
+
self.inheritance_adjacency[src_key].add(tgt_key)
|
|
307
|
+
self.reverse_inheritance_adjacency[tgt_key].add(src_key)
|
|
308
|
+
self.inheritance_edge_meta[(src_key, tgt_key)] = confidence
|
|
309
|
+
|
|
310
|
+
def get_callees(self, symbol_name: str, file_path: str = "", max_depth: int = 3) -> list[dict]:
|
|
311
|
+
"""
|
|
312
|
+
Returns symbols called by the given symbol up to max_depth
|
|
313
|
+
(downstream impact). CALLS ONLY -- traverses `self.adjacency`
|
|
314
|
+
exclusively, never `self.inheritance_adjacency` (Phase 4.1's own
|
|
315
|
+
separation invariant, unchanged by Phase 4.2).
|
|
316
|
+
|
|
317
|
+
Phase 4.2 (roadmap §37 item 4.2; §11): each result now also
|
|
318
|
+
carries `confidence` (looked up from `self.edge_meta`, the same
|
|
319
|
+
3-tier "high"/"low"/"unresolved" vocabulary already used
|
|
320
|
+
everywhere else -- no new vocabulary), and results are sorted
|
|
321
|
+
deterministically (confidence, then call_count descending, then
|
|
322
|
+
file, then symbol) so the same repository snapshot always
|
|
323
|
+
produces the same ordered list, regardless of process-local
|
|
324
|
+
Python set-iteration order. The BFS/visited-set mechanics
|
|
325
|
+
themselves are UNCHANGED from before this milestone -- still a
|
|
326
|
+
single traversal, still cycle-safe and deduplicated purely
|
|
327
|
+
through `visited`, still reporting each key's true shortest-path
|
|
328
|
+
depth (the first, and only, time it is ever enqueued).
|
|
329
|
+
"""
|
|
330
|
+
file_path = file_path.replace("\\", "/")
|
|
331
|
+
|
|
332
|
+
start_keys: list[tuple[str, str]] = []
|
|
333
|
+
if file_path:
|
|
334
|
+
start_keys.append((file_path, symbol_name))
|
|
335
|
+
else:
|
|
336
|
+
# Find all keys in adjacency matching the symbol name
|
|
337
|
+
for (f, sym) in self.adjacency.keys():
|
|
338
|
+
if sym == symbol_name:
|
|
339
|
+
start_keys.append((f, sym))
|
|
340
|
+
|
|
341
|
+
if not start_keys:
|
|
342
|
+
start_keys.append(("", symbol_name))
|
|
343
|
+
|
|
344
|
+
visited = set(start_keys)
|
|
345
|
+
queue = deque([(k, 1) for k in start_keys])
|
|
346
|
+
results = []
|
|
347
|
+
|
|
348
|
+
while queue:
|
|
349
|
+
curr_key, depth = queue.popleft()
|
|
350
|
+
if depth > max_depth:
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
for next_key in self.adjacency.get(curr_key, set()):
|
|
354
|
+
if next_key not in visited:
|
|
355
|
+
visited.add(next_key)
|
|
356
|
+
tgt_file, tgt_sym = next_key
|
|
357
|
+
confidence, call_count = self.edge_meta.get((curr_key, next_key), (None, 0))
|
|
358
|
+
results.append({
|
|
359
|
+
"symbol": tgt_sym,
|
|
360
|
+
"file": tgt_file if tgt_file else "external",
|
|
361
|
+
"depth": depth,
|
|
362
|
+
"confidence": confidence,
|
|
363
|
+
"_call_count": call_count,
|
|
364
|
+
})
|
|
365
|
+
queue.append((next_key, depth + 1))
|
|
366
|
+
|
|
367
|
+
return self._sort_and_strip(results)
|
|
368
|
+
|
|
369
|
+
def get_callers(self, symbol_name: str, file_path: str = "", max_depth: int = 3) -> list[dict]:
|
|
370
|
+
"""
|
|
371
|
+
Returns symbols that call the given symbol up to max_depth
|
|
372
|
+
(upstream blast radius). CALLS ONLY -- traverses
|
|
373
|
+
`self.reverse_adjacency` exclusively, never
|
|
374
|
+
`self.reverse_inheritance_adjacency`.
|
|
375
|
+
|
|
376
|
+
Phase 4.2: same `confidence` + deterministic-ordering additions
|
|
377
|
+
as `get_callees` (see its docstring) -- each queued item now also
|
|
378
|
+
carries the key it directly calls (`points_to`), so the
|
|
379
|
+
confidence of THAT exact edge (caller -> points_to) can be looked
|
|
380
|
+
up from `self.edge_meta` at result-construction time. This is
|
|
381
|
+
the only change to the traversal's own bookkeeping; the
|
|
382
|
+
visited-set/queue mechanics, shortest-path depth assignment, and
|
|
383
|
+
cycle safety are otherwise identical to before this milestone.
|
|
384
|
+
"""
|
|
385
|
+
file_path = file_path.replace("\\", "/")
|
|
386
|
+
candidate_keys: list[tuple[str, str]] = []
|
|
387
|
+
|
|
388
|
+
if file_path:
|
|
389
|
+
candidate_keys.append((file_path, symbol_name))
|
|
390
|
+
candidate_keys.append(("", symbol_name))
|
|
391
|
+
else:
|
|
392
|
+
# Match any target key where symbol name matches
|
|
393
|
+
for (f, sym) in self.reverse_adjacency.keys():
|
|
394
|
+
if sym == symbol_name:
|
|
395
|
+
candidate_keys.append((f, sym))
|
|
396
|
+
|
|
397
|
+
if not candidate_keys:
|
|
398
|
+
candidate_keys.append(("", symbol_name))
|
|
399
|
+
|
|
400
|
+
visited = set()
|
|
401
|
+
queue = deque()
|
|
402
|
+
|
|
403
|
+
for k in candidate_keys:
|
|
404
|
+
visited.add(k)
|
|
405
|
+
for caller in self.reverse_adjacency.get(k, set()):
|
|
406
|
+
if caller not in visited:
|
|
407
|
+
visited.add(caller)
|
|
408
|
+
queue.append((caller, 1, k))
|
|
409
|
+
|
|
410
|
+
results = []
|
|
411
|
+
while queue:
|
|
412
|
+
curr_key, depth, points_to = queue.popleft()
|
|
413
|
+
src_file, src_sym = curr_key
|
|
414
|
+
confidence, call_count = self.edge_meta.get((curr_key, points_to), (None, 0))
|
|
415
|
+
results.append({
|
|
416
|
+
"symbol": src_sym,
|
|
417
|
+
"file": src_file,
|
|
418
|
+
"depth": depth,
|
|
419
|
+
"confidence": confidence,
|
|
420
|
+
"_call_count": call_count,
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
if depth < max_depth:
|
|
424
|
+
for next_caller in self.reverse_adjacency.get(curr_key, set()):
|
|
425
|
+
if next_caller not in visited:
|
|
426
|
+
visited.add(next_caller)
|
|
427
|
+
queue.append((next_caller, depth + 1, curr_key))
|
|
428
|
+
|
|
429
|
+
return self._sort_and_strip(results)
|
|
430
|
+
|
|
431
|
+
def _sort_and_strip(self, results: list[dict]) -> list[dict]:
|
|
432
|
+
"""
|
|
433
|
+
Phase 4.2 deterministic-ordering helper, shared by `get_callers`/
|
|
434
|
+
`get_callees`: sorts by (confidence, call_count descending, file,
|
|
435
|
+
symbol) -- reusing §12's own stated "sorted by confidence then
|
|
436
|
+
call-count" convention rather than inventing a new ranking -- and
|
|
437
|
+
strips the internal `_call_count` sort key before returning, so
|
|
438
|
+
the public result shape stays exactly `{symbol, file, depth,
|
|
439
|
+
confidence}`.
|
|
440
|
+
"""
|
|
441
|
+
results.sort(
|
|
442
|
+
key=lambda r: (
|
|
443
|
+
self._CONFIDENCE_SORT_RANK.get(r["confidence"], 3),
|
|
444
|
+
-r["_call_count"],
|
|
445
|
+
r["file"],
|
|
446
|
+
r["symbol"],
|
|
447
|
+
)
|
|
448
|
+
)
|
|
449
|
+
for r in results:
|
|
450
|
+
del r["_call_count"]
|
|
451
|
+
return results
|
|
452
|
+
|
|
453
|
+
def get_direct_subclasses(self, symbol_name: str, file_path: str = "") -> list[dict]:
|
|
454
|
+
"""
|
|
455
|
+
Phase 4.4 (roadmap §37 item 4.4 "References endpoint"; §21's
|
|
456
|
+
`references` endpoint group, kept explicitly SEPARATE from
|
|
457
|
+
`graph (callers/callees/impact)`). "References" is a DIRECT,
|
|
458
|
+
single-hop lookup, not a transitive/bucketed traversal the way
|
|
459
|
+
impact analysis is (Phase 4.2's own explicit calls-only,
|
|
460
|
+
depth-bucketed design is a different concept, deliberately not
|
|
461
|
+
reused here) -- so this is a plain adjacency lookup, not a BFS.
|
|
462
|
+
|
|
463
|
+
Reads `self.reverse_inheritance_adjacency`/
|
|
464
|
+
`self.inheritance_edge_meta` exclusively -- NEVER the calls-only
|
|
465
|
+
`self.reverse_adjacency`, preserving Phase 4.1's separation
|
|
466
|
+
invariant. Returns every class that directly lists `symbol_name`
|
|
467
|
+
as one of its `base_classes`, each with its own resolution
|
|
468
|
+
`confidence` (the same high/low/unresolved vocabulary, no new
|
|
469
|
+
tier), deterministically ordered by `(confidence, file, symbol)`
|
|
470
|
+
-- no `call_count` tiebreaker, since every inheritance edge's
|
|
471
|
+
`call_count` is always `1` (not a real signal, see
|
|
472
|
+
`CallGraphEdge.relationship_type`'s own docstring).
|
|
473
|
+
"""
|
|
474
|
+
file_path = file_path.replace("\\", "/")
|
|
475
|
+
candidate_keys: list[tuple[str, str]] = []
|
|
476
|
+
|
|
477
|
+
if file_path:
|
|
478
|
+
candidate_keys.append((file_path, symbol_name))
|
|
479
|
+
candidate_keys.append(("", symbol_name))
|
|
480
|
+
else:
|
|
481
|
+
candidate_keys = [
|
|
482
|
+
(f, s) for (f, s) in self.reverse_inheritance_adjacency if s == symbol_name
|
|
483
|
+
]
|
|
484
|
+
|
|
485
|
+
if not candidate_keys:
|
|
486
|
+
candidate_keys.append(("", symbol_name))
|
|
487
|
+
|
|
488
|
+
seen: set[tuple[str, str]] = set()
|
|
489
|
+
results = []
|
|
490
|
+
for key in candidate_keys:
|
|
491
|
+
for sub_key in self.reverse_inheritance_adjacency.get(key, set()):
|
|
492
|
+
if sub_key in seen:
|
|
493
|
+
continue
|
|
494
|
+
seen.add(sub_key)
|
|
495
|
+
src_file, src_sym = sub_key
|
|
496
|
+
confidence = self.inheritance_edge_meta.get((sub_key, key))
|
|
497
|
+
results.append({"symbol": src_sym, "file": src_file, "confidence": confidence})
|
|
498
|
+
|
|
499
|
+
results.sort(
|
|
500
|
+
key=lambda r: (self._CONFIDENCE_SORT_RANK.get(r["confidence"], 3), r["file"], r["symbol"])
|
|
501
|
+
)
|
|
502
|
+
return results
|