codmap 0.0.18__tar.gz → 0.0.20__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.18 → codmap-0.0.20}/PKG-INFO +2 -2
- {codmap-0.0.18 → codmap-0.0.20}/README.md +1 -1
- {codmap-0.0.18 → codmap-0.0.20}/codemap/arch.py +31 -14
- {codmap-0.0.18 → codmap-0.0.20}/codemap/extract/griffe_extractor.py +85 -7
- {codmap-0.0.18 → codmap-0.0.20}/codemap/query.py +154 -14
- {codmap-0.0.18 → codmap-0.0.20}/codemap/scope.py +4 -1
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/api_surface.py +45 -2
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/architecture.py +70 -21
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/audit.py +16 -12
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/check.py +6 -5
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/livingdocs.py +10 -9
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/session.py +5 -0
- {codmap-0.0.18 → codmap-0.0.20}/codmap.egg-info/PKG-INFO +2 -2
- {codmap-0.0.18 → codmap-0.0.20}/codmap.egg-info/SOURCES.txt +4 -0
- {codmap-0.0.18 → codmap-0.0.20}/pyproject.toml +1 -1
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_m16_architecture.py +4 -1
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_m1_query.py +16 -4
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c29_lazy_imports.py +23 -12
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c48_type_checking_imports.py +6 -6
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c49_type_only_cycles.py +6 -5
- codmap-0.0.20/tests/test_r1c55_override_grade.py +118 -0
- codmap-0.0.20/tests/test_r1c56_stub_files.py +166 -0
- codmap-0.0.20/tests/test_r1c57_facade_package.py +136 -0
- codmap-0.0.20/tests/test_r1c58_cycle_tangles.py +151 -0
- {codmap-0.0.18 → codmap-0.0.20}/LICENSE +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/__init__.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/apidiff.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/cli.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/diagnostics.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/extract/behavior.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/extract/roots.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/extract/union.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/freshness.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/incremental.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/integrations/base.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/model.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/provenance.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/impact.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/limits.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/mcp_server.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/pack.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/rag.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/review.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/scip.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/server.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/vault.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/store.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/tomlio.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codemap/watch.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/setup.cfg +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_epistemic.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_integrations.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_m15_review.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_m2_views.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_m9_family.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c30f2_check_scope.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c38_tool_drift.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c39_edge_resolution.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c40_flow_reach.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c41_scope_membership.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c42_deep_tier_stability.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c43_incremental_splice.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c44_absent_answers.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c45_repeat_union.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c46_shadowed_definitions.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c47_incremental_resample.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c50_flow_entry_points.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c53_narrowing_declared.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c54_deterministic_rendering.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_scip_export.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/tests/test_scope.py +0 -0
- {codmap-0.0.18 → codmap-0.0.20}/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.20
|
|
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, **972 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 &
|
|
@@ -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, **972 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 &
|
|
@@ -133,6 +133,20 @@ def _core_layer_edges(query) -> list[tuple[str, str, str, str]]:
|
|
|
133
133
|
return out
|
|
134
134
|
|
|
135
135
|
|
|
136
|
+
|
|
137
|
+
def _tangle_line(tangle: dict) -> str:
|
|
138
|
+
"""One gate line per tangle: the example cycle, then the members (R1-C58).
|
|
139
|
+
|
|
140
|
+
The example comes first because it is what a reader acts on; the membership follows
|
|
141
|
+
because the tangle, not the cycle, is what has to be broken.
|
|
142
|
+
"""
|
|
143
|
+
ex = tangle["example"]
|
|
144
|
+
head = " → ".join(ex) + " → " + ex[0]
|
|
145
|
+
if tangle["size"] <= len(ex):
|
|
146
|
+
return head
|
|
147
|
+
return f"{head} (tangle of {tangle['size']}: {', '.join(tangle['modules'])})"
|
|
148
|
+
|
|
149
|
+
|
|
136
150
|
def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
|
|
137
151
|
"""Evaluate every rule against the graph; return the violations (empty = clean)."""
|
|
138
152
|
if contract.is_empty():
|
|
@@ -192,13 +206,16 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
|
|
|
192
206
|
# migrated into the gate. So: the gate stays eager, the *disclosure* is mandatory (see
|
|
193
207
|
# `build_check`), and a contract that wants the coupling gated says so.
|
|
194
208
|
if contract.no_cycles:
|
|
195
|
-
|
|
196
|
-
if
|
|
197
|
-
|
|
209
|
+
tangles = query.import_tangles()
|
|
210
|
+
if tangles:
|
|
211
|
+
# R1-C58: one violation per **tangle**, not per simple cycle. A tangle of 19
|
|
212
|
+
# modules used to produce 1080 violations that were all the same problem, and
|
|
213
|
+
# the gate's own output then took a thousand lines to say it once.
|
|
198
214
|
violations.append(Violation(
|
|
199
215
|
"no_cycles",
|
|
200
|
-
f"{len(
|
|
201
|
-
|
|
216
|
+
f"{len(tangles)} import tangle(s), "
|
|
217
|
+
f"{sum(t['size'] for t in tangles)} module(s)",
|
|
218
|
+
modules=tuple(_tangle_line(t) for t in tangles),
|
|
202
219
|
))
|
|
203
220
|
|
|
204
221
|
# -- no_lazy_cycles: opt in to gating the coupling a lazy import hides -------
|
|
@@ -206,13 +223,13 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
|
|
|
206
223
|
# not a gate, and a lazy import is the accepted way to break an import cycle — so this
|
|
207
224
|
# is the contract owner's call to state, not a default to pick on their behalf.
|
|
208
225
|
if contract.no_lazy_cycles:
|
|
209
|
-
lazy = query.
|
|
226
|
+
lazy = query.lazy_import_tangles()
|
|
210
227
|
if lazy:
|
|
211
|
-
worst = sorted(lazy, key=lambda c: (len(c), c))
|
|
212
228
|
violations.append(Violation(
|
|
213
229
|
"no_lazy_cycles",
|
|
214
|
-
f"{len(lazy)}
|
|
215
|
-
|
|
230
|
+
f"{len(lazy)} tangle(s) closed only by a function-local import, "
|
|
231
|
+
f"{sum(t['size'] for t in lazy)} module(s)",
|
|
232
|
+
modules=tuple(_tangle_line(t) for t in lazy),
|
|
216
233
|
))
|
|
217
234
|
|
|
218
235
|
# -- no_type_only_cycles: the third kind, and the one with no runtime dependency ----
|
|
@@ -223,14 +240,14 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
|
|
|
223
240
|
# against lazy imports used as a way around `no_cycles`, and the standard typing
|
|
224
241
|
# idiom is not that.
|
|
225
242
|
if contract.no_type_only_cycles:
|
|
226
|
-
type_only = query.
|
|
243
|
+
type_only = query.type_only_import_tangles()
|
|
227
244
|
if type_only:
|
|
228
|
-
worst = sorted(type_only, key=lambda c: (len(c), c))
|
|
229
245
|
violations.append(Violation(
|
|
230
246
|
"no_type_only_cycles",
|
|
231
|
-
f"{len(type_only)}
|
|
232
|
-
f"`if TYPE_CHECKING:`"
|
|
233
|
-
|
|
247
|
+
f"{len(type_only)} tangle(s) closed only by an import that never runs "
|
|
248
|
+
f"(`if TYPE_CHECKING:` or a `.pyi`), "
|
|
249
|
+
f"{sum(t['size'] for t in type_only)} module(s)",
|
|
250
|
+
modules=tuple(_tangle_line(t) for t in type_only),
|
|
234
251
|
))
|
|
235
252
|
|
|
236
253
|
# -- exhaustive: every core module's layer must be declared -----------------
|
|
@@ -80,6 +80,63 @@ def _assert_is_the_target(loaded, pkg_dir: Path, module_name: str) -> None:
|
|
|
80
80
|
)
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
#: How many sibling packages one load may pull in before we stop trying (R1-C57).
|
|
84
|
+
_MAX_SIBLING_LOADS = 8
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _load_with_siblings(module_name: str, search_path: Path):
|
|
88
|
+
"""``griffe.load`` the target, pulling in siblings its re-exports point at (R1-C57).
|
|
89
|
+
|
|
90
|
+
The facade shape — a public package whose whole job is to re-export a private one,
|
|
91
|
+
as ``pytest``/``_pytest`` and ``attrs``/``attr`` do — used to **crash the build**:
|
|
92
|
+
griffe merges a ``.pyi`` beside ``__init__.py``, that merge resolves aliases, and an
|
|
93
|
+
alias into a package nobody loaded raises ``AliasResolutionError``. Exit code 1, no
|
|
94
|
+
graph, for a six-file package that is valid and published.
|
|
95
|
+
|
|
96
|
+
An unresolvable re-export is partiality, not a crash. So: load the sibling the alias
|
|
97
|
+
names into the *same* collection and retry. The sibling has to be there — it sits on
|
|
98
|
+
the same search path, which is how Python finds it too — and loading it does not put
|
|
99
|
+
it in the graph: the walk starts from the target's own root, and targets outside it
|
|
100
|
+
stay external, exactly as they were before.
|
|
101
|
+
|
|
102
|
+
Returns ``(root, siblings)``; ``siblings`` is what had to be pulled in, and it is
|
|
103
|
+
recorded in the input report so a reader can see the load was not self-contained.
|
|
104
|
+
"""
|
|
105
|
+
collection = griffe.ModulesCollection()
|
|
106
|
+
siblings: list[str] = []
|
|
107
|
+
for _ in range(_MAX_SIBLING_LOADS + 1):
|
|
108
|
+
try:
|
|
109
|
+
return griffe.load(module_name, search_paths=[str(search_path)],
|
|
110
|
+
try_relative_path=False,
|
|
111
|
+
modules_collection=collection), siblings
|
|
112
|
+
except griffe.AliasResolutionError as exc:
|
|
113
|
+
top = exc.alias.target_path.split(".")[0]
|
|
114
|
+
if top == module_name or top in siblings:
|
|
115
|
+
raise # retrying would loop on the same alias
|
|
116
|
+
if not _package_on_path(top, search_path):
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"`{module_name}` re-exports `{exc.alias.target_path}`, and "
|
|
119
|
+
f"`{top}` is not beside it in {search_path}. This is the facade "
|
|
120
|
+
f"layout (a public package re-exporting a private one): build from "
|
|
121
|
+
f"a tree that holds both, or point codemap at the implementation "
|
|
122
|
+
f"package instead."
|
|
123
|
+
) from exc
|
|
124
|
+
griffe.load(top, search_paths=[str(search_path)], try_relative_path=False,
|
|
125
|
+
modules_collection=collection)
|
|
126
|
+
siblings.append(top)
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"`{module_name}` still had unresolved re-exports after loading "
|
|
129
|
+
f"{len(siblings)} sibling package(s): {', '.join(siblings)}."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _package_on_path(name: str, search_path: Path) -> bool:
|
|
134
|
+
"""True when ``name`` is importable as a package/module from ``search_path``."""
|
|
135
|
+
d = search_path / name
|
|
136
|
+
return (d / "__init__.py").is_file() or (d / "__init__.pyi").is_file() \
|
|
137
|
+
or (search_path / f"{name}.py").is_file() or (search_path / f"{name}.pyi").is_file()
|
|
138
|
+
|
|
139
|
+
|
|
83
140
|
def build_structural(package_path: str | Path):
|
|
84
141
|
"""The cheap, deterministic base: griffe load + definition nodes + structural
|
|
85
142
|
edges (contains / imports / inherits / decorated_by / export). No behavioral
|
|
@@ -99,7 +156,7 @@ def build_structural(package_path: str | Path):
|
|
|
99
156
|
# a repo whose root holds `pkg/`, `build /elsewhere/pkg` then silently analysed the
|
|
100
157
|
# local `pkg` — same shape of answer, different code. We always know the directory we
|
|
101
158
|
# were handed, so the name must resolve through `search_paths` and nowhere else.
|
|
102
|
-
root =
|
|
159
|
+
root, siblings = _load_with_siblings(module_name, search_path)
|
|
103
160
|
# Defence in depth: whatever the finder does next (a .pth file, a namespace package,
|
|
104
161
|
# a future default), a graph must describe the directory that was asked for. A wrong
|
|
105
162
|
# answer here is invisible downstream — it is well-formed, complete and about the
|
|
@@ -402,13 +459,18 @@ def _collect(graph, obj, root, target_pkg, walk) -> None:
|
|
|
402
459
|
# carry, or carries under the wrong scope, in one parse.
|
|
403
460
|
nested, eager = _source_import_targets(obj)
|
|
404
461
|
type_checking = {tgt for tgt, scope in nested if scope == "type_checking"}
|
|
462
|
+
# R1-C56/D1: a `.pyi` is a declaration — Python never executes it, so *none* of
|
|
463
|
+
# its imports run, whatever they look like. Recorded as a fourth scope rather
|
|
464
|
+
# than borrowed from `type_checking`: the mechanism is a different one, and the
|
|
465
|
+
# cycle classes are named by mechanism.
|
|
466
|
+
stub = _is_stub(obj)
|
|
405
467
|
for name, tgt in (obj.imports or {}).items():
|
|
406
468
|
# griffe files an import under `if TYPE_CHECKING:` as module-level; it never
|
|
407
469
|
# runs. Demote it unless the same target is also imported eagerly (D2).
|
|
408
470
|
scope = "type_checking" if tgt in type_checking and tgt not in eager else "module"
|
|
409
|
-
walk.imports.append((obj.canonical_path, tgt, scope))
|
|
471
|
+
walk.imports.append((obj.canonical_path, tgt, "stub" if stub else scope))
|
|
410
472
|
for tgt, scope in nested:
|
|
411
|
-
walk.imports.append((obj.canonical_path, tgt, scope))
|
|
473
|
+
walk.imports.append((obj.canonical_path, tgt, "stub" if stub else scope))
|
|
412
474
|
for name, member in obj.members.items():
|
|
413
475
|
if member.is_alias:
|
|
414
476
|
# capture ALL re-exports (public flag kept) — a symbol can be importable
|
|
@@ -487,7 +549,7 @@ def _emit_decorated_by(graph, obj) -> None:
|
|
|
487
549
|
#: import is a **function-local** dependency: it can execute, and labelling it by the
|
|
488
550
|
#: import that cannot would let a tree launder runtime coupling into the type layer
|
|
489
551
|
#: (R1-C49, and the test that says so).
|
|
490
|
-
_SCOPE_RANK = {"module": 0, "function": 1, "type_checking": 2}
|
|
552
|
+
_SCOPE_RANK = {"module": 0, "function": 1, "type_checking": 2, "stub": 3}
|
|
491
553
|
|
|
492
554
|
|
|
493
555
|
def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
@@ -506,7 +568,18 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
|
506
568
|
# no edge at all. Same narrow gate as pass B: only when the head names a
|
|
507
569
|
# module sitting beside the re-exporter, and labelled as the inference it is.
|
|
508
570
|
if _flat_sibling(parent_module, target_path, known_modules) is None:
|
|
509
|
-
|
|
571
|
+
# R1-C57: `import numpy as np` is not this package's API and stays out.
|
|
572
|
+
# A **public** alias to an outside definition is the opposite case: it is
|
|
573
|
+
# exactly what the package exposes, and dropping it made the facade layout
|
|
574
|
+
# (`pytest` re-exporting 90 names from `_pytest`) report a public surface
|
|
575
|
+
# of one symbol. Kept, with the target left as written and marked external
|
|
576
|
+
# — the definition is outside this graph, and the edge says so rather than
|
|
577
|
+
# pretending to resolve it.
|
|
578
|
+
if not is_public:
|
|
579
|
+
continue
|
|
580
|
+
extras["external"] = True
|
|
581
|
+
graph.add_edge(Edge("export", parent_module, target_path, extras=extras))
|
|
582
|
+
continue
|
|
510
583
|
target_path = f"{parent_module.rsplit('.', 1)[0]}.{target_path}"
|
|
511
584
|
extras["resolution"] = "flat"
|
|
512
585
|
graph.add_edge(Edge("export", parent_module, target_path, extras=extras))
|
|
@@ -599,6 +672,12 @@ def _add_node(graph, obj, root) -> None:
|
|
|
599
672
|
)
|
|
600
673
|
|
|
601
674
|
|
|
675
|
+
def _is_stub(obj) -> bool:
|
|
676
|
+
"""True when ``obj`` lives in a ``.pyi`` — a declaration Python never executes."""
|
|
677
|
+
f = module_file(obj)
|
|
678
|
+
return f is not None and f.suffix == ".pyi"
|
|
679
|
+
|
|
680
|
+
|
|
602
681
|
def _stub_marked(extras: dict, obj) -> dict:
|
|
603
682
|
"""Label a symbol that exists only in a ``.pyi`` stub (R1-C23 / design D5).
|
|
604
683
|
|
|
@@ -607,8 +686,7 @@ def _stub_marked(extras: dict, obj) -> dict:
|
|
|
607
686
|
a stubs distribution, and leaving them unmarked presents a function that does not
|
|
608
687
|
exist as if it did. Consumers that reason about execution (dead-code) exclude them.
|
|
609
688
|
"""
|
|
610
|
-
|
|
611
|
-
if f is not None and f.suffix == ".pyi":
|
|
689
|
+
if _is_stub(obj):
|
|
612
690
|
extras = {**extras, "stub": True}
|
|
613
691
|
return extras
|
|
614
692
|
|
|
@@ -153,7 +153,10 @@ class Query:
|
|
|
153
153
|
# R1-C49: between the two — everything that can execute. A `TYPE_CHECKING` import
|
|
154
154
|
# never does, so a cycle that needs one is not runtime coupling at all.
|
|
155
155
|
self._imports_runtime = nx.DiGraph()
|
|
156
|
-
|
|
156
|
+
# R1-C56: `stub` is the fourth scope — a `.pyi` is never executed, so none of its
|
|
157
|
+
# imports run. Counted apart from `type_checking` (different mechanism, same
|
|
158
|
+
# consequence) and, like it, kept out of both the eager and the runtime graph.
|
|
159
|
+
self._import_scopes = {"module": 0, "function": 0, "type_checking": 0, "stub": 0}
|
|
157
160
|
for n in graph.nodes.values():
|
|
158
161
|
if n.kind == "module":
|
|
159
162
|
self._imports.add_node(n.id)
|
|
@@ -163,9 +166,9 @@ class Query:
|
|
|
163
166
|
if e.type == "imports":
|
|
164
167
|
self._imports.add_edge(e.source, e.target)
|
|
165
168
|
scope = e.extras.get("scope")
|
|
166
|
-
if scope in ("function", "type_checking"):
|
|
167
|
-
# R1-C29: runs when the function runs; R1-C48: never runs.
|
|
168
|
-
# is an import-time edge — but only the first is a runtime one.
|
|
169
|
+
if scope in ("function", "type_checking", "stub"):
|
|
170
|
+
# R1-C29: runs when the function runs; R1-C48 and R1-C56: never runs.
|
|
171
|
+
# Neither is an import-time edge — but only the first is a runtime one.
|
|
169
172
|
self._import_scopes[scope] += 1
|
|
170
173
|
if scope == "function":
|
|
171
174
|
self._imports_runtime.add_edge(e.source, e.target)
|
|
@@ -665,10 +668,57 @@ class Query:
|
|
|
665
668
|
out = [c for c in out if _CONFIDENCE_RANK[c["confidence"]] >= floor]
|
|
666
669
|
return out
|
|
667
670
|
|
|
671
|
+
def _overridden_base(self, n: Node) -> tuple[str, int] | None:
|
|
672
|
+
"""The ancestor method ``n`` overrides, and that ancestor's inbound calls (R1-C55).
|
|
673
|
+
|
|
674
|
+
An override is not reached by its own name: the base is called and dynamic
|
|
675
|
+
dispatch lands here, so "no inbound calls" is a statement about the *name*, not
|
|
676
|
+
about the body. Measured on Pillow, where 40 of 63 ``high`` candidates were
|
|
677
|
+
``_open`` implementations of a template method the graph itself records as
|
|
678
|
+
called from ``ImageFile.__init__`` — the strongest grade, on the most ordinary
|
|
679
|
+
shape in object-oriented Python.
|
|
680
|
+
|
|
681
|
+
Walks ``inherits`` transitively and weighs **every** ancestor that declares the
|
|
682
|
+
name, not just the nearest: in a three-deep chain the middle link is an override
|
|
683
|
+
too, so it has no inbound call of its own, and stopping there would report "the
|
|
684
|
+
base is itself uncalled" while the call sits one level further up. An ancestor
|
|
685
|
+
with inbound calls therefore wins; failing that, the nearest one is named.
|
|
686
|
+
"""
|
|
687
|
+
cls, _, meth = n.id.rpartition(".")
|
|
688
|
+
if not cls or cls not in self._inherits:
|
|
689
|
+
return None
|
|
690
|
+
nearest: tuple[str, int] | None = None
|
|
691
|
+
seen, queue = {cls}, [cls]
|
|
692
|
+
while queue: # breadth-first, so `nearest` is the closest declaration
|
|
693
|
+
for base in self.bases(queue.pop(0)):
|
|
694
|
+
if base in seen:
|
|
695
|
+
continue
|
|
696
|
+
seen.add(base)
|
|
697
|
+
queue.append(base)
|
|
698
|
+
cand = f"{base}.{meth}"
|
|
699
|
+
if cand not in self.graph.nodes:
|
|
700
|
+
continue
|
|
701
|
+
in_calls = self._calls.in_degree(cand) if cand in self._calls else 0
|
|
702
|
+
if in_calls:
|
|
703
|
+
return cand, in_calls
|
|
704
|
+
nearest = nearest or (cand, 0)
|
|
705
|
+
return nearest
|
|
706
|
+
|
|
668
707
|
def _grade_dead(self, n: Node) -> dict:
|
|
669
708
|
"""Score one uncalled-private candidate → {id, confidence, root, reasons}."""
|
|
670
709
|
refs = self.references_to(n.id) # inbound of every kind, across roots
|
|
671
710
|
registry = n.extras.get("registry")
|
|
711
|
+
override = self._overridden_base(n)
|
|
712
|
+
if override and not refs:
|
|
713
|
+
# R1-C55: the name is uncalled; the body is reachable through the base.
|
|
714
|
+
base, in_calls = override
|
|
715
|
+
if in_calls:
|
|
716
|
+
return {"id": n.id, "confidence": "low", "root": self.root_of(n.id),
|
|
717
|
+
"reasons": [f"overrides {base}, which has {in_calls} inbound "
|
|
718
|
+
f"call(s) — reached by dispatch, not by name"]}
|
|
719
|
+
return {"id": n.id, "confidence": "medium", "root": self.root_of(n.id),
|
|
720
|
+
"reasons": [f"overrides {base}, which is itself uncalled here — "
|
|
721
|
+
f"dead only if the base is"]}
|
|
672
722
|
if refs:
|
|
673
723
|
by: dict[tuple[str, str], int] = {}
|
|
674
724
|
for r in refs:
|
|
@@ -1079,16 +1129,103 @@ class Query:
|
|
|
1079
1129
|
|
|
1080
1130
|
# -- graph-wide ----------------------------------------------------------
|
|
1081
1131
|
|
|
1132
|
+
def _tangle_sets(self, g) -> set[frozenset]:
|
|
1133
|
+
"""Strongly connected components of ``g`` that are cycles (R1-C58).
|
|
1134
|
+
|
|
1135
|
+
A tangle — not a cycle — is the unit a reader acts on: *these N modules cannot be
|
|
1136
|
+
separated*. The number of **simple cycles** through them is combinatorial and says
|
|
1137
|
+
nothing more; on pytest's `_pytest` it was 1080 / 95 001 / 464 109 for one tangle
|
|
1138
|
+
of 78 modules, and enumerating them cost 10 s of a report that printed twenty.
|
|
1139
|
+
"""
|
|
1140
|
+
out = {frozenset(c) for c in nx.strongly_connected_components(g) if len(c) > 1}
|
|
1141
|
+
out |= {frozenset({n}) for n in g.nodes if g.has_edge(n, n)}
|
|
1142
|
+
return out
|
|
1143
|
+
|
|
1144
|
+
def _example_cycle(self, g, modules: frozenset,
|
|
1145
|
+
via: set | None = None) -> tuple[list[str], tuple | None]:
|
|
1146
|
+
"""One cycle through ``modules``, chosen deterministically (R1-C58).
|
|
1147
|
+
|
|
1148
|
+
Deliberately **not** promised to be the shortest — promising it would buy a
|
|
1149
|
+
narrowing we would then have to declare. It is an example, and it is chosen to
|
|
1150
|
+
pass through an edge of the class that *defines* the tangle (``via``): a lazy
|
|
1151
|
+
tangle must show a function-local edge, or the example reads like an eager cycle
|
|
1152
|
+
in the section about lazy ones. Returns the cycle and that edge, so the answer can
|
|
1153
|
+
name *which* import is the one holding the tangle together.
|
|
1154
|
+
"""
|
|
1155
|
+
sub = g.subgraph(modules)
|
|
1156
|
+
chosen = sorted(e for e in sub.edges if via is None or e in via)
|
|
1157
|
+
edges = chosen or sorted(sub.edges)
|
|
1158
|
+
if not edges:
|
|
1159
|
+
return sorted(modules), None # a self-loop: the module is the whole cycle
|
|
1160
|
+
src, dst = edges[0]
|
|
1161
|
+
edge = (src, dst) if chosen else None
|
|
1162
|
+
if src == dst:
|
|
1163
|
+
return [src], edge
|
|
1164
|
+
try:
|
|
1165
|
+
path = nx.shortest_path(sub, dst, src)
|
|
1166
|
+
except nx.NetworkXNoPath: # cannot happen inside an SCC; not asserted
|
|
1167
|
+
return sorted(modules), edge
|
|
1168
|
+
return _canonical_cycles([path])[0], edge
|
|
1169
|
+
|
|
1170
|
+
def _tangles(self, g, weaker: set[frozenset], via: set | None = None) -> list[dict]:
|
|
1171
|
+
"""Tangles of ``g`` that are not already reported in a stronger class."""
|
|
1172
|
+
out = []
|
|
1173
|
+
for tangle in self._tangle_sets(g) - weaker:
|
|
1174
|
+
example, edge = self._example_cycle(g, tangle, via)
|
|
1175
|
+
sub = g.subgraph(tangle)
|
|
1176
|
+
row = {"modules": sorted(tangle), "size": len(tangle),
|
|
1177
|
+
# R1-C58: the cycle rank (first Betti number) of the tangle — how many
|
|
1178
|
+
# **independent** loops it holds. This is the part of the old count that
|
|
1179
|
+
# was worth keeping: two loops sharing one module are two problems, and
|
|
1180
|
+
# collapsing them to "one tangle" would hide that. Unlike the number of
|
|
1181
|
+
# simple cycles it is linear to compute and does not explode: the 19-module
|
|
1182
|
+
# tangle in pytest has 1080 simple cycles and 31 independent loops.
|
|
1183
|
+
"loops": sub.number_of_edges() - len(tangle) + 1,
|
|
1184
|
+
"example": example}
|
|
1185
|
+
if edge:
|
|
1186
|
+
row["closed_by"] = list(edge)
|
|
1187
|
+
out.append(row)
|
|
1188
|
+
return sorted(out, key=lambda r: (r["size"], r["modules"]))
|
|
1189
|
+
|
|
1190
|
+
def import_tangles(self) -> list[dict]:
|
|
1191
|
+
"""Mutually-dependent module groups in the **eager** import graph (R1-C58).
|
|
1192
|
+
|
|
1193
|
+
Each entry is ``{modules, size, example}``: the group that cannot be separated,
|
|
1194
|
+
and one cycle through it. The count of simple cycles is not reported — see
|
|
1195
|
+
:meth:`_tangle_sets`.
|
|
1196
|
+
"""
|
|
1197
|
+
return self._tangles(self._imports_eager, set())
|
|
1198
|
+
|
|
1199
|
+
def lazy_import_tangles(self) -> list[dict]:
|
|
1200
|
+
"""Tangles that exist only once function-local imports are counted."""
|
|
1201
|
+
return self._tangles(self._imports_runtime,
|
|
1202
|
+
self._tangle_sets(self._imports_eager),
|
|
1203
|
+
via={(e.source, e.target) for e in self.graph.edges
|
|
1204
|
+
if e.type == "imports"
|
|
1205
|
+
and e.extras.get("scope") == "function"})
|
|
1206
|
+
|
|
1207
|
+
def type_only_import_tangles(self) -> list[dict]:
|
|
1208
|
+
"""Tangles that exist only once imports that never execute are counted."""
|
|
1209
|
+
return self._tangles(self._imports, self._tangle_sets(self._imports_runtime),
|
|
1210
|
+
via={(e.source, e.target) for e in self.graph.edges
|
|
1211
|
+
if e.type == "imports"
|
|
1212
|
+
and e.extras.get("scope") in ("type_checking", "stub")})
|
|
1213
|
+
|
|
1082
1214
|
def import_cycles(self) -> list[list[str]]:
|
|
1083
|
-
"""
|
|
1215
|
+
"""One example cycle per **eager** tangle — the import-order landmines.
|
|
1084
1216
|
|
|
1085
1217
|
R1-C29: deliberately *not* computed over every import edge. A function-local
|
|
1086
1218
|
import does not run at import time, so a cycle closed only by one does not break
|
|
1087
1219
|
on import — it is what a developer writes to stop it breaking. Counting it here
|
|
1088
1220
|
would report someone's fix as their bug. Those cycles are still real coupling
|
|
1089
1221
|
and are returned by :meth:`lazy_import_cycles`.
|
|
1222
|
+
|
|
1223
|
+
R1-C58 changed what one entry **means**: it used to be every simple cycle, which
|
|
1224
|
+
is a combinatorial quantity (1080 entries for one tangle of 78 modules), and is
|
|
1225
|
+
now one representative per tangle. :meth:`import_tangles` carries the group each
|
|
1226
|
+
example stands for.
|
|
1090
1227
|
"""
|
|
1091
|
-
return _canonical_cycles(
|
|
1228
|
+
return _canonical_cycles(t["example"] for t in self.import_tangles())
|
|
1092
1229
|
|
|
1093
1230
|
def lazy_import_cycles(self) -> list[list[str]]:
|
|
1094
1231
|
"""Dependency cycles that close **only** through a function-local import.
|
|
@@ -1105,22 +1242,24 @@ class Query:
|
|
|
1105
1242
|
function-local import stays *lazy* however many type imports also run between
|
|
1106
1243
|
them, or a tree could hide real coupling by adding one.
|
|
1107
1244
|
"""
|
|
1108
|
-
|
|
1109
|
-
return _canonical_cycles(c for c in nx.simple_cycles(self._imports_runtime)
|
|
1110
|
-
if frozenset(c) not in eager)
|
|
1245
|
+
return _canonical_cycles(t["example"] for t in self.lazy_import_tangles())
|
|
1111
1246
|
|
|
1112
1247
|
def type_only_import_cycles(self) -> list[list[str]]:
|
|
1113
|
-
"""Dependency cycles that close **only** with an import
|
|
1248
|
+
"""Dependency cycles that close **only** with an import that never executes.
|
|
1114
1249
|
|
|
1115
1250
|
The third kind (R1-C49, issue #18). These modules name each other's types and have
|
|
1116
1251
|
**no runtime dependency whatever**: neither import pulls the other at any moment of
|
|
1117
1252
|
execution. That is why they are not gated by ``no_lazy_cycles`` — which exists
|
|
1118
1253
|
against a lazy import used to walk *around* ``no_cycles`` — and get their own
|
|
1119
1254
|
opt-in rule instead.
|
|
1255
|
+
|
|
1256
|
+
Two mechanisms, one consequence (R1-C56/D2): an import under ``if TYPE_CHECKING:``
|
|
1257
|
+
and an import written in a ``.pyi``, which Python does not execute at all. The
|
|
1258
|
+
class partitions by consequence — what a reader needs is whether the import can
|
|
1259
|
+
break — while the edge keeps the mechanism in ``extras.scope``. Measured on
|
|
1260
|
+
Pillow, whose *only* "hard" cycle was a stub declaring the module that imports it.
|
|
1120
1261
|
"""
|
|
1121
|
-
|
|
1122
|
-
return _canonical_cycles(c for c in nx.simple_cycles(self._imports)
|
|
1123
|
-
if frozenset(c) not in runtime)
|
|
1262
|
+
return _canonical_cycles(t["example"] for t in self.type_only_import_tangles())
|
|
1124
1263
|
|
|
1125
1264
|
def import_map(self) -> dict:
|
|
1126
1265
|
"""How much of the import graph each scope contributed (R1-C29).
|
|
@@ -1132,7 +1271,8 @@ class Query:
|
|
|
1132
1271
|
"""
|
|
1133
1272
|
return {"module_level": self._import_scopes["module"],
|
|
1134
1273
|
"function_local": self._import_scopes["function"],
|
|
1135
|
-
"type_checking": self._import_scopes["type_checking"]
|
|
1274
|
+
"type_checking": self._import_scopes["type_checking"],
|
|
1275
|
+
"stub": self._import_scopes["stub"]}
|
|
1136
1276
|
|
|
1137
1277
|
def orphan_modules(self, root: str | None = None) -> list[str]:
|
|
1138
1278
|
"""Modules with no incoming imports (dead-code candidates — heuristic).
|
|
@@ -19,7 +19,10 @@ import subprocess
|
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
21
|
# What codemap actually consumes as input (source + docs it indexes as references).
|
|
22
|
-
|
|
22
|
+
# R1-C56/D3: `.pyi` is here because griffe reads it. Leaving it out made the manifest
|
|
23
|
+
# describe a different input than the graph was built from, so `scope_id` — and with it
|
|
24
|
+
# `--incremental` and `watch` — read as unknown on every tree that ships stubs.
|
|
25
|
+
DEFAULT_INCLUDE = ("*.py", "*.pyi", "*.md")
|
|
23
26
|
# fs-mode default excludes (git mode gets these for free via .gitignore).
|
|
24
27
|
DEFAULT_EXCLUDE_DIRS = frozenset({
|
|
25
28
|
"__pycache__", ".git", ".venv", "venv", "node_modules", "build", "dist",
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from collections import defaultdict
|
|
11
11
|
|
|
12
|
+
from codemap.diagnostics import diagnostics, render_lines
|
|
12
13
|
from codemap.model import Graph
|
|
13
14
|
|
|
14
15
|
_SYMBOL_KINDS = {"class", "function", "attribute"}
|
|
@@ -41,16 +42,39 @@ def build_api_surface(graph: Graph) -> dict:
|
|
|
41
42
|
"doc": _first_line(n.docstring), "file": n.file, "lineno": n.lineno}
|
|
42
43
|
for n in symbols
|
|
43
44
|
]})
|
|
45
|
+
reexported = _reexported_from_outside(graph)
|
|
44
46
|
return {
|
|
45
47
|
"kind": "api-surface",
|
|
46
48
|
"target": graph.target,
|
|
47
49
|
"totals": {"symbols": sum(len(m["symbols"]) for m in modules),
|
|
48
50
|
"modules_with_symbols": len(modules),
|
|
49
|
-
"public_modules": len(public_modules)
|
|
51
|
+
"public_modules": len(public_modules),
|
|
52
|
+
# R1-C57: always present, zero included — a reader must not have to
|
|
53
|
+
# tell "this package re-exports nothing" from "we did not look".
|
|
54
|
+
"reexported_from_outside": len(reexported)},
|
|
50
55
|
"modules": modules,
|
|
56
|
+
"reexported_from_outside": reexported,
|
|
57
|
+
"diagnostics": diagnostics(graph),
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
|
|
61
|
+
def _reexported_from_outside(graph: Graph) -> list[dict]:
|
|
62
|
+
"""Public names this package exposes whose definition lives outside it (R1-C57).
|
|
63
|
+
|
|
64
|
+
The facade layout — `pytest` re-exporting 90 names from `_pytest`, `attrs` from
|
|
65
|
+
`attr` — has almost no symbols of its own, so counting nodes reported a public
|
|
66
|
+
surface of **one** for a package whose whole purpose is its API. These names are
|
|
67
|
+
the surface; the definitions are simply not in this graph, and the answer says so
|
|
68
|
+
instead of omitting them.
|
|
69
|
+
"""
|
|
70
|
+
out = []
|
|
71
|
+
for e in graph.edges:
|
|
72
|
+
if e.type == "export" and e.extras.get("external") and e.extras.get("public"):
|
|
73
|
+
out.append({"name": e.extras.get("as", ""), "module": e.source,
|
|
74
|
+
"defined_at": e.target})
|
|
75
|
+
return sorted(out, key=lambda r: (r["module"], r["name"]))
|
|
76
|
+
|
|
77
|
+
|
|
54
78
|
def render_api_surface(graph: Graph) -> str:
|
|
55
79
|
"""Render the public API surface of ``graph`` as Markdown."""
|
|
56
80
|
by_module: dict[str, list] = defaultdict(list)
|
|
@@ -61,12 +85,24 @@ def render_api_surface(graph: Graph) -> str:
|
|
|
61
85
|
by_module[module].append(node)
|
|
62
86
|
|
|
63
87
|
lines = [f"# API surface — `{graph.target}`", ""]
|
|
88
|
+
# R1-C57: every other report carried the build's diagnostics and this one did not, so
|
|
89
|
+
# a facade package printed its surface with no hint that the import graph behind it was
|
|
90
|
+
# empty. A warning that reaches four reports out of five is a warning the reader can
|
|
91
|
+
# miss by asking the wrong question.
|
|
92
|
+
lines.extend(render_lines(graph))
|
|
64
93
|
public_modules = sorted(
|
|
65
94
|
n.id for n in graph.nodes.values() if n.kind == "module" and n.visibility == "public"
|
|
66
95
|
)
|
|
67
96
|
total = sum(len(v) for v in by_module.values())
|
|
68
|
-
|
|
97
|
+
reexported = _reexported_from_outside(graph)
|
|
98
|
+
lines.append(f"_{total} public symbols across {len(public_modules)} modules; "
|
|
99
|
+
f"{len(reexported)} more re-exported from outside this root._")
|
|
69
100
|
lines.append("")
|
|
101
|
+
if reexported:
|
|
102
|
+
lines.append(f"> ⚠ This package exposes **{len(reexported)}** name(s) it does not "
|
|
103
|
+
f"define — the facade layout. Their definitions are in another root "
|
|
104
|
+
f"and are **not judged here**; build that package to see them.")
|
|
105
|
+
lines.append("")
|
|
70
106
|
|
|
71
107
|
for module in public_modules:
|
|
72
108
|
symbols = sorted(by_module.get(module, []), key=lambda n: n.id)
|
|
@@ -84,6 +120,13 @@ def render_api_surface(graph: Graph) -> str:
|
|
|
84
120
|
lines.append(f" - {doc}")
|
|
85
121
|
lines.append("")
|
|
86
122
|
|
|
123
|
+
if reexported:
|
|
124
|
+
lines.append("## Re-exported from outside this root")
|
|
125
|
+
lines.append("")
|
|
126
|
+
for r in reexported:
|
|
127
|
+
lines.append(f"- **`{r['name']}`** → `{r['defined_at']}` (via `{r['module']}`)")
|
|
128
|
+
lines.append("")
|
|
129
|
+
|
|
87
130
|
return "\n".join(lines).rstrip() + "\n"
|
|
88
131
|
|
|
89
132
|
|