codmap 0.0.22__tar.gz → 0.0.24__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.22/codmap.egg-info → codmap-0.0.24}/PKG-INFO +18 -12
- {codmap-0.0.22 → codmap-0.0.24}/README.md +17 -11
- {codmap-0.0.22 → codmap-0.0.24}/codemap/arch.py +76 -1
- {codmap-0.0.22 → codmap-0.0.24}/codemap/cli.py +53 -4
- {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/attrflow.py +3 -2
- {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/behavior.py +175 -35
- {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/griffe_extractor.py +58 -12
- {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/gsource.py +23 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/roots.py +2 -4
- {codmap-0.0.22 → codmap-0.0.24}/codemap/model.py +11 -1
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/audit.py +44 -10
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/check.py +42 -3
- codmap-0.0.24/codemap/serve/currency.py +67 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/session.py +8 -1
- {codmap-0.0.22 → codmap-0.0.24/codmap.egg-info}/PKG-INFO +18 -12
- {codmap-0.0.22 → codmap-0.0.24}/codmap.egg-info/SOURCES.txt +8 -0
- {codmap-0.0.22 → codmap-0.0.24}/pyproject.toml +1 -1
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_pack_honesty.py +3 -2
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c3_arch_contract.py +17 -2
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c63_answer_format.py +13 -1
- codmap-0.0.24/tests/test_r1c68_dotted_imports.py +123 -0
- codmap-0.0.24/tests/test_r1c69_contract_validation.py +106 -0
- codmap-0.0.24/tests/test_r1c70_check_graph_currency.py +130 -0
- codmap-0.0.24/tests/test_r1c71_attribute_chain_calls.py +82 -0
- codmap-0.0.24/tests/test_r1c72_flat_inference_in_packages.py +99 -0
- codmap-0.0.24/tests/test_r1c73_cli_version.py +72 -0
- codmap-0.0.24/tests/test_r1c74_call_site_conservation.py +169 -0
- {codmap-0.0.22 → codmap-0.0.24}/LICENSE +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/__init__.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/apidiff.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/diagnostics.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/probe.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/union.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/freshness.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/incremental.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/integrations/base.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/provenance.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/query.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/scope.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/architecture.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/impact.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/limits.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/livingdocs.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/mcp_server.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/pack.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/rag.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/review.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/scip.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/server.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/vault.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/store.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/tomlio.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codemap/watch.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/setup.cfg +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_epistemic.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_integrations.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_m15_review.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_m16_architecture.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_m1_query.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_m2_views.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_m9_family.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c29_lazy_imports.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c30f2_check_scope.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c38_tool_drift.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c39_edge_resolution.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c40_flow_reach.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c41_scope_membership.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c42_deep_tier_stability.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c43_incremental_splice.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c44_absent_answers.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c45_repeat_union.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c46_shadowed_definitions.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c47_incremental_resample.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c48_type_checking_imports.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c49_type_only_cycles.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c50_flow_entry_points.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c53_narrowing_declared.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c54_deterministic_rendering.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c55_override_grade.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c56_stub_files.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c57_facade_package.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c58_cycle_tangles.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c59_completeness_drop.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c60_nested_definitions.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c62_phantom_rules.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c64_dependency_probe.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c66_pack_direction.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_scip_export.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/tests/test_scope.py +0 -0
- {codmap-0.0.22 → codmap-0.0.24}/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.24
|
|
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
|
|
@@ -44,7 +44,7 @@ and a **SCIP index** for interop with Sourcegraph / Glean and other precise-code
|
|
|
44
44
|
|
|
45
45
|
[](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
|
|
46
46
|
|
|
47
|
-
**Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **
|
|
47
|
+
**Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **1128 tests with no failures on
|
|
48
48
|
Python 3.11–3.14** ([in CI](docs/ci.md): the full suite including the dogfood pass, a determinism check, a
|
|
49
49
|
wheel smoke test, and ctags/SCIP interop against the real CLIs), warm serve surface with 31 ops (28 exposed
|
|
50
50
|
as MCP tools), and SCIP export. See **[DESIGN.md](DESIGN.md)** (product design &
|
|
@@ -107,18 +107,24 @@ answer.
|
|
|
107
107
|
|
|
108
108
|
That sentence used to end at "None answers these", and a second pass over the 68k-star tool made it
|
|
109
109
|
narrower: its *importable library* does carry a cycle finder, reachable from neither its CLI nor its MCP
|
|
110
|
-
tools and called nowhere in its own source. Measured on the same tree it
|
|
111
|
-
|
|
112
|
-
included — is less flattering than that sounds:
|
|
110
|
+
tools and called nowhere in its own source. Measured on the same tree (its 1.6.0) it reported **136
|
|
111
|
+
cycles**; codemap reported **1**. Scoring both against the truth set — every intra-package import,
|
|
112
|
+
function-local ones included — is less flattering than that sounds:
|
|
113
113
|
|
|
114
|
-
| | reported | of the
|
|
114
|
+
| | reported | of the 45 real ones | precision | recall |
|
|
115
115
|
|---|---:|---:|---:|---:|
|
|
116
|
-
| codemap,
|
|
117
|
-
| the peer's library API | 136 | 13 | 10% |
|
|
118
|
-
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
116
|
+
| codemap, before R1-C29 | 1 | 1 | 100% | **2.2%** |
|
|
117
|
+
| the peer's library API, 1.6.0 | 136 | 13 | 10% | 29% |
|
|
118
|
+
| the peer's library API, **1.6.2** (re-measured 2026-10-15) | 25 | 16 | **64%** | 36% |
|
|
119
|
+
| codemap, after R1-C29 (0.0.4) | 45 | 45 | 100% | 100% |
|
|
120
|
+
|
|
121
|
+
*Corrected 2026-10-15.* This table first said **41** real cycles, recall 32% and 2.4%. The 41 were counted
|
|
122
|
+
on a later commit of the same repository than the one both tools were measured on; on the measured tree
|
|
123
|
+
there are 45 ([re-measure](research/codegraph_remeasure.md) §7, C3). The peer fixed the call edges we
|
|
124
|
+
reported and its 1.6.2 row is the current one.
|
|
125
|
+
|
|
126
|
+
Theirs over-reported because it walks name-resolved call edges — a `dict.get` became a call into an
|
|
127
|
+
unrelated class (fixed upstream in 1.6.1). **Ours under-reported because its import map was module-level only**, and it phrased
|
|
122
128
|
that as *"import graph is acyclic"* — a property claim over a partial map, which is the worse of the two
|
|
123
129
|
errors even though it was the smaller one. Both halves of that came from
|
|
124
130
|
[#11](https://github.com/kogriv/codemap/issues/11), filed off a different target and fixed the same day
|
|
@@ -8,7 +8,7 @@ and a **SCIP index** for interop with Sourcegraph / Glean and other precise-code
|
|
|
8
8
|
|
|
9
9
|
[](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
|
|
10
10
|
|
|
11
|
-
**Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **
|
|
11
|
+
**Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **1128 tests with no failures on
|
|
12
12
|
Python 3.11–3.14** ([in CI](docs/ci.md): the full suite including the dogfood pass, a determinism check, a
|
|
13
13
|
wheel smoke test, and ctags/SCIP interop against the real CLIs), warm serve surface with 31 ops (28 exposed
|
|
14
14
|
as MCP tools), and SCIP export. See **[DESIGN.md](DESIGN.md)** (product design &
|
|
@@ -71,18 +71,24 @@ answer.
|
|
|
71
71
|
|
|
72
72
|
That sentence used to end at "None answers these", and a second pass over the 68k-star tool made it
|
|
73
73
|
narrower: its *importable library* does carry a cycle finder, reachable from neither its CLI nor its MCP
|
|
74
|
-
tools and called nowhere in its own source. Measured on the same tree it
|
|
75
|
-
|
|
76
|
-
included — is less flattering than that sounds:
|
|
74
|
+
tools and called nowhere in its own source. Measured on the same tree (its 1.6.0) it reported **136
|
|
75
|
+
cycles**; codemap reported **1**. Scoring both against the truth set — every intra-package import,
|
|
76
|
+
function-local ones included — is less flattering than that sounds:
|
|
77
77
|
|
|
78
|
-
| | reported | of the
|
|
78
|
+
| | reported | of the 45 real ones | precision | recall |
|
|
79
79
|
|---|---:|---:|---:|---:|
|
|
80
|
-
| codemap,
|
|
81
|
-
| the peer's library API | 136 | 13 | 10% |
|
|
82
|
-
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
| codemap, before R1-C29 | 1 | 1 | 100% | **2.2%** |
|
|
81
|
+
| the peer's library API, 1.6.0 | 136 | 13 | 10% | 29% |
|
|
82
|
+
| the peer's library API, **1.6.2** (re-measured 2026-10-15) | 25 | 16 | **64%** | 36% |
|
|
83
|
+
| codemap, after R1-C29 (0.0.4) | 45 | 45 | 100% | 100% |
|
|
84
|
+
|
|
85
|
+
*Corrected 2026-10-15.* This table first said **41** real cycles, recall 32% and 2.4%. The 41 were counted
|
|
86
|
+
on a later commit of the same repository than the one both tools were measured on; on the measured tree
|
|
87
|
+
there are 45 ([re-measure](research/codegraph_remeasure.md) §7, C3). The peer fixed the call edges we
|
|
88
|
+
reported and its 1.6.2 row is the current one.
|
|
89
|
+
|
|
90
|
+
Theirs over-reported because it walks name-resolved call edges — a `dict.get` became a call into an
|
|
91
|
+
unrelated class (fixed upstream in 1.6.1). **Ours under-reported because its import map was module-level only**, and it phrased
|
|
86
92
|
that as *"import graph is acyclic"* — a property claim over a partial map, which is the worse of the two
|
|
87
93
|
errors even though it was the smaller one. Both halves of that came from
|
|
88
94
|
[#11](https://github.com/kogriv/codemap/issues/11), filed off a different target and fixed the same day
|
|
@@ -104,8 +104,83 @@ def load_contract(root: str | Path = ".") -> ArchitectureContract:
|
|
|
104
104
|
return replace(parse_contract(data.get("architecture", {})), path=str(path))
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
#: R1-C69: the closed schema of ``[architecture]``. A key outside it is an error, not a skip.
|
|
108
|
+
_FLAGS = ("no_cycles", "no_lazy_cycles", "no_type_only_cycles", "exhaustive", "no_phantom_rules")
|
|
109
|
+
_KNOWN_KEYS = ("layers", "independent", "forbidden") + _FLAGS
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_str_list(v) -> bool:
|
|
113
|
+
return isinstance(v, list) and all(isinstance(x, str) for x in v)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def contract_problems(section) -> list[str]:
|
|
117
|
+
"""Every way ``section`` departs from the ``[architecture]`` schema, in reading order (R1-C69).
|
|
118
|
+
|
|
119
|
+
Empty when the section is valid. Design ``docs/design/contract_validation.md`` D1: unknown
|
|
120
|
+
keys (with the nearest known one suggested), values of the wrong type — `"false"` is a
|
|
121
|
+
string and used to switch a rule *on* — and malformed items, each named by key and index.
|
|
122
|
+
Names that do not exist in the graph are not a schema question (D3, R1-C62).
|
|
123
|
+
"""
|
|
124
|
+
import difflib
|
|
125
|
+
|
|
126
|
+
if not isinstance(section, dict):
|
|
127
|
+
return [f"`[architecture]` must be a table, got {type(section).__name__}"]
|
|
128
|
+
problems: list[str] = []
|
|
129
|
+
for key in section:
|
|
130
|
+
if key not in _KNOWN_KEYS:
|
|
131
|
+
near = difflib.get_close_matches(key, _KNOWN_KEYS, n=1, cutoff=0.6)
|
|
132
|
+
hint = f" (did you mean `{near[0]}`?)" if near else ""
|
|
133
|
+
problems.append(f"unknown key `{key}`{hint}; known keys: {', '.join(_KNOWN_KEYS)}")
|
|
134
|
+
if "layers" in section and not _is_str_list(section["layers"]):
|
|
135
|
+
problems.append("`layers` must be a list of strings")
|
|
136
|
+
if "independent" in section:
|
|
137
|
+
groups = section["independent"]
|
|
138
|
+
if not isinstance(groups, list):
|
|
139
|
+
problems.append("`independent` must be a list of lists of strings")
|
|
140
|
+
else:
|
|
141
|
+
for i, grp in enumerate(groups):
|
|
142
|
+
if not _is_str_list(grp) or len(grp) < 2:
|
|
143
|
+
problems.append(f"`independent[{i}]` must be a list of at least 2 strings")
|
|
144
|
+
if "forbidden" in section:
|
|
145
|
+
items = section["forbidden"]
|
|
146
|
+
if not isinstance(items, list):
|
|
147
|
+
problems.append("`forbidden` must be a list of `{ from = \"…\", to = \"…\" }` tables")
|
|
148
|
+
else:
|
|
149
|
+
for i, item in enumerate(items):
|
|
150
|
+
if not isinstance(item, dict):
|
|
151
|
+
problems.append(f"`forbidden[{i}]` must be a table `{{ from = \"…\", to = \"…\" }}`")
|
|
152
|
+
continue
|
|
153
|
+
extra = sorted(set(item) - {"from", "to"})
|
|
154
|
+
missing = [k for k in ("from", "to") if k not in item]
|
|
155
|
+
wrong = [k for k in ("from", "to") if k in item and not isinstance(item[k], str)]
|
|
156
|
+
if extra:
|
|
157
|
+
problems.append(f"`forbidden[{i}]` has unknown key(s) "
|
|
158
|
+
+ ", ".join(f"`{k}`" for k in extra))
|
|
159
|
+
if missing:
|
|
160
|
+
problems.append(f"`forbidden[{i}]` is missing " + ", ".join(f"`{k}`" for k in missing))
|
|
161
|
+
if wrong:
|
|
162
|
+
problems.append(f"`forbidden[{i}]`: " + ", ".join(f"`{k}`" for k in wrong)
|
|
163
|
+
+ " must be a string")
|
|
164
|
+
for flag in _FLAGS:
|
|
165
|
+
if flag in section and not isinstance(section[flag], bool):
|
|
166
|
+
problems.append(f"`{flag}` must be true or false, got {type(section[flag]).__name__} "
|
|
167
|
+
f"`{section[flag]!r}`")
|
|
168
|
+
return problems
|
|
169
|
+
|
|
170
|
+
|
|
107
171
|
def parse_contract(section: dict) -> ArchitectureContract:
|
|
108
|
-
"""Build a contract from the parsed ``[architecture]`` table
|
|
172
|
+
"""Build a contract from the parsed ``[architecture]`` table.
|
|
173
|
+
|
|
174
|
+
R1-C69: validated against a closed schema first. A section with any problem is **not
|
|
175
|
+
enforced at all** — it comes back as an ``error`` contract, the R1-C27 path, so the CLI
|
|
176
|
+
exits 2 and MCP answers ``ok: false`` naming every problem. Enforcing the valid half used
|
|
177
|
+
to be the silent part: ``forbiden = [...]`` beside ``no_cycles = true`` printed a green
|
|
178
|
+
tick over a rule the gate never ran.
|
|
179
|
+
"""
|
|
180
|
+
problems = contract_problems(section)
|
|
181
|
+
if problems:
|
|
182
|
+
return ArchitectureContract(
|
|
183
|
+
error="`[architecture]` is not a valid contract: " + "; ".join(problems))
|
|
109
184
|
forbidden = []
|
|
110
185
|
for item in section.get("forbidden", []) or []:
|
|
111
186
|
if isinstance(item, dict) and "from" in item and "to" in item:
|
|
@@ -807,6 +807,12 @@ def _cmd_pack(args) -> int:
|
|
|
807
807
|
return 1 if "error" in p else 0
|
|
808
808
|
|
|
809
809
|
|
|
810
|
+
def _check_exit(violations, currency: dict | None, allow_stale: bool) -> int:
|
|
811
|
+
"""2 on a violation, or on a graph behind its source unless ``--allow-stale`` (R1-C70)."""
|
|
812
|
+
behind = currency is not None and currency.get("status") == "behind"
|
|
813
|
+
return 2 if (violations or (behind and not allow_stale)) else 0
|
|
814
|
+
|
|
815
|
+
|
|
810
816
|
def _cmd_check(args) -> int:
|
|
811
817
|
"""Enforce the [architecture] contract → exit 2 on any violation (R1-C3).
|
|
812
818
|
|
|
@@ -820,21 +826,28 @@ def _cmd_check(args) -> int:
|
|
|
820
826
|
from codemap.serve.check import render_check
|
|
821
827
|
q = Query(_graph_from(args))
|
|
822
828
|
contract = load_contract(args.root)
|
|
829
|
+
# R1-C70: a graph read from disk may be behind the tree it is about to be judged as.
|
|
830
|
+
currency = None
|
|
831
|
+
if getattr(args, "graph", None) and not getattr(args, "build", None):
|
|
832
|
+
from codemap.serve.currency import graph_currency
|
|
833
|
+
currency = graph_currency(args.graph, (q.graph.provenance or {}).get("scope_id"))
|
|
834
|
+
allow_stale = getattr(args, "allow_stale", False)
|
|
823
835
|
# R1-C27: check `error` before anything else. A malformed codemap.toml used to be
|
|
824
836
|
# indistinguishable from an absent one, so removing a single `]` turned a gate that
|
|
825
837
|
# reported 14 violations into exit 0. Exit 2 — the same code as a violation, not a new
|
|
826
838
|
# one: the status answers "may the pipeline proceed?" (no, either way), and a new code
|
|
827
839
|
# would sort an unreadable contract into the success branch of every `if rc == 2` that
|
|
828
840
|
# already exists in someone's pipeline.
|
|
841
|
+
kw = {"currency": currency, "allow_stale": allow_stale}
|
|
829
842
|
if contract.error:
|
|
830
|
-
_emit_answer(render_check(q, contract, []))
|
|
843
|
+
_emit_answer(render_check(q, contract, [], **kw))
|
|
831
844
|
return 2
|
|
832
845
|
if contract.is_empty() and args.require_contract:
|
|
833
|
-
_emit_answer(render_check(q, contract, []))
|
|
846
|
+
_emit_answer(render_check(q, contract, [], **kw))
|
|
834
847
|
raise SystemExit("error: no [architecture] contract found (--require-contract)")
|
|
835
848
|
violations = check_contract(q, contract)
|
|
836
|
-
_emit_answer(render_check(q, contract, violations))
|
|
837
|
-
return
|
|
849
|
+
_emit_answer(render_check(q, contract, violations, **kw))
|
|
850
|
+
return _check_exit(violations, currency, allow_stale)
|
|
838
851
|
|
|
839
852
|
|
|
840
853
|
def _cmd_diff(args) -> int:
|
|
@@ -969,8 +982,41 @@ def _add_source(p) -> None:
|
|
|
969
982
|
help="Deep call resolution via jedi (richer, ~1 min; default fast).")
|
|
970
983
|
|
|
971
984
|
|
|
985
|
+
def version_line() -> str:
|
|
986
|
+
"""``codemap 0.0.24 @abc1234 · answer format 4 · schema 0.13`` (R1-C73).
|
|
987
|
+
|
|
988
|
+
Built from ``provenance.tool_identity()`` — the identity every graph records as
|
|
989
|
+
``provenance.tool`` — so the CLI and the artifact cannot disagree. Commit and dirtiness
|
|
990
|
+
appear only when known, as there; a missing distribution is said, never invented.
|
|
991
|
+
"""
|
|
992
|
+
from codemap.model import ANSWER_FORMAT, SCHEMA_VERSION
|
|
993
|
+
from codemap.provenance import tool_identity
|
|
994
|
+
t = tool_identity()
|
|
995
|
+
head = f"codemap {t['version']}" if t.get("version") else \
|
|
996
|
+
"codemap version unknown (not installed as a distribution)"
|
|
997
|
+
if t.get("commit"):
|
|
998
|
+
head += f" @{t['commit']}"
|
|
999
|
+
if t.get("dirty"):
|
|
1000
|
+
head += " (dirty)"
|
|
1001
|
+
return f"{head} · answer format {ANSWER_FORMAT} · schema {SCHEMA_VERSION}"
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
class _VersionAction(argparse.Action):
|
|
1005
|
+
"""``--version``, computed only when asked: ``tool_identity()`` calls git (design D1)."""
|
|
1006
|
+
|
|
1007
|
+
def __init__(self, option_strings, dest=argparse.SUPPRESS, default=argparse.SUPPRESS,
|
|
1008
|
+
help="Print the installed version, the answer format and the graph schema, then exit."):
|
|
1009
|
+
super().__init__(option_strings=option_strings, dest=dest, default=default, nargs=0,
|
|
1010
|
+
help=help)
|
|
1011
|
+
|
|
1012
|
+
def __call__(self, parser, namespace, values, option_string=None):
|
|
1013
|
+
print(version_line())
|
|
1014
|
+
parser.exit(0)
|
|
1015
|
+
|
|
1016
|
+
|
|
972
1017
|
def build_parser() -> argparse.ArgumentParser:
|
|
973
1018
|
p = argparse.ArgumentParser(prog="codemap", description="Static code-graph builder.")
|
|
1019
|
+
p.add_argument("--version", action=_VersionAction)
|
|
974
1020
|
sub = p.add_subparsers(dest="command", required=True)
|
|
975
1021
|
|
|
976
1022
|
b = sub.add_parser("build", help="Build the canonical graph from a package path.")
|
|
@@ -1173,6 +1219,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1173
1219
|
help="Dir with codemap.toml holding [architecture] (default: cwd).")
|
|
1174
1220
|
ck.add_argument("--require-contract", action="store_true",
|
|
1175
1221
|
help="Fail if no [architecture] contract is present (default: no-op success).")
|
|
1222
|
+
ck.add_argument("--allow-stale", action="store_true",
|
|
1223
|
+
help="With --graph: do not fail when the graph is behind its source tree "
|
|
1224
|
+
"(R1-C70); the 'behind' line is still printed.")
|
|
1176
1225
|
ck.set_defaults(func=_cmd_check)
|
|
1177
1226
|
|
|
1178
1227
|
return p
|
|
@@ -50,7 +50,7 @@ from codemap.extract.behavior import (
|
|
|
50
50
|
_node_id,
|
|
51
51
|
_own_nodes,
|
|
52
52
|
)
|
|
53
|
-
from codemap.extract.gsource import module_file, module_imports
|
|
53
|
+
from codemap.extract.gsource import flat_inference_applies, module_file, module_imports
|
|
54
54
|
from codemap.model import Edge
|
|
55
55
|
|
|
56
56
|
|
|
@@ -66,7 +66,8 @@ def add_attrflow(graph, griffe_root, target_pkg: str, *, deep: bool = False,
|
|
|
66
66
|
(R1-C9), mirroring :func:`add_behavior`.
|
|
67
67
|
"""
|
|
68
68
|
modules = _index_modules(griffe_root)
|
|
69
|
-
|
|
69
|
+
# R1-C21 sibling lookup, gated like behavior's (R1-C72).
|
|
70
|
+
known_modules = set(modules) if flat_inference_applies(graph, target_pkg) else frozenset()
|
|
70
71
|
project = _jedi_project(search_path) if deep else None
|
|
71
72
|
pkg = target_pkg + "."
|
|
72
73
|
# (func_id, attr_id, access, resolution) — dedup collapses repeated sites.
|
|
@@ -27,8 +27,8 @@ import ast
|
|
|
27
27
|
import math
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
|
|
30
|
-
from codemap.extract.gsource import (NESTED_IMPORT_HINT,
|
|
31
|
-
qualify_target)
|
|
30
|
+
from codemap.extract.gsource import (NESTED_IMPORT_HINT, flat_inference_applies, module_file,
|
|
31
|
+
module_imports, qualify_target)
|
|
32
32
|
from codemap.model import Edge
|
|
33
33
|
|
|
34
34
|
_BUILTINS = set(vars(__import__("builtins")))
|
|
@@ -51,7 +51,9 @@ def add_behavior(graph, griffe_root, target_pkg: str, *, deep: bool = False,
|
|
|
51
51
|
modules left out. When ``None`` (default) every module is processed.
|
|
52
52
|
"""
|
|
53
53
|
modules = _index_modules(griffe_root)
|
|
54
|
-
|
|
54
|
+
# R1-C21 flat-layout sibling lookup — empty, so it never fires, in a core that imports
|
|
55
|
+
# itself by name (R1-C72): there `import warnings` is the standard library.
|
|
56
|
+
known_modules = set(modules) if flat_inference_applies(graph, target_pkg) else frozenset()
|
|
55
57
|
project = _jedi_project(search_path) if deep else None
|
|
56
58
|
# R1-C30-f1: a call to a re-exported name points at a name that is not a definition.
|
|
57
59
|
# The structural pass already recorded where each re-export really leads.
|
|
@@ -81,11 +83,18 @@ def add_behavior(graph, griffe_root, target_pkg: str, *, deep: bool = False,
|
|
|
81
83
|
# R1-C46: the surviving definition says which earlier bodies it replaced. Stamped
|
|
82
84
|
# here because this pass holds the AST; the walkers below skip those bodies.
|
|
83
85
|
_stamp_shadows(graph, tree, modpath)
|
|
86
|
+
walked_here: set[str] = set() # R1-C74: nodes that already took a body in this module
|
|
87
|
+
shadow_skip = _shadow_map(tree)[0]
|
|
84
88
|
for fnode, class_stack, scope in _named_functions_scoped(tree):
|
|
85
89
|
node_id = _node_id(modpath, class_stack, fnode.name)
|
|
86
90
|
local = local_imports.get(id(fnode))
|
|
87
91
|
fn_imports = {**imports, **local} if local else imports
|
|
88
|
-
|
|
92
|
+
# R1-C74: a def nested in a function is never a definition node, whatever its
|
|
93
|
+
# name. `_node_id` sees only the class chain, so `def getfixturedefs` inside a method
|
|
94
|
+
# of `FixtureManager` used to *become* the method `FixtureManager.getfixturedefs` —
|
|
95
|
+
# its calls and edges attributed to a namesake (found by the conservation check).
|
|
96
|
+
nested_in_function = len(scope) > len(class_stack)
|
|
97
|
+
if node_id not in graph.nodes or nested_in_function:
|
|
89
98
|
# R1-C22 D3: a closure / dynamically-built class body is not a definition
|
|
90
99
|
# node, but the calls inside it are real. Attribute them to the innermost
|
|
91
100
|
# definition that *does* exist instead of discarding them.
|
|
@@ -101,7 +110,9 @@ def add_behavior(graph, griffe_root, target_pkg: str, *, deep: bool = False,
|
|
|
101
110
|
return _resolve(call, modpath, _cp, _im, _mm, _me, reexports, nodes)
|
|
102
111
|
resolve = (_deep_then_fast(graph, script, target_pkg, fast, modpath, known_modules)
|
|
103
112
|
if script else fast)
|
|
104
|
-
_process_function(graph, node_id, fnode, resolve
|
|
113
|
+
_process_function(graph, node_id, fnode, resolve,
|
|
114
|
+
accumulate=node_id in walked_here)
|
|
115
|
+
walked_here.add(node_id)
|
|
105
116
|
# R1-C22 D1: functions/classes named as *values* here (dispatch tables,
|
|
106
117
|
# `default=` callbacks) — a use the call layer cannot see.
|
|
107
118
|
_emit_name_references(graph, node_id, fnode, modpath, fn_imports, modmembers,
|
|
@@ -113,7 +124,11 @@ def add_behavior(graph, griffe_root, target_pkg: str, *, deep: bool = False,
|
|
|
113
124
|
mod_resolve = (_deep_then_fast(graph, script, target_pkg, mod_fast, modpath, known_modules)
|
|
114
125
|
if script else mod_fast)
|
|
115
126
|
_process_module_level(graph, modpath, tree, mod_resolve, imports, modmembers,
|
|
116
|
-
reexports)
|
|
127
|
+
reexports, shadow_skip=shadow_skip)
|
|
128
|
+
# R1-C74: class bodies run at import time and were walked by nobody.
|
|
129
|
+
_process_class_bodies(graph, modpath, tree, mod_resolve, imports, modmembers,
|
|
130
|
+
script, target_pkg, nested, known_modules, reexports,
|
|
131
|
+
skip=shadow_skip)
|
|
117
132
|
_emit_nested_calls(graph, nested)
|
|
118
133
|
|
|
119
134
|
|
|
@@ -523,30 +538,49 @@ def _deep_then_fast(graph, script, target_pkg, fast, modpath, known_modules):
|
|
|
523
538
|
return resolve
|
|
524
539
|
|
|
525
540
|
|
|
526
|
-
def
|
|
527
|
-
|
|
541
|
+
def _new_counts() -> dict:
|
|
542
|
+
return {"out": 0, "resolved": 0, "external": 0, "unresolved": 0, "dynamic": 0}
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _tally(counts: dict, graph, target, resolution) -> bool:
|
|
546
|
+
"""Count one call site by how it resolved; True when it becomes an internal edge.
|
|
547
|
+
|
|
548
|
+
One rule for every scope (R1-C74): a function body, a nested function, module-level
|
|
549
|
+
code and a class body are classified the same way, so their counters can be summed
|
|
550
|
+
and checked against the source.
|
|
551
|
+
"""
|
|
552
|
+
counts["out"] += 1
|
|
553
|
+
if resolution in _EDGE_RESOLUTIONS and target and target in graph.nodes:
|
|
554
|
+
counts["resolved"] += 1
|
|
555
|
+
return True
|
|
556
|
+
if resolution == "external":
|
|
557
|
+
counts["external"] += 1
|
|
558
|
+
elif resolution == "dynamic":
|
|
559
|
+
counts["dynamic"] += 1
|
|
560
|
+
else:
|
|
561
|
+
counts["unresolved"] += 1
|
|
562
|
+
return False
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _process_function(graph, node_id, fnode, resolve, accumulate: bool = False) -> None:
|
|
566
|
+
"""``accumulate``: this node already took a body in this pass (R1-C74). A name defined in
|
|
567
|
+
both branches of an ``if``/``try``, or a legitimate rebinding (a property's setter), keeps
|
|
568
|
+
*both* bodies walked (R1-C46) — their counts add up instead of the last overwriting."""
|
|
569
|
+
counts = _new_counts()
|
|
528
570
|
# F7: edges are deduped caller->callee, so aggregate the per-call-site argument
|
|
529
571
|
# contract before emitting — a refactorer needs "how is it called", and the
|
|
530
572
|
# collapse itself (2 sites -> 1 edge) must stay visible via `callsites`.
|
|
531
573
|
by_target: dict[str, dict] = {}
|
|
532
574
|
for call in _own_calls(fnode):
|
|
533
|
-
counts["out"] += 1
|
|
534
575
|
target, resolution = resolve(call)
|
|
535
576
|
# Soundness (R1-C13-f1/f2): an internal call edge must point at a real
|
|
536
577
|
# graph node. A resolver can name a non-node — a local variable jedi typed
|
|
537
578
|
# to its own scope-path, or a nested/closure function that is not a
|
|
538
579
|
# definition node — so downgrade any such target to unresolved rather than
|
|
539
580
|
# emit an edge to nothing (which would poison callers/callees/impact).
|
|
540
|
-
if
|
|
541
|
-
counts["resolved"] += 1
|
|
581
|
+
if _tally(counts, graph, target, resolution):
|
|
542
582
|
agg = by_target.setdefault(target, {"resolution": resolution, "shapes": []})
|
|
543
583
|
agg["shapes"].append(_arg_shape(call))
|
|
544
|
-
elif resolution == "external":
|
|
545
|
-
counts["external"] += 1
|
|
546
|
-
elif resolution == "dynamic":
|
|
547
|
-
counts["dynamic"] += 1
|
|
548
|
-
else:
|
|
549
|
-
counts["unresolved"] += 1
|
|
550
584
|
|
|
551
585
|
for target in sorted(by_target):
|
|
552
586
|
agg = by_target[target]
|
|
@@ -554,7 +588,11 @@ def _process_function(graph, node_id, fnode, resolve) -> None:
|
|
|
554
588
|
graph.add_edge(Edge("calls", node_id, target, extras=extras))
|
|
555
589
|
|
|
556
590
|
node = graph.nodes[node_id]
|
|
557
|
-
|
|
591
|
+
if accumulate and "calls" in node.extras:
|
|
592
|
+
for k, v in counts.items():
|
|
593
|
+
node.extras["calls"][k] = node.extras["calls"].get(k, 0) + v
|
|
594
|
+
else:
|
|
595
|
+
node.extras["calls"] = counts
|
|
558
596
|
node.extras["control"] = _control(fnode)
|
|
559
597
|
node.extras["complexity"] = _complexity(fnode)
|
|
560
598
|
|
|
@@ -588,16 +626,33 @@ def _arg_contract(shapes: list[tuple]) -> dict:
|
|
|
588
626
|
return contract
|
|
589
627
|
|
|
590
628
|
|
|
629
|
+
def _is_internal(target: str, modpath: str) -> bool:
|
|
630
|
+
"""R1-C71/D3: the package root is inside the package. ``startswith(pkg + ".")`` alone
|
|
631
|
+
filed ``import pkg; pkg.f()`` — and the bound name of ``import pkg.a.b`` — as external."""
|
|
632
|
+
root = modpath.split(".")[0]
|
|
633
|
+
return target == root or target.startswith(root + ".")
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _attr_chain(node) -> tuple[str, list[str]] | None:
|
|
637
|
+
"""``a.b.c`` → ``("a", ["b", "c"])`` when the chain bottoms out in a plain name."""
|
|
638
|
+
parts = []
|
|
639
|
+
while isinstance(node, ast.Attribute):
|
|
640
|
+
parts.append(node.attr)
|
|
641
|
+
node = node.value
|
|
642
|
+
if not isinstance(node, ast.Name):
|
|
643
|
+
return None
|
|
644
|
+
return node.id, parts[::-1]
|
|
645
|
+
|
|
646
|
+
|
|
591
647
|
def _resolve(call, modpath, class_prefix, imports, modmembers, members, reexports=None,
|
|
592
648
|
nodes=frozenset()):
|
|
593
649
|
"""Return (target_id, resolution). Only module/self/imported become edges."""
|
|
594
|
-
pkg = modpath.split(".")[0] + "."
|
|
595
650
|
f = call.func
|
|
596
651
|
if isinstance(f, ast.Name):
|
|
597
652
|
name = f.id
|
|
598
653
|
if name in imports:
|
|
599
654
|
tgt = imports[name]
|
|
600
|
-
if tgt
|
|
655
|
+
if _is_internal(tgt, modpath):
|
|
601
656
|
return _follow_reexport(tgt, reexports or {}, nodes), "imported"
|
|
602
657
|
return tgt, "external"
|
|
603
658
|
if name in modmembers:
|
|
@@ -613,13 +668,27 @@ def _resolve(call, modpath, class_prefix, imports, modmembers, members, reexport
|
|
|
613
668
|
# method call on self — resolve to the class that actually defines
|
|
614
669
|
# the member (own or a base), never a phantom same-class id.
|
|
615
670
|
return f"{members[attr]}.{attr}", "self"
|
|
616
|
-
if recv.id in imports:
|
|
617
|
-
tgt = imports[recv.id]
|
|
618
|
-
if tgt.startswith(pkg):
|
|
619
|
-
return _follow_reexport(f"{tgt}.{attr}", reexports or {}, nodes), "imported"
|
|
620
|
-
return f"{tgt}.{attr}", "external"
|
|
621
|
-
if recv.id in modmembers:
|
|
671
|
+
if recv.id in modmembers and recv.id not in imports:
|
|
622
672
|
return f"{modpath}.{recv.id}.{attr}", "module"
|
|
673
|
+
# R1-C71 (design ``attribute_chain_calls.md``): a callee rooted at an imported name,
|
|
674
|
+
# at any depth. Internal: follow it hop by hop, each hop through a re-export when the
|
|
675
|
+
# name is no node (D1) — the depth-1 case is the one-hop instance and answers as it
|
|
676
|
+
# always did; the soundness guard downstream still refuses a target that is no node,
|
|
677
|
+
# so `b.registry.get()` stays unresolved. External: external at any depth (D2) —
|
|
678
|
+
# `np.random.rand()` is no more unknown than `np.zeros()`.
|
|
679
|
+
chain = _attr_chain(f)
|
|
680
|
+
if chain is not None and chain[0] in imports:
|
|
681
|
+
head, parts = chain
|
|
682
|
+
tgt = imports[head]
|
|
683
|
+
if not _is_internal(tgt, modpath):
|
|
684
|
+
return f"{tgt}.{'.'.join(parts)}", "external"
|
|
685
|
+
cur = tgt
|
|
686
|
+
for part in parts:
|
|
687
|
+
cur = _follow_reexport(f"{cur}.{part}", reexports or {}, nodes)
|
|
688
|
+
if len(parts) == 1 or cur in nodes:
|
|
689
|
+
return cur, "imported"
|
|
690
|
+
# a deeper chain that lands on no definition (`b.registry.get("k")`) keeps the
|
|
691
|
+
# answer it had before R1-C71: string-keyed dispatch, else unresolved
|
|
623
692
|
# X.method('literal', ...) — dynamic string-keyed dispatch (registry/dict)
|
|
624
693
|
if call.args and isinstance(call.args[0], ast.Constant) \
|
|
625
694
|
and isinstance(call.args[0].value, str) and attr in ("create", "get", "register"):
|
|
@@ -868,10 +937,9 @@ def _module_level_nodes(tree):
|
|
|
868
937
|
def _resolve_name(name, modpath, imports, modmembers, reexports=None, nodes=frozenset()):
|
|
869
938
|
"""(target_id, resolution) for a bare name used as a value — the `_resolve` rules for
|
|
870
939
|
``ast.Name`` minus the call. Only module/imported become edges."""
|
|
871
|
-
pkg = modpath.split(".")[0] + "."
|
|
872
940
|
if name in imports:
|
|
873
941
|
tgt = imports[name]
|
|
874
|
-
if tgt
|
|
942
|
+
if _is_internal(tgt, modpath):
|
|
875
943
|
return _follow_reexport(tgt, reexports or {}, nodes), "imported"
|
|
876
944
|
return tgt, "external"
|
|
877
945
|
if name in modmembers:
|
|
@@ -909,14 +977,30 @@ def _emit_name_references(graph, src_id, scope, modpath, imports, modmembers,
|
|
|
909
977
|
|
|
910
978
|
|
|
911
979
|
def _process_module_level(graph, modpath, tree, resolve, imports, modmembers,
|
|
912
|
-
reexports=None) -> None:
|
|
913
|
-
"""Calls and value-references in module-level code, sourced from the module (D1/D2).
|
|
980
|
+
reexports=None, shadow_skip=None) -> None:
|
|
981
|
+
"""Calls and value-references in module-level code, sourced from the module (D1/D2).
|
|
982
|
+
|
|
983
|
+
R1-C74: the module node also carries ``extras.calls`` — its sites used to make edges
|
|
984
|
+
and appear in no resolution figure.
|
|
985
|
+
"""
|
|
914
986
|
by_target: dict[str, dict] = {}
|
|
987
|
+
counts = _new_counts()
|
|
915
988
|
for call in (n for n in _module_level_nodes(tree) if isinstance(n, ast.Call)):
|
|
916
989
|
target, resolution = resolve(call)
|
|
917
|
-
if
|
|
990
|
+
if _tally(counts, graph, target, resolution):
|
|
918
991
|
agg = by_target.setdefault(target, {"resolution": resolution, "shapes": []})
|
|
919
992
|
agg["shapes"].append(_arg_shape(call))
|
|
993
|
+
if modpath in graph.nodes:
|
|
994
|
+
graph.nodes[modpath].extras["calls"] = counts
|
|
995
|
+
# R1-C74 D1: a replaced body is not walked (R1-C46) — said, not absorbed.
|
|
996
|
+
skipped = _shadow_map(tree)[0] if shadow_skip is None else shadow_skip
|
|
997
|
+
if skipped:
|
|
998
|
+
seen: set[int] = set()
|
|
999
|
+
for node in ast.walk(tree):
|
|
1000
|
+
if id(node) in skipped:
|
|
1001
|
+
seen.update(id(c) for c in ast.walk(node) if isinstance(c, ast.Call))
|
|
1002
|
+
if seen:
|
|
1003
|
+
graph.nodes[modpath].extras["calls_shadowed"] = len(seen)
|
|
920
1004
|
for target in sorted(by_target):
|
|
921
1005
|
agg = by_target[target]
|
|
922
1006
|
graph.add_edge(Edge("calls", modpath, target,
|
|
@@ -926,6 +1010,57 @@ def _process_module_level(graph, modpath, tree, resolve, imports, modmembers,
|
|
|
926
1010
|
reexports=reexports)
|
|
927
1011
|
|
|
928
1012
|
|
|
1013
|
+
def _process_class_bodies(graph, modpath, tree, resolve, imports, modmembers, script,
|
|
1014
|
+
target_pkg, nested, known_modules=frozenset(), reexports=None,
|
|
1015
|
+
skip=None) -> None:
|
|
1016
|
+
"""Calls in class bodies outside their methods — bases, keywords, decorators, statements.
|
|
1017
|
+
|
|
1018
|
+
R1-C74 (design ``call_site_conservation.md`` D1). A class body runs at import time, so a
|
|
1019
|
+
class that is a graph node is the source of its own ``calls`` edges and carries
|
|
1020
|
+
``extras.calls``, resolved like module-level code. A class nested in a function runs
|
|
1021
|
+
when the function does: its sites go to the owner as nested, like a closure's. Shadowed
|
|
1022
|
+
definitions are skipped, as everywhere (R1-C46).
|
|
1023
|
+
"""
|
|
1024
|
+
skip = _shadow_map(tree)[0] if skip is None else skip
|
|
1025
|
+
|
|
1026
|
+
def visit(node, scope, in_function):
|
|
1027
|
+
for child in ast.iter_child_nodes(node):
|
|
1028
|
+
if id(child) in skip:
|
|
1029
|
+
continue
|
|
1030
|
+
if isinstance(child, ast.ClassDef):
|
|
1031
|
+
cscope = scope + [child.name]
|
|
1032
|
+
calls = [n for n in _own_nodes(child) if isinstance(n, ast.Call)]
|
|
1033
|
+
cid = ".".join([modpath, *cscope])
|
|
1034
|
+
if not in_function and cid in graph.nodes:
|
|
1035
|
+
counts = _new_counts()
|
|
1036
|
+
by_target: dict[str, dict] = {}
|
|
1037
|
+
for call in calls:
|
|
1038
|
+
target, resolution = resolve(call)
|
|
1039
|
+
if _tally(counts, graph, target, resolution) and target != cid:
|
|
1040
|
+
agg = by_target.setdefault(target, {"resolution": resolution,
|
|
1041
|
+
"shapes": []})
|
|
1042
|
+
agg["shapes"].append(_arg_shape(call))
|
|
1043
|
+
graph.nodes[cid].extras["calls"] = counts
|
|
1044
|
+
for target in sorted(by_target):
|
|
1045
|
+
agg = by_target[target]
|
|
1046
|
+
graph.add_edge(Edge("calls", cid, target,
|
|
1047
|
+
extras={"resolution": agg["resolution"],
|
|
1048
|
+
**_arg_contract(agg["shapes"])}))
|
|
1049
|
+
elif calls:
|
|
1050
|
+
owner = _nearest_owner(graph, modpath, scope)
|
|
1051
|
+
if owner is not None:
|
|
1052
|
+
_collect_nested_calls(graph, owner, child, modpath, imports, modmembers,
|
|
1053
|
+
script, target_pkg, nested, known_modules,
|
|
1054
|
+
reexports, calls=calls)
|
|
1055
|
+
visit(child, cscope, in_function)
|
|
1056
|
+
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1057
|
+
visit(child, scope + [child.name], True)
|
|
1058
|
+
else:
|
|
1059
|
+
visit(child, scope, in_function)
|
|
1060
|
+
|
|
1061
|
+
visit(tree, [], False)
|
|
1062
|
+
|
|
1063
|
+
|
|
929
1064
|
def _named_functions_scoped(tree):
|
|
930
1065
|
"""Like :func:`_named_functions`, plus the **full** enclosing scope names.
|
|
931
1066
|
|
|
@@ -969,17 +1104,22 @@ def _nearest_owner(graph, modpath, scope) -> str | None:
|
|
|
969
1104
|
|
|
970
1105
|
def _collect_nested_calls(graph, owner, fnode, modpath, imports, modmembers,
|
|
971
1106
|
script, target_pkg, out, known_modules=frozenset(),
|
|
972
|
-
reexports=None) -> None:
|
|
973
|
-
"""Resolve a non-node function's own calls and stage them under ``owner`` (D3).
|
|
1107
|
+
reexports=None, calls=None) -> None:
|
|
1108
|
+
"""Resolve a non-node function's own calls and stage them under ``owner`` (D3).
|
|
1109
|
+
|
|
1110
|
+
``fnode`` may also be a class nested in a function (R1-C74): its body runs inside the
|
|
1111
|
+
owner, like a closure's. Every site is counted into the owner's ``extras.calls_nested``.
|
|
1112
|
+
"""
|
|
974
1113
|
def fast(call):
|
|
975
1114
|
return _resolve(call, modpath, "", imports, modmembers, {}, reexports, graph.nodes)
|
|
976
1115
|
# R1-C26: the same union the named-function path uses — a closure's calls must not be
|
|
977
1116
|
# resolved by a weaker rule than its neighbours'.
|
|
978
1117
|
resolve = (_deep_then_fast(graph, script, target_pkg, fast, modpath, known_modules)
|
|
979
1118
|
if script is not None else fast)
|
|
980
|
-
|
|
1119
|
+
counts = graph.nodes[owner].extras.setdefault("calls_nested", _new_counts())
|
|
1120
|
+
for call in (calls if calls is not None else _own_calls(fnode)):
|
|
981
1121
|
target, resolution = resolve(call)
|
|
982
|
-
if
|
|
1122
|
+
if not _tally(counts, graph, target, resolution):
|
|
983
1123
|
continue
|
|
984
1124
|
if target == owner:
|
|
985
1125
|
continue # a closure calling its own enclosing function — recursion, not a dep
|