codegraph-engine 2.1.2__py3-none-any.whl → 2.1.7__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 (61) hide show
  1. codegraph/__init__.py +1 -1
  2. codegraph/agent_brain.py +1961 -0
  3. codegraph/agent_capabilities.py +3500 -0
  4. codegraph/agent_rules.py +530 -0
  5. codegraph/architecture.py +48 -5
  6. codegraph/binding_resolver.py +616 -0
  7. codegraph/cli.py +345 -5
  8. codegraph/config.py +2 -0
  9. codegraph/context.py +845 -93
  10. codegraph/database/__init__.py +58 -0
  11. codegraph/database/extractor.py +2956 -0
  12. codegraph/database/interrogation.py +1085 -0
  13. codegraph/database/models.py +310 -0
  14. codegraph/epistemic.py +95 -0
  15. codegraph/errors.py +7 -0
  16. codegraph/evidence/citations.py +22 -1
  17. codegraph/evidence_contract.py +455 -0
  18. codegraph/frameworks.py +372 -11
  19. codegraph/git.py +92 -10
  20. codegraph/graph/models.py +15 -0
  21. codegraph/graph/traversal.py +680 -121
  22. codegraph/indexing/__init__.py +15 -1
  23. codegraph/indexing/classifier.py +343 -58
  24. codegraph/indexing/indexer.py +1515 -141
  25. codegraph/indexing/models.py +75 -0
  26. codegraph/indexing/parser.py +1843 -70
  27. codegraph/indexing/scanner.py +4 -1
  28. codegraph/indexing/telemetry.py +326 -0
  29. codegraph/installer.py +1652 -0
  30. codegraph/interrogation.py +708 -122
  31. codegraph/mcp/server.py +525 -107
  32. codegraph/mcp_diagnostics.py +879 -0
  33. codegraph/models.py +14 -0
  34. codegraph/monorepo.py +922 -0
  35. codegraph/observability.py +113 -2
  36. codegraph/optimizer.py +647 -42
  37. codegraph/ranking.py +61 -16
  38. codegraph/resolver.py +2006 -77
  39. codegraph/resources/monitor.py +1 -1
  40. codegraph/retrieval_policy.py +96 -17
  41. codegraph/route_composer.py +363 -0
  42. codegraph/runtime/__init__.py +32 -0
  43. codegraph/runtime/ingestor.py +672 -0
  44. codegraph/runtime/models.py +130 -0
  45. codegraph/runtime/reconciliation.py +492 -0
  46. codegraph/search/__init__.py +2 -0
  47. codegraph/search/hybrid.py +572 -53
  48. codegraph/security/__init__.py +50 -2
  49. codegraph/security/paths.py +162 -8
  50. codegraph/security/redaction.py +633 -0
  51. codegraph/semantic_decorators.py +341 -0
  52. codegraph/target_resolver.py +6 -6
  53. codegraph/tool_selection_eval.py +1158 -0
  54. codegraph_engine-2.1.7.dist-info/METADATA +617 -0
  55. codegraph_engine-2.1.7.dist-info/RECORD +84 -0
  56. {codegraph_engine-2.1.2.dist-info → codegraph_engine-2.1.7.dist-info}/licenses/LICENSE +1 -1
  57. codegraph_engine-2.1.2.dist-info/METADATA +0 -336
  58. codegraph_engine-2.1.2.dist-info/RECORD +0 -63
  59. {codegraph_engine-2.1.2.dist-info → codegraph_engine-2.1.7.dist-info}/WHEEL +0 -0
  60. {codegraph_engine-2.1.2.dist-info → codegraph_engine-2.1.7.dist-info}/entry_points.txt +0 -0
  61. {codegraph_engine-2.1.2.dist-info → codegraph_engine-2.1.7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,616 @@
1
+ """Conservative Deterministic Local Binding and Lightweight Data-Flow Engine.
2
+
3
+ Resolves:
4
+ - name -> symbol
5
+ - name -> imported symbol
6
+ - name -> module.attribute
7
+ - name -> function reference
8
+ - name -> class reference
9
+ - alias -> alias -> target (bounded chain)
10
+ - dictionary key -> symbol
11
+ - list element -> symbol
12
+ - simple assignment chains
13
+
14
+ Adheres to:
15
+ 1. Lexical scope priority before module/global fallback (inner scopes shadow outer scopes)
16
+ 2. Source-order awareness for sequential reassignments
17
+ 3. Conditional/branching reassignments remain POSSIBLE or UNKNOWN
18
+ 4. RESOLVES_TO is an identity/binding edge, never a CALLS edge
19
+ 5. Attribute resolution only occurs when the base object is statically grounded
20
+ 6. Conservative JS/TS handling
21
+ 7. Cycle-safe with visited set and max_depth = 10
22
+ 8. Bounded, deterministic, no runtime execution
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Callable
27
+ from typing import TYPE_CHECKING
28
+
29
+ from codegraph.epistemic import RelationshipEvidenceClass
30
+ from codegraph.indexing.models import BindingRef, ImportRef, Symbol
31
+
32
+ if TYPE_CHECKING:
33
+ pass
34
+
35
+
36
+ class LocalBindingResolver:
37
+ """Pure deterministic local variable binding and alias chain resolver."""
38
+
39
+ def __init__(
40
+ self,
41
+ bindings: list[BindingRef],
42
+ symbols: list[Symbol],
43
+ imports: list[ImportRef],
44
+ known_files: set[str],
45
+ resolve_symbol_in_module: Callable[[str, str], Symbol | None],
46
+ resolve_import_target: Callable[[ImportRef], tuple[str | None, str | None]],
47
+ ) -> None:
48
+ self.bindings = bindings
49
+ self.symbols = symbols
50
+ self.imports = imports
51
+ self.known_files = known_files
52
+ self.resolve_symbol_in_module = resolve_symbol_in_module
53
+ self.resolve_import_target = resolve_import_target
54
+
55
+ # Index bindings: (file_path, target_name) -> list[BindingRef] and target_name -> list[BindingRef]
56
+ self.bindings_by_file_target: dict[tuple[str, str], list[BindingRef]] = {}
57
+ self.bindings_by_target: dict[str, list[BindingRef]] = {}
58
+ for b in bindings:
59
+ self.bindings_by_file_target.setdefault((b.file_path, b.target_name), []).append(b)
60
+ self.bindings_by_target.setdefault(b.target_name, []).append(b)
61
+
62
+ # Ensure all binding lists are deterministically sorted by line
63
+ for b_list in self.bindings_by_file_target.values():
64
+ b_list.sort(key=lambda b: (b.line, b.column or 0))
65
+
66
+ # Index symbols by (file_path, name) and canonical_id
67
+ self.file_symbols: dict[tuple[str, str], list[Symbol]] = {}
68
+ self.canonical_map: dict[str, Symbol] = {}
69
+ for s in symbols:
70
+ self.file_symbols.setdefault((s.path, s.name), []).append(s)
71
+ self.canonical_map[s.canonical_id] = s
72
+
73
+ # Index imports by (file_path, local_name)
74
+ self.file_imports: dict[tuple[str, str], list[ImportRef]] = {}
75
+ for imp in imports:
76
+ loc = imp.local_name or imp.alias or imp.name or ""
77
+ if loc:
78
+ self.file_imports.setdefault((imp.source_file, loc), []).append(imp)
79
+
80
+ self._resolve_cache: dict[
81
+ tuple[str, str, str, int | None],
82
+ tuple[str | None, RelationshipEvidenceClass, str],
83
+ ] = {}
84
+
85
+ def resolve(
86
+ self,
87
+ file_path: str,
88
+ name: str,
89
+ scope: str = "",
90
+ use_line: int | None = None,
91
+ visited: set[tuple[str, str, str]] | None = None,
92
+ depth: int = 0,
93
+ ) -> tuple[str | None, RelationshipEvidenceClass, str]:
94
+ """Resolve a variable or alias identifier to its target canonical ID.
95
+
96
+ Returns (canonical_id | None, evidence_class, reason).
97
+ """
98
+ is_top_level = visited is None and depth == 0
99
+ cache_key = (file_path, name, scope, use_line)
100
+ if is_top_level and cache_key in self._resolve_cache:
101
+ return self._resolve_cache[cache_key]
102
+
103
+ res = self._resolve_impl(file_path, name, scope, use_line, visited, depth)
104
+ if is_top_level:
105
+ self._resolve_cache[cache_key] = res
106
+ return res
107
+
108
+ def _resolve_impl(
109
+ self,
110
+ file_path: str,
111
+ name: str,
112
+ scope: str = "",
113
+ use_line: int | None = None,
114
+ visited: set[tuple[str, str, str]] | None = None,
115
+ depth: int = 0,
116
+ ) -> tuple[str | None, RelationshipEvidenceClass, str]:
117
+ visited = visited or set()
118
+ key = (file_path, scope, name)
119
+
120
+ # Cycle protection
121
+ if key in visited:
122
+ return None, RelationshipEvidenceClass.UNKNOWN, "cyclic_alias"
123
+
124
+ # Bounded depth
125
+ if depth >= 10:
126
+ return None, RelationshipEvidenceClass.UNKNOWN, "max_depth_exceeded"
127
+
128
+ visited.add(key)
129
+
130
+ # 1. Candidate scope search order (inner scopes shadow outer scopes)
131
+ if scope:
132
+ parts = scope.split(".")
133
+ candidate_scopes = [".".join(parts[:i]) for i in range(len(parts), 0, -1)] + [""]
134
+ else:
135
+ candidate_scopes = [""]
136
+
137
+ all_file_bindings = self.bindings_by_file_target.get((file_path, name), [])
138
+ scope_bindings: list[BindingRef] = []
139
+ matching_scope: str | None = None
140
+
141
+ for scp in candidate_scopes:
142
+ matched = [b for b in all_file_bindings if b.scope == scp]
143
+ if matched:
144
+ matching_scope = scp
145
+ scope_bindings = matched
146
+ break
147
+
148
+ # If no local binding exists in any lexical scope:
149
+ if not scope_bindings or matching_scope is None:
150
+ ev_cls = (
151
+ RelationshipEvidenceClass.DATAFLOW_VERIFIED
152
+ if depth > 0
153
+ else RelationshipEvidenceClass.AST_VERIFIED
154
+ )
155
+ # Check if name is an import in this file
156
+ for imp in self.file_imports.get((file_path, name), []):
157
+ _tpath, target_mod = self.resolve_import_target(imp)
158
+ if target_mod:
159
+ orig_name = imp.imported_name or name
160
+ if orig_name != "*":
161
+ sym = self.resolve_symbol_in_module(target_mod, orig_name)
162
+ if sym:
163
+ return (
164
+ sym.canonical_id,
165
+ ev_cls,
166
+ f"imported_symbol -> {sym.canonical_id}",
167
+ )
168
+
169
+ # Check if name is a direct symbol definition in this file
170
+ for s in self.file_symbols.get((file_path, name), []):
171
+ if not s.scope or s.scope in candidate_scopes:
172
+ return (
173
+ s.canonical_id,
174
+ ev_cls,
175
+ f"direct_symbol -> {s.canonical_id}",
176
+ )
177
+
178
+ return None, RelationshipEvidenceClass.UNKNOWN, f"unresolved_identifier: {name}"
179
+
180
+ # 2. Source-order awareness and sequential reassignment (all_file_bindings is pre-sorted)
181
+
182
+ if use_line is not None:
183
+ before = [b for b in scope_bindings if b.line <= use_line]
184
+ if not before:
185
+ # Forward reference (used before assignment)
186
+ return None, RelationshipEvidenceClass.UNKNOWN, f"used_before_assignment: {name}"
187
+
188
+ selected = before[-1]
189
+
190
+ # 3. Conditional / branching assignments must remain POSSIBLE
191
+ if selected.is_conditional:
192
+ return None, RelationshipEvidenceClass.POSSIBLE, "conditional_binding"
193
+
194
+ # Check for conditional ambiguity up to use_line
195
+ if any(b.is_conditional for b in before):
196
+ return None, RelationshipEvidenceClass.POSSIBLE, "conditional_ambiguity"
197
+ else:
198
+ if any(b.is_conditional for b in scope_bindings):
199
+ return None, RelationshipEvidenceClass.POSSIBLE, "conditional_binding"
200
+ if len(scope_bindings) > 1:
201
+ return None, RelationshipEvidenceClass.UNKNOWN, "ambiguous_reassignment"
202
+ selected = scope_bindings[0]
203
+
204
+ # 4. Resolve RHS based on expression kind
205
+ if selected.expr_kind == "DYNAMIC":
206
+ return None, RelationshipEvidenceClass.UNKNOWN, "runtime_dynamic_attribute"
207
+
208
+ if selected.expr_kind == "IDENTIFIER":
209
+ rhs_name = selected.base_expr or selected.source_expr
210
+
211
+ # Prevent immediate self-assignment
212
+ if rhs_name == name and selected.scope == matching_scope:
213
+ return None, RelationshipEvidenceClass.UNKNOWN, "cyclic_alias"
214
+
215
+ # Check direct symbol in the same file
216
+ for s in self.file_symbols.get((file_path, rhs_name), []):
217
+ if not s.scope or s.scope in candidate_scopes:
218
+ return (
219
+ s.canonical_id,
220
+ RelationshipEvidenceClass.DATAFLOW_VERIFIED,
221
+ f"local_alias -> {s.canonical_id}",
222
+ )
223
+
224
+ # Check direct import in file
225
+ for imp in self.file_imports.get((file_path, rhs_name), []):
226
+ _tpath, target_mod = self.resolve_import_target(imp)
227
+ if target_mod:
228
+ orig_name = imp.imported_name or rhs_name
229
+ if orig_name != "*":
230
+ sym = self.resolve_symbol_in_module(target_mod, orig_name)
231
+ if sym:
232
+ return (
233
+ sym.canonical_id,
234
+ RelationshipEvidenceClass.DATAFLOW_VERIFIED,
235
+ f"imported_alias -> {sym.canonical_id}",
236
+ )
237
+
238
+ # Otherwise recursively follow alias chain in same scope
239
+ return self.resolve(
240
+ file_path=file_path,
241
+ name=rhs_name,
242
+ scope=selected.scope,
243
+ use_line=selected.line,
244
+ visited=set(visited),
245
+ depth=depth + 1,
246
+ )
247
+
248
+ if selected.expr_kind == "ATTRIBUTE":
249
+ # Requirement 5: Base object must be statically grounded
250
+ base = selected.base_expr
251
+ attr = selected.attr_name
252
+
253
+ grounded_mod: str | None = None
254
+ grounded_sym: Symbol | None = None
255
+
256
+ # Check if base is an imported module/symbol
257
+ for imp in self.file_imports.get((file_path, base), []):
258
+ _tpath, target_mod = self.resolve_import_target(imp)
259
+ if target_mod:
260
+ if imp.import_type in ("namespace", "module") or imp.imported_name in (None, "*"):
261
+ grounded_mod = target_mod
262
+ else:
263
+ orig_name = imp.imported_name or base
264
+ grounded_sym = self.resolve_symbol_in_module(target_mod, orig_name)
265
+ break
266
+
267
+ # Check if base is a class or top-level symbol in this file
268
+ if not grounded_mod and not grounded_sym:
269
+ if base in ("celery", "click", "app", "django", "typer"):
270
+ grounded_mod = base
271
+ else:
272
+ for s in self.file_symbols.get((file_path, base), []):
273
+ if s.kind in ("class", "function", "method"):
274
+ grounded_sym = s
275
+ break
276
+
277
+ # Check if base is a local alias that resolves to a grounded symbol
278
+ if not grounded_mod and not grounded_sym:
279
+ base_id, base_ev, _base_reason = self.resolve(
280
+ file_path=file_path,
281
+ name=base,
282
+ scope=selected.scope,
283
+ use_line=selected.line,
284
+ visited=set(visited),
285
+ depth=depth + 1,
286
+ )
287
+ if base_id and base_ev == RelationshipEvidenceClass.DATAFLOW_VERIFIED:
288
+ if base_id in self.canonical_map:
289
+ grounded_sym = self.canonical_map[base_id]
290
+
291
+ if not grounded_mod and not grounded_sym:
292
+ # Base is UNGROUNDED! Must return UNKNOWN
293
+ return None, RelationshipEvidenceClass.UNKNOWN, f"unknown_attribute_base: {base}"
294
+
295
+ # Base is grounded: resolve attribute
296
+ if grounded_mod:
297
+ target_sym = self.resolve_symbol_in_module(grounded_mod, attr)
298
+ if target_sym:
299
+ return (
300
+ target_sym.canonical_id,
301
+ RelationshipEvidenceClass.DATAFLOW_VERIFIED,
302
+ f"attribute_alias -> {target_sym.canonical_id}",
303
+ )
304
+ # Framework / external library module attribute
305
+ if grounded_mod in ("celery", "click", "app", "django", "typer") or "." in grounded_mod:
306
+ return (
307
+ f"{grounded_mod}.{attr}",
308
+ RelationshipEvidenceClass.DATAFLOW_VERIFIED,
309
+ f"external_attribute_alias -> {grounded_mod}.{attr}",
310
+ )
311
+ elif grounded_sym:
312
+ cand_canon = f"{grounded_sym.canonical_id}.{attr}"
313
+ if cand_canon in self.canonical_map:
314
+ return (
315
+ cand_canon,
316
+ RelationshipEvidenceClass.DATAFLOW_VERIFIED,
317
+ f"method_alias -> {cand_canon}",
318
+ )
319
+
320
+ return None, RelationshipEvidenceClass.UNKNOWN, f"attribute_not_found_on_base: {base}.{attr}"
321
+
322
+ if selected.expr_kind == "SUBSCRIPT":
323
+ target_dict = selected.subscript_target or ""
324
+ sub_key = selected.subscript_key
325
+ idx = selected.subscript_index
326
+ return self.resolve_subscript(
327
+ file_path=file_path,
328
+ target_dict=target_dict,
329
+ key=sub_key,
330
+ index=idx,
331
+ scope=selected.scope,
332
+ use_line=selected.line,
333
+ visited=set(visited),
334
+ depth=depth,
335
+ )
336
+
337
+ return None, RelationshipEvidenceClass.UNKNOWN, "unsupported_binding_kind"
338
+
339
+ def resolve_subscript(
340
+ self,
341
+ file_path: str,
342
+ target_dict: str,
343
+ key: str | None = None,
344
+ index: int | None = None,
345
+ scope: str = "",
346
+ use_line: int | None = None,
347
+ visited: set[tuple[str, str, str]] | None = None,
348
+ depth: int = 0,
349
+ ) -> tuple[str | None, RelationshipEvidenceClass, str]:
350
+ """Resolve a subscript access (e.g. HANDLERS['create'] or PIPELINE[0]) deterministically."""
351
+ visited = visited or set()
352
+ if depth > 10:
353
+ return None, RelationshipEvidenceClass.UNKNOWN, "max_depth_exceeded"
354
+
355
+ if key == "<DYNAMIC>":
356
+ return None, RelationshipEvidenceClass.UNKNOWN, "dynamic_dispatch"
357
+
358
+ # 1. Look for bindings of target_dict in the current file
359
+ dict_bindings = self.bindings_by_file_target.get((file_path, target_dict), [])
360
+ dict_candidates = [
361
+ b
362
+ for b in dict_bindings
363
+ if (b.scope == scope or not b.scope)
364
+ and (use_line is None or b.line <= use_line)
365
+ ]
366
+
367
+ # Check for subscript assignment in dict_candidates (e.g. HANDLERS["create"] = create_user)
368
+ if key is not None and dict_candidates:
369
+ # Walk backwards from use_line to find latest assignment for this key
370
+ for b in reversed(dict_candidates):
371
+ if (
372
+ b.expr_kind in ("DICT_ASSIGN", "EVENT_ON", "SUBSCRIPT_ASSIGN", "CALL_REGISTER")
373
+ and b.subscript_key == key
374
+ ):
375
+ if b.is_conditional:
376
+ return None, RelationshipEvidenceClass.POSSIBLE, "conditional_ambiguity"
377
+ if b.source_expr == "<DYNAMIC>":
378
+ return None, RelationshipEvidenceClass.UNKNOWN, "dynamic_dispatch"
379
+ if "." in b.source_expr:
380
+ base, attr = b.source_expr.split(".", 1)
381
+ return self._resolve_attribute_expr(
382
+ file_path, base, attr, scope, b.line, set(visited), depth + 1
383
+ )
384
+ target_id, _ev, _rsn = self.resolve(
385
+ file_path=file_path,
386
+ name=b.source_expr,
387
+ scope=scope,
388
+ use_line=b.line,
389
+ visited=set(visited),
390
+ depth=depth + 1,
391
+ )
392
+ if target_id:
393
+ return (
394
+ target_id,
395
+ RelationshipEvidenceClass.DATAFLOW_VERIFIED,
396
+ f"dict_assignment -> {target_id}",
397
+ )
398
+ return None, RelationshipEvidenceClass.UNKNOWN, f"unresolved_subscript_val: {b.source_expr}"
399
+
400
+ # Check for literal definition (DICT_LITERAL or LIST_LITERAL)
401
+ if dict_candidates:
402
+ dict_b = dict_candidates[-1]
403
+ if dict_b.expr_kind == "DICT_LITERAL" and key is not None:
404
+ for k, val_name in dict_b.dict_entries:
405
+ if k == key:
406
+ if "." in val_name:
407
+ base, attr = val_name.split(".", 1)
408
+ return self._resolve_attribute_expr(
409
+ file_path, base, attr, scope, dict_b.line, set(visited), depth + 1
410
+ )
411
+ target_id, _ev, _rsn = self.resolve(
412
+ file_path=file_path,
413
+ name=val_name,
414
+ scope=scope,
415
+ use_line=dict_b.line,
416
+ visited=set(visited),
417
+ depth=depth + 1,
418
+ )
419
+ if target_id:
420
+ return (
421
+ target_id,
422
+ RelationshipEvidenceClass.DATAFLOW_VERIFIED,
423
+ f"dict_subscript -> {target_id}",
424
+ )
425
+ return None, RelationshipEvidenceClass.UNKNOWN, f"unresolved_subscript_val: {val_name}"
426
+ return None, RelationshipEvidenceClass.UNKNOWN, f"key_not_in_dict: {key}"
427
+
428
+ if dict_b.expr_kind == "LIST_LITERAL" and index is not None:
429
+ if 0 <= index < len(dict_b.list_entries):
430
+ elem_name = dict_b.list_entries[index]
431
+ if "." in elem_name:
432
+ base, attr = elem_name.split(".", 1)
433
+ return self._resolve_attribute_expr(
434
+ file_path, base, attr, scope, dict_b.line, set(visited), depth + 1
435
+ )
436
+ target_id, _ev, _rsn = self.resolve(
437
+ file_path=file_path,
438
+ name=elem_name,
439
+ scope=scope,
440
+ use_line=dict_b.line,
441
+ visited=set(visited),
442
+ depth=depth + 1,
443
+ )
444
+ if target_id:
445
+ return (
446
+ target_id,
447
+ RelationshipEvidenceClass.DATAFLOW_VERIFIED,
448
+ f"list_subscript -> {target_id}",
449
+ )
450
+ return None, RelationshipEvidenceClass.UNKNOWN, f"unresolved_subscript_elem: {elem_name}"
451
+ return None, RelationshipEvidenceClass.UNKNOWN, f"index_out_of_bounds: {index}"
452
+
453
+ # 2. Check cross-file imports: e.g. from registry import HANDLERS
454
+ for imp in self.file_imports.get((file_path, target_dict), []):
455
+ _tpath, target_mod = self.resolve_import_target(imp)
456
+ if target_mod:
457
+ orig_name = imp.imported_name or target_dict
458
+ return self.resolve_cross_file_registry(
459
+ target_mod, orig_name, key, visited=set(visited), depth=depth + 1
460
+ )
461
+
462
+ return None, RelationshipEvidenceClass.UNKNOWN, f"unknown_subscript_target: {target_dict}"
463
+
464
+ def resolve_cross_file_registry(
465
+ self,
466
+ target_mod: str,
467
+ target_name: str,
468
+ key: str | None,
469
+ visited: set[tuple[str, str, str]] | None = None,
470
+ depth: int = 0,
471
+ ) -> tuple[str | None, RelationshipEvidenceClass, str]:
472
+ """Resolve a subscript key in a cross-file shared registry."""
473
+ if key is None or key == "<DYNAMIC>":
474
+ return None, RelationshipEvidenceClass.UNKNOWN, "dynamic_dispatch"
475
+
476
+ visited = visited or set()
477
+ v_key = (target_mod, target_name, key)
478
+ if v_key in visited or depth > 10:
479
+ return None, RelationshipEvidenceClass.UNKNOWN, "cycle_or_depth_exceeded"
480
+ visited.add(v_key)
481
+
482
+ candidates: list[BindingRef] = []
483
+ for b in self.bindings_by_target.get(target_name, ()):
484
+ if (
485
+ b.expr_kind in ("DICT_ASSIGN", "EVENT_ON", "SUBSCRIPT_ASSIGN", "CALL_REGISTER")
486
+ and b.subscript_key == key
487
+ ):
488
+ candidates.append(b)
489
+ elif b.expr_kind == "DICT_LITERAL":
490
+ for k, _v in b.dict_entries:
491
+ if k == key:
492
+ candidates.append(b)
493
+ break
494
+
495
+ if not candidates:
496
+ return None, RelationshipEvidenceClass.UNKNOWN, f"key_not_in_cross_file_registry: {key}"
497
+
498
+ # If any candidate is conditional, order/presence is ambiguous -> POSSIBLE
499
+ if any(c.is_conditional for c in candidates):
500
+ return None, RelationshipEvidenceClass.POSSIBLE, "conditional_ambiguity"
501
+
502
+ resolved_targets: set[str] = set()
503
+ for cand in candidates:
504
+ if cand.expr_kind == "DICT_LITERAL":
505
+ for k, v in cand.dict_entries:
506
+ if k == key:
507
+ tid, _ev, _rsn = self.resolve(
508
+ cand.file_path,
509
+ v,
510
+ scope=cand.scope,
511
+ use_line=cand.line,
512
+ visited=set(visited),
513
+ depth=depth + 1,
514
+ )
515
+ if tid:
516
+ resolved_targets.add(tid)
517
+ else:
518
+ if cand.source_expr == "<DYNAMIC>":
519
+ return None, RelationshipEvidenceClass.UNKNOWN, "dynamic_dispatch"
520
+ if "." in cand.source_expr:
521
+ base, attr = cand.source_expr.split(".", 1)
522
+ tid, _ev, _rsn = self._resolve_attribute_expr(
523
+ cand.file_path, base, attr, cand.scope, cand.line, set(visited), depth + 1
524
+ )
525
+ if tid:
526
+ resolved_targets.add(tid)
527
+ else:
528
+ tid, _ev, _rsn = self.resolve(
529
+ cand.file_path,
530
+ cand.source_expr,
531
+ scope=cand.scope,
532
+ use_line=cand.line,
533
+ visited=set(visited),
534
+ depth=depth + 1,
535
+ )
536
+ if tid:
537
+ resolved_targets.add(tid)
538
+
539
+ if not resolved_targets:
540
+ return None, RelationshipEvidenceClass.UNKNOWN, f"unresolved_cross_file_registry_key: {key}"
541
+
542
+ if len(resolved_targets) > 1:
543
+ # Conflicting mutations across files -> POSSIBLE
544
+ return None, RelationshipEvidenceClass.POSSIBLE, "cross_file_mutation_ambiguity"
545
+
546
+ final_id = next(iter(resolved_targets))
547
+ return (
548
+ final_id,
549
+ RelationshipEvidenceClass.DATAFLOW_VERIFIED,
550
+ f"cross_file_registry -> {final_id}",
551
+ )
552
+
553
+ def _resolve_attribute_expr(
554
+ self,
555
+ file_path: str,
556
+ base: str,
557
+ attr: str,
558
+ scope: str,
559
+ line: int,
560
+ visited: set[tuple[str, str, str]],
561
+ depth: int,
562
+ ) -> tuple[str | None, RelationshipEvidenceClass, str]:
563
+ """Resolve attribute expression when base is statically grounded."""
564
+ grounded_mod: str | None = None
565
+ grounded_sym: Symbol | None = None
566
+
567
+ for imp in self.file_imports.get((file_path, base), []):
568
+ _tpath, target_mod = self.resolve_import_target(imp)
569
+ if target_mod:
570
+ if imp.import_type in ("namespace", "module") or imp.imported_name in (None, "*"):
571
+ grounded_mod = target_mod
572
+ else:
573
+ orig_name = imp.imported_name or base
574
+ grounded_sym = self.resolve_symbol_in_module(target_mod, orig_name)
575
+ break
576
+
577
+ if not grounded_mod and not grounded_sym:
578
+ for s in self.file_symbols.get((file_path, base), []):
579
+ if s.kind in ("class", "function", "method"):
580
+ grounded_sym = s
581
+ break
582
+
583
+ if not grounded_mod and not grounded_sym:
584
+ base_id, base_ev, _base_reason = self.resolve(
585
+ file_path=file_path,
586
+ name=base,
587
+ scope=scope,
588
+ use_line=line,
589
+ visited=set(visited),
590
+ depth=depth + 1,
591
+ )
592
+ if base_id and base_ev == RelationshipEvidenceClass.DATAFLOW_VERIFIED:
593
+ if base_id in self.canonical_map:
594
+ grounded_sym = self.canonical_map[base_id]
595
+
596
+ if not grounded_mod and not grounded_sym:
597
+ return None, RelationshipEvidenceClass.UNKNOWN, f"unknown_attribute_base: {base}"
598
+
599
+ if grounded_mod:
600
+ target_sym = self.resolve_symbol_in_module(grounded_mod, attr)
601
+ if target_sym:
602
+ return (
603
+ target_sym.canonical_id,
604
+ RelationshipEvidenceClass.DATAFLOW_VERIFIED,
605
+ f"attribute_alias -> {target_sym.canonical_id}",
606
+ )
607
+ elif grounded_sym:
608
+ cand_canon = f"{grounded_sym.canonical_id}.{attr}"
609
+ if cand_canon in self.canonical_map:
610
+ return (
611
+ cand_canon,
612
+ RelationshipEvidenceClass.DATAFLOW_VERIFIED,
613
+ f"method_alias -> {cand_canon}",
614
+ )
615
+
616
+ return None, RelationshipEvidenceClass.UNKNOWN, f"attribute_not_found_on_base: {base}.{attr}"