node-walk 0.3.1__tar.gz → 0.4.0__tar.gz

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 (59) hide show
  1. {node_walk-0.3.1 → node_walk-0.4.0}/CHANGELOG.md +4 -0
  2. {node_walk-0.3.1 → node_walk-0.4.0}/PKG-INFO +1 -1
  3. node_walk-0.4.0/plans/receiver-resolution-plan.md +361 -0
  4. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/analysis/python/visitor.py +55 -14
  5. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/resolution/calls.py +39 -72
  6. node_walk-0.4.0/src/node_walk/resolution/receiver.py +198 -0
  7. {node_walk-0.3.1 → node_walk-0.4.0}/tests/test_indexer.py +39 -0
  8. {node_walk-0.3.1 → node_walk-0.4.0}/.github/workflows/ci.yml +0 -0
  9. {node_walk-0.3.1 → node_walk-0.4.0}/.github/workflows/release.yml +0 -0
  10. {node_walk-0.3.1 → node_walk-0.4.0}/.gitignore +0 -0
  11. {node_walk-0.3.1 → node_walk-0.4.0}/LICENSE +0 -0
  12. {node_walk-0.3.1 → node_walk-0.4.0}/README.md +0 -0
  13. {node_walk-0.3.1 → node_walk-0.4.0}/plans/binding-and-receiver-resolution-plan.md +0 -0
  14. {node_walk-0.3.1 → node_walk-0.4.0}/plans/graph-explorer-ux-plan.md +0 -0
  15. {node_walk-0.3.1 → node_walk-0.4.0}/plans/plan.md +0 -0
  16. {node_walk-0.3.1 → node_walk-0.4.0}/plans/relationship-resolution-redesign.md +0 -0
  17. {node_walk-0.3.1 → node_walk-0.4.0}/plans/traversable-graph.md +0 -0
  18. {node_walk-0.3.1 → node_walk-0.4.0}/pyproject.toml +0 -0
  19. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/__init__.py +0 -0
  20. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/analysis/__init__.py +0 -0
  21. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/analysis/base.py +0 -0
  22. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/analysis/python/__init__.py +0 -0
  23. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/analysis/python/analyzer.py +0 -0
  24. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/analysis/python/scope.py +0 -0
  25. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/analysis/python_analyzer.py +0 -0
  26. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/cli/__init__.py +0 -0
  27. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/cli/main.py +0 -0
  28. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/indexer.py +0 -0
  29. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/ir/__init__.py +0 -0
  30. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/ir/enums.py +0 -0
  31. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/ir/models.py +0 -0
  32. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/query/__init__.py +0 -0
  33. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/query/engine.py +0 -0
  34. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/query/tree_formatter.py +0 -0
  35. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/resolution/__init__.py +0 -0
  36. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/resolution/base.py +0 -0
  37. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/resolution/bindings.py +0 -0
  38. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/resolution/imports.py +0 -0
  39. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/resolution/inheritance.py +0 -0
  40. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/storage/__init__.py +0 -0
  41. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/storage/base.py +0 -0
  42. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/storage/repository.py +0 -0
  43. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/storage/schema.py +0 -0
  44. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/storage/sqlite_store.py +0 -0
  45. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/web/__init__.py +0 -0
  46. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/web/app.js +0 -0
  47. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/web/index.html +0 -0
  48. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/web/server.py +0 -0
  49. {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/web/style.css +0 -0
  50. {node_walk-0.3.1 → node_walk-0.4.0}/tests/fixtures/nested_project/base.py +0 -0
  51. {node_walk-0.3.1 → node_walk-0.4.0}/tests/fixtures/nested_project/impl.py +0 -0
  52. {node_walk-0.3.1 → node_walk-0.4.0}/tests/fixtures/simple_project/services.py +0 -0
  53. {node_walk-0.3.1 → node_walk-0.4.0}/tests/test_ir.py +0 -0
  54. {node_walk-0.3.1 → node_walk-0.4.0}/tests/test_python_analyzer.py +0 -0
  55. {node_walk-0.3.1 → node_walk-0.4.0}/tests/test_query_engine.py +0 -0
  56. {node_walk-0.3.1 → node_walk-0.4.0}/tests/test_resolution.py +0 -0
  57. {node_walk-0.3.1 → node_walk-0.4.0}/tests/test_resolution_bindings.py +0 -0
  58. {node_walk-0.3.1 → node_walk-0.4.0}/tests/test_storage.py +0 -0
  59. {node_walk-0.3.1 → node_walk-0.4.0}/tests/test_web_server.py +0 -0
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
11
 
12
12
  ---
13
13
 
14
+ ## [0.4.0] - 2026-08-26
15
+
16
+ ---
17
+
14
18
  ## [0.3.1] - 2026-08-25
15
19
 
16
20
  ---
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: node-walk
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Semantic code intelligence — local-first graph of your codebase for humans and LLMs
5
5
  Author: CodeGraph Contributors
6
6
  License: MIT
@@ -0,0 +1,361 @@
1
+ # Receiver and Dependency-Injection Resolution Plan
2
+
3
+ ## Problem
4
+
5
+ `CALLS` resolution is now able to use simple bindings, but it still assumes
6
+ that the receiver's type can be found in one local fact. Real Python code often
7
+ passes an implementation through several symbols before a call is made:
8
+
9
+ ```python
10
+ class Controller:
11
+ def __init__(self, service: UserService):
12
+ self.service = service
13
+
14
+ def get(self, user_id):
15
+ return self.service.get_by_id(user_id)
16
+
17
+ class App:
18
+ def __init__(self, controller: Controller):
19
+ self.controller = controller
20
+
21
+ def run(self, user_id):
22
+ return self.controller.get(user_id)
23
+ ```
24
+
25
+ The resolver must answer one general question:
26
+
27
+ > What symbol does this receiver expression denote at this call site, and
28
+ > which member belongs to that symbol?
29
+
30
+ This applies equally to local variables, constructor parameters, instance
31
+ attributes, imported aliases, factories, and nested paths. It should not grow
32
+ as a collection of DI, repository, or adapter special cases.
33
+
34
+ ## Goals
35
+
36
+ - Resolve `receiver.member(...)` as `receiver binding -> member lookup`.
37
+ - Support bindings that flow through multiple classes and methods.
38
+ - Preserve source facts and explain every resolution decision.
39
+ - Prefer a correct `PROBABLE` or `UNRESOLVED` result over an incorrect edge.
40
+ - Keep resolution bounded and deterministic for dynamic Python.
41
+ - Make re-resolution possible without reparsing source files.
42
+
43
+ ## Non-goals
44
+
45
+ - Full Python type inference or runtime execution.
46
+ - Proving arbitrary dynamic attribute assignment, monkey patching, or
47
+ metaclass behavior.
48
+ - Modeling every possible container/service-locator framework in the first
49
+ implementation.
50
+
51
+ ## Current Gaps
52
+
53
+ 1. Binding facts currently store a type hint, but the resolver treats them as
54
+ isolated facts rather than a graph of aliases and assignments.
55
+ 2. `self.repository` may be established in `__init__` and used in another
56
+ method, so method-local lookup is insufficient.
57
+ 3. A binding such as `self.service = factory()` has no stable target unless the
58
+ factory return type or returned symbol is known.
59
+ 4. Nested receivers such as `self.repo.session.client.send()` require repeated
60
+ member resolution, not one string concatenation.
61
+ 5. Resolver order currently affects whether a binding is available when calls
62
+ are resolved.
63
+ 6. The visitor can emit duplicate or incomplete binding facts while walking
64
+ nested assignment nodes.
65
+ 7. FastAPI dependency declarations such as
66
+ `service: UserService = Depends(get_service)` mix a parameter annotation,
67
+ a provider reference, and framework metadata in one AST expression. Treating
68
+ the whole expression as an ordinary call loses the injected type contract.
69
+
70
+ ## Target Model
71
+
72
+ Represent receiver resolution as a chain of typed steps:
73
+
74
+ ```text
75
+ call site: self.repo.session.find
76
+ self.repo -> Repository
77
+ Repository.session -> Session
78
+ Session.find -> method symbol
79
+ ```
80
+
81
+ Each step should carry:
82
+
83
+ - expression/path (`self.repo`)
84
+ - source scope and class owner
85
+ - candidate symbol id(s)
86
+ - evidence (`parameter_annotation`, `constructor_call`, `attribute_assignment`,
87
+ `import_alias`, `return_annotation`, `fastapi_depends`, etc.)
88
+ - confidence (`RESOLVED`, `PROBABLE`, `UNRESOLVED`)
89
+ - diagnostic details
90
+
91
+ The existing `RelationshipFact` table remains the persisted observation layer.
92
+ The implementation may add metadata fields or a dedicated binding index, but
93
+ must not hide inferred bindings only in Python process state.
94
+
95
+ ## Resolution Pipeline
96
+
97
+ ### Phase 1: Normalize Extraction Facts
98
+
99
+ Make the visitor emit stable facts without deciding the final target:
100
+
101
+ - calls: `receiver_text`, `callee_name`, complete `raw_text`, call scope
102
+ - assignments: target path and RHS expression
103
+ - parameters: parameter path and annotation expression
104
+ - returns: return expression and optional annotation
105
+ - imports: local alias and imported qualified path
106
+
107
+ Normalize target paths structurally where possible. For example, represent
108
+ `self.repository` as path segments `self`, `repository`, while retaining the
109
+ original text for diagnostics.
110
+
111
+ Add tests for:
112
+
113
+ - `service.get_by_id()`
114
+ - `self.repository.get_by_id()`
115
+ - `self.repo.session.client.send()`
116
+ - alias assignments (`repo = self.repository`)
117
+ - tuple/destructured assignments where safely supported
118
+ - duplicate traversal not producing duplicate binding facts
119
+
120
+ ### Phase 2: Build a Repository Binding Index
121
+
122
+ After all files are stored and imports/classes are known, build an index from
123
+ binding facts. The index should support:
124
+
125
+ - lookup by file and scope
126
+ - lookup by enclosing class
127
+ - lookup by exact path (`self.repository`)
128
+ - lookup by root alias (`repository`)
129
+ - lookup of the latest or applicable assignment before a call line
130
+
131
+ Do not rely on fact insertion order. Use source line and lexical ownership to
132
+ make the result deterministic.
133
+
134
+ Initial binding sources, in priority order:
135
+
136
+ 1. explicit type annotation (`service: UserService`)
137
+ 2. constructor call (`service = UserService()`)
138
+ 3. imported symbol or module alias
139
+ 4. assignment from an already-known binding (`self.repo = repository`)
140
+ 5. annotated return value of a known factory
141
+ 6. FastAPI dependency parameter annotation or provider return annotation
142
+ 7. unique global fallback, marked `PROBABLE`
143
+
144
+ ### FastAPI `Depends()`
145
+
146
+ Handle FastAPI dependency injection as a framework-specific binding pattern,
147
+ not as a special case in member lookup. The common forms are:
148
+
149
+ ```python
150
+ from fastapi import Depends
151
+
152
+ def get_service() -> UserService:
153
+ return UserService()
154
+
155
+ @router.get("/users/{user_id}")
156
+ def get_user(
157
+ user_id: int,
158
+ service: UserService = Depends(get_service),
159
+ ):
160
+ return service.get_by_id(user_id)
161
+ ```
162
+
163
+ Extraction should record separate facts:
164
+
165
+ - parameter binding: `service -> UserService`
166
+ - provider relationship: `get_user depends_on get_service`
167
+ - optional provider return contract: `get_service -> UserService`
168
+
169
+ The `Depends` wrapper itself should not become a misleading `CALLS` edge from
170
+ the route handler to `Depends`. Instead:
171
+
172
+ 1. recognize a default value whose call target is `Depends`
173
+ 2. extract its first argument as the provider symbol/path
174
+ 3. preserve the parameter annotation as the strongest receiver type evidence
175
+ 4. resolve `service.get_by_id()` through `UserService`
176
+ 5. optionally materialize a separate `DEPENDS_ON` relationship type later;
177
+ until then, retain the provider fact for diagnostics without polluting
178
+ runtime call traversal
179
+
180
+ Support both named and module-qualified wrappers:
181
+
182
+ - `Depends(get_service)`
183
+ - `fastapi.Depends(get_service)`
184
+ - imported aliases where the import resolver proves the alias refers to
185
+ `fastapi.Depends`
186
+
187
+ Do not assume every function passed to `Depends` returns the annotated type.
188
+ When both exist, compare the provider return annotation and parameter
189
+ annotation. If they disagree, keep the parameter binding `PROBABLE` and record
190
+ the conflict rather than creating a resolved edge silently.
191
+
192
+ ### Phase 3: Resolve Binding Chains
193
+
194
+ Introduce a resolver/service with an API equivalent to:
195
+
196
+ ```python
197
+ resolve_receiver(call_fact) -> ReceiverResolution
198
+ resolve_member(receiver_resolution, member_name) -> SymbolResolution
199
+ ```
200
+
201
+ For each receiver path:
202
+
203
+ 1. Resolve the root (`self`, local name, imported alias, or known symbol).
204
+ 2. Apply each remaining attribute segment through class/module member lookup.
205
+ 3. Stop on ambiguity, missing member, or unsupported dynamic behavior.
206
+ 4. Detect cycles and enforce a small maximum path depth.
207
+
208
+ `self` resolves to the enclosing class instance. A binding created in
209
+ `__init__` is available to other methods of the same class, but not to an
210
+ unrelated class merely because the attribute names match.
211
+
212
+ ### Phase 4: Propagate Constructor and Method Contracts
213
+
214
+ Support the common multi-class DI flow without pretending to execute Python:
215
+
216
+ ```python
217
+ class Controller:
218
+ def __init__(self, service: UserService):
219
+ self.service = service
220
+ ```
221
+
222
+ Record that `Controller.service` has type `UserService`. Then:
223
+
224
+ - resolve `Controller.service.get_by_id` in any controller method
225
+ - resolve `Controller(...)` constructor calls to the class and initializer
226
+ - pass known argument bindings into typed parameters when a call target is
227
+ known
228
+
229
+ For a call such as `Controller(service)`, bind the constructor parameter
230
+ `service` to the argument's known symbol. For unknown arguments, retain the
231
+ annotation as a probable contract rather than inventing a target.
232
+
233
+ Handle return annotations next. A known `make_service() -> UserService`
234
+ should allow `make_service().get_by_id()` only when the AST shape and symbol
235
+ model make that relationship unambiguous.
236
+
237
+ ### Phase 5: Materialize Calls with Evidence
238
+
239
+ Update `CrossFileCallResolver` to consume the binding index and receiver
240
+ resolver. Its decision order should be:
241
+
242
+ 1. same-class member resolution for `self` / `cls`
243
+ 2. exact receiver-path binding and member lookup
244
+ 3. imported module/class alias lookup
245
+ 4. direct imported function lookup
246
+ 5. narrowly scoped unique fallback
247
+ 6. unresolved result with diagnostics
248
+
249
+ Diagnostics should include:
250
+
251
+ - receiver expression
252
+ - resolved binding path and target
253
+ - member lookup path
254
+ - evidence used
255
+ - ambiguity or missing-member reason
256
+ - resolution confidence
257
+
258
+ Remove the old one-off class-name receiver heuristic once equivalent coverage
259
+ exists. Keep arrow direction and final graph materialization unchanged.
260
+
261
+ ## Multiple-Class Scenarios to Cover
262
+
263
+ These should become fixtures and integration tests:
264
+
265
+ 1. **Constructor injection**
266
+ `Controller.__init__(service: Service)` followed by
267
+ `self.service.method()`.
268
+ 2. **Injection across an application root**
269
+ `App(controller: Controller)` followed by `self.controller.get()`.
270
+ 3. **Local aliasing**
271
+ `repository = self.repository` followed by `repository.find()`.
272
+ 4. **Factory return**
273
+ `make_repository() -> Repository` followed by
274
+ `make_repository().find()` where the AST supports a safe representation.
275
+ 5. **Imported implementation**
276
+ `from package.repo import Repository` followed by typed injection.
277
+ 6. **FastAPI typed dependency**
278
+ `service: UserService = Depends(get_service)` followed by
279
+ `service.get_by_id()`. The graph should resolve the method call to
280
+ `UserService.get_by_id` and retain the provider fact separately.
281
+ 7. **FastAPI untyped dependency**
282
+ `service = Depends(get_service)`. Resolve through the provider return
283
+ annotation only when it is unambiguous; otherwise mark the binding
284
+ `PROBABLE` or `UNRESOLVED` with a diagnostic.
285
+ 8. **FastAPI qualified dependency**
286
+ `service: UserService = fastapi.Depends(get_service)`.
287
+ 9. **Module receiver**
288
+ `package.service.create()` resolved through the imported module symbol.
289
+ 10. **Nested attributes**
290
+ `self.repository.session.execute()` with each intermediate member known.
291
+ 11. **Ambiguous implementations**
292
+ Two classes share a member name; result must not become a false resolved
293
+ edge without binding evidence.
294
+ 12. **Inheritance**
295
+ A method is declared on a base class and called through an injected
296
+ subclass; member lookup may walk known base classes.
297
+ 13. **Unsupported dynamic code**
298
+ `setattr(self, name, value)` remains unresolved with a useful diagnostic.
299
+
300
+ ## Testing Strategy
301
+
302
+ ### Unit tests
303
+
304
+ - path parsing and normalization
305
+ - binding index precedence
306
+ - lexical/class ownership lookup
307
+ - member lookup across one and multiple segments
308
+ - cycle and depth-limit behavior
309
+ - FastAPI `Depends` extraction without a false `CALLS` edge to `Depends`
310
+ - provider/parameter annotation agreement and conflict diagnostics
311
+ - confidence and diagnostic payloads
312
+
313
+ ### Integration tests
314
+
315
+ Index temporary multi-file projects and assert the final `CALLS` edge target.
316
+ For every fixture also inspect the original call fact and assert its resolver
317
+ diagnostics, so a passing edge cannot conceal an accidental fallback.
318
+
319
+ ### Regression matrix
320
+
321
+ Run the existing analyzer, resolution, indexer, query, storage, and web tests.
322
+ Add explicit assertions that unresolved or ambiguous calls do not create a
323
+ resolved relationship to a same-named method in an unrelated class.
324
+
325
+ ## Migration Order
326
+
327
+ 1. Add normalized binding/call fact fields without changing graph output.
328
+ 2. Add binding-index data structures and unit tests.
329
+ 3. Add receiver-chain and member lookup service.
330
+ 4. Route `CrossFileCallResolver` through that service.
331
+ 5. Add constructor argument propagation and class attribute contracts.
332
+ 6. Add FastAPI `Depends` extraction and provider contracts.
333
+ 7. Add return annotations and bounded factory support.
334
+ 8. Remove obsolete resolver heuristics and duplicate extraction paths.
335
+ 9. Re-index representative repositories and compare resolved/probable/
336
+ unresolved counts before and after.
337
+
338
+ ## Acceptance Criteria
339
+
340
+ - `self.repository.get_by_id()` resolves to the repository method.
341
+ - The same repository binding works from any method of its owning class.
342
+ - A controller injected into another class resolves through the second class.
343
+ - A FastAPI parameter using `Depends(provider)` resolves calls through its
344
+ annotation or unambiguous provider return type, without a false call to
345
+ `Depends`.
346
+ - Nested known receiver paths resolve segment by segment.
347
+ - Ambiguous and dynamic cases remain non-resolved and explain why.
348
+ - Existing 80-test baseline remains green, with new multi-class fixtures green.
349
+ - Re-running resolution produces the same results independent of file traversal
350
+ order.
351
+
352
+ ## Risks and Guardrails
353
+
354
+ - **False positives:** require binding evidence before marking a call resolved.
355
+ - **Stale assignments:** use source location and lexical scope when selecting
356
+ an applicable binding.
357
+ - **Dynamic Python:** cap path depth and return unresolved diagnostics.
358
+ - **Performance:** build indexes once per indexing pass, not with full-repository
359
+ scans for every call fact.
360
+ - **Migration churn:** keep the existing graph edge contract while replacing
361
+ only the resolution internals.
@@ -712,18 +712,42 @@ class SymbolCollector:
712
712
  return bindings
713
713
 
714
714
  for child in params_node.children:
715
- if child.type not in {"typed_parameter", "typed_default_parameter"}:
715
+ if child.type not in {"typed_parameter", "typed_default_parameter", "default_parameter"}:
716
716
  continue
717
717
 
718
718
  identifier = next((c for c in child.children if c.type == "identifier"), None)
719
719
  type_node = next((c for c in child.children if c.type == "type"), None)
720
- if not identifier or not type_node:
720
+
721
+ if not identifier:
721
722
  continue
722
723
 
723
724
  param_name = node_text(identifier, self.source)
724
- type_name = node_text(type_node, self.source)
725
- if param_name and type_name:
726
- bindings[param_name] = type_name
725
+ type_name = node_text(type_node, self.source) if type_node else ""
726
+ provider_ref = ""
727
+
728
+ # Check for FastAPI Depends in default value
729
+ if child.type in {"typed_default_parameter", "default_parameter"}:
730
+ # The default value is usually after the '=' sign
731
+ eq_found = False
732
+ for c in child.children:
733
+ if c.type == "=":
734
+ eq_found = True
735
+ continue
736
+ if eq_found and c.type == "call":
737
+ func_node = c.child_by_field_name("function")
738
+ if func_node:
739
+ call_name = node_text(func_node, self.source)
740
+ if call_name in {"Depends", "fastapi.Depends"}:
741
+ args_node = c.child_by_field_name("arguments")
742
+ if args_node:
743
+ for arg in args_node.children:
744
+ if arg.type in {"identifier", "attribute"}:
745
+ provider_ref = node_text(arg, self.source)
746
+ break
747
+
748
+ if param_name and (type_name or provider_ref):
749
+ # If we have both, prefer type_name for the binding, but we can store provider_ref in metadata
750
+ bindings[param_name] = type_name or provider_ref
727
751
  self.relationship_facts.append(
728
752
  RelationshipFact(
729
753
  file_id=self.file_info.id,
@@ -733,7 +757,12 @@ class SymbolCollector:
733
757
  simple_name=param_name.split(".")[-1],
734
758
  qualified_hint=type_name,
735
759
  source_location=SourceLocation(file_id=self.file_info.id, line=start_line(child)),
736
- metadata={"binding_type": "parameter"},
760
+ scope_symbol_id=func_sym.id,
761
+ metadata={
762
+ "binding_type": "parameter",
763
+ "provider_ref": provider_ref,
764
+ "fastapi_depends": bool(provider_ref)
765
+ },
737
766
  )
738
767
  )
739
768
 
@@ -761,11 +790,11 @@ class SymbolCollector:
761
790
  if not target_expr:
762
791
  return
763
792
 
764
- inferred_hint = self._infer_assignment_binding_hint(node, bindings)
765
- if not inferred_hint:
793
+ inferred_hint, provider_ref = self._infer_assignment_binding_hint(node, bindings)
794
+ if not inferred_hint and not provider_ref:
766
795
  return
767
796
 
768
- bindings[target_expr] = inferred_hint
797
+ bindings[target_expr] = inferred_hint or provider_ref
769
798
  self.relationship_facts.append(
770
799
  RelationshipFact(
771
800
  file_id=self.file_info.id,
@@ -776,7 +805,12 @@ class SymbolCollector:
776
805
  receiver_text=".".join(target_expr.split(".")[:-1]),
777
806
  qualified_hint=inferred_hint,
778
807
  source_location=SourceLocation(file_id=self.file_info.id, line=start_line(node)),
779
- metadata={"binding_type": "assignment"},
808
+ scope_symbol_id=func_sym.id,
809
+ metadata={
810
+ "binding_type": "assignment",
811
+ "provider_ref": provider_ref,
812
+ "fastapi_depends": bool(provider_ref)
813
+ },
780
814
  )
781
815
  )
782
816
 
@@ -795,7 +829,7 @@ class SymbolCollector:
795
829
  return text
796
830
  return ""
797
831
 
798
- def _infer_assignment_binding_hint(self, node: Node, bindings: dict[str, str]) -> str:
832
+ def _infer_assignment_binding_hint(self, node: Node, bindings: dict[str, str]) -> tuple[str, str]:
799
833
  rhs_started = False
800
834
  rhs_identifier = ""
801
835
  for child in node.children:
@@ -803,12 +837,19 @@ class SymbolCollector:
803
837
  if child.type == "call":
804
838
  func_node = child.child_by_field_name("function")
805
839
  if func_node:
806
- return node_text(func_node, self.source)
840
+ call_name = node_text(func_node, self.source)
841
+ if call_name in {"Depends", "fastapi.Depends"}:
842
+ args_node = child.child_by_field_name("arguments")
843
+ if args_node:
844
+ for arg in args_node.children:
845
+ if arg.type in {"identifier", "attribute"}:
846
+ return "", node_text(arg, self.source)
847
+ return call_name, ""
807
848
  if child.type in {"identifier", "attribute"} and not rhs_identifier:
808
849
  rhs_identifier = node_text(child, self.source).strip()
809
850
  elif child.type == "=":
810
851
  rhs_started = True
811
852
 
812
853
  if rhs_identifier:
813
- return bindings.get(rhs_identifier, "")
814
- return ""
854
+ return bindings.get(rhs_identifier, ""), ""
855
+ return "", ""
@@ -219,85 +219,52 @@ class CrossFileCallResolver(FactResolver):
219
219
  def name(self) -> str:
220
220
  return "CrossFileCallResolver"
221
221
 
222
+ def run(self, store: GraphStore, facts: list[RelationshipFact]) -> int:
223
+ from node_walk.resolution.receiver import BindingIndex, ReceiverService
224
+ self._index = BindingIndex(store)
225
+ self._service = ReceiverService(store, self._index)
226
+ return super().run(store, facts)
227
+
222
228
  def resolve(self, store: GraphStore, fact: RelationshipFact) -> ResolutionResult | None:
223
229
  if fact.fact_type != FactType.CALL:
224
230
  return None
225
231
 
226
- # 1. Fetch resolved imports and bindings for this call's file/scope.
232
+ # 1. Try resolving through receiver chains
233
+ if fact.receiver_text:
234
+ receiver_res = self._service.resolve_receiver(fact)
235
+ if receiver_res:
236
+ member_res = self._service.resolve_member(receiver_res, fact.simple_name)
237
+ if member_res:
238
+ return ResolutionResult(
239
+ status=FactStatus(member_res.confidence.lower()),
240
+ resolved_target_id=member_res.resolved_symbol_id,
241
+ diagnostics={
242
+ "strategy": "cross_file_receiver_chain",
243
+ "receiver_diagnostics": receiver_res.diagnostics,
244
+ "member_diagnostics": member_res.diagnostics,
245
+ "evidence": receiver_res.evidence
246
+ }
247
+ )
248
+ else:
249
+ return ResolutionResult(
250
+ status=FactStatus.UNRESOLVED,
251
+ diagnostics={
252
+ "strategy": "cross_file_missing_member",
253
+ "receiver_diagnostics": receiver_res.diagnostics
254
+ }
255
+ )
256
+
257
+ # 2. Try simple import match for direct function calls
227
258
  import_facts = store.get_relationship_facts(
228
259
  fact_type=FactType.IMPORT, status=FactStatus.RESOLVED
229
260
  )
230
- file_imports = [f for f in import_facts if f.file_id == fact.file_id]
231
- binding_facts = store.get_relationship_facts(
232
- fact_type=FactType.BINDING, status=FactStatus.RESOLVED
233
- )
234
- caller = store.get_symbol(fact.source_symbol_id)
235
- enclosing_class_id = ""
236
- current_id = caller.parent_id if caller else ""
237
- while current_id:
238
- current = store.get_symbol(current_id)
239
- if not current:
240
- break
241
- if current.kind in (SymbolKind.CLASS, SymbolKind.INTERFACE):
242
- enclosing_class_id = current.id
243
- break
244
- current_id = current.parent_id or ""
245
-
246
- binding_source_ids = {fact.source_symbol_id}
247
- if enclosing_class_id:
248
- for candidate in store.get_all_symbols():
249
- current_id = candidate.parent_id
250
- while current_id:
251
- current = store.get_symbol(current_id)
252
- if not current:
253
- break
254
- if current.id == enclosing_class_id:
255
- binding_source_ids.add(candidate.id)
256
- break
257
- current_id = current.parent_id or ""
258
- scope_bindings = [
259
- f for f in binding_facts
260
- if f.file_id == fact.file_id
261
- and f.source_symbol_id in binding_source_ids
262
- and f.resolved_target_id
263
- ]
264
-
265
- # 2. Build local namespace maps from imports and receiver bindings.
266
- import_map: dict[str, str] = {}
267
- for imp in file_imports:
268
- # simple_name of the import fact is the imported name
269
- # e.g., `from jarvis.model import adapter` -> simple_name = `adapter`
270
- # For now we map the simple_name to the resolved target ID
271
- if imp.resolved_target_id:
272
- import_map[imp.simple_name] = imp.resolved_target_id
273
-
274
- binding_map = {
275
- binding.raw_text: binding.resolved_target_id for binding in scope_bindings}
276
-
277
- # 3. Resolve the receiver, then resolve its member.
278
- match_id = None
279
- receiver_id = binding_map.get(fact.receiver_text, "")
280
- if not receiver_id:
281
- receiver_id = import_map.get(fact.receiver_text, "")
282
-
283
- if receiver_id:
284
- receiver_sym = store.get_symbol(receiver_id)
285
- if receiver_sym:
286
- target_qname = f"{receiver_sym.qualified_name}.{fact.simple_name}"
287
- candidates = store.find_symbols_by_qualified_name(target_qname)
288
- if candidates:
289
- match_id = candidates[0].id
290
-
291
- # Case B: `chat()` where `chat` is imported directly.
292
- elif not fact.receiver_text and fact.simple_name in import_map:
293
- match_id = import_map[fact.simple_name]
294
-
295
- if match_id:
296
- return ResolutionResult(
297
- status=FactStatus.RESOLVED,
298
- resolved_target_id=match_id,
299
- diagnostics={"strategy": "cross_file_import_aware"}
300
- )
261
+ for imp in import_facts:
262
+ if imp.file_id == fact.file_id and imp.simple_name == fact.simple_name and imp.resolved_target_id:
263
+ return ResolutionResult(
264
+ status=FactStatus.RESOLVED,
265
+ resolved_target_id=imp.resolved_target_id,
266
+ diagnostics={"strategy": "cross_file_direct_import"}
267
+ )
301
268
 
302
269
  # 4. Fallback: Unambiguous global match
303
270
  # If we couldn't resolve via imports, maybe the simple name is completely unique across the repo?
@@ -0,0 +1,198 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+ from node_walk.ir.enums import FactStatus, FactType, SymbolKind
5
+ from node_walk.ir.models import RelationshipFact, Symbol
6
+ from node_walk.storage.base import GraphStore
7
+
8
+
9
+ @dataclass
10
+ class ReceiverResolution:
11
+ resolved_symbol_id: str
12
+ confidence: str # "RESOLVED", "PROBABLE", "UNRESOLVED"
13
+ diagnostics: dict
14
+ evidence: str
15
+
16
+
17
+ @dataclass
18
+ class SymbolResolution:
19
+ resolved_symbol_id: str
20
+ confidence: str
21
+ diagnostics: dict
22
+
23
+
24
+ class BindingIndex:
25
+ """
26
+ Indexes resolved BINDING facts for fast lookup by file, scope, and path.
27
+ """
28
+
29
+ def __init__(self, store: GraphStore):
30
+ self._store = store
31
+ self._bindings_by_file: dict[str, list[RelationshipFact]] = {}
32
+
33
+ # Load all resolved bindings
34
+ for fact in self._store.get_relationship_facts(fact_type=FactType.BINDING):
35
+ if fact.status in (FactStatus.RESOLVED, FactStatus.PROBABLE) and fact.resolved_target_id:
36
+ self._bindings_by_file.setdefault(fact.file_id, []).append(fact)
37
+
38
+ def get_binding(self, file_id: str, scope_id: str, path: str) -> RelationshipFact | None:
39
+ """
40
+ Look up the most applicable binding for a path in the given scope.
41
+ """
42
+ bindings = self._bindings_by_file.get(file_id, [])
43
+ if not bindings:
44
+ return None
45
+
46
+ # Gather acceptable scope IDs (current scope + enclosing class)
47
+ valid_scope_ids = {scope_id}
48
+
49
+ caller = self._store.get_symbol(scope_id)
50
+ if caller:
51
+ current_id = caller.parent_id
52
+ while current_id:
53
+ current = self._store.get_symbol(current_id)
54
+ if not current:
55
+ break
56
+ if current.kind in (SymbolKind.CLASS, SymbolKind.INTERFACE):
57
+ valid_scope_ids.add(current.id)
58
+ # Also include constructor method as a valid scope for class attributes
59
+ for m_id in self._get_init_methods(current.id):
60
+ valid_scope_ids.add(m_id)
61
+ break
62
+ current_id = current.parent_id
63
+
64
+ # Find exact path matches in valid scopes
65
+ applicable = [
66
+ b for b in bindings
67
+ if b.raw_text == path and b.scope_symbol_id in valid_scope_ids
68
+ ]
69
+
70
+ if applicable:
71
+ # Sort by line number to get the latest assignment before usage, or just the first for now.
72
+ return applicable[-1]
73
+
74
+ return None
75
+
76
+ def _get_init_methods(self, class_id: str) -> list[str]:
77
+ # Return the IDs of any __init__ methods in the class
78
+ inits = []
79
+ cls_sym = self._store.get_symbol(class_id)
80
+ if not cls_sym:
81
+ return inits
82
+
83
+ init_qname = f"{cls_sym.qualified_name}.__init__"
84
+ for s in self._store.find_symbols_by_qualified_name(init_qname):
85
+ inits.append(s.id)
86
+ return inits
87
+
88
+
89
+ class ReceiverService:
90
+ def __init__(self, store: GraphStore, index: BindingIndex):
91
+ self._store = store
92
+ self._index = index
93
+
94
+ def resolve_receiver(self, call_fact: RelationshipFact) -> ReceiverResolution | None:
95
+ receiver_text = call_fact.receiver_text
96
+ if not receiver_text:
97
+ return None
98
+
99
+ # Support nested paths (self.repo.session)
100
+ segments = receiver_text.split(".")
101
+
102
+ # 0. Check exact path in BindingIndex (e.g., self.repository, self.repo.session)
103
+ exact_binding = self._index.get_binding(call_fact.file_id, call_fact.scope_symbol_id, receiver_text)
104
+ if exact_binding:
105
+ confidence = "RESOLVED" if exact_binding.status == FactStatus.RESOLVED else "PROBABLE"
106
+ return ReceiverResolution(
107
+ resolved_symbol_id=exact_binding.resolved_target_id,
108
+ confidence=confidence,
109
+ diagnostics={"strategy": "binding_index_exact", "binding_id": exact_binding.id},
110
+ evidence=exact_binding.metadata.get("binding_type", "unknown")
111
+ )
112
+
113
+ # 1. Resolve root segment
114
+ root_segment = segments[0]
115
+ current_resolved_id = None
116
+ confidence = "RESOLVED"
117
+ diagnostics = {}
118
+ evidence = ""
119
+
120
+ # Check if root is 'self'
121
+ if root_segment == "self":
122
+ caller = self._store.get_symbol(call_fact.scope_symbol_id)
123
+ if caller:
124
+ enclosing = self._get_enclosing_class(caller)
125
+ if enclosing:
126
+ current_resolved_id = enclosing.id
127
+ diagnostics={"strategy": "self"}
128
+ evidence = "self"
129
+
130
+ # 1. Check BindingIndex
131
+ binding = self._index.get_binding(call_fact.file_id, call_fact.scope_symbol_id, root_segment)
132
+ print(f"DEBUG: resolve_receiver path='{receiver_text}', root='{root_segment}', caller_id={call_fact.scope_symbol_id}, binding={binding}")
133
+ if not current_resolved_id and binding:
134
+ confidence = "RESOLVED" if binding.status == FactStatus.RESOLVED else "PROBABLE"
135
+ current_resolved_id = binding.resolved_target_id
136
+ diagnostics={"strategy": "binding_index", "binding_id": binding.id}
137
+ evidence=binding.metadata.get("binding_type", "unknown")
138
+
139
+ # Check imports for root
140
+ if not current_resolved_id:
141
+ import_facts = self._store.get_relationship_facts(
142
+ fact_type=FactType.IMPORT, status=FactStatus.RESOLVED
143
+ )
144
+ for imp in import_facts:
145
+ if imp.file_id == call_fact.file_id and imp.simple_name == root_segment and imp.resolved_target_id:
146
+ current_resolved_id = imp.resolved_target_id
147
+ confidence = "RESOLVED"
148
+ diagnostics={"strategy": "import_alias"}
149
+ evidence = "import"
150
+ break
151
+
152
+ if not current_resolved_id:
153
+ return None
154
+
155
+ # 2. Apply remaining segments
156
+ for segment in segments[1:]:
157
+ res = self.resolve_member(
158
+ ReceiverResolution(current_resolved_id, confidence, diagnostics, evidence),
159
+ segment
160
+ )
161
+ if not res:
162
+ return None
163
+ current_resolved_id = res.resolved_symbol_id
164
+ confidence = res.confidence
165
+
166
+ return ReceiverResolution(
167
+ resolved_symbol_id=current_resolved_id,
168
+ confidence=confidence,
169
+ diagnostics=diagnostics,
170
+ evidence=evidence
171
+ )
172
+
173
+ def resolve_member(self, receiver_res: ReceiverResolution, member_name: str) -> SymbolResolution | None:
174
+ receiver_sym = self._store.get_symbol(receiver_res.resolved_symbol_id)
175
+ if not receiver_sym:
176
+ return None
177
+
178
+ target_qname = f"{receiver_sym.qualified_name}.{member_name}"
179
+ candidates = self._store.find_symbols_by_qualified_name(target_qname)
180
+ if candidates:
181
+ return SymbolResolution(
182
+ resolved_symbol_id=candidates[0].id,
183
+ confidence=receiver_res.confidence,
184
+ diagnostics={"strategy": "member_lookup"}
185
+ )
186
+
187
+ return None
188
+
189
+ def _get_enclosing_class(self, sym: Symbol) -> Symbol | None:
190
+ current_id = sym.parent_id
191
+ while current_id:
192
+ current = self._store.get_symbol(current_id)
193
+ if not current:
194
+ return None
195
+ if current.kind in (SymbolKind.CLASS, SymbolKind.INTERFACE):
196
+ return current
197
+ current_id = current.parent_id
198
+ return None
@@ -149,3 +149,42 @@ class TransactionService:
149
149
  assert any(rel.target_id == repository_method.id for rel in calls)
150
150
  finally:
151
151
  store.close()
152
+
153
+ def test_indexer_resolves_fastapi_depends(tmp_path):
154
+ project = tmp_path / "fastapi_project"
155
+ project.mkdir()
156
+ (project / "main.py").write_text(
157
+ """
158
+ class UserService:
159
+ def get_by_id(self, user_id):
160
+ return user_id
161
+
162
+ def get_service() -> UserService:
163
+ return UserService()
164
+
165
+ def get_user(service: UserService = Depends(get_service)):
166
+ return service.get_by_id(1)
167
+ """.strip(),
168
+ encoding="utf-8",
169
+ )
170
+
171
+ db_path = tmp_path / "graph5.db"
172
+ store = SQLiteGraphStore(db_path)
173
+ try:
174
+ from node_walk.indexer import Indexer
175
+ Indexer(store).index(project)
176
+
177
+ get_user = next(
178
+ s for s in store.get_all_symbols()
179
+ if s.qualified_name == "main.get_user"
180
+ )
181
+ service_method = next(
182
+ s for s in store.get_all_symbols()
183
+ if s.qualified_name == "main.UserService.get_by_id"
184
+ )
185
+
186
+ calls = store.get_relationships_from(
187
+ get_user.id, RelationshipType.CALLS)
188
+ assert any(rel.target_id == service_method.id for rel in calls)
189
+ finally:
190
+ store.close()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes