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.
- codemap/__init__.py +10 -0
- codemap/apidiff.py +208 -0
- codemap/arch.py +190 -0
- codemap/cli.py +718 -0
- codemap/diagnostics.py +256 -0
- codemap/extract/__init__.py +10 -0
- codemap/extract/attrflow.py +230 -0
- codemap/extract/behavior.py +771 -0
- codemap/extract/dataflow.py +97 -0
- codemap/extract/dispatch.py +248 -0
- codemap/extract/griffe_extractor.py +496 -0
- codemap/extract/gsource.py +83 -0
- codemap/extract/roots.py +427 -0
- codemap/freshness.py +94 -0
- codemap/incremental.py +195 -0
- codemap/integrations/__init__.py +51 -0
- codemap/integrations/base.py +196 -0
- codemap/integrations/cocoindex.py +78 -0
- codemap/integrations/gate.py +58 -0
- codemap/integrations/gitnexus.py +93 -0
- codemap/integrations/registry.py +69 -0
- codemap/integrations/transport.py +46 -0
- codemap/model.py +178 -0
- codemap/provenance.py +248 -0
- codemap/query.py +1164 -0
- codemap/scope.py +212 -0
- codemap/serve/__init__.py +26 -0
- codemap/serve/_scip_pb2.py +100 -0
- codemap/serve/api_surface.py +60 -0
- codemap/serve/apidiff.py +83 -0
- codemap/serve/architecture.py +101 -0
- codemap/serve/audit.py +176 -0
- codemap/serve/check.py +80 -0
- codemap/serve/ctags.py +203 -0
- codemap/serve/impact.py +84 -0
- codemap/serve/livingdocs.py +174 -0
- codemap/serve/mcp_server.py +278 -0
- codemap/serve/mermaid.py +120 -0
- codemap/serve/pack.py +93 -0
- codemap/serve/rag.py +142 -0
- codemap/serve/review.py +197 -0
- codemap/serve/scip.py +183 -0
- codemap/serve/semantic.py +71 -0
- codemap/serve/server.py +43 -0
- codemap/serve/session.py +482 -0
- codemap/serve/subsystems.py +85 -0
- codemap/serve/vault.py +156 -0
- codemap/store.py +28 -0
- codemap/tomlio.py +59 -0
- codmap-0.0.3.dist-info/METADATA +245 -0
- codmap-0.0.3.dist-info/RECORD +55 -0
- codmap-0.0.3.dist-info/WHEEL +5 -0
- codmap-0.0.3.dist-info/entry_points.txt +2 -0
- codmap-0.0.3.dist-info/licenses/LICENSE +21 -0
- 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"
|