codmap 0.0.22__tar.gz → 0.0.23__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.23}/PKG-INFO +18 -12
- {codmap-0.0.22 → codmap-0.0.23}/README.md +17 -11
- {codmap-0.0.22 → codmap-0.0.23}/codemap/arch.py +76 -1
- {codmap-0.0.22 → codmap-0.0.23}/codemap/cli.py +20 -4
- {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/attrflow.py +3 -2
- {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/behavior.py +45 -13
- {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/griffe_extractor.py +58 -12
- {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/gsource.py +23 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/roots.py +2 -4
- {codmap-0.0.22 → codmap-0.0.23}/codemap/model.py +7 -1
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/check.py +42 -3
- codmap-0.0.23/codemap/serve/currency.py +67 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/session.py +8 -1
- {codmap-0.0.22 → codmap-0.0.23/codmap.egg-info}/PKG-INFO +18 -12
- {codmap-0.0.22 → codmap-0.0.23}/codmap.egg-info/SOURCES.txt +6 -0
- {codmap-0.0.22 → codmap-0.0.23}/pyproject.toml +1 -1
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_pack_honesty.py +3 -2
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c3_arch_contract.py +17 -2
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c63_answer_format.py +12 -1
- codmap-0.0.23/tests/test_r1c68_dotted_imports.py +123 -0
- codmap-0.0.23/tests/test_r1c69_contract_validation.py +106 -0
- codmap-0.0.23/tests/test_r1c70_check_graph_currency.py +130 -0
- codmap-0.0.23/tests/test_r1c71_attribute_chain_calls.py +82 -0
- codmap-0.0.23/tests/test_r1c72_flat_inference_in_packages.py +99 -0
- {codmap-0.0.22 → codmap-0.0.23}/LICENSE +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/__init__.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/apidiff.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/diagnostics.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/probe.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/union.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/freshness.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/incremental.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/integrations/base.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/provenance.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/query.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/scope.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/architecture.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/audit.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/impact.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/limits.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/livingdocs.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/mcp_server.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/pack.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/rag.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/review.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/scip.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/server.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/vault.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/store.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/tomlio.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codemap/watch.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/setup.cfg +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_epistemic.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_integrations.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_m15_review.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_m16_architecture.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_m1_query.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_m2_views.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_m9_family.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c29_lazy_imports.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c30f2_check_scope.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c38_tool_drift.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c39_edge_resolution.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c40_flow_reach.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c41_scope_membership.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c42_deep_tier_stability.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c43_incremental_splice.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c44_absent_answers.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c45_repeat_union.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c46_shadowed_definitions.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c47_incremental_resample.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c48_type_checking_imports.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c49_type_only_cycles.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c50_flow_entry_points.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c53_narrowing_declared.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c54_deterministic_rendering.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c55_override_grade.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c56_stub_files.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c57_facade_package.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c58_cycle_tangles.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c59_completeness_drop.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c60_nested_definitions.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c62_phantom_rules.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c64_dependency_probe.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c66_pack_direction.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_scip_export.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/tests/test_scope.py +0 -0
- {codmap-0.0.22 → codmap-0.0.23}/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.23
|
|
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, **1115 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, **1115 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:
|
|
@@ -1173,6 +1186,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1173
1186
|
help="Dir with codemap.toml holding [architecture] (default: cwd).")
|
|
1174
1187
|
ck.add_argument("--require-contract", action="store_true",
|
|
1175
1188
|
help="Fail if no [architecture] contract is present (default: no-op success).")
|
|
1189
|
+
ck.add_argument("--allow-stale", action="store_true",
|
|
1190
|
+
help="With --graph: do not fail when the graph is behind its source tree "
|
|
1191
|
+
"(R1-C70); the 'behind' line is still printed.")
|
|
1176
1192
|
ck.set_defaults(func=_cmd_check)
|
|
1177
1193
|
|
|
1178
1194
|
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.
|
|
@@ -588,16 +590,33 @@ def _arg_contract(shapes: list[tuple]) -> dict:
|
|
|
588
590
|
return contract
|
|
589
591
|
|
|
590
592
|
|
|
593
|
+
def _is_internal(target: str, modpath: str) -> bool:
|
|
594
|
+
"""R1-C71/D3: the package root is inside the package. ``startswith(pkg + ".")`` alone
|
|
595
|
+
filed ``import pkg; pkg.f()`` — and the bound name of ``import pkg.a.b`` — as external."""
|
|
596
|
+
root = modpath.split(".")[0]
|
|
597
|
+
return target == root or target.startswith(root + ".")
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _attr_chain(node) -> tuple[str, list[str]] | None:
|
|
601
|
+
"""``a.b.c`` → ``("a", ["b", "c"])`` when the chain bottoms out in a plain name."""
|
|
602
|
+
parts = []
|
|
603
|
+
while isinstance(node, ast.Attribute):
|
|
604
|
+
parts.append(node.attr)
|
|
605
|
+
node = node.value
|
|
606
|
+
if not isinstance(node, ast.Name):
|
|
607
|
+
return None
|
|
608
|
+
return node.id, parts[::-1]
|
|
609
|
+
|
|
610
|
+
|
|
591
611
|
def _resolve(call, modpath, class_prefix, imports, modmembers, members, reexports=None,
|
|
592
612
|
nodes=frozenset()):
|
|
593
613
|
"""Return (target_id, resolution). Only module/self/imported become edges."""
|
|
594
|
-
pkg = modpath.split(".")[0] + "."
|
|
595
614
|
f = call.func
|
|
596
615
|
if isinstance(f, ast.Name):
|
|
597
616
|
name = f.id
|
|
598
617
|
if name in imports:
|
|
599
618
|
tgt = imports[name]
|
|
600
|
-
if tgt
|
|
619
|
+
if _is_internal(tgt, modpath):
|
|
601
620
|
return _follow_reexport(tgt, reexports or {}, nodes), "imported"
|
|
602
621
|
return tgt, "external"
|
|
603
622
|
if name in modmembers:
|
|
@@ -613,13 +632,27 @@ def _resolve(call, modpath, class_prefix, imports, modmembers, members, reexport
|
|
|
613
632
|
# method call on self — resolve to the class that actually defines
|
|
614
633
|
# the member (own or a base), never a phantom same-class id.
|
|
615
634
|
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:
|
|
635
|
+
if recv.id in modmembers and recv.id not in imports:
|
|
622
636
|
return f"{modpath}.{recv.id}.{attr}", "module"
|
|
637
|
+
# R1-C71 (design ``attribute_chain_calls.md``): a callee rooted at an imported name,
|
|
638
|
+
# at any depth. Internal: follow it hop by hop, each hop through a re-export when the
|
|
639
|
+
# name is no node (D1) — the depth-1 case is the one-hop instance and answers as it
|
|
640
|
+
# always did; the soundness guard downstream still refuses a target that is no node,
|
|
641
|
+
# so `b.registry.get()` stays unresolved. External: external at any depth (D2) —
|
|
642
|
+
# `np.random.rand()` is no more unknown than `np.zeros()`.
|
|
643
|
+
chain = _attr_chain(f)
|
|
644
|
+
if chain is not None and chain[0] in imports:
|
|
645
|
+
head, parts = chain
|
|
646
|
+
tgt = imports[head]
|
|
647
|
+
if not _is_internal(tgt, modpath):
|
|
648
|
+
return f"{tgt}.{'.'.join(parts)}", "external"
|
|
649
|
+
cur = tgt
|
|
650
|
+
for part in parts:
|
|
651
|
+
cur = _follow_reexport(f"{cur}.{part}", reexports or {}, nodes)
|
|
652
|
+
if len(parts) == 1 or cur in nodes:
|
|
653
|
+
return cur, "imported"
|
|
654
|
+
# a deeper chain that lands on no definition (`b.registry.get("k")`) keeps the
|
|
655
|
+
# answer it had before R1-C71: string-keyed dispatch, else unresolved
|
|
623
656
|
# X.method('literal', ...) — dynamic string-keyed dispatch (registry/dict)
|
|
624
657
|
if call.args and isinstance(call.args[0], ast.Constant) \
|
|
625
658
|
and isinstance(call.args[0].value, str) and attr in ("create", "get", "register"):
|
|
@@ -868,10 +901,9 @@ def _module_level_nodes(tree):
|
|
|
868
901
|
def _resolve_name(name, modpath, imports, modmembers, reexports=None, nodes=frozenset()):
|
|
869
902
|
"""(target_id, resolution) for a bare name used as a value — the `_resolve` rules for
|
|
870
903
|
``ast.Name`` minus the call. Only module/imported become edges."""
|
|
871
|
-
pkg = modpath.split(".")[0] + "."
|
|
872
904
|
if name in imports:
|
|
873
905
|
tgt = imports[name]
|
|
874
|
-
if tgt
|
|
906
|
+
if _is_internal(tgt, modpath):
|
|
875
907
|
return _follow_reexport(tgt, reexports or {}, nodes), "imported"
|
|
876
908
|
return tgt, "external"
|
|
877
909
|
if name in modmembers:
|
|
@@ -20,6 +20,7 @@ from __future__ import annotations
|
|
|
20
20
|
import ast
|
|
21
21
|
import multiprocessing
|
|
22
22
|
import os
|
|
23
|
+
import sys
|
|
23
24
|
from dataclasses import dataclass, field
|
|
24
25
|
from pathlib import Path
|
|
25
26
|
|
|
@@ -29,7 +30,8 @@ from codemap.extract.attrflow import add_attrflow
|
|
|
29
30
|
from codemap.extract.behavior import add_behavior
|
|
30
31
|
from codemap.extract.dataflow import add_dataflow
|
|
31
32
|
from codemap.extract.dispatch import add_dispatch, add_family_links
|
|
32
|
-
from codemap.extract.gsource import NESTED_IMPORT_HINT, module_file,
|
|
33
|
+
from codemap.extract.gsource import (DOTTED_IMPORT_HINT, NESTED_IMPORT_HINT, module_file,
|
|
34
|
+
module_identity)
|
|
33
35
|
from codemap.extract.union import merge_samples
|
|
34
36
|
from codemap.provenance import build_provenance
|
|
35
37
|
from codemap.model import Edge, Graph, Node
|
|
@@ -322,9 +324,10 @@ def _input_report(graph, pkg_dir: Path, root: Path, walk) -> dict:
|
|
|
322
324
|
return report
|
|
323
325
|
|
|
324
326
|
|
|
325
|
-
def _source_import_targets(module) -> tuple[list[tuple[str, str]], set[str]]:
|
|
326
|
-
"""``([(target, scope)], eager targets)`` — the imports griffe's
|
|
327
|
-
not carry,
|
|
327
|
+
def _source_import_targets(module) -> tuple[list[tuple[str, str]], set[str], set[str]]:
|
|
328
|
+
"""``([(target, scope)], eager targets, griffe heads to drop)`` — the imports griffe's
|
|
329
|
+
module-level map does not carry, the targets it carries correctly, and the entries it
|
|
330
|
+
carries wrongly.
|
|
328
331
|
|
|
329
332
|
Two families, one traversal, one parse — griffe records neither, and both used to be
|
|
330
333
|
invisible to the import graph:
|
|
@@ -345,6 +348,13 @@ def _source_import_targets(module) -> tuple[list[tuple[str, str]], set[str]]:
|
|
|
345
348
|
of a condition that is always false at run time — so the second value returned is
|
|
346
349
|
the set of targets imported at plain module level: the caller demotes griffe's entry
|
|
347
350
|
for a target that appears only under ``TYPE_CHECKING``.
|
|
351
|
+
- **dotted `import a.b.c` at module level** (R1-C68, design ``dotted_imports.md``).
|
|
352
|
+
griffe records the *bound name*, ``{"a": "a"}``, so the edge went to the root package
|
|
353
|
+
and a contract banning ``core → analysis`` passed over ``import pkg.analysis.b``. The
|
|
354
|
+
full name is recorded here, scope ``"module"``; the third value returned is the heads
|
|
355
|
+
griffe's entry should be dropped for — bound by dotted imports only, never by a plain
|
|
356
|
+
``import a`` (D2). Under ``TYPE_CHECKING`` the full name already came out of this
|
|
357
|
+
pass; the head griffe filed beside it was left behind as an *eager* edge to ``a``.
|
|
348
358
|
|
|
349
359
|
Cost discipline, and it is not theoretical: the first version of this walked every
|
|
350
360
|
function's subtree separately, which is quadratic in nesting and cost the dogfood
|
|
@@ -356,13 +366,13 @@ def _source_import_targets(module) -> tuple[list[tuple[str, str]], set[str]]:
|
|
|
356
366
|
try:
|
|
357
367
|
src = module.source
|
|
358
368
|
except Exception: # no source (namespace dir, synthetic)
|
|
359
|
-
return [], set()
|
|
360
|
-
if not NESTED_IMPORT_HINT.search(src):
|
|
361
|
-
return [], set()
|
|
369
|
+
return [], set(), set()
|
|
370
|
+
if not (NESTED_IMPORT_HINT.search(src) or DOTTED_IMPORT_HINT.search(src)):
|
|
371
|
+
return [], set(), set()
|
|
362
372
|
try:
|
|
363
373
|
tree = ast.parse(src)
|
|
364
374
|
except (SyntaxError, ValueError):
|
|
365
|
-
return [], set()
|
|
375
|
+
return [], set(), set() # unreadable: D2's report owns this file
|
|
366
376
|
modpath = module.canonical_path
|
|
367
377
|
f = module_file(module)
|
|
368
378
|
is_pkg = f is not None and f.name == "__init__.py"
|
|
@@ -376,6 +386,19 @@ def _source_import_targets(module) -> tuple[list[tuple[str, str]], set[str]]:
|
|
|
376
386
|
|
|
377
387
|
out: list[tuple[str, str]] = []
|
|
378
388
|
eager: set[str] = set() # targets imported at plain module level (griffe has them)
|
|
389
|
+
# R1-C68/D2: heads griffe records for module-level `import a.b.c` (it files the bound
|
|
390
|
+
# name), against heads a plain `import a` really binds. Only the difference is dropped.
|
|
391
|
+
dotted_heads: set[str] = set()
|
|
392
|
+
plain_heads: set[str] = set()
|
|
393
|
+
|
|
394
|
+
def note_heads(names) -> None:
|
|
395
|
+
for a in names:
|
|
396
|
+
if a.asname is not None:
|
|
397
|
+
continue # `import a.b as x`: griffe records a.b, correctly
|
|
398
|
+
if "." in a.name:
|
|
399
|
+
dotted_heads.add(a.name.split(".", 1)[0])
|
|
400
|
+
else:
|
|
401
|
+
plain_heads.add(a.name)
|
|
379
402
|
|
|
380
403
|
def visit(node, scope: str | None) -> None:
|
|
381
404
|
"""``scope`` is None at module level (griffe has those), else
|
|
@@ -398,10 +421,16 @@ def _source_import_targets(module) -> tuple[list[tuple[str, str]], set[str]]:
|
|
|
398
421
|
for stmt in child.orelse:
|
|
399
422
|
visit_stmt(stmt, else_scope if else_scope else scope)
|
|
400
423
|
elif isinstance(child, ast.Import):
|
|
424
|
+
if scope in (None, "type_checking"):
|
|
425
|
+
# griffe's map holds these (TYPE_CHECKING included, as module-level)
|
|
426
|
+
note_heads(child.names)
|
|
401
427
|
if scope is not None:
|
|
402
428
|
out.extend((a.name, scope) for a in child.names)
|
|
403
429
|
else:
|
|
404
430
|
eager.update(a.name for a in child.names)
|
|
431
|
+
# R1-C68: griffe has `a` for `import a.b.c`; the dependency is a.b.c
|
|
432
|
+
out.extend((a.name, "module") for a in child.names
|
|
433
|
+
if a.asname is None and "." in a.name)
|
|
405
434
|
elif isinstance(child, ast.ImportFrom):
|
|
406
435
|
target = resolve(child)
|
|
407
436
|
if not target:
|
|
@@ -428,7 +457,7 @@ def _source_import_targets(module) -> tuple[list[tuple[str, str]], set[str]]:
|
|
|
428
457
|
visit(holder, scope)
|
|
429
458
|
|
|
430
459
|
visit(tree, None)
|
|
431
|
-
return out, eager
|
|
460
|
+
return out, eager, dotted_heads - plain_heads
|
|
432
461
|
|
|
433
462
|
|
|
434
463
|
def _type_checking_branches(node: ast.If) -> tuple[str | None, str | None] | None:
|
|
@@ -457,7 +486,7 @@ def _collect(graph, obj, root, target_pkg, walk) -> None:
|
|
|
457
486
|
# R1-C23/D3 (star imports) + R1-C29 (function-local and class-body imports) +
|
|
458
487
|
# R1-C48 (`if TYPE_CHECKING:`): everything griffe's module-level map does not
|
|
459
488
|
# carry, or carries under the wrong scope, in one parse.
|
|
460
|
-
nested, eager = _source_import_targets(obj)
|
|
489
|
+
nested, eager, dotted_only = _source_import_targets(obj)
|
|
461
490
|
type_checking = {tgt for tgt, scope in nested if scope == "type_checking"}
|
|
462
491
|
# R1-C56/D1: a `.pyi` is a declaration — Python never executes it, so *none* of
|
|
463
492
|
# its imports run, whatever they look like. Recorded as a fourth scope rather
|
|
@@ -465,6 +494,10 @@ def _collect(graph, obj, root, target_pkg, walk) -> None:
|
|
|
465
494
|
# cycle classes are named by mechanism.
|
|
466
495
|
stub = _is_stub(obj)
|
|
467
496
|
for name, tgt in (obj.imports or {}).items():
|
|
497
|
+
if name == tgt and tgt in dotted_only:
|
|
498
|
+
# R1-C68/D2: the bound name of `import a.b.c`, not a dependency on `a`;
|
|
499
|
+
# the full name came from the AST pass above.
|
|
500
|
+
continue
|
|
468
501
|
# griffe files an import under `if TYPE_CHECKING:` as module-level; it never
|
|
469
502
|
# runs. Demote it unless the same target is also imported eagerly (D2).
|
|
470
503
|
scope = "type_checking" if tgt in type_checking and tgt not in eager else "module"
|
|
@@ -558,6 +591,19 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
|
558
591
|
)
|
|
559
592
|
|
|
560
593
|
known_modules = set(module_ids)
|
|
594
|
+
# R1-C72 (design ``flat_inference_in_packages.md`` D1, revised): the flat inference is for
|
|
595
|
+
# a directory that is itself on sys.path. The evidence is a bare import that can only be a
|
|
596
|
+
# sibling — it names a module beside the importer (not the importer itself) and is no
|
|
597
|
+
# standard-library name. With it, siblings shadow the stdlib too and every bare import is
|
|
598
|
+
# rewritten; without it, `import warnings` beside `_pytest/warnings.py` is the standard
|
|
599
|
+
# library and nothing is. A namespace root needs no evidence. Decided from the collected
|
|
600
|
+
# imports before anything is emitted, so edge order is kept.
|
|
601
|
+
root_node = graph.nodes.get(target_pkg)
|
|
602
|
+
flat_ok = (root_node is not None and root_node.file is None) or any(
|
|
603
|
+
(sib := _flat_sibling(src, t, known_modules)) is not None and sib != src
|
|
604
|
+
and t.split(".", 1)[0] not in sys.stdlib_module_names
|
|
605
|
+
for src, t, _scope in imports
|
|
606
|
+
if not (t == target_pkg or t.startswith(target_pkg + ".")))
|
|
561
607
|
for parent_module, name, target_path, is_public in aliases:
|
|
562
608
|
extras = {"as": name, "public": is_public}
|
|
563
609
|
if not (target_path == target_pkg or target_path.startswith(target_pkg + ".")):
|
|
@@ -567,7 +613,7 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
|
567
613
|
# here — so every re-export in such a tree was filed as external and produced
|
|
568
614
|
# no edge at all. Same narrow gate as pass B: only when the head names a
|
|
569
615
|
# module sitting beside the re-exporter, and labelled as the inference it is.
|
|
570
|
-
if _flat_sibling(parent_module, target_path, known_modules) is None:
|
|
616
|
+
if not flat_ok or _flat_sibling(parent_module, target_path, known_modules) is None:
|
|
571
617
|
# R1-C57: `import numpy as np` is not this package's API and stays out.
|
|
572
618
|
# A **public** alias to an outside definition is the opposite case: it is
|
|
573
619
|
# exactly what the package exposes, and dropping it made the facade layout
|
|
@@ -614,7 +660,7 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
|
614
660
|
# sys.path. griffe records the source-literal target, so pass A cannot tell it from
|
|
615
661
|
# `pandas.DataFrame`. Retry it against the importer's own package — and label it, since
|
|
616
662
|
# this is an inference about sys.path, not something the source states.
|
|
617
|
-
for src_module, target_path, scope in unresolved:
|
|
663
|
+
for src_module, target_path, scope in (unresolved if flat_ok else ()): # R1-C72
|
|
618
664
|
tgt_module = _flat_sibling(src_module, target_path, known_modules)
|
|
619
665
|
if tgt_module is None or tgt_module == src_module:
|
|
620
666
|
continue
|
|
@@ -29,6 +29,13 @@ from pathlib import Path
|
|
|
29
29
|
#: on one rule instead of two that can drift apart.
|
|
30
30
|
NESTED_IMPORT_HINT = re.compile(r"^[ \t]+(?:from|import)\s|import\s+\*", re.MULTILINE)
|
|
31
31
|
|
|
32
|
+
#: R1-C68 (design ``dotted_imports.md`` D3): a top-level ``import`` statement naming a dotted
|
|
33
|
+
#: module. griffe files ``import a.b.c`` under its bound name ``a``, so the import graph has to
|
|
34
|
+
#: read the source for it — but only the import graph: call resolution keeps the gate above,
|
|
35
|
+
#: since ``a`` *is* the name bound. A false positive (``import a.b as x``) costs one parse.
|
|
36
|
+
#: Not seen: a dotted name after a backslash continuation line.
|
|
37
|
+
DOTTED_IMPORT_HINT = re.compile(r"^import\s[^\n#]*\w\.\w", re.MULTILINE)
|
|
38
|
+
|
|
32
39
|
|
|
33
40
|
def module_file(obj) -> Path | None:
|
|
34
41
|
"""The single source file behind a griffe object, or ``None`` if it has none."""
|
|
@@ -98,3 +105,19 @@ def qualify_target(target: str, modpath: str, known_modules) -> str:
|
|
|
98
105
|
if not target.startswith(pkg) and f"{parent}.{target.split('.', 1)[0]}" in known_modules:
|
|
99
106
|
return f"{parent}.{target}"
|
|
100
107
|
return target
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def flat_inference_applies(graph, core_pkg: str) -> bool:
|
|
111
|
+
"""Does the flat-layout inference (R1-C21) apply to this core? One predicate, three users.
|
|
112
|
+
|
|
113
|
+
True for a namespace root (no file), or when the structural pass emitted at least one
|
|
114
|
+
``imports`` edge labelled ``resolution="flat"``. Since R1-C72 that pass emits none in a core
|
|
115
|
+
that imports itself by package-qualified or relative name: there a bare ``import warnings``
|
|
116
|
+
is absolute — the standard library — however a module beside the importer is named
|
|
117
|
+
(design ``flat_inference_in_packages.md``). The import map for calls and attribute access
|
|
118
|
+
(``module_imports`` / ``qualify_target``, the deep tier's ``_flat_qualify``) and the
|
|
119
|
+
cross-root gate (``roots.core_is_flat``) all ask this, so they cannot disagree.
|
|
120
|
+
"""
|
|
121
|
+
root = graph.nodes.get(core_pkg)
|
|
122
|
+
return (root is not None and root.file is None) or any(
|
|
123
|
+
e.type == "imports" and e.extras.get("resolution") == "flat" for e in graph.edges)
|
|
@@ -152,10 +152,8 @@ class _CoreIndex:
|
|
|
152
152
|
# script cannot be reaching `core/alpha.py` — inferring an edge there would invent
|
|
153
153
|
# one. Both flat shapes satisfy this: a namespace directory (root has no file), or a
|
|
154
154
|
# directory whose own imports needed the flat inference.
|
|
155
|
-
|
|
156
|
-
self.core_is_flat = (
|
|
157
|
-
e.type == "imports" and e.extras.get("resolution") == "flat" for e in graph.edges
|
|
158
|
-
)
|
|
155
|
+
from codemap.extract.gsource import flat_inference_applies
|
|
156
|
+
self.core_is_flat = flat_inference_applies(graph, core_pkg)
|
|
159
157
|
self.top_modules = {
|
|
160
158
|
mid.split(".", 1)[1].split(".")[0]
|
|
161
159
|
for mid in self.module_ids
|
|
@@ -103,7 +103,13 @@ SCHEMA_VERSION = "0.13"
|
|
|
103
103
|
# uses them — with `coverage.walk` naming the walk (R1-C66,
|
|
104
104
|
# docs/design/pack_direction.md). Not a separate number: no release ever
|
|
105
105
|
# answered 2 without it.
|
|
106
|
-
|
|
106
|
+
# 3 — the gate's verdict changed shape twice: an `[architecture]` section that departs from
|
|
107
|
+
# the schema is refused ("Contract not read — nothing was enforced", exit 2) where it was
|
|
108
|
+
# read as absent or partly enforced (R1-C69, docs/design/contract_validation.md); and
|
|
109
|
+
# `check --graph` carries a currency line, and its structured answer a `graph_currency`
|
|
110
|
+
# block, with a graph behind its source tree failing the gate (R1-C70,
|
|
111
|
+
# docs/design/check_graph_currency.md).
|
|
112
|
+
ANSWER_FORMAT = 3
|
|
107
113
|
|
|
108
114
|
# Closed vocabulary of edge types (R1-C7). Node ``kind`` is deliberately an OPEN set
|
|
109
115
|
# (DESIGN §2 — new entity kinds may appear), but edges are TYPED: every relationship
|