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.
Files changed (56) hide show
  1. codepulse/__init__.py +5 -0
  2. codepulse/api/app.py +1336 -0
  3. codepulse/api/errors.py +172 -0
  4. codepulse/api/security.py +189 -0
  5. codepulse/auth.py +406 -0
  6. codepulse/cli.py +1052 -0
  7. codepulse/config.py +475 -0
  8. codepulse/data_location.py +78 -0
  9. codepulse/embeddings/chunker.py +138 -0
  10. codepulse/embeddings/embedder.py +120 -0
  11. codepulse/git_diff.py +201 -0
  12. codepulse/indexer/engine.py +2655 -0
  13. codepulse/indexer/ignore.py +120 -0
  14. codepulse/ingestion_safety.py +169 -0
  15. codepulse/jobs.py +257 -0
  16. codepulse/mcp_server.py +269 -0
  17. codepulse/models.py +140 -0
  18. codepulse/observability.py +130 -0
  19. codepulse/parser/__init__.py +8 -0
  20. codepulse/parser/base.py +58 -0
  21. codepulse/parser/factory.py +37 -0
  22. codepulse/parser/graph.py +502 -0
  23. codepulse/parser/javascript.py +428 -0
  24. codepulse/parser/module_graph.py +232 -0
  25. codepulse/parser/python.py +331 -0
  26. codepulse/rate_limit.py +142 -0
  27. codepulse/repository_identity.py +83 -0
  28. codepulse/search/bm25.py +342 -0
  29. codepulse/search/ranker.py +382 -0
  30. codepulse/search/tokenizer.py +80 -0
  31. codepulse/search/vector.py +357 -0
  32. codepulse/storage/db.py +526 -0
  33. codepulse/storage/migrations.py +347 -0
  34. codepulse/storage/schema_migrations/README +1 -0
  35. codepulse/storage/schema_migrations/env.py +70 -0
  36. codepulse/storage/schema_migrations/script.py.mako +28 -0
  37. codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
  38. codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
  39. codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
  40. codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
  41. codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
  42. codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
  43. codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
  44. codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
  45. codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
  46. codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
  47. codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
  48. codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
  49. codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
  50. codepulse/symbol_identity.py +29 -0
  51. codepulse_engine-0.1.0.dist-info/METADATA +191 -0
  52. codepulse_engine-0.1.0.dist-info/RECORD +56 -0
  53. codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
  54. codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
  55. codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
  56. 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