diffcontext 0.5.1__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 (40) hide show
  1. diffcontext/__init__.py +233 -0
  2. diffcontext/_warn_once.py +112 -0
  3. diffcontext/cache.py +216 -0
  4. diffcontext/cli/__init__.py +655 -0
  5. diffcontext/context/__init__.py +1 -0
  6. diffcontext/context/compiler.py +643 -0
  7. diffcontext/context/selector.py +258 -0
  8. diffcontext/diff/__init__.py +1 -0
  9. diffcontext/diff/git_diff.py +298 -0
  10. diffcontext/diff/state_manager.py +75 -0
  11. diffcontext/graph_builder.py +1026 -0
  12. diffcontext/history.py +154 -0
  13. diffcontext/impact/__init__.py +1 -0
  14. diffcontext/impact/blast_radius.py +58 -0
  15. diffcontext/impact/scoring.py +223 -0
  16. diffcontext/impact/traversal.py +58 -0
  17. diffcontext/impact/visualizer.py +338 -0
  18. diffcontext/languages/__init__.py +80 -0
  19. diffcontext/languages/typescript.py +960 -0
  20. diffcontext/lexical.py +108 -0
  21. diffcontext/models.py +180 -0
  22. diffcontext/parser.py +183 -0
  23. diffcontext/pipeline.py +887 -0
  24. diffcontext/py.typed +0 -0
  25. diffcontext/rerank/__init__.py +17 -0
  26. diffcontext/rerank/features.py +356 -0
  27. diffcontext/rerank/model.py +175 -0
  28. diffcontext/resolver.py +288 -0
  29. diffcontext/scanner.py +153 -0
  30. diffcontext/symbols.py +254 -0
  31. diffcontext/verify/__init__.py +68 -0
  32. diffcontext/verify/cases.py +631 -0
  33. diffcontext/verify/history.py +396 -0
  34. diffcontext/verify/sufficiency.py +324 -0
  35. diffcontext-0.5.1.dist-info/METADATA +219 -0
  36. diffcontext-0.5.1.dist-info/RECORD +40 -0
  37. diffcontext-0.5.1.dist-info/WHEEL +5 -0
  38. diffcontext-0.5.1.dist-info/entry_points.txt +2 -0
  39. diffcontext-0.5.1.dist-info/licenses/LICENSE +21 -0
  40. diffcontext-0.5.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,288 @@
1
+ """
2
+ resolver.py — Resolve Python import statements to filesystem paths.
3
+ """
4
+
5
+ import ast
6
+ import logging
7
+ import os
8
+ from collections import deque
9
+ from typing import Dict, List, Optional, Tuple
10
+
11
+ from ._warn_once import warn_syntax_error_once, check_and_warn_encoding
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ # Import statements can only appear in statement blocks — never inside an
17
+ # expression — so import scanning walks only these fields instead of every
18
+ # AST node (expressions dominate node count ~10:1). Field order mirrors the
19
+ # AST's own field order so import precedence matches a full BFS walk.
20
+ _STMT_BLOCK_FIELDS = ("body", "handlers", "orelse", "finalbody", "cases")
21
+
22
+
23
+ def _iter_import_nodes(tree: "ast.Module"):
24
+ """Yield every Import/ImportFrom in the tree, in BFS document order,
25
+ without descending into expression subtrees."""
26
+ queue = deque(tree.body)
27
+ while queue:
28
+ node = queue.popleft()
29
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
30
+ yield node
31
+ continue
32
+ for field in _STMT_BLOCK_FIELDS:
33
+ block = getattr(node, field, None)
34
+ if block:
35
+ queue.extend(block)
36
+
37
+
38
+ def build_import_map(
39
+ filename: str,
40
+ repo_path: str,
41
+ tree: "Optional[ast.Module]" = None,
42
+ ) -> Dict[str, str]:
43
+ """
44
+ Parse imports in a file and resolve them to absolute paths.
45
+
46
+ Returns:
47
+ dict: local_name -> absolute_path_of_source_file
48
+ e.g. {"helper": "/repo/utils.py", "Session": "/repo/sessions.py"}
49
+
50
+ __init__.py transparency:
51
+ When an import resolves to a package __init__.py, we scan that
52
+ __init__.py for re-export statements (`from .sub import Name`) and
53
+ follow them to the actual definition file. This means:
54
+
55
+ from flask import Flask
56
+ # resolves to src/flask/__init__.py
57
+ # __init__.py has: from .app import Flask
58
+ # final result: src/flask/app.py ← correct
59
+ """
60
+ # Accept a pre-parsed AST to avoid re-reading and re-parsing the file
61
+ # (the pipeline parses each file exactly once and shares the tree).
62
+ if tree is None:
63
+ with open(filename, "rb") as f:
64
+ raw = f.read()
65
+ check_and_warn_encoding(logger, filename, raw)
66
+ source = raw.decode("utf-8", errors="ignore")
67
+
68
+ try:
69
+ tree = ast.parse(source)
70
+ except SyntaxError as e:
71
+ warn_syntax_error_once(logger, filename, e)
72
+ return {}
73
+
74
+ imports: Dict[str, str] = {}
75
+ file_dir = os.path.dirname(os.path.abspath(filename))
76
+ repo_abs = os.path.abspath(repo_path)
77
+
78
+ for node in _iter_import_nodes(tree):
79
+
80
+ if isinstance(node, ast.ImportFrom):
81
+ module = node.module or ""
82
+ level = node.level
83
+
84
+ if level > 0:
85
+ # Relative import: from .utils import helper
86
+ base = file_dir
87
+ for _ in range(level - 1):
88
+ base = os.path.dirname(base)
89
+ module_path = os.path.join(base, module.replace(".", os.sep))
90
+ resolved = _resolve_module_path(module_path)
91
+ else:
92
+ # Absolute import: from requests.utils import helper
93
+ # Resolved against every source root (repo root, then src/):
94
+ # black, flask, and most modern PyPI projects keep their
95
+ # packages under src/, where a repo-root-only lookup finds
96
+ # nothing and silently drops every edge into the package.
97
+ module_path, resolved = _resolve_absolute_module(
98
+ module, repo_abs
99
+ )
100
+
101
+ for alias in node.names:
102
+ local_name = alias.asname or alias.name
103
+
104
+ # Check if this is a submodule import (from package import module)
105
+ submodule_path = os.path.join(module_path, alias.name)
106
+ submodule_resolved = _resolve_module_path(submodule_path)
107
+
108
+ if submodule_resolved:
109
+ imports[local_name] = submodule_resolved
110
+ elif resolved:
111
+ # __init__.py transparency: follow re-exports one level
112
+ if resolved.endswith("__init__.py"):
113
+ real = _follow_init_reexport(resolved, alias.name, repo_abs)
114
+ imports[local_name] = real if real else resolved
115
+ else:
116
+ imports[local_name] = resolved
117
+
118
+ elif isinstance(node, ast.Import):
119
+ for alias in node.names:
120
+ top_module = alias.name.split(".")[0]
121
+
122
+ if alias.asname:
123
+ # `import a.b as ab` — ab refers to the submodule a.b
124
+ _mp, resolved = _resolve_absolute_module(alias.name, repo_abs)
125
+ if not resolved:
126
+ sibling = os.path.join(
127
+ file_dir, alias.name.replace(".", os.sep)
128
+ )
129
+ resolved = _resolve_module_path(sibling)
130
+ if resolved:
131
+ imports[alias.asname] = resolved
132
+ continue
133
+
134
+ # `import a` / `import a.b` — the bound local name is the TOP
135
+ # package `a` (binding it to a/b.py, as previously done, sent
136
+ # `a.other()` calls into the wrong file).
137
+ _mp, resolved = _resolve_absolute_module(top_module, repo_abs)
138
+ if not resolved:
139
+ # Bare `import x` — try the importing file's own directory
140
+ sibling_path = os.path.join(
141
+ file_dir, top_module.replace(".", os.sep)
142
+ )
143
+ resolved = _resolve_module_path(sibling_path)
144
+ if resolved:
145
+ imports[top_module] = resolved
146
+
147
+ # `import a.b` also makes `a.b.fn()` callable — record the
148
+ # full dotted path (dots can't collide with identifiers).
149
+ if "." in alias.name:
150
+ _mp, sub_resolved = _resolve_absolute_module(
151
+ alias.name, repo_abs
152
+ )
153
+ if sub_resolved:
154
+ imports[alias.name] = sub_resolved
155
+
156
+ return imports
157
+
158
+
159
+ # Conventional source roots tried, in order, when resolving absolute
160
+ # imports. "" is the repo root itself (flat layout); "src" is the
161
+ # setuptools src-layout used by black, flask, requests, and most modern
162
+ # PyPI projects.
163
+ _SOURCE_ROOT_NAMES = ("", "src")
164
+
165
+
166
+ def _resolve_absolute_module(module: str, repo_abs: str):
167
+ """
168
+ Resolve a dotted absolute module name against each candidate source
169
+ root of the repository.
170
+
171
+ Returns (module_path, resolved_file):
172
+ module_path — directory-ish path for the module under the root that
173
+ matched (used for submodule probing by the caller);
174
+ falls back to the repo-root join when nothing matched.
175
+ resolved_file — the module's .py / package __init__.py, or None.
176
+ """
177
+ rel = module.replace(".", os.sep)
178
+ fallback = os.path.join(repo_abs, rel)
179
+ for root_name in _SOURCE_ROOT_NAMES:
180
+ root = os.path.join(repo_abs, root_name) if root_name else repo_abs
181
+ candidate = os.path.join(root, rel)
182
+ resolved = _resolve_module_path(candidate)
183
+ if resolved:
184
+ return candidate, resolved
185
+ # Namespace package (no __init__.py): a real directory still lets
186
+ # the caller find `from pkg import submodule` targets inside it.
187
+ if os.path.isdir(candidate):
188
+ return candidate, None
189
+ return fallback, None
190
+
191
+
192
+ # Session cache of parsed __init__.py re-export specs, keyed by absolute
193
+ # path and validated by (mtime_ns, size) so an edited file is re-read. A
194
+ # flagship package's __init__.py (django.db.models, flask) is consulted by
195
+ # hundreds of importing files per cold index; without this each consult
196
+ # re-read and re-parsed it from disk.
197
+ _init_export_cache: "Dict[str, Tuple[Tuple[int, int], Optional[Dict[str, List[Tuple[str, int, str]]]]]]" = {}
198
+
199
+
200
+ def _init_export_specs(
201
+ init_path: str,
202
+ ) -> "Optional[Dict[str, List[Tuple[str, int, str]]]]":
203
+ """
204
+ exported_name -> [(module, level, original_name), ...] for every
205
+ `from X import Y` in the file, in document order. None when the file
206
+ is unreadable or unparsable.
207
+ """
208
+ try:
209
+ st = os.stat(init_path)
210
+ stat_key = (st.st_mtime_ns, st.st_size)
211
+ except OSError:
212
+ return None
213
+ cached = _init_export_cache.get(init_path)
214
+ if cached is not None and cached[0] == stat_key:
215
+ return cached[1]
216
+
217
+ specs: "Optional[Dict[str, List[Tuple[str, int, str]]]]"
218
+ try:
219
+ with open(init_path, "rb") as f:
220
+ raw = f.read()
221
+ tree = ast.parse(raw.decode("utf-8", errors="ignore"))
222
+ except (OSError, SyntaxError):
223
+ specs = None
224
+ else:
225
+ specs = {}
226
+ for node in _iter_import_nodes(tree):
227
+ if not isinstance(node, ast.ImportFrom):
228
+ continue
229
+ for alias in node.names:
230
+ exported = alias.asname or alias.name
231
+ specs.setdefault(exported, []).append(
232
+ (node.module or "", node.level, alias.name)
233
+ )
234
+ _init_export_cache[init_path] = (stat_key, specs)
235
+ return specs
236
+
237
+
238
+ def _follow_init_reexport(init_path: str, name: str, repo_abs: str) -> Optional[str]:
239
+ """
240
+ Given a package __init__.py and a name imported from it, check whether
241
+ __init__.py re-exports that name from a submodule.
242
+
243
+ Example:
244
+ __init__.py contains: from .app import Flask
245
+ name = "Flask"
246
+ → returns /abs/path/to/app.py
247
+
248
+ Both relative (`from .app import Flask`, flask style) and absolute
249
+ (`from black.parsing import parse_ast`, black style) re-exports are
250
+ followed. Only follows one level (no recursive re-export chasing) to
251
+ stay fast. Returns None if the name is not re-exported or the
252
+ submodule can't be found.
253
+ """
254
+ specs = _init_export_specs(init_path)
255
+ if not specs:
256
+ return None
257
+
258
+ init_dir = os.path.dirname(init_path)
259
+
260
+ for module, level, original_name in specs.get(name, ()):
261
+ if level > 0:
262
+ # Relative re-export: from .sub import X
263
+ base = init_dir
264
+ for _ in range(level - 1):
265
+ base = os.path.dirname(base)
266
+ sub_path = os.path.join(base, module.replace(".", os.sep))
267
+ else:
268
+ # Absolute re-export: from black.parsing import X
269
+ sub_path, _resolved = _resolve_absolute_module(module, repo_abs)
270
+ # Check if the original name itself is a submodule
271
+ submodule_path = os.path.join(sub_path, original_name)
272
+ resolved = _resolve_module_path(submodule_path) or _resolve_module_path(sub_path)
273
+ if resolved and resolved != os.path.normpath(init_path):
274
+ return resolved
275
+
276
+ return None
277
+
278
+
279
+ def _resolve_module_path(module_path: str) -> Optional[str]:
280
+ """Try to find a .py file or package __init__.py for a given path."""
281
+ candidates = [
282
+ module_path + ".py",
283
+ os.path.join(module_path, "__init__.py"),
284
+ ]
285
+ for candidate in candidates:
286
+ if os.path.isfile(candidate):
287
+ return os.path.normpath(candidate)
288
+ return None
diffcontext/scanner.py ADDED
@@ -0,0 +1,153 @@
1
+ """
2
+ scanner.py — Discover source files in a repository.
3
+
4
+ Python always; other languages via the optional adapters in languages/
5
+ (each adapter contributes its extensions to discovery only when its
6
+ runtime deps are installed).
7
+ """
8
+
9
+ import os
10
+ import subprocess
11
+ from typing import List, Optional, Set, Tuple
12
+
13
+ EXCLUDED_DIRS: Set[str] = {
14
+ "__pycache__",
15
+ ".git",
16
+ ".tox",
17
+ ".mypy_cache",
18
+ ".pytest_cache",
19
+ "venv",
20
+ ".venv",
21
+ "env",
22
+ "node_modules",
23
+ "experimental",
24
+ "examples",
25
+ "docs",
26
+ "tests",
27
+ "test",
28
+ "benchmarks",
29
+ "datasets",
30
+ "dist",
31
+ "build",
32
+ "egg-info",
33
+ }
34
+
35
+ # Directories excluded from indexing by default. These are deliberately
36
+ # NOT retrieval candidates: tests/, benchmarks/, docs/ are tracked in git
37
+ # but rarely the "code that matters for a change", and indexing them would
38
+ # both bloat the graph and drown real blast-radius signal in test scaffolding.
39
+ #
40
+ # This is the single biggest practical gotcha: a commit that spans an
41
+ # excluded dir (e.g. benchmarks/) produces "was not found in the index"
42
+ # warnings for every changed symbol in that dir, and the changed file is
43
+ # omitted from the context entirely — the tool looks broken when it is
44
+ # merely mis-scoped. Override with `--include <dir>...` (see
45
+ # find_source_files / the CLI) to index an excluded dir; .gitignore still
46
+ # applies on top (a gitignored dir is not indexed even with --include).
47
+
48
+
49
+ def _is_excluded_dir(name: str, include: Optional[Set[str]] = None) -> bool:
50
+ """True if directory `name` should be pruned, unless it is in `include`
51
+ (a set of directory names to keep despite the default exclusions)."""
52
+ if include and name in include:
53
+ return False
54
+ return name in EXCLUDED_DIRS or name.endswith(".egg-info")
55
+
56
+
57
+ def _excluded(rel_path: str, include: Optional[Set[str]] = None) -> bool:
58
+ """True if any directory component of rel_path is excluded (and not
59
+ overridden by `include`)."""
60
+ parts = rel_path.replace(os.sep, "/").split("/")[:-1]
61
+ return any(_is_excluded_dir(p, include) for p in parts)
62
+
63
+
64
+ def first_excluded_dir(
65
+ rel_path: str, include: Optional[Set[str]] = None,
66
+ ) -> Optional[str]:
67
+ """Return the first directory component of `rel_path` that is excluded
68
+ (and not overridden by `include`), else None.
69
+
70
+ Used by warn_unknown_symbols to distinguish "your changed symbol's file
71
+ is outside the indexed tree" (actionable: re-run with --include) from
72
+ "typo / renamed / deleted" (a different kind of mistake)."""
73
+ parts = rel_path.replace(os.sep, "/").split("/")[:-1]
74
+ for p in parts:
75
+ if _is_excluded_dir(p, include):
76
+ return p
77
+ return None
78
+
79
+
80
+ def _git_source_files(
81
+ root_dir: str, extensions: "Tuple[str, ...]",
82
+ include: Optional[Set[str]] = None,
83
+ ) -> Optional[List[str]]:
84
+ """
85
+ Enumerate matching files via git: tracked + untracked-but-not-ignored.
86
+
87
+ This makes indexing respect .gitignore, so vendored checkouts (e.g. a
88
+ cloned benchmark repo) never pollute the index — a hardcoded dir list
89
+ can't anticipate those. Returns None outside a git work tree or if git
90
+ is unavailable, so the caller falls back to the filesystem walk.
91
+
92
+ `include` overrides the hardcoded EXCLUDED_DIRS (e.g. {"benchmarks"}
93
+ keeps benchmarks/ even though it is excluded by default). It does NOT
94
+ override .gitignore — a gitignored dir is still omitted by git ls-files.
95
+ """
96
+ try:
97
+ out = subprocess.run(
98
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
99
+ cwd=root_dir, capture_output=True, timeout=15,
100
+ )
101
+ except (OSError, subprocess.TimeoutExpired):
102
+ return None
103
+ if out.returncode != 0:
104
+ return None
105
+
106
+ matched = []
107
+ for rel in out.stdout.decode("utf-8", "replace").split("\0"):
108
+ if not rel.endswith(extensions) or _excluded(rel, include):
109
+ continue
110
+ full = os.path.join(root_dir, rel)
111
+ # --cached lists tracked files even after deletion from disk
112
+ if os.path.isfile(full):
113
+ matched.append(full)
114
+ return matched
115
+
116
+
117
+ def find_source_files(
118
+ root_dir: str, extensions: "Tuple[str, ...]",
119
+ include: Optional[Set[str]] = None,
120
+ ) -> List[str]:
121
+ """
122
+ Return paths of files matching `extensions`: .gitignore-aware via git
123
+ when root_dir is inside a git work tree, else a tree walk. Both paths
124
+ skip EXCLUDED_DIRS (deliberate exclusions like tests/ and docs/ that
125
+ are tracked in git but not useful retrieval candidates).
126
+
127
+ `include` is a set of directory names to KEEP despite the default
128
+ exclusions (e.g. {"benchmarks", "tests"} indexes those dirs too).
129
+ Matching is by directory-name component anywhere in the tree, so
130
+ `--include tests` un-excludes both top-level tests/ and any nested
131
+ dir named tests/. .gitignore still applies: a gitignored dir is not
132
+ indexed even when named in `include`.
133
+ """
134
+ git_files = _git_source_files(root_dir, extensions, include)
135
+ if git_files is not None:
136
+ return git_files
137
+
138
+ matched = []
139
+ for root, dirs, files in os.walk(root_dir):
140
+ dirs[:] = [d for d in dirs if not _is_excluded_dir(d, include)]
141
+
142
+ for f in files:
143
+ if f.endswith(extensions):
144
+ matched.append(os.path.join(root, f))
145
+
146
+ return matched
147
+
148
+
149
+ def find_python_files(
150
+ root_dir: str, include: Optional[Set[str]] = None,
151
+ ) -> List[str]:
152
+ """Return list of .py file paths (see find_source_files)."""
153
+ return find_source_files(root_dir, (".py",), include)
diffcontext/symbols.py ADDED
@@ -0,0 +1,254 @@
1
+ """
2
+ symbols.py — Attribute ownership extraction for resolving self.attr.method() chains.
3
+
4
+ Given a class with `self.router = APIRouter()`, this module figures out that
5
+ `self.router` has type `APIRouter`, enabling cross-file method resolution.
6
+ """
7
+
8
+ import ast
9
+ from typing import Dict, Optional, Tuple
10
+
11
+
12
+ def _iter_statements(body):
13
+ """Yield statements in source order, recursing into if/for/while/with/try."""
14
+ for stmt in body:
15
+ yield stmt
16
+
17
+ if isinstance(stmt, (ast.If, ast.For, ast.AsyncFor, ast.While)):
18
+ yield from _iter_statements(stmt.body)
19
+ yield from _iter_statements(stmt.orelse)
20
+
21
+ elif isinstance(stmt, (ast.With, ast.AsyncWith)):
22
+ yield from _iter_statements(stmt.body)
23
+
24
+ elif isinstance(stmt, ast.Try):
25
+ yield from _iter_statements(stmt.body)
26
+ for handler in stmt.handlers:
27
+ yield from _iter_statements(handler.body)
28
+ yield from _iter_statements(stmt.orelse)
29
+ yield from _iter_statements(stmt.finalbody)
30
+
31
+
32
+ def _extract_annotation_name(annotation) -> Optional[Tuple[Optional[str], str]]:
33
+ """
34
+ Returns (qualifier, bare_name) or None.
35
+
36
+ Router -> (None, "Router")
37
+ Optional[Router]-> (None, "Router") <- unwrap subscript
38
+ routing.Router -> ("routing", "Router")
39
+ """
40
+ if isinstance(annotation, ast.Name):
41
+ return (None, annotation.id)
42
+
43
+ if isinstance(annotation, ast.Attribute) and isinstance(annotation.value, ast.Name):
44
+ return (annotation.value.id, annotation.attr)
45
+
46
+ # Optional[Router], List[Router], etc. — unwrap one level
47
+ if isinstance(annotation, ast.Subscript):
48
+ return _extract_annotation_name(annotation.slice)
49
+
50
+ return None
51
+
52
+
53
+ def _extract_call_owner(value) -> Optional[Tuple[Optional[str], str]]:
54
+ """
55
+ Returns (qualifier, bare_name) or None.
56
+
57
+ APIRouter() -> (None, "APIRouter")
58
+ routing.APIRouter() -> ("routing", "APIRouter")
59
+ make_helper() -> (None, "make_helper")
60
+ """
61
+ if not isinstance(value, ast.Call):
62
+ return None
63
+
64
+ func = value.func
65
+
66
+ if isinstance(func, ast.Name):
67
+ return (None, func.id)
68
+
69
+ if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
70
+ return (func.value.id, func.attr)
71
+
72
+ if isinstance(func, ast.Attribute):
73
+ return (None, func.attr)
74
+
75
+ return None
76
+
77
+
78
+ def _extract_assign_owner(value, local_var_types=None, param_types=None):
79
+ """
80
+ Handles non-Call RHS cases that still indicate a type:
81
+ self.x = router or Router() -> BoolOp -> try each operand
82
+ self.x = router -> bare Name -> no type info (return None)
83
+ """
84
+ if local_var_types is None:
85
+ local_var_types = {}
86
+ if param_types is None:
87
+ param_types = {}
88
+
89
+ if isinstance(value, ast.Call):
90
+ return _extract_call_owner(value)
91
+
92
+ if isinstance(value, ast.Name):
93
+ if value.id in param_types:
94
+ return param_types[value.id]
95
+ if value.id in local_var_types:
96
+ return local_var_types[value.id]
97
+ return None
98
+
99
+ if isinstance(value, ast.BoolOp):
100
+ call_result = None
101
+ name_result = None
102
+ for operand in value.values:
103
+ ref = _extract_assign_owner(operand, local_var_types, param_types)
104
+ if ref is None:
105
+ continue
106
+ if isinstance(operand, ast.Call):
107
+ call_result = call_result or ref
108
+ else:
109
+ name_result = name_result or ref
110
+ return call_result or name_result
111
+
112
+ return None
113
+
114
+
115
+ def extract_local_var_types(
116
+ fn_node,
117
+ param_types: Optional[Dict[str, Tuple[Optional[str], str]]] = None,
118
+ ) -> Dict[str, Tuple[Optional[str], str]]:
119
+ """
120
+ Track local variable -> type assignments within a SINGLE function body,
121
+ free function or method alike:
122
+
123
+ h = Helper() -> {"h": (None, "Helper")}
124
+ r: routing.Router = ... -> {"r": ("routing", "Router")}
125
+ x = make_helper() -> chases factory functions the same way
126
+ attribute ownership tracking does
127
+ (resolution of the factory return type
128
+ itself happens later, in graph_builder)
129
+
130
+ This is the free-function counterpart to the self.attr tracking that
131
+ extract_attribute_ownerships does for class bodies. It exists because
132
+ a huge fraction of real code instantiates a class in a local variable
133
+ inside a plain function (not as a self.attr) and immediately calls a
134
+ method on it -- e.g.:
135
+
136
+ def run():
137
+ h = Handler()
138
+ return h.process()
139
+
140
+ Without this, `h.process()` can never resolve: there's nowhere that
141
+ records "h" has type "Handler". extract_attribute_ownerships alone
142
+ can't help, because it only ever looks inside ast.ClassDef bodies.
143
+
144
+ Returns: {local_var_name: (qualifier, bare_type_name)}
145
+ """
146
+ if param_types is None:
147
+ param_types = {}
148
+
149
+ local_var_types: Dict[str, Tuple[Optional[str], str]] = {}
150
+
151
+ for stmt in _iter_statements(fn_node.body):
152
+
153
+ if isinstance(stmt, ast.AnnAssign):
154
+ target = stmt.target
155
+ if isinstance(target, ast.Name):
156
+ ref = _extract_annotation_name(stmt.annotation)
157
+ if ref:
158
+ local_var_types[target.id] = ref
159
+ continue
160
+
161
+ if isinstance(stmt, ast.Assign):
162
+ for target in stmt.targets:
163
+ if isinstance(target, ast.Name):
164
+ ref = _extract_assign_owner(
165
+ stmt.value, local_var_types, param_types
166
+ )
167
+ if ref:
168
+ local_var_types[target.id] = ref
169
+ continue
170
+
171
+ return local_var_types
172
+
173
+
174
+ def extract_param_types(fn_node) -> Dict[str, Tuple[Optional[str], str]]:
175
+ """
176
+ Map annotated parameter names to their (qualifier, bare_name) type, e.g.
177
+
178
+ def f(router: routing.Router, name: str): ...
179
+
180
+ -> {"router": ("routing", "Router")} (unannotated/non-type params skipped)
181
+ """
182
+ param_types: Dict[str, Tuple[Optional[str], str]] = {}
183
+ for arg in fn_node.args.args:
184
+ if arg.annotation is not None:
185
+ ref = _extract_annotation_name(arg.annotation)
186
+ if ref:
187
+ param_types[arg.arg] = ref
188
+ return param_types
189
+
190
+
191
+ def extract_attribute_ownerships(tree) -> Dict[str, Tuple[Optional[str], str]]:
192
+ """
193
+ Extracts mappings like:
194
+ self.router: routing.Router = routing.Router()
195
+ self.router = Router()
196
+ self.router2 = router # typed constructor param
197
+ h = Helper(); self.helper = h # local var tracking
198
+ self.router3 = router or Router()
199
+ self.opt_router: Optional[Router] = None
200
+
201
+ Returns: {"FastAPI.router": (qualifier, type_name), ...}
202
+ """
203
+ ownerships: Dict[str, Tuple[Optional[str], str]] = {}
204
+
205
+ for node in tree.body:
206
+ if not isinstance(node, ast.ClassDef):
207
+ continue
208
+
209
+ class_name = node.name
210
+
211
+ for item in node.body:
212
+ if not isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
213
+ continue
214
+
215
+ param_types = extract_param_types(item)
216
+
217
+ local_var_types = {}
218
+
219
+ for stmt in _iter_statements(item.body):
220
+
221
+ if isinstance(stmt, ast.AnnAssign):
222
+ target = stmt.target
223
+ if (
224
+ isinstance(target, ast.Attribute)
225
+ and isinstance(target.value, ast.Name)
226
+ and target.value.id == "self"
227
+ ):
228
+ ref = _extract_annotation_name(stmt.annotation)
229
+ if ref:
230
+ ownerships[f"{class_name}.{target.attr}"] = ref
231
+ continue
232
+
233
+ if isinstance(stmt, ast.Assign):
234
+ for target in stmt.targets:
235
+ if (
236
+ isinstance(target, ast.Attribute)
237
+ and isinstance(target.value, ast.Name)
238
+ and target.value.id == "self"
239
+ ):
240
+ ref = _extract_assign_owner(
241
+ stmt.value, local_var_types, param_types
242
+ )
243
+ if ref:
244
+ ownerships[f"{class_name}.{target.attr}"] = ref
245
+
246
+ elif isinstance(target, ast.Name):
247
+ ref = _extract_assign_owner(
248
+ stmt.value, local_var_types, param_types
249
+ )
250
+ if ref:
251
+ local_var_types[target.id] = ref
252
+ continue
253
+
254
+ return ownerships