codmap 0.0.3__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 (55) hide show
  1. codemap/__init__.py +10 -0
  2. codemap/apidiff.py +208 -0
  3. codemap/arch.py +190 -0
  4. codemap/cli.py +718 -0
  5. codemap/diagnostics.py +256 -0
  6. codemap/extract/__init__.py +10 -0
  7. codemap/extract/attrflow.py +230 -0
  8. codemap/extract/behavior.py +771 -0
  9. codemap/extract/dataflow.py +97 -0
  10. codemap/extract/dispatch.py +248 -0
  11. codemap/extract/griffe_extractor.py +496 -0
  12. codemap/extract/gsource.py +83 -0
  13. codemap/extract/roots.py +427 -0
  14. codemap/freshness.py +94 -0
  15. codemap/incremental.py +195 -0
  16. codemap/integrations/__init__.py +51 -0
  17. codemap/integrations/base.py +196 -0
  18. codemap/integrations/cocoindex.py +78 -0
  19. codemap/integrations/gate.py +58 -0
  20. codemap/integrations/gitnexus.py +93 -0
  21. codemap/integrations/registry.py +69 -0
  22. codemap/integrations/transport.py +46 -0
  23. codemap/model.py +178 -0
  24. codemap/provenance.py +248 -0
  25. codemap/query.py +1164 -0
  26. codemap/scope.py +212 -0
  27. codemap/serve/__init__.py +26 -0
  28. codemap/serve/_scip_pb2.py +100 -0
  29. codemap/serve/api_surface.py +60 -0
  30. codemap/serve/apidiff.py +83 -0
  31. codemap/serve/architecture.py +101 -0
  32. codemap/serve/audit.py +176 -0
  33. codemap/serve/check.py +80 -0
  34. codemap/serve/ctags.py +203 -0
  35. codemap/serve/impact.py +84 -0
  36. codemap/serve/livingdocs.py +174 -0
  37. codemap/serve/mcp_server.py +278 -0
  38. codemap/serve/mermaid.py +120 -0
  39. codemap/serve/pack.py +93 -0
  40. codemap/serve/rag.py +142 -0
  41. codemap/serve/review.py +197 -0
  42. codemap/serve/scip.py +183 -0
  43. codemap/serve/semantic.py +71 -0
  44. codemap/serve/server.py +43 -0
  45. codemap/serve/session.py +482 -0
  46. codemap/serve/subsystems.py +85 -0
  47. codemap/serve/vault.py +156 -0
  48. codemap/store.py +28 -0
  49. codemap/tomlio.py +59 -0
  50. codmap-0.0.3.dist-info/METADATA +245 -0
  51. codmap-0.0.3.dist-info/RECORD +55 -0
  52. codmap-0.0.3.dist-info/WHEEL +5 -0
  53. codmap-0.0.3.dist-info/entry_points.txt +2 -0
  54. codmap-0.0.3.dist-info/licenses/LICENSE +21 -0
  55. codmap-0.0.3.dist-info/top_level.txt +1 -0
codemap/diagnostics.py ADDED
@@ -0,0 +1,256 @@
1
+ """Build-level diagnostics derived from a graph (R1-C21 / design D5).
2
+
3
+ A graph can be *well-formed and vacuous*: if the extractor did not understand the
4
+ target's layout, whole edge classes come out empty and every conclusion drawn from
5
+ them inverts. The worst case found in the wild (issues #4/#5) was a flat module
6
+ directory: 0 ``imports`` edges, after which ``architecture`` reports "no layer
7
+ violations / acyclic" and ``dead-code`` calls every live module an orphan. Absence of
8
+ data rendered as a clean bill of health.
9
+
10
+ These checks name that condition wherever the graph is presented. They are
11
+ **derived, never stored** — ``graph.json`` keeps no diagnostic field, so there is
12
+ nothing to keep in sync, and any consumer recomputes the signal from the graph it
13
+ already holds.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ NO_IMPORT_EDGES = "no_import_edges"
19
+ NAMESPACE_TARGET = "namespace_target"
20
+ NO_CROSS_ROOT_EDGES = "no_cross_root_edges"
21
+ SCHEMA_MISMATCH = "schema_mismatch"
22
+ UNREAD_INPUTS = "unread_inputs"
23
+ MODULE_COUNT_MISMATCH = "module_count_mismatch"
24
+
25
+ #: A ``warning`` invalidates the conclusions a surface draws from the graph — read them as
26
+ #: unknown. A ``note`` states a fact about how the graph was built and invalidates nothing.
27
+ #: Each check owns its own ``consequence`` sentence: presenters must not supply one, or a
28
+ #: correct result ends up captioned with another check's meaning (issue #8).
29
+ WARNING = "warning"
30
+ NOTE = "note"
31
+
32
+
33
+ def import_graph_diagnostic(graph) -> dict | None:
34
+ """Flag an empty import graph over a multi-module target, else ``None``.
35
+
36
+ A single-module package legitimately has no imports, so the check needs ≥2
37
+ modules; beyond that, a Python package whose modules never import one another
38
+ is far rarer than a layout the extractor failed to parse.
39
+ """
40
+ modules = sum(1 for n in graph.nodes.values() if n.kind == "module")
41
+ if modules < 2:
42
+ return None
43
+ if any(e.type == "imports" for e in graph.edges):
44
+ return None
45
+ return {
46
+ "code": NO_IMPORT_EDGES,
47
+ "severity": WARNING,
48
+ "modules": modules,
49
+ "consequence": ("Findings below are derived from that empty import graph — read "
50
+ "them as **unknown**, not as a clean bill of health."),
51
+ "message": (
52
+ f"0 import edges across {modules} modules — the import graph is empty, so "
53
+ "layers, cycles, coupling and orphan detection are vacuous rather than clean. "
54
+ "This usually means a layout the extractor did not understand (e.g. a flat "
55
+ "module directory whose files import each other by bare name)."
56
+ ),
57
+ }
58
+
59
+
60
+ def namespace_target_diagnostic(graph) -> dict | None:
61
+ """Flag a target that is a **namespace package** (a directory with no ``__init__.py``).
62
+
63
+ Derived, like the rest: griffe gives such a directory no source file, so the target's
64
+ own module node carries ``file=None`` while its children have real files. Worth naming
65
+ because the layout is a silent fork in behaviour — sibling imports resolve only by the
66
+ flat-layout inference (labelled ``resolution="flat"`` on the edge), not by packaging.
67
+ """
68
+ root = graph.nodes.get(graph.target)
69
+ if root is None or root.kind != "module" or root.file is not None:
70
+ return None
71
+ children = [n for n in graph.nodes.values() if n.kind == "module" and n.id != root.id]
72
+ if not children:
73
+ return None
74
+ return {
75
+ "code": NAMESPACE_TARGET,
76
+ "severity": NOTE, # a fact about provenance; it invalidates nothing (issue #8)
77
+ "target": graph.target,
78
+ "message": (
79
+ f"`{graph.target}` has no __init__.py — it is a namespace package, so its "
80
+ "modules are only a package by directory. Imports between them are resolved "
81
+ "by codemap's flat-layout inference (edges labelled resolution=\"flat\"), "
82
+ "not by packaging."
83
+ ),
84
+ }
85
+
86
+
87
+ def cross_root_diagnostic(graph) -> dict | None:
88
+ """Flag consumer/doc roots that reach the core **not at all** (R1-C21-f1, issue #6).
89
+
90
+ ``--consumer`` exists so ``impact`` can answer "who uses X across the whole repo".
91
+ If roots were supplied and *nothing* in them references the core, the answer to that
92
+ question is a confident zero — and the far likelier cause is that their imports were
93
+ not understood than that four directories of code genuinely use none of it.
94
+
95
+ Deliberately separate from :func:`import_graph_diagnostic`: the case that prompted it
96
+ had 75 import edges (so the "empty graph" check stayed quiet) and none of them crossing
97
+ a root boundary. One check per dimension, rather than one check trying to be general.
98
+ """
99
+ root_of = {n.id: (n.extras.get("root") or "core") for n in graph.nodes.values()}
100
+ outer = sorted({r for r in root_of.values() if r != "core"})
101
+ if not outer:
102
+ return None # single-package graph: no boundary to cross
103
+ for e in graph.edges:
104
+ if root_of.get(e.source, "core") != "core" and root_of.get(e.target, "core") == "core":
105
+ return None
106
+ return {
107
+ "code": NO_CROSS_ROOT_EDGES,
108
+ "severity": WARNING,
109
+ "roots": outer,
110
+ "consequence": ("Any cross-root finding below — who uses a symbol outside its own "
111
+ "root — is **unknown**, not empty."),
112
+ "message": (
113
+ f"{len(outer)} non-core root(s) supplied ({', '.join(outer)}) but not one "
114
+ "reference from them reaches the core — cross-root `impact` will read as "
115
+ "\"isolated\" for every symbol. Usually an import form the scanner did not "
116
+ "understand, not an unused core."
117
+ ),
118
+ }
119
+
120
+
121
+ def schema_diagnostic(graph) -> dict | None:
122
+ """Flag a stored graph whose schema is not the running tool's (R1-C25 / D3).
123
+
124
+ ``codemap_schema`` was written and never read: a graph built before an extraction
125
+ change was consumed by a later tool without a word, and answered with that tool's
126
+ confidence over the older tool's blindness. Measured: one frozen tree, two codemap
127
+ builds four commits apart — 30 edges vs 38, and ``dead-code high`` 12 vs 7 — with
128
+ **both files declaring 0.11**, because only open ``extras`` had changed. So the
129
+ check cannot prove semantic equivalence; what it can do is stop a *known* mismatch
130
+ from passing silently.
131
+
132
+ Only fires for a graph that came from a file (``loaded_schema is None`` on a fresh
133
+ build). Never a refusal: every stored graph in existence predates 0.12, and turning
134
+ an upgrade into an outage is not the honest option — a labelled answer is (R1-C13).
135
+ """
136
+ from codemap.model import SCHEMA_VERSION
137
+ from codemap.provenance import MATCH, NEWER, OLDER, describe, schema_status
138
+ if graph.loaded_schema is None:
139
+ return None
140
+ status = schema_status(graph.loaded_schema or None, SCHEMA_VERSION)
141
+ if status == MATCH:
142
+ return None
143
+ declared = graph.loaded_schema or "none declared"
144
+ direction = {OLDER: "predates this tool",
145
+ NEWER: "is newer than this tool"}.get(status, "declares no usable version")
146
+ return {
147
+ "code": SCHEMA_MISMATCH,
148
+ "severity": WARNING,
149
+ "loaded": graph.loaded_schema,
150
+ "running": SCHEMA_VERSION,
151
+ "status": status,
152
+ "provenance": describe(graph.provenance),
153
+ "consequence": ("Findings below may differ from a fresh build of the same source "
154
+ "— rebuild before trusting a close call."),
155
+ "message": (
156
+ f"graph declares schema {declared}, this codemap writes {SCHEMA_VERSION} "
157
+ f"— the artifact {direction}. Extraction semantics change without a schema "
158
+ f"bump (open `extras`), so the two are not interchangeable. "
159
+ f"Built by: {describe(graph.provenance)}."
160
+ ),
161
+ }
162
+
163
+
164
+ def unread_inputs_diagnostic(graph) -> dict | None:
165
+ """Flag input files the extractor could not read (R1-C23 / design D2).
166
+
167
+ A file with a syntax error or a non-UTF-8 byte used to vanish from the graph in
168
+ silence, after which every report answered over a tree it had not fully seen —
169
+ the same shape as issue #5, where absence of data rendered as a clean bill of health.
170
+ """
171
+ skipped = ((graph.provenance or {}).get("inputs") or {}).get("skipped") or []
172
+ if not skipped:
173
+ return None
174
+ by_reason = {}
175
+ for s in skipped:
176
+ by_reason.setdefault(s.get("reason", "unread"), []).append(s.get("path"))
177
+ listed = ", ".join(f"{len(v)} {k}" for k, v in sorted(by_reason.items()))
178
+ sample = ", ".join(sorted(p for v in by_reason.values() for p in v)[:5])
179
+ return {
180
+ "code": UNREAD_INPUTS,
181
+ "severity": WARNING,
182
+ "skipped": skipped,
183
+ "consequence": ("Anything those files define or depend on is **missing**, not "
184
+ "absent — dead-code, layers and impact are all short by that much."),
185
+ "message": (
186
+ f"{len(skipped)} input file(s) produced no module ({listed}): {sample}"
187
+ + (" …" if len(skipped) > 5 else "")
188
+ ),
189
+ }
190
+
191
+
192
+ def module_count_diagnostic(graph) -> dict | None:
193
+ """Conservation law over the build: modules cannot outnumber the files that define
194
+ them, nor silently fall short of them (R1-C23 / design D6).
195
+
196
+ Deliberately not a heuristic and deliberately not tuned — both directions are
197
+ *provably* wrong states, so there is no threshold to argue about. It is the check
198
+ that catches a cause we have not met yet: it flags the symlink-cycle explosion
199
+ without knowing what a symlink is, and an unexplained shortfall without knowing what
200
+ a syntax error is. It would have fired on issue #5.
201
+ """
202
+ inputs = (graph.provenance or {}).get("inputs") or {}
203
+ expected = inputs.get("python_files")
204
+ if expected is None:
205
+ return None # pre-0.12 graph, or a build that recorded no input count
206
+ # Core only: ``inputs`` counts the extractor's walk of the target package, so a
207
+ # repo-scoped graph's consumer modules (``--consumer tests``) are not in that number
208
+ # and must not be compared against it.
209
+ from_py = sum(1 for n in graph.nodes.values()
210
+ if n.kind == "module" and (n.file or "").endswith(".py")
211
+ and (n.extras.get("root") or "core") == "core")
212
+ skipped = len(inputs.get("skipped") or [])
213
+ if from_py > expected:
214
+ direction = (f"{from_py} modules built from {expected} input file(s) — a module "
215
+ "cannot outnumber the files that define it. The tree was probably "
216
+ "walked more than once (a directory symlink into its own ancestry).")
217
+ elif from_py < expected - skipped:
218
+ direction = (f"{from_py} modules built from {expected} input file(s), only "
219
+ f"{skipped} of which are accounted for as unreadable — "
220
+ f"{expected - skipped - from_py} file(s) went missing unexplained.")
221
+ else:
222
+ return None
223
+ return {
224
+ "code": MODULE_COUNT_MISMATCH,
225
+ "severity": WARNING,
226
+ "modules": from_py,
227
+ "input_files": expected,
228
+ "consequence": ("Every aggregate below — counts, layers, cycles, hotspots, "
229
+ "dead-code — is computed over that graph, so read it as "
230
+ "**unknown**."),
231
+ "message": direction,
232
+ }
233
+
234
+
235
+ def diagnostics(graph) -> list[dict]:
236
+ """Every diagnostic that applies to ``graph`` (empty list when it looks sound)."""
237
+ checks = (import_graph_diagnostic(graph), namespace_target_diagnostic(graph),
238
+ cross_root_diagnostic(graph), schema_diagnostic(graph),
239
+ unread_inputs_diagnostic(graph), module_count_diagnostic(graph))
240
+ return [d for d in checks if d is not None]
241
+
242
+
243
+ def render_lines(graph) -> list[str]:
244
+ """Markdown blockquote lines for a report header (empty when the graph looks sound).
245
+
246
+ Presenters call this instead of formatting diagnostics themselves — a caption that
247
+ belongs to one check must never end up under another (issue #8: the namespace *note*
248
+ was rendered with the empty-import-graph *warning*'s "everything below is unknown",
249
+ on a graph with 404 import edges).
250
+ """
251
+ lines: list[str] = []
252
+ for d in diagnostics(graph):
253
+ mark = "⚠️" if d.get("severity", WARNING) == WARNING else "ℹ️"
254
+ text = " ".join(part for part in (d["message"], d.get("consequence")) if part)
255
+ lines.extend([f"> {mark} {text}", ""])
256
+ return lines
@@ -0,0 +1,10 @@
1
+ """Extractors: source -> neutral graph. v1 ships the Python (griffe) extractor.
2
+
3
+ Extractors are the only language-aware layer (DESIGN §12): each emits into the
4
+ neutral model, so adding a language later is additive, not a core rewrite.
5
+ """
6
+
7
+ from codemap.extract.griffe_extractor import extract
8
+ from codemap.extract.roots import extract_repo
9
+
10
+ __all__ = ["extract", "extract_repo"]
@@ -0,0 +1,230 @@
1
+ """Attribute-access pass — function → attribute read/write edges (R1-C20, issue #1).
2
+
3
+ codemap models relationships between *symbols* (calls/imports/inherits/…) and
4
+ between functions and *string-keyed columns* (reads/writes, M12) — but **not**
5
+ between code and Python *attributes*. So ``impact`` on a class field returned
6
+ ``refs: []`` / ``risk: "none"`` (an affirmative "nothing depends on this") even
7
+ when the field had many real read/write sites: attribute nodes exist but nothing
8
+ in the graph pointed at them (gaps/attribute_impact_gap_2026-08-22).
9
+
10
+ This pass makes attribute access first-class, mirroring what ``dataflow.py`` did
11
+ for columns:
12
+
13
+ - an ``accesses`` edge (function → the ``attribute`` node it touches), with
14
+ ``extras.access`` (``read`` | ``write``) and ``extras.resolution``.
15
+
16
+ **Access forms and how each resolves** (only edges whose target is a real
17
+ ``attribute`` node are emitted — R1-C13-f2 soundness; everything else is a
18
+ counter, never an edge to nothing):
19
+
20
+ - ``self.field`` / ``cls.field`` → the enclosing class's attribute, via the same
21
+ ``members`` owner-map behavior.py uses (``self.<inherited>`` → the base that
22
+ defines it, R1-C13-f1). ``resolution = "self"``. Fast tier.
23
+ - ``ClassName.field`` → the named class's attribute, class resolved through
24
+ imports / module members. ``resolution = "class"``. Fast tier.
25
+ - construction kwargs ``Cls(field=…)`` → a *write* to ``Cls.field`` (this is how
26
+ dataclass fields are most often set). ``resolution = "construct"``. Fast tier.
27
+ - ``obj.field`` on a typed local → jedi types ``obj`` → its attribute.
28
+ ``resolution = "deep"``. Deep tier only (``deep=True``).
29
+ - ``obj.field`` on an untyped local → **unresolved** (counter, no edge). Honest.
30
+
31
+ **Boundaries.** Method / ``property`` access is *not* an attribute access — the
32
+ target there is a ``function`` node, so the ``kind == "attribute"`` gate excludes
33
+ it (properties are functions in griffe; out of scope, by design). Dynamic access
34
+ (``getattr``/``setattr``) is never guessed. Value-level dataflow (which value
35
+ flows into the field) is out of scope — this is *access* modelling, not taint.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import ast
41
+ from pathlib import Path
42
+
43
+ from codemap.extract.behavior import (
44
+ _SKIP_RECEIVERS,
45
+ _class_members,
46
+ _index_modules,
47
+ _jedi_project,
48
+ _jedi_script,
49
+ _named_functions,
50
+ _node_id,
51
+ _own_nodes,
52
+ )
53
+ from codemap.extract.gsource import module_file, module_imports
54
+ from codemap.model import Edge
55
+
56
+
57
+ def add_attrflow(graph, griffe_root, target_pkg: str, *, deep: bool = False,
58
+ search_path=None, only=None) -> None:
59
+ """Add ``accesses`` edges (function → attribute) for read/write sites.
60
+
61
+ ``deep=True`` enables the jedi tier for ``obj.field`` on typed locals; the fast
62
+ tier (``self.``/``ClassName.``/construction kwargs) needs only stdlib ``ast``.
63
+ Per-function ``extras.attr_access`` coverage counts (out / resolved / unresolved)
64
+ are recorded so the graph reports its own honesty, like ``extras.calls``.
65
+ ``only`` (a set of module paths) restricts the pass — the incremental hook
66
+ (R1-C9), mirroring :func:`add_behavior`.
67
+ """
68
+ modules = _index_modules(griffe_root)
69
+ known_modules = set(modules) # R1-C21: flat-layout sibling lookup
70
+ project = _jedi_project(search_path) if deep else None
71
+ pkg = target_pkg + "."
72
+ # (func_id, attr_id, access, resolution) — dedup collapses repeated sites.
73
+ edges: set[tuple[str, str, str, str]] = set()
74
+ for modpath in sorted(modules):
75
+ if only is not None and modpath not in only:
76
+ continue
77
+ if "samples.embedded" in modpath:
78
+ continue # embedded datasets are data, not code (matches dataflow.py)
79
+ mod = modules[modpath]
80
+ fp = module_file(mod) # None for a namespace dir (R1-C21)
81
+ if fp is None:
82
+ continue
83
+ try:
84
+ source = fp.read_text(encoding="utf-8")
85
+ tree = ast.parse(source)
86
+ except (OSError, SyntaxError):
87
+ continue
88
+ imports = module_imports(mod, modpath, known_modules) # R1-C21: flat-aware
89
+ modmembers = set(mod.members.keys())
90
+ script = _jedi_script(source, fp, project) if deep else None
91
+ for fnode, class_stack in _named_functions(tree):
92
+ node_id = _node_id(modpath, class_stack, fnode.name)
93
+ if node_id not in graph.nodes:
94
+ continue # nested closure — not a definition node
95
+ members = _class_members(mod, class_stack, modules) if class_stack else {}
96
+ counts = {"out": 0, "resolved": 0, "unresolved": 0}
97
+ for attr_id, access, resolution in _own_attr_uses(
98
+ fnode, graph, modpath, members, imports, modmembers, pkg, script
99
+ ):
100
+ counts["out"] += 1
101
+ if attr_id is None:
102
+ counts["unresolved"] += 1
103
+ continue
104
+ counts["resolved"] += 1
105
+ edges.add((node_id, attr_id, access, resolution))
106
+ if counts["out"]:
107
+ graph.nodes[node_id].extras["attr_access"] = counts
108
+
109
+ for src, attr_id, access, resolution in sorted(edges):
110
+ graph.add_edge(Edge("accesses", src, attr_id,
111
+ extras={"access": access, "resolution": resolution}))
112
+
113
+
114
+ def _is_attribute_node(graph, node_id: str) -> bool:
115
+ """True iff ``node_id`` is a real ``attribute`` node.
116
+
117
+ The soundness gate (R1-C13-f2): only emit ``accesses`` to an attribute — this
118
+ also excludes methods and ``property`` (function nodes) that share the ``.name``
119
+ shape, so those stay in the calls layer where they belong.
120
+ """
121
+ node = graph.nodes.get(node_id)
122
+ return node is not None and node.kind == "attribute"
123
+
124
+
125
+ def _resolve_class(name: str, modpath: str, imports: dict, modmembers: set,
126
+ pkg: str) -> str | None:
127
+ """Canonical id of the package class ``name`` refers to (imports / module), else None.
128
+
129
+ Mirrors behavior.py's call-target resolution: an imported name maps to its
130
+ (griffe-resolved) target path; a module-level name to ``{modpath}.{name}``.
131
+ """
132
+ if name in imports:
133
+ tgt = imports[name]
134
+ return tgt if tgt.startswith(pkg) else None
135
+ if name in modmembers:
136
+ return f"{modpath}.{name}"
137
+ return None
138
+
139
+
140
+ def _own_attr_uses(fnode, graph, modpath, members, imports, modmembers, pkg, script):
141
+ """Yield (attr_id | None, access, resolution) for attribute uses in a function body.
142
+
143
+ ``attr_id is None`` marks an access site we saw but could not resolve to an
144
+ attribute node (an honest unresolved counter — never an edge to nothing).
145
+ Skips nested defs/classes (their own scope), matching ``_own_nodes``.
146
+ """
147
+ nodes = list(_own_nodes(fnode))
148
+ # Attributes that are the callee of a call (``self.foo()``) are *method calls*,
149
+ # handled by the behavioral layer — not field access. Skip exactly those.
150
+ call_funcs = {id(n.func) for n in nodes if isinstance(n, ast.Call)}
151
+
152
+ for node in nodes:
153
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
154
+ # construction kwargs: Cls(field=…) → write to Cls.field
155
+ cls_id = _resolve_class(node.func.id, modpath, imports, modmembers, pkg)
156
+ if cls_id is not None:
157
+ for kw in node.keywords:
158
+ if kw.arg is None:
159
+ continue # **kwargs splat — arity unknown, don't guess
160
+ attr_id = f"{cls_id}.{kw.arg}"
161
+ if _is_attribute_node(graph, attr_id):
162
+ yield attr_id, "write", "construct"
163
+ else:
164
+ yield None, "write", "construct"
165
+ continue
166
+
167
+ if not isinstance(node, ast.Attribute) or id(node) in call_funcs:
168
+ continue
169
+ recv = node.value
170
+ if not isinstance(recv, ast.Name):
171
+ # obj.attr where obj is itself an expression — deep tier only.
172
+ yield from _deep_attr(node, graph, pkg, script)
173
+ continue
174
+ access = "write" if isinstance(node.ctx, ast.Store) else "read"
175
+ if recv.id in _SKIP_RECEIVERS:
176
+ # self.field / cls.field — resolve via the class member owner-map.
177
+ owner = members.get(node.attr)
178
+ attr_id = f"{owner}.{node.attr}" if owner else None
179
+ if attr_id and _is_attribute_node(graph, attr_id):
180
+ yield attr_id, access, "self"
181
+ else:
182
+ yield None, access, "self"
183
+ else:
184
+ cls_id = _resolve_class(recv.id, modpath, imports, modmembers, pkg)
185
+ if cls_id is not None:
186
+ # ClassName.field
187
+ attr_id = f"{cls_id}.{node.attr}"
188
+ if _is_attribute_node(graph, attr_id):
189
+ yield attr_id, access, "class"
190
+ else:
191
+ yield None, access, "class"
192
+ else:
193
+ # obj.field on a local — deep tier resolves the type, else unresolved.
194
+ yield from _deep_attr(node, graph, pkg, script)
195
+
196
+
197
+ def _deep_attr(node, graph, pkg, script):
198
+ """Resolve ``obj.field`` via jedi (deep tier); yield (attr_id | None, access, 'deep').
199
+
200
+ Infers the **type of the receiver** (``obj`` in ``obj.field``) → its class →
201
+ ``{class}.{field}``, rather than ``goto``-ing the attribute name: jedi's goto on
202
+ an instance attribute lands on whichever assignment statement it can see
203
+ (``Class.method.field``), not the class-level field node. Inferring the receiver
204
+ is exact and also threads chains (``self.cfg.field`` — the receiver ``self.cfg``
205
+ is itself typed). Yields nothing on the fast tier (no jedi script) so a typed
206
+ local isn't even counted as unresolved there — the fast tier makes no claim.
207
+ """
208
+ if script is None:
209
+ return
210
+ access = "write" if isinstance(node.ctx, ast.Store) else "read"
211
+ recv = node.value
212
+ end_line = getattr(recv, "end_lineno", None)
213
+ end_col = getattr(recv, "end_col_offset", None)
214
+ if end_line is None or end_col is None:
215
+ yield None, access, "deep"
216
+ return
217
+ try:
218
+ types = script.infer(end_line, end_col)
219
+ except Exception:
220
+ yield None, access, "deep"
221
+ return
222
+ for t in sorted(types, key=lambda d: d.full_name or ""):
223
+ cls = t.full_name
224
+ if not cls or not cls.startswith(pkg):
225
+ continue
226
+ attr_id = f"{cls}.{node.attr}"
227
+ if _is_attribute_node(graph, attr_id):
228
+ yield attr_id, access, "deep"
229
+ return
230
+ yield None, access, "deep"