codmap 0.0.10__tar.gz → 0.0.12__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.
- {codmap-0.0.10/codmap.egg-info → codmap-0.0.12}/PKG-INFO +18 -3
- {codmap-0.0.10 → codmap-0.0.12}/README.md +17 -2
- {codmap-0.0.10 → codmap-0.0.12}/codemap/cli.py +52 -8
- {codmap-0.0.10 → codmap-0.0.12}/codemap/diagnostics.py +36 -6
- {codmap-0.0.10 → codmap-0.0.12}/codemap/extract/behavior.py +95 -1
- {codmap-0.0.10 → codmap-0.0.12}/codemap/extract/griffe_extractor.py +55 -4
- {codmap-0.0.10 → codmap-0.0.12}/codemap/extract/roots.py +30 -5
- codmap-0.0.12/codemap/extract/union.py +144 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/incremental.py +69 -19
- {codmap-0.0.10 → codmap-0.0.12}/codemap/model.py +7 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/provenance.py +25 -3
- {codmap-0.0.10 → codmap-0.0.12}/codemap/query.py +26 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/audit.py +16 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/impact.py +3 -1
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/session.py +94 -10
- {codmap-0.0.10 → codmap-0.0.12/codmap.egg-info}/PKG-INFO +18 -3
- {codmap-0.0.10 → codmap-0.0.12}/codmap.egg-info/SOURCES.txt +5 -0
- {codmap-0.0.10 → codmap-0.0.12}/pyproject.toml +1 -1
- codmap-0.0.12/tests/test_r1c44_absent_answers.py +197 -0
- codmap-0.0.12/tests/test_r1c45_repeat_union.py +236 -0
- codmap-0.0.12/tests/test_r1c46_shadowed_definitions.py +142 -0
- codmap-0.0.12/tests/test_r1c47_incremental_resample.py +155 -0
- {codmap-0.0.10 → codmap-0.0.12}/LICENSE +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/__init__.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/apidiff.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/arch.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/freshness.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/integrations/base.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/scope.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/architecture.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/check.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/limits.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/livingdocs.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/mcp_server.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/pack.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/rag.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/review.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/scip.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/server.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/vault.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/store.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/tomlio.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codemap/watch.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/setup.cfg +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_epistemic.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_integrations.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_m15_review.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_m16_architecture.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_m1_query.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_m2_views.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_m9_family.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c29_lazy_imports.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c30f2_check_scope.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c38_tool_drift.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c41_scope_membership.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c42_deep_tier_stability.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c43_incremental_splice.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_scip_export.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_scope.py +0 -0
- {codmap-0.0.10 → codmap-0.0.12}/tests/test_subsystems.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codmap
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.12
|
|
4
4
|
Summary: Static analyzer that turns a package's source into a queryable code graph.
|
|
5
5
|
Author-email: kogriv <kogriv@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -161,12 +161,15 @@ Optional extras: `mcp` (Model Context Protocol server), `scip` (protobuf, for SC
|
|
|
161
161
|
## Quickstart
|
|
162
162
|
|
|
163
163
|
```bash
|
|
164
|
-
# build the canonical graph of a package
|
|
164
|
+
# build the canonical graph of a package (its directory name is the package name every id starts with)
|
|
165
165
|
codemap build ./yourpkg -o graph.json
|
|
166
166
|
|
|
167
167
|
# repo-scoped: add consumers (tests/examples) + docs for blast-radius/impact
|
|
168
168
|
codemap build ./yourpkg --deep --mode full --consumer ./tests --docs ./docs -o graph.json
|
|
169
169
|
|
|
170
|
+
# deep tier: union N independent samples (one build misses a real edge ~1 time in 4)
|
|
171
|
+
codemap build ./yourpkg --deep --repeat 3 -o graph.json
|
|
172
|
+
|
|
170
173
|
# ask about a symbol (JSON by default; --format text for humans)
|
|
171
174
|
codemap query analyze_zones --graph graph.json
|
|
172
175
|
|
|
@@ -262,7 +265,19 @@ build says so in its own diagnostics, and `codemap diff` says so when both sides
|
|
|
262
265
|
delta of a few call edges as possible tool noise before reading it as a change in the code. Anything
|
|
263
266
|
that must be reproducible byte-for-byte — a gate, a two-release comparison — belongs on the fast tier.
|
|
264
267
|
|
|
265
|
-
**
|
|
268
|
+
**How many times to build.** A consumer measured 175 full deep builds of one tree and found a real
|
|
269
|
+
edge present in **75 %** of them; eight builds here agreed (5 of 8, the same edge, and nothing else
|
|
270
|
+
unstable among 13 675). So one build misses such an edge about 1 time in 4, two builds 1 in 16, three
|
|
271
|
+
1 in 64. `codemap build --deep --repeat N` builds N samples — each in a fresh interpreter, the
|
|
272
|
+
regime the share was measured in; in-process repeats come in correlated streaks — and unions them: an edge seen in fewer
|
|
273
|
+
than N runs carries `extras.seen`, `provenance.samples` records N and how many edges varied, and the
|
|
274
|
+
note on the graph says both. Refused out loud on the fast tier (byte-stable already). With `--incremental`
|
|
275
|
+
and `watch`, N becomes a property of the chain and every place the chain samples — base, fallback, each
|
|
276
|
+
tick's recompute — samples N times; measured on twenty real commits, that is what removes the chain's misses,
|
|
277
|
+
and a periodic full rebuild does not.
|
|
278
|
+
|
|
279
|
+
**Measurement:** [gaps/deep_tier_nondeterminism_2026-09-02.md](gaps/deep_tier_nondeterminism_2026-09-02.md),
|
|
280
|
+
[gaps/deep_tier_union_by_repeat_2026-09-04.md](gaps/deep_tier_union_by_repeat_2026-09-04.md).
|
|
266
281
|
|
|
267
282
|
## Dogfooding
|
|
268
283
|
|
|
@@ -125,12 +125,15 @@ Optional extras: `mcp` (Model Context Protocol server), `scip` (protobuf, for SC
|
|
|
125
125
|
## Quickstart
|
|
126
126
|
|
|
127
127
|
```bash
|
|
128
|
-
# build the canonical graph of a package
|
|
128
|
+
# build the canonical graph of a package (its directory name is the package name every id starts with)
|
|
129
129
|
codemap build ./yourpkg -o graph.json
|
|
130
130
|
|
|
131
131
|
# repo-scoped: add consumers (tests/examples) + docs for blast-radius/impact
|
|
132
132
|
codemap build ./yourpkg --deep --mode full --consumer ./tests --docs ./docs -o graph.json
|
|
133
133
|
|
|
134
|
+
# deep tier: union N independent samples (one build misses a real edge ~1 time in 4)
|
|
135
|
+
codemap build ./yourpkg --deep --repeat 3 -o graph.json
|
|
136
|
+
|
|
134
137
|
# ask about a symbol (JSON by default; --format text for humans)
|
|
135
138
|
codemap query analyze_zones --graph graph.json
|
|
136
139
|
|
|
@@ -226,7 +229,19 @@ build says so in its own diagnostics, and `codemap diff` says so when both sides
|
|
|
226
229
|
delta of a few call edges as possible tool noise before reading it as a change in the code. Anything
|
|
227
230
|
that must be reproducible byte-for-byte — a gate, a two-release comparison — belongs on the fast tier.
|
|
228
231
|
|
|
229
|
-
**
|
|
232
|
+
**How many times to build.** A consumer measured 175 full deep builds of one tree and found a real
|
|
233
|
+
edge present in **75 %** of them; eight builds here agreed (5 of 8, the same edge, and nothing else
|
|
234
|
+
unstable among 13 675). So one build misses such an edge about 1 time in 4, two builds 1 in 16, three
|
|
235
|
+
1 in 64. `codemap build --deep --repeat N` builds N samples — each in a fresh interpreter, the
|
|
236
|
+
regime the share was measured in; in-process repeats come in correlated streaks — and unions them: an edge seen in fewer
|
|
237
|
+
than N runs carries `extras.seen`, `provenance.samples` records N and how many edges varied, and the
|
|
238
|
+
note on the graph says both. Refused out loud on the fast tier (byte-stable already). With `--incremental`
|
|
239
|
+
and `watch`, N becomes a property of the chain and every place the chain samples — base, fallback, each
|
|
240
|
+
tick's recompute — samples N times; measured on twenty real commits, that is what removes the chain's misses,
|
|
241
|
+
and a periodic full rebuild does not.
|
|
242
|
+
|
|
243
|
+
**Measurement:** [gaps/deep_tier_nondeterminism_2026-09-02.md](gaps/deep_tier_nondeterminism_2026-09-02.md),
|
|
244
|
+
[gaps/deep_tier_union_by_repeat_2026-09-04.md](gaps/deep_tier_union_by_repeat_2026-09-04.md).
|
|
230
245
|
|
|
231
246
|
## Dogfooding
|
|
232
247
|
|
|
@@ -83,18 +83,37 @@ def _graph_from(args):
|
|
|
83
83
|
|
|
84
84
|
def _cmd_build(args) -> int:
|
|
85
85
|
incr_info = None
|
|
86
|
+
repeat = getattr(args, "repeat", None)
|
|
87
|
+
repeat = 1 if repeat is None else repeat
|
|
88
|
+
# R1-C45 / D7: refused out loud, never swallowed. The fast tier is byte-stable and CI
|
|
89
|
+
# pins it, so N runs there buy nothing and cost N minutes. With --incremental the flag
|
|
90
|
+
# is allowed since R1-C47: N is a property of the chain, and a different N restarts it.
|
|
91
|
+
if repeat < 1:
|
|
92
|
+
print("error: --repeat must be >= 1", file=sys.stderr)
|
|
93
|
+
return 2
|
|
94
|
+
if repeat > 1 and not args.deep:
|
|
95
|
+
print(f"error: --repeat {repeat} needs --deep — the fast tier is byte-stable, so "
|
|
96
|
+
"repeating it unions identical samples", file=sys.stderr)
|
|
97
|
+
return 2
|
|
86
98
|
if args.consumer or args.docs:
|
|
99
|
+
if getattr(args, "incremental", False):
|
|
100
|
+
# R1-C44 / D6: the flag used to be swallowed here without a word. Not an
|
|
101
|
+
# error — a stated boundary: the incremental path is single-package.
|
|
102
|
+
print("[note] --incremental was not applied: --consumer/--docs make this a "
|
|
103
|
+
"repo-scoped build, which is always full (the incremental path is "
|
|
104
|
+
"single-package only)", file=sys.stderr)
|
|
87
105
|
graph = extract_repo(
|
|
88
106
|
args.path,
|
|
89
107
|
consumers=tuple(args.consumer or ()),
|
|
90
108
|
docs=tuple(args.docs or ()),
|
|
91
109
|
mode=args.mode,
|
|
92
110
|
deep=args.deep,
|
|
111
|
+
repeat=repeat,
|
|
93
112
|
)
|
|
94
113
|
elif getattr(args, "incremental", False) and args.out:
|
|
95
114
|
graph, incr_info = _incremental_build(args)
|
|
96
115
|
else:
|
|
97
|
-
graph = extract(args.path, deep=args.deep)
|
|
116
|
+
graph = extract(args.path, deep=args.deep, repeat=repeat)
|
|
98
117
|
# R1-C25: stamp the input's identity into the graph itself. The scope manifest is
|
|
99
118
|
# resolved here once and reused for the sidecar below — the graph gets the part that
|
|
100
119
|
# must travel with it (scope_id, source commit), the sidecar keeps the rebuild recipe.
|
|
@@ -108,7 +127,10 @@ def _cmd_build(args) -> int:
|
|
|
108
127
|
# R1-C43: this call *overwrites* whatever update_graph stamped, so the flag has
|
|
109
128
|
# to be re-derived here or it is lost. `unchanged` counts as carried over too —
|
|
110
129
|
# nothing in that graph was recomputed by this build at all.
|
|
111
|
-
incremental=(incr_info or {}).get("mode") in ("incremental", "unchanged")
|
|
130
|
+
incremental=(incr_info or {}).get("mode") in ("incremental", "unchanged"),
|
|
131
|
+
# R1-C45: same trap — the extractor stamped the sample count; carry it, or a
|
|
132
|
+
# `--repeat 3` graph leaves here saying `runs: 1`.
|
|
133
|
+
samples=graph.provenance.get("samples"))
|
|
112
134
|
# R1-C21: a well-formed but vacuous graph must announce itself — silence is what
|
|
113
135
|
# lets an unparsed layout read as a clean bill of health downstream.
|
|
114
136
|
for d in diagnostics(graph):
|
|
@@ -179,12 +201,15 @@ def _incremental_build(args):
|
|
|
179
201
|
old_meta = read_meta(args.out)
|
|
180
202
|
old_scope = (old_meta or {}).get("scope")
|
|
181
203
|
if not (Path(args.out).exists() and old_scope):
|
|
182
|
-
return extract(args.path, deep=args.deep
|
|
204
|
+
return extract(args.path, deep=args.deep, repeat=getattr(args, "repeat", None) or 1), \
|
|
205
|
+
{"mode": "full", "affected": []}
|
|
183
206
|
old_graph = store.load(args.out)
|
|
184
207
|
new_scope = resolve_scope(args.path)
|
|
208
|
+
repeat = getattr(args, "repeat", None) or 1
|
|
185
209
|
if old_graph.target != Path(args.path).resolve().name:
|
|
186
|
-
return extract(args.path, deep=args.deep), {"mode": "full", "affected": []}
|
|
187
|
-
return update_graph(old_graph, args.path, old_scope, new_scope, deep=args.deep
|
|
210
|
+
return extract(args.path, deep=args.deep, repeat=repeat), {"mode": "full", "affected": []}
|
|
211
|
+
return update_graph(old_graph, args.path, old_scope, new_scope, deep=args.deep,
|
|
212
|
+
repeat=repeat)
|
|
188
213
|
|
|
189
214
|
|
|
190
215
|
def _watch_build_argv(args) -> list[str]:
|
|
@@ -197,6 +222,8 @@ def _watch_build_argv(args) -> list[str]:
|
|
|
197
222
|
argv = ["build", args.path, "-o", args.out, "--incremental"]
|
|
198
223
|
if args.deep:
|
|
199
224
|
argv.append("--deep")
|
|
225
|
+
if (getattr(args, "repeat", None) or 1) > 1:
|
|
226
|
+
argv += ["--repeat", str(args.repeat)] # R1-C47: N is a property of the chain
|
|
200
227
|
for c in args.consumer or ():
|
|
201
228
|
argv += ["--consumer", c]
|
|
202
229
|
for d in args.docs or ():
|
|
@@ -239,6 +266,7 @@ def _cmd_watch(args) -> int:
|
|
|
239
266
|
build_args = _argparse.Namespace(
|
|
240
267
|
path=args.path, out=args.out, deep=args.deep, consumer=args.consumer,
|
|
241
268
|
docs=args.docs, mode=args.mode, incremental=True,
|
|
269
|
+
repeat=getattr(args, "repeat", None) or 1,
|
|
242
270
|
_argv=_watch_build_argv(args))
|
|
243
271
|
|
|
244
272
|
reported: set[str] = set()
|
|
@@ -869,7 +897,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
869
897
|
sub = p.add_subparsers(dest="command", required=True)
|
|
870
898
|
|
|
871
899
|
b = sub.add_parser("build", help="Build the canonical graph from a package path.")
|
|
872
|
-
b.add_argument("path", help="Path to the package directory (holds __init__.py)."
|
|
900
|
+
b.add_argument("path", help="Path to the package directory (holds __init__.py). Its "
|
|
901
|
+
"directory name is the package name every node id starts "
|
|
902
|
+
"with — a copy under another name answers to that name.")
|
|
873
903
|
b.add_argument("-o", "--out", help="Write graph.json here (default: stdout JSON).")
|
|
874
904
|
b.add_argument("--deep", action="store_true",
|
|
875
905
|
help="Deep call resolution via jedi (richer, ~1 min; default fast).")
|
|
@@ -882,8 +912,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
882
912
|
help="Consumer granularity: thin=per-file (default), full=per-function.")
|
|
883
913
|
b.add_argument("--incremental", action="store_true",
|
|
884
914
|
help="Reuse an existing --out graph + its scope sidecar: recompute "
|
|
885
|
-
"only changed modules (R1-C9).
|
|
886
|
-
"
|
|
915
|
+
"only changed modules (R1-C9). Byte-identical to a full build on "
|
|
916
|
+
"the fast tier; on --deep it carries the earlier build's jedi "
|
|
917
|
+
"sample forward (docs/incremental.md). Much faster on --deep. "
|
|
918
|
+
"Single-package only (no --consumer/--docs).")
|
|
919
|
+
b.add_argument("--repeat", type=int, default=1, metavar="N",
|
|
920
|
+
help="--deep only: run the jedi layer N times and union the samples. "
|
|
921
|
+
"One deep build is one sample (a real edge was present in 75%% "
|
|
922
|
+
"of single builds, measured); an edge seen in fewer than N runs "
|
|
923
|
+
"carries extras.seen, provenance.samples records N. Costs N× the "
|
|
924
|
+
"jedi time, in parallel. With --incremental N becomes a property "
|
|
925
|
+
"of the chain: a different N restarts it from a full build.")
|
|
887
926
|
b.set_defaults(func=_cmd_build)
|
|
888
927
|
|
|
889
928
|
sc = sub.add_parser("scope", help="Resolve the input scope manifest (scope_id + profile), or --diff two.")
|
|
@@ -987,6 +1026,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
987
1026
|
w.add_argument("--docs", action="append", metavar="PATH", help="Docs root. Repeatable.")
|
|
988
1027
|
w.add_argument("--mode", choices=["thin", "full"], default="thin",
|
|
989
1028
|
help="Consumer granularity (as for `build`).")
|
|
1029
|
+
w.add_argument("--repeat", type=int, default=1, metavar="N",
|
|
1030
|
+
help="--deep only: sample the jedi layer N times on every build this "
|
|
1031
|
+
"loop makes — base, fallback and each tick's recompute (R1-C47). "
|
|
1032
|
+
"Measured: an incremental chain's misses come from single samples, "
|
|
1033
|
+
"not from age.")
|
|
990
1034
|
w.add_argument("--cycles", type=int, default=None,
|
|
991
1035
|
help="Stop after N polls instead of running forever (CI / testing).")
|
|
992
1036
|
_add_poll_options(
|
|
@@ -207,20 +207,50 @@ def deep_tier_diagnostic(graph) -> dict | None:
|
|
|
207
207
|
per-script execution budget — an inference that runs out of it returns nothing, and
|
|
208
208
|
that reads as `unresolved` rather than as an error.
|
|
209
209
|
"""
|
|
210
|
-
|
|
210
|
+
prov = graph.provenance or {}
|
|
211
|
+
if prov.get("tier") != "deep":
|
|
211
212
|
return None
|
|
213
|
+
# R1-C45: a reader deciding whether to trust an *absence* needs the per-edge share and
|
|
214
|
+
# what N does to it — "roughly one run in three" named the noise and not its remedy.
|
|
215
|
+
# The 75 % is stated as measured on one tree for one edge (126 of 168 full builds,
|
|
216
|
+
# issue #16; 5 of 8 here), never as a property of the tier: the consumer's own batches
|
|
217
|
+
# ranged from 1 of 7 to 7 of 7, and eight builds cannot see a 95 % edge at all.
|
|
218
|
+
samples = prov.get("samples") or {}
|
|
219
|
+
runs = samples.get("runs") or 1
|
|
220
|
+
if runs >= 2:
|
|
221
|
+
unstable = samples.get("unstable", 0)
|
|
222
|
+
residual = 0.25 ** runs
|
|
223
|
+
return {
|
|
224
|
+
"code": DEEP_TIER_UNSTABLE,
|
|
225
|
+
"severity": NOTE,
|
|
226
|
+
"tier": "deep",
|
|
227
|
+
"samples": runs,
|
|
228
|
+
"unstable": unstable,
|
|
229
|
+
"consequence": ("Edges carrying `extras.seen` are the ones the tier is unsure "
|
|
230
|
+
"about; everything else was present in every run."),
|
|
231
|
+
"message": (
|
|
232
|
+
f"union of {runs} deep (jedi) samples of one tree: {unstable} edge(s) "
|
|
233
|
+
f"were seen in fewer than {runs} runs and carry `extras.seen`. At the "
|
|
234
|
+
f"measured per-run rate (a real edge present in 75 % of single builds) "
|
|
235
|
+
f"an edge missed by all {runs} runs has probability about "
|
|
236
|
+
f"{residual * 100:.2g} %."
|
|
237
|
+
),
|
|
238
|
+
}
|
|
212
239
|
return {
|
|
213
240
|
"code": DEEP_TIER_UNSTABLE,
|
|
214
241
|
"severity": NOTE,
|
|
215
242
|
"tier": "deep",
|
|
243
|
+
"samples": 1,
|
|
216
244
|
"consequence": ("Everything here is a lower bound as usual; treat a difference "
|
|
217
245
|
"of a few jedi-resolved edges between two deep graphs as tool "
|
|
218
|
-
"noise rather than a change in the code
|
|
246
|
+
"noise rather than a change in the code, and do not read a "
|
|
247
|
+
"missing call or attribute edge in one build as absence."),
|
|
219
248
|
"message": (
|
|
220
|
-
"built on the deep (jedi) tier, which is not byte-stable
|
|
221
|
-
"
|
|
222
|
-
"
|
|
223
|
-
"`
|
|
249
|
+
"built on the deep (jedi) tier, which is not byte-stable — one build is one "
|
|
250
|
+
"sample. Measured on an 88-module tree, a real `accesses` edge was present in "
|
|
251
|
+
"126 of 168 full builds (75 %): one build misses such an edge about 1 time in "
|
|
252
|
+
"4, two builds 1 in 16, three 1 in 64. `build --repeat N` unions N samples "
|
|
253
|
+
"and marks what varied."
|
|
224
254
|
),
|
|
225
255
|
}
|
|
226
256
|
|
|
@@ -78,6 +78,9 @@ def add_behavior(graph, griffe_root, target_pkg: str, *, deep: bool = False,
|
|
|
78
78
|
modmembers = set(mod.members.keys())
|
|
79
79
|
script = _jedi_script(source, fp, project) if deep else None
|
|
80
80
|
nested: dict[tuple[str, str], dict] = {}
|
|
81
|
+
# R1-C46: the surviving definition says which earlier bodies it replaced. Stamped
|
|
82
|
+
# here because this pass holds the AST; the walkers below skip those bodies.
|
|
83
|
+
_stamp_shadows(graph, tree, modpath)
|
|
81
84
|
for fnode, class_stack, scope in _named_functions_scoped(tree):
|
|
82
85
|
node_id = _node_id(modpath, class_stack, fnode.name)
|
|
83
86
|
local = local_imports.get(id(fnode))
|
|
@@ -349,17 +352,105 @@ def _class_members(mod, class_stack, modules) -> dict:
|
|
|
349
352
|
|
|
350
353
|
# -- ast scope walking -------------------------------------------------------
|
|
351
354
|
|
|
355
|
+
_DEF_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
356
|
+
# Decorators that make a repeated binding of one name legitimate (R1-C46 / D2): an
|
|
357
|
+
# `@overload` *before* the implementation, a property accessor or a singledispatch
|
|
358
|
+
# registration *after* the first binding. Matched on the decorator's last dotted segment
|
|
359
|
+
# with any call stripped, so `typing.overload` and `@f.register(int)` both count.
|
|
360
|
+
_EXEMPT_EARLIER = frozenset({"overload"})
|
|
361
|
+
_EXEMPT_LATER = frozenset({"setter", "getter", "deleter", "register"})
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _decorator_tails(node) -> list[str]:
|
|
365
|
+
out = []
|
|
366
|
+
for d in node.decorator_list:
|
|
367
|
+
text = ast.unparse(d).split("(", 1)[0]
|
|
368
|
+
out.append(text.rsplit(".", 1)[-1])
|
|
369
|
+
return out
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _rebinding_is_legitimate(earlier, later) -> bool:
|
|
373
|
+
if later.name == "_":
|
|
374
|
+
return True # singledispatch convention
|
|
375
|
+
if any(t in _EXEMPT_EARLIER for t in _decorator_tails(earlier)):
|
|
376
|
+
return True
|
|
377
|
+
return any(t in _EXEMPT_LATER for t in _decorator_tails(later))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _shadow_map(tree) -> tuple[set[int], dict, list[tuple[int, int]]]:
|
|
381
|
+
"""Definitions that a later definition of the same name in the same body replaces.
|
|
382
|
+
|
|
383
|
+
R1-C46: griffe keeps the **last** body of a name defined twice and drops the first
|
|
384
|
+
without a word; the AST walkers here used to visit *both* and attribute both bodies'
|
|
385
|
+
calls to the one surviving node — an edge from code that can never run. So every
|
|
386
|
+
walker skips the shadowed subtree, and the surviving node records what it shadowed.
|
|
387
|
+
|
|
388
|
+
Only a *direct* statement of a module, class or function body shadows or is
|
|
389
|
+
shadowed. A definition under ``if`` / ``try`` / ``with`` is conditional on either
|
|
390
|
+
side (``if TYPE_CHECKING:``, an ``except ImportError`` fallback): it neither replaces
|
|
391
|
+
nor is replaced, and both bodies keep being walked — the honest over-approximation.
|
|
392
|
+
|
|
393
|
+
Returns ``(ids of AST nodes to skip, records, dead line ranges)``; ``records`` maps
|
|
394
|
+
``(scope chain, name) -> {"linenos": [...], "node": bool}`` where ``node`` says the
|
|
395
|
+
chain is classes only, i.e. the survivor is a definition node the graph holds.
|
|
396
|
+
"""
|
|
397
|
+
skip: set[int] = set()
|
|
398
|
+
records: dict[tuple[tuple[str, ...], str], dict] = {}
|
|
399
|
+
ranges: list[tuple[int, int]] = []
|
|
400
|
+
|
|
401
|
+
def walk(node, scope: list[str], kinds: list[str]) -> None:
|
|
402
|
+
if isinstance(node, (ast.Module, *_DEF_NODES)):
|
|
403
|
+
last: dict[str, ast.AST] = {}
|
|
404
|
+
for stmt in node.body:
|
|
405
|
+
if not isinstance(stmt, _DEF_NODES):
|
|
406
|
+
continue
|
|
407
|
+
prev = last.get(stmt.name)
|
|
408
|
+
if prev is not None and not _rebinding_is_legitimate(prev, stmt):
|
|
409
|
+
skip.add(id(prev))
|
|
410
|
+
ranges.append((prev.lineno, getattr(prev, "end_lineno", prev.lineno)))
|
|
411
|
+
rec = records.setdefault((tuple(scope), stmt.name),
|
|
412
|
+
{"linenos": [], "node": all(k == "c" for k in kinds)})
|
|
413
|
+
rec["linenos"].append(prev.lineno)
|
|
414
|
+
last[stmt.name] = stmt
|
|
415
|
+
for child in ast.iter_child_nodes(node):
|
|
416
|
+
if id(child) in skip:
|
|
417
|
+
continue
|
|
418
|
+
if isinstance(child, ast.ClassDef):
|
|
419
|
+
walk(child, scope + [child.name], kinds + ["c"])
|
|
420
|
+
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
421
|
+
walk(child, scope + [child.name], kinds + ["f"])
|
|
422
|
+
else:
|
|
423
|
+
walk(child, scope, kinds)
|
|
424
|
+
|
|
425
|
+
walk(tree, [], [])
|
|
426
|
+
return skip, records, ranges
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _stamp_shadows(graph, tree, prefix: str) -> None:
|
|
430
|
+
"""Write ``extras.shadows`` on every surviving definition node under ``prefix``."""
|
|
431
|
+
for (scope, name), rec in _shadow_map(tree)[1].items():
|
|
432
|
+
if not rec["node"]:
|
|
433
|
+
continue
|
|
434
|
+
nid = ".".join([prefix, *scope, name])
|
|
435
|
+
if nid in graph.nodes:
|
|
436
|
+
graph.nodes[nid].extras["shadows"] = sorted(rec["linenos"])
|
|
437
|
+
|
|
438
|
+
|
|
352
439
|
def _named_functions(tree):
|
|
353
440
|
"""Yield (FunctionDef, [class names]) for functions reachable as definitions.
|
|
354
441
|
|
|
355
442
|
Tracks the class nesting so we can rebuild canonical ids. Functions nested
|
|
356
443
|
inside other functions are still yielded but filtered out by the caller
|
|
357
|
-
(their id won't match a graph node).
|
|
444
|
+
(their id won't match a graph node). A definition shadowed by a later one of
|
|
445
|
+
the same name in the same body is not yielded (R1-C46, :func:`_shadow_map`).
|
|
358
446
|
"""
|
|
359
447
|
results = []
|
|
448
|
+
skip = _shadow_map(tree)[0]
|
|
360
449
|
|
|
361
450
|
def visit(node, class_stack):
|
|
362
451
|
for child in ast.iter_child_nodes(node):
|
|
452
|
+
if id(child) in skip:
|
|
453
|
+
continue
|
|
363
454
|
if isinstance(child, ast.ClassDef):
|
|
364
455
|
visit(child, class_stack + [child.name])
|
|
365
456
|
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
@@ -845,9 +936,12 @@ def _named_functions_scoped(tree):
|
|
|
845
936
|
because `dataflow`/`dispatch`/`attrflow` unpack the two-tuple.
|
|
846
937
|
"""
|
|
847
938
|
results = []
|
|
939
|
+
skip = _shadow_map(tree)[0] # R1-C46: a shadowed body is not walked
|
|
848
940
|
|
|
849
941
|
def visit(node, class_stack, scope):
|
|
850
942
|
for child in ast.iter_child_nodes(node):
|
|
943
|
+
if id(child) in skip:
|
|
944
|
+
continue
|
|
851
945
|
if isinstance(child, ast.ClassDef):
|
|
852
946
|
visit(child, class_stack + [child.name], scope + [child.name])
|
|
853
947
|
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
@@ -18,6 +18,7 @@ Emits (M0 + M1 + M1.5):
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
import ast
|
|
21
|
+
import multiprocessing
|
|
21
22
|
import os
|
|
22
23
|
from dataclasses import dataclass, field
|
|
23
24
|
from pathlib import Path
|
|
@@ -29,6 +30,7 @@ from codemap.extract.behavior import add_behavior
|
|
|
29
30
|
from codemap.extract.dataflow import add_dataflow
|
|
30
31
|
from codemap.extract.dispatch import add_dispatch, add_family_links
|
|
31
32
|
from codemap.extract.gsource import NESTED_IMPORT_HINT, module_file, module_identity
|
|
33
|
+
from codemap.extract.union import merge_samples
|
|
32
34
|
from codemap.provenance import build_provenance
|
|
33
35
|
from codemap.model import Edge, Graph, Node
|
|
34
36
|
|
|
@@ -138,20 +140,69 @@ def add_behavioral_layer(graph, root, module_name, search_path, *, deep: bool,
|
|
|
138
140
|
only=attr_only)
|
|
139
141
|
|
|
140
142
|
|
|
141
|
-
def
|
|
143
|
+
def _sample_worker(package_path: str, deep: bool, only=None) -> dict:
|
|
144
|
+
"""One extraction in a child interpreter; returns the graph as a dict (R1-C45).
|
|
145
|
+
|
|
146
|
+
``only`` (a set of module paths) restricts the two jedi passes to those modules —
|
|
147
|
+
the incremental hook (R1-C9), so a chain can resample just what a tick touched
|
|
148
|
+
(R1-C47). The structural base is rebuilt in every worker regardless: cheap,
|
|
149
|
+
deterministic, and cheaper than shipping it across processes.
|
|
150
|
+
"""
|
|
151
|
+
graph, root, module_name, search_path = build_structural(package_path)
|
|
152
|
+
add_behavioral_layer(graph, root, module_name, search_path, deep=deep,
|
|
153
|
+
behavior_only=only, attr_only=only)
|
|
154
|
+
return graph.to_dict()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def collect_samples(package_path, *, deep: bool, runs: int,
|
|
158
|
+
workers: int | None = None, only=None) -> list[Graph]:
|
|
159
|
+
"""``runs`` independent samples of ``package_path``, each in a fresh interpreter.
|
|
160
|
+
|
|
161
|
+
Lives here, beside the two functions the worker calls, so ``extract/union.py`` stays
|
|
162
|
+
a pure merge and the import graph stays acyclic — codemap's own ``no_lazy_cycles``
|
|
163
|
+
contract caught the first draft, which reached back from ``union`` with a
|
|
164
|
+
function-local import. ``workers`` bounds concurrency (default: the machine's core
|
|
165
|
+
count, capped at ``runs``). Spawn, not fork: a forked child would inherit exactly the
|
|
166
|
+
state that makes an in-process repeat correlate with the pass before it.
|
|
167
|
+
"""
|
|
168
|
+
ctx = multiprocessing.get_context("spawn")
|
|
169
|
+
procs = max(1, min(runs, workers or os.cpu_count() or 1))
|
|
170
|
+
with ctx.Pool(processes=procs) as pool:
|
|
171
|
+
dicts = pool.starmap(_sample_worker,
|
|
172
|
+
[(str(package_path), deep, set(only) if only else None)] * runs)
|
|
173
|
+
samples = []
|
|
174
|
+
for d in dicts:
|
|
175
|
+
g = Graph.from_dict(d)
|
|
176
|
+
g.loaded_schema = None # a fresh build, not a file — keep schema_diagnostic quiet
|
|
177
|
+
samples.append(g)
|
|
178
|
+
return samples
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def extract(package_path: str | Path, *, deep: bool = False, repeat: int = 1) -> Graph:
|
|
142
182
|
"""Build a code graph from a Python package directory.
|
|
143
183
|
|
|
144
184
|
``deep=True`` runs the jedi-backed call resolver (M5) — richer call-graph
|
|
145
185
|
(local-variable type inference) at ~1 min build cost; default is the fast
|
|
146
186
|
ast tier (sub-second). See ``extract/behavior.py``.
|
|
187
|
+
|
|
188
|
+
``repeat=N`` (R1-C45) builds N samples — **each in a fresh interpreter**, the
|
|
189
|
+
regime whose recovery share was measured; in-process repeats come in correlated
|
|
190
|
+
streaks (see ``extract/union.py``) — and unions them: a deep build is one sample
|
|
191
|
+
of jedi's bounded inference, and an edge seen in fewer than N runs carries
|
|
192
|
+
``extras.seen``.
|
|
147
193
|
"""
|
|
148
|
-
|
|
149
|
-
|
|
194
|
+
if repeat > 1:
|
|
195
|
+
graph, stats = merge_samples(collect_samples(package_path, deep=deep, runs=repeat))
|
|
196
|
+
else:
|
|
197
|
+
graph, root, module_name, search_path = build_structural(package_path)
|
|
198
|
+
add_behavioral_layer(graph, root, module_name, search_path, deep=deep)
|
|
199
|
+
stats = {"runs": 1}
|
|
150
200
|
# R1-C25: even a library-built graph says which tool and which tier made it. The
|
|
151
201
|
# input identity (scope_id, source commit) is added by whoever resolved the scope —
|
|
152
202
|
# `extract` deliberately does not hash the tree a second time.
|
|
153
203
|
graph.provenance = build_provenance(tier="deep" if deep else "fast",
|
|
154
|
-
inputs=graph.provenance.get("inputs")
|
|
204
|
+
inputs=graph.provenance.get("inputs"),
|
|
205
|
+
samples=stats)
|
|
155
206
|
return graph
|
|
156
207
|
|
|
157
208
|
|
|
@@ -32,7 +32,7 @@ import os
|
|
|
32
32
|
import re
|
|
33
33
|
from pathlib import Path
|
|
34
34
|
|
|
35
|
-
from codemap.extract.behavior import _arg_contract, _arg_shape
|
|
35
|
+
from codemap.extract.behavior import _arg_contract, _arg_shape, _shadow_map
|
|
36
36
|
from codemap.extract.griffe_extractor import extract
|
|
37
37
|
from codemap.provenance import canonicalize
|
|
38
38
|
from codemap.model import Edge, Graph, Node
|
|
@@ -78,18 +78,20 @@ def extract_repo(
|
|
|
78
78
|
docs: tuple[str | Path, ...] = (),
|
|
79
79
|
mode: str = "thin",
|
|
80
80
|
deep: bool = False,
|
|
81
|
+
repeat: int = 1,
|
|
81
82
|
) -> Graph:
|
|
82
83
|
"""Build a repo-scoped graph: core package + consumer roots + doc roots.
|
|
83
84
|
|
|
84
85
|
``core`` is analysed exactly as the single-package extractor (griffe, plus the
|
|
85
|
-
behavioral pass when ``deep
|
|
86
|
-
|
|
86
|
+
behavioral pass when ``deep``, unioned over ``repeat`` samples — R1-C45).
|
|
87
|
+
``consumers`` and ``docs`` are extra root directories scanned for references into
|
|
88
|
+
the core; those scans are deterministic and run once. ``mode`` is ``"thin"`` or
|
|
87
89
|
``"full"`` (see module docstring).
|
|
88
90
|
"""
|
|
89
91
|
if mode not in ("thin", "full"):
|
|
90
92
|
raise ValueError(f"mode must be 'thin' or 'full', got {mode!r}")
|
|
91
93
|
|
|
92
|
-
graph = extract(core, deep=deep)
|
|
94
|
+
graph = extract(core, deep=deep, repeat=repeat)
|
|
93
95
|
core_pkg = graph.target
|
|
94
96
|
for node in graph.nodes.values():
|
|
95
97
|
node.extras.setdefault("root", "core")
|
|
@@ -266,8 +268,14 @@ def _scan_consumer_module(graph, py, base, label, tree, index, mode, origin=None
|
|
|
266
268
|
if isinstance(n, ast.Attribute) and isinstance(n.value, ast.Attribute)
|
|
267
269
|
}
|
|
268
270
|
func_ranges = _func_ranges(tree) if mode == "full" else []
|
|
271
|
+
# R1-C46: a use inside a body that a later definition replaced is not a use — it
|
|
272
|
+
# would attribute a reference (or a call) to code that never runs, in either mode.
|
|
273
|
+
dead = _shadow_map(tree)[2]
|
|
269
274
|
|
|
270
275
|
for node in ast.walk(tree):
|
|
276
|
+
line = getattr(node, "lineno", None)
|
|
277
|
+
if line is not None and any(a <= line <= b for a, b in dead):
|
|
278
|
+
continue
|
|
271
279
|
target = None
|
|
272
280
|
use_node = node
|
|
273
281
|
if isinstance(node, ast.Name) and node.id in symbol_map:
|
|
@@ -375,14 +383,28 @@ def _materialize_defs(graph, tree, mod_id, label, file: str | None = None) -> No
|
|
|
375
383
|
extras={"root": label}))
|
|
376
384
|
parent = ".".join([mod_id, *class_stack]) if class_stack else mod_id
|
|
377
385
|
graph.add_edge(Edge("contains", parent, node_id))
|
|
386
|
+
# R1-C46: one node and one `contains` per definition that can run (issue #16 found
|
|
387
|
+
# the record twice); the survivor says which earlier bodies it replaced.
|
|
388
|
+
for (scope, name), rec in _shadow_map(tree)[1].items():
|
|
389
|
+
if rec["node"]:
|
|
390
|
+
nid = ".".join([mod_id, *scope, name])
|
|
391
|
+
if nid in graph.nodes:
|
|
392
|
+
graph.nodes[nid].extras["shadows"] = sorted(rec["linenos"])
|
|
378
393
|
|
|
379
394
|
|
|
380
395
|
def _defs(tree):
|
|
381
|
-
"""Yield (def-node, [enclosing class/def names]) for top-level & nested defs.
|
|
396
|
+
"""Yield (def-node, [enclosing class/def names]) for top-level & nested defs.
|
|
397
|
+
|
|
398
|
+
A body shadowed by a later definition of the same name in the same scope is not
|
|
399
|
+
yielded (R1-C46, :func:`_shadow_map`).
|
|
400
|
+
"""
|
|
382
401
|
results = []
|
|
402
|
+
skip = _shadow_map(tree)[0]
|
|
383
403
|
|
|
384
404
|
def visit(node, stack):
|
|
385
405
|
for child in ast.iter_child_nodes(node):
|
|
406
|
+
if id(child) in skip:
|
|
407
|
+
continue
|
|
386
408
|
if isinstance(child, ast.ClassDef):
|
|
387
409
|
results.append((child, list(stack)))
|
|
388
410
|
visit(child, stack + [child.name])
|
|
@@ -399,9 +421,12 @@ def _defs(tree):
|
|
|
399
421
|
def _func_ranges(tree):
|
|
400
422
|
"""(start, end, node_id) for each def, longest-first, to place a use by line."""
|
|
401
423
|
ranges = []
|
|
424
|
+
skip = _shadow_map(tree)[0] # R1-C46: a shadowed body owns no uses
|
|
402
425
|
|
|
403
426
|
def visit(node, mod_stack):
|
|
404
427
|
for child in ast.iter_child_nodes(node):
|
|
428
|
+
if id(child) in skip:
|
|
429
|
+
continue
|
|
405
430
|
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
406
431
|
nid = ".".join([*mod_stack, child.name])
|
|
407
432
|
end = getattr(child, "end_lineno", child.lineno)
|