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.
- {node_walk-0.3.1 → node_walk-0.4.0}/CHANGELOG.md +4 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/PKG-INFO +1 -1
- node_walk-0.4.0/plans/receiver-resolution-plan.md +361 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/analysis/python/visitor.py +55 -14
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/resolution/calls.py +39 -72
- node_walk-0.4.0/src/node_walk/resolution/receiver.py +198 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/tests/test_indexer.py +39 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/.github/workflows/ci.yml +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/.github/workflows/release.yml +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/.gitignore +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/LICENSE +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/README.md +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/plans/binding-and-receiver-resolution-plan.md +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/plans/graph-explorer-ux-plan.md +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/plans/plan.md +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/plans/relationship-resolution-redesign.md +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/plans/traversable-graph.md +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/pyproject.toml +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/__init__.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/analysis/__init__.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/analysis/base.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/analysis/python/__init__.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/analysis/python/analyzer.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/analysis/python/scope.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/analysis/python_analyzer.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/cli/__init__.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/cli/main.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/indexer.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/ir/__init__.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/ir/enums.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/ir/models.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/query/__init__.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/query/engine.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/query/tree_formatter.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/resolution/__init__.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/resolution/base.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/resolution/bindings.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/resolution/imports.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/resolution/inheritance.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/storage/__init__.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/storage/base.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/storage/repository.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/storage/schema.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/storage/sqlite_store.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/web/__init__.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/web/app.js +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/web/index.html +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/web/server.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/src/node_walk/web/style.css +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/tests/fixtures/nested_project/base.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/tests/fixtures/nested_project/impl.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/tests/fixtures/simple_project/services.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/tests/test_ir.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/tests/test_python_analyzer.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/tests/test_query_engine.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/tests/test_resolution.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/tests/test_resolution_bindings.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/tests/test_storage.py +0 -0
- {node_walk-0.3.1 → node_walk-0.4.0}/tests/test_web_server.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
726
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|