codmap 0.0.19__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.19 → codmap-0.0.20}/PKG-INFO +2 -2
- {codmap-0.0.19 → codmap-0.0.20}/README.md +1 -1
- {codmap-0.0.19 → codmap-0.0.20}/codemap/arch.py +31 -14
- {codmap-0.0.19 → codmap-0.0.20}/codemap/query.py +91 -8
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/architecture.py +65 -17
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/audit.py +12 -8
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/check.py +2 -2
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/livingdocs.py +8 -7
- {codmap-0.0.19 → codmap-0.0.20}/codmap.egg-info/PKG-INFO +2 -2
- {codmap-0.0.19 → codmap-0.0.20}/codmap.egg-info/SOURCES.txt +1 -0
- {codmap-0.0.19 → codmap-0.0.20}/pyproject.toml +1 -1
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_m16_architecture.py +4 -1
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c29_lazy_imports.py +20 -9
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c48_type_checking_imports.py +1 -1
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c49_type_only_cycles.py +1 -1
- codmap-0.0.20/tests/test_r1c58_cycle_tangles.py +151 -0
- {codmap-0.0.19 → codmap-0.0.20}/LICENSE +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/__init__.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/apidiff.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/cli.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/diagnostics.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/extract/behavior.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/extract/griffe_extractor.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/extract/roots.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/extract/union.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/freshness.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/incremental.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/integrations/base.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/model.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/provenance.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/scope.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/impact.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/limits.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/mcp_server.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/pack.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/rag.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/review.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/scip.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/server.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/session.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/vault.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/store.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/tomlio.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codemap/watch.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/setup.cfg +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_epistemic.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_integrations.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_m15_review.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_m1_query.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_m2_views.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_m9_family.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c30f2_check_scope.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c38_tool_drift.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c39_edge_resolution.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c40_flow_reach.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c41_scope_membership.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c42_deep_tier_stability.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c43_incremental_splice.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c44_absent_answers.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c45_repeat_union.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c46_shadowed_definitions.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c47_incremental_resample.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c50_flow_entry_points.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c53_narrowing_declared.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c54_deterministic_rendering.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c55_override_grade.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c56_stub_files.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c57_facade_package.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_scip_export.py +0 -0
- {codmap-0.0.19 → codmap-0.0.20}/tests/test_scope.py +0 -0
- {codmap-0.0.19 → 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 -----------------
|
|
@@ -1129,16 +1129,103 @@ class Query:
|
|
|
1129
1129
|
|
|
1130
1130
|
# -- graph-wide ----------------------------------------------------------
|
|
1131
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
|
+
|
|
1132
1214
|
def import_cycles(self) -> list[list[str]]:
|
|
1133
|
-
"""
|
|
1215
|
+
"""One example cycle per **eager** tangle — the import-order landmines.
|
|
1134
1216
|
|
|
1135
1217
|
R1-C29: deliberately *not* computed over every import edge. A function-local
|
|
1136
1218
|
import does not run at import time, so a cycle closed only by one does not break
|
|
1137
1219
|
on import — it is what a developer writes to stop it breaking. Counting it here
|
|
1138
1220
|
would report someone's fix as their bug. Those cycles are still real coupling
|
|
1139
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.
|
|
1140
1227
|
"""
|
|
1141
|
-
return _canonical_cycles(
|
|
1228
|
+
return _canonical_cycles(t["example"] for t in self.import_tangles())
|
|
1142
1229
|
|
|
1143
1230
|
def lazy_import_cycles(self) -> list[list[str]]:
|
|
1144
1231
|
"""Dependency cycles that close **only** through a function-local import.
|
|
@@ -1155,9 +1242,7 @@ class Query:
|
|
|
1155
1242
|
function-local import stays *lazy* however many type imports also run between
|
|
1156
1243
|
them, or a tree could hide real coupling by adding one.
|
|
1157
1244
|
"""
|
|
1158
|
-
|
|
1159
|
-
return _canonical_cycles(c for c in nx.simple_cycles(self._imports_runtime)
|
|
1160
|
-
if frozenset(c) not in eager)
|
|
1245
|
+
return _canonical_cycles(t["example"] for t in self.lazy_import_tangles())
|
|
1161
1246
|
|
|
1162
1247
|
def type_only_import_cycles(self) -> list[list[str]]:
|
|
1163
1248
|
"""Dependency cycles that close **only** with an import that never executes.
|
|
@@ -1174,9 +1259,7 @@ class Query:
|
|
|
1174
1259
|
break — while the edge keeps the mechanism in ``extras.scope``. Measured on
|
|
1175
1260
|
Pillow, whose *only* "hard" cycle was a stub declaring the module that imports it.
|
|
1176
1261
|
"""
|
|
1177
|
-
|
|
1178
|
-
return _canonical_cycles(c for c in nx.simple_cycles(self._imports)
|
|
1179
|
-
if frozenset(c) not in runtime)
|
|
1262
|
+
return _canonical_cycles(t["example"] for t in self.type_only_import_tangles())
|
|
1180
1263
|
|
|
1181
1264
|
def import_map(self) -> dict:
|
|
1182
1265
|
"""How much of the import graph each scope contributed (R1-C29).
|
|
@@ -15,6 +15,36 @@ from codemap.diagnostics import render_lines
|
|
|
15
15
|
from codemap.query import Query
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def _tangle_head(tangles: list[dict]) -> str:
|
|
19
|
+
"""`N tangle(s) (M modules)` — the count that is stable under adding an edge.
|
|
20
|
+
|
|
21
|
+
R1-C58: the previous head counted **simple cycles**, which is combinatorial. A tangle
|
|
22
|
+
of three mutually-dependent modules has one head here and produced five entries there;
|
|
23
|
+
pytest's `_pytest` produced 1080, 95 001 and 464 109 for one tangle each.
|
|
24
|
+
"""
|
|
25
|
+
if not tangles:
|
|
26
|
+
return "0"
|
|
27
|
+
return (f"{len(tangles)} tangle(s), {sum(t['size'] for t in tangles)} module(s), "
|
|
28
|
+
f"{sum(t['loops'] for t in tangles)} independent loop(s)")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _tangle_lines(tangles: list[dict]) -> list[str]:
|
|
32
|
+
"""One block per tangle: its members, and one cycle through it as an example."""
|
|
33
|
+
out: list[str] = []
|
|
34
|
+
for tg in tangles:
|
|
35
|
+
if tg["size"] == 1:
|
|
36
|
+
out.append(f"- `{tg['modules'][0]}` — imports itself")
|
|
37
|
+
continue
|
|
38
|
+
out.append(f"- **{tg['size']} modules, {tg['loops']} independent loop(s):** "
|
|
39
|
+
+ ", ".join(f"`{m}`" for m in tg["modules"]))
|
|
40
|
+
ex = tg["example"]
|
|
41
|
+
line = f" - e.g. {' → '.join(ex)} → {ex[0]}"
|
|
42
|
+
if tg.get("closed_by"):
|
|
43
|
+
line += f" — held together by `{tg['closed_by'][0]}` → `{tg['closed_by'][1]}`"
|
|
44
|
+
out.append(line)
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
18
48
|
def build_architecture(query: Query) -> dict:
|
|
19
49
|
"""Structured whole-system overview (cycles + layers + coupling + hotspots).
|
|
20
50
|
|
|
@@ -30,9 +60,16 @@ def build_architecture(query: Query) -> dict:
|
|
|
30
60
|
"""
|
|
31
61
|
return {
|
|
32
62
|
"target": query.graph.target,
|
|
63
|
+
# R1-C58: the unit is the tangle (a strongly connected group of modules). The
|
|
64
|
+
# `*_cycles` keys stay, carrying **one example per tangle** — they used to carry
|
|
65
|
+
# every simple cycle, a combinatorial quantity that reached 464 109 entries for a
|
|
66
|
+
# single tangle of 78 modules and said nothing the tangle does not.
|
|
33
67
|
"cycles": query.import_cycles(),
|
|
34
68
|
"lazy_cycles": query.lazy_import_cycles(),
|
|
35
69
|
"type_only_cycles": query.type_only_import_cycles(),
|
|
70
|
+
"tangles": query.import_tangles(),
|
|
71
|
+
"lazy_tangles": query.lazy_import_tangles(),
|
|
72
|
+
"type_only_tangles": query.type_only_import_tangles(),
|
|
36
73
|
"import_map": query.import_map(),
|
|
37
74
|
"layers": query.layers(),
|
|
38
75
|
"coupling": query.coupling(),
|
|
@@ -54,15 +91,27 @@ def render_architecture(query: Query) -> str:
|
|
|
54
91
|
|
|
55
92
|
# -- layers -------------------------------------------------------------
|
|
56
93
|
lay = a["layers"]
|
|
94
|
+
# R1-C58/D4: a layer is the first path segment under the root. A package with no
|
|
95
|
+
# subpackages therefore has one layer per module, and the section reads as an
|
|
96
|
+
# architectural overview while saying only "this package is flat". Measured on
|
|
97
|
+
# Pillow: "Layers (105)" over 105 modules. Say it instead of implying structure.
|
|
98
|
+
degenerate = bool(lay["layers"]) and all(len(m) == 1 for m in lay["layers"].values())
|
|
57
99
|
out.append(f"## Layers ({len(lay['layers'])})")
|
|
58
100
|
out.append("")
|
|
101
|
+
if degenerate:
|
|
102
|
+
out.append("_This package has no subpackages, so **layer = module** here: the "
|
|
103
|
+
"grouping below is the module list, and the inter-layer view would "
|
|
104
|
+
"repeat the import graph edge for edge. Not a statement about "
|
|
105
|
+
"structure — a statement that there is none to report._")
|
|
106
|
+
out.append("")
|
|
59
107
|
for name, mods in lay["layers"].items():
|
|
60
108
|
out.append(f"- **{name}** — {len(mods)} module(s)")
|
|
61
109
|
out.append("")
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
110
|
+
if not degenerate:
|
|
111
|
+
out.append("### Inter-layer dependencies")
|
|
112
|
+
out.append("")
|
|
113
|
+
out.extend([f"- {edge} ({n})" for edge, n in lay["edges"].items()] or ["_none._"])
|
|
114
|
+
out.append("")
|
|
66
115
|
if lay["violations"]:
|
|
67
116
|
out.append("### ⚠ Layer violations (mutual dependency)")
|
|
68
117
|
out.append("")
|
|
@@ -75,10 +124,15 @@ def render_architecture(query: Query) -> str:
|
|
|
75
124
|
# R1-C29: never state acyclicity as a property. The map is only as complete as the
|
|
76
125
|
# imports it read, and the reader cannot see which those were unless we say so.
|
|
77
126
|
im = a["import_map"]
|
|
78
|
-
out.append(f"## Import cycles: {
|
|
127
|
+
out.append(f"## Import cycles: {_tangle_head(a['tangles'])}")
|
|
128
|
+
out.append("")
|
|
129
|
+
out.append("_A **tangle** is a group of modules that cannot be separated — the unit you "
|
|
130
|
+
"would act on — and its **independent loops** are how many distinct ways it "
|
|
131
|
+
"closes (the cycle rank). The number of *simple* cycles is combinatorial (one "
|
|
132
|
+
"19-module tangle of a real package has 1080, and its 78-module tangle has "
|
|
133
|
+
"464 109) and is deliberately not reported; one example cycle per tangle is._")
|
|
79
134
|
out.append("")
|
|
80
|
-
out.extend([
|
|
81
|
-
sorted(a["cycles"], key=lambda c: (len(c), c))]
|
|
135
|
+
out.extend(_tangle_lines(a["tangles"])
|
|
82
136
|
or ["_none found in the eager import graph._"])
|
|
83
137
|
out.append("")
|
|
84
138
|
out.append(f"_Read {im['module_level']} module-level, {im['function_local']} "
|
|
@@ -89,33 +143,27 @@ def render_architecture(query: Query) -> str:
|
|
|
89
143
|
out.append("")
|
|
90
144
|
if a["lazy_cycles"]:
|
|
91
145
|
out.append(f"### Dependency cycles closed only by a function-local import: "
|
|
92
|
-
f"{
|
|
146
|
+
f"{_tangle_head(a['lazy_tangles'])}")
|
|
93
147
|
out.append("")
|
|
94
148
|
out.append("_These do **not** break at import time — the lazy import is what "
|
|
95
149
|
"prevents that, and is usually deliberate. They are listed because "
|
|
96
150
|
"the modules are still mutually dependent at run time: neither can be "
|
|
97
151
|
"extracted without the other._")
|
|
98
152
|
out.append("")
|
|
99
|
-
out.extend(
|
|
100
|
-
sorted(a["lazy_cycles"], key=lambda c: (len(c), c))[:20])
|
|
101
|
-
if len(a["lazy_cycles"]) > 20:
|
|
102
|
-
out.append(f"- _… {len(a['lazy_cycles']) - 20} more_")
|
|
153
|
+
out.extend(_tangle_lines(a["lazy_tangles"]))
|
|
103
154
|
out.append("")
|
|
104
155
|
if a["type_only_cycles"]:
|
|
105
156
|
# R1-C49: the third kind, kept apart from the second because the difference is the
|
|
106
157
|
# whole point — these modules have no runtime dependency on each other at all.
|
|
107
158
|
out.append(f"### Dependency cycles closed only by an import that never runs "
|
|
108
|
-
f"(`if TYPE_CHECKING:` or a `.pyi`): {
|
|
159
|
+
f"(`if TYPE_CHECKING:` or a `.pyi`): {_tangle_head(a['type_only_tangles'])}")
|
|
109
160
|
out.append("")
|
|
110
161
|
out.append("_Neither module pulls the other at any moment of execution — they name "
|
|
111
162
|
"each other's types. Not an import-time failure and not runtime coupling; "
|
|
112
163
|
"`no_type_only_cycles = true` gates them if the type layer must not close "
|
|
113
164
|
"a cycle either._")
|
|
114
165
|
out.append("")
|
|
115
|
-
out.extend(
|
|
116
|
-
sorted(a["type_only_cycles"], key=lambda c: (len(c), c))[:20])
|
|
117
|
-
if len(a["type_only_cycles"]) > 20:
|
|
118
|
-
out.append(f"- _… {len(a['type_only_cycles']) - 20} more_")
|
|
166
|
+
out.extend(_tangle_lines(a["type_only_tangles"]))
|
|
119
167
|
out.append("")
|
|
120
168
|
|
|
121
169
|
# -- coupling -----------------------------------------------------------
|
|
@@ -17,15 +17,19 @@ def render_dependencies(query: Query) -> str:
|
|
|
17
17
|
# it; each check supplies its own consequence, or none (issue #8).
|
|
18
18
|
lines.extend(render_lines(query.graph))
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
lazy = query.
|
|
22
|
-
type_only = query.
|
|
20
|
+
tangles = query.import_tangles()
|
|
21
|
+
lazy = query.lazy_import_tangles()
|
|
22
|
+
type_only = query.type_only_import_tangles()
|
|
23
23
|
im = query.import_map()
|
|
24
|
-
|
|
24
|
+
# R1-C58: tangles, not simple cycles — the count of the latter is combinatorial.
|
|
25
|
+
lines.append(f"## Import cycles: {len(tangles)} tangle(s)"
|
|
26
|
+
+ (f", {sum(t['size'] for t in tangles)} module(s)" if tangles else ""))
|
|
25
27
|
lines.append("")
|
|
26
|
-
if
|
|
27
|
-
for
|
|
28
|
-
|
|
28
|
+
if tangles:
|
|
29
|
+
for tg in tangles:
|
|
30
|
+
ex = tg["example"]
|
|
31
|
+
lines.append(f"- **{tg['size']} modules** — e.g. "
|
|
32
|
+
f"{' → '.join(ex)} → {ex[0]}")
|
|
29
33
|
else:
|
|
30
34
|
# R1-C29: "none found" is what was measured; "acyclic" is a property, and the
|
|
31
35
|
# map that would have to be complete to support it demonstrably is not.
|
|
@@ -34,7 +38,7 @@ def render_dependencies(query: Query) -> str:
|
|
|
34
38
|
lines.append(f"_Read {im['module_level']} module-level, {im['function_local']} "
|
|
35
39
|
f"function-local, {im['type_checking']} `TYPE_CHECKING` and {im['stub']} "
|
|
36
40
|
f"`.pyi` import(s); only the first run at import time. {len(lazy)} further "
|
|
37
|
-
f"
|
|
41
|
+
f"tangle(s) close through a function-local import (runtime coupling, not an "
|
|
38
42
|
f"import-time failure) and {len(type_only)} through an import that never "
|
|
39
43
|
f"runs — `if TYPE_CHECKING:` or a `.pyi` (no runtime dependency at all)._")
|
|
40
44
|
lines.append("")
|
|
@@ -34,8 +34,8 @@ def _not_judged(query, contract: ArchitectureContract) -> list[dict]:
|
|
|
34
34
|
# R1-C49: two kinds may be left out, and they are different things. Whichever of them
|
|
35
35
|
# this contract does not gate is what the reader must be told about — reporting one
|
|
36
36
|
# sum over both is the defect the third consumer filed.
|
|
37
|
-
lazy = [] if contract.no_lazy_cycles else query.
|
|
38
|
-
type_only = [] if contract.no_type_only_cycles else query.
|
|
37
|
+
lazy = [] if contract.no_lazy_cycles else query.lazy_import_tangles()
|
|
38
|
+
type_only = [] if contract.no_type_only_cycles else query.type_only_import_tangles()
|
|
39
39
|
return [{
|
|
40
40
|
"rule": "no_cycles",
|
|
41
41
|
"judged": "the eager import graph — imports that run at import time",
|
|
@@ -142,25 +142,26 @@ def render_docs(query: Query) -> str:
|
|
|
142
142
|
out.append("")
|
|
143
143
|
|
|
144
144
|
# -- architecture caveats (the honest health section) -------------------
|
|
145
|
-
cycles = query.
|
|
146
|
-
lazy = query.
|
|
147
|
-
type_only = query.
|
|
145
|
+
cycles = query.import_tangles()
|
|
146
|
+
lazy = query.lazy_import_tangles()
|
|
147
|
+
type_only = query.type_only_import_tangles()
|
|
148
148
|
lay = query.layers()
|
|
149
149
|
gods = query.hotspots()["god_classes"]
|
|
150
150
|
out.append("## Architecture notes")
|
|
151
151
|
out.append("")
|
|
152
152
|
if cycles:
|
|
153
|
-
out.append(f"- **{len(cycles)} import
|
|
154
|
-
|
|
153
|
+
out.append(f"- **{len(cycles)} import tangle(s)**, {sum(c['size'] for c in cycles)} "
|
|
154
|
+
f"module(s) that cannot be separated — e.g. "
|
|
155
|
+
+ "; ".join(" → ".join(c["example"]) for c in cycles[:3]))
|
|
155
156
|
else:
|
|
156
157
|
# R1-C29: no cycle *found*, over the imports that run at import time — not a
|
|
157
158
|
# proof of acyclicity, which this map cannot support.
|
|
158
159
|
out.append("- No import cycle found in the eager import graph.")
|
|
159
160
|
if lazy:
|
|
160
|
-
out.append(f"- **{len(lazy)}
|
|
161
|
+
out.append(f"- **{len(lazy)} tangle(s) closed only by a function-local "
|
|
161
162
|
f"import** — deliberate, and still runtime coupling.")
|
|
162
163
|
if type_only:
|
|
163
|
-
out.append(f"- **{len(type_only)}
|
|
164
|
+
out.append(f"- **{len(type_only)} tangle(s) closed only by an import that "
|
|
164
165
|
f"never runs** (`if TYPE_CHECKING:` or a `.pyi`) — the modules name each "
|
|
165
166
|
f"other's types and have no runtime dependency.")
|
|
166
167
|
if lay["violations"]:
|
|
@@ -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 &
|
|
@@ -127,6 +127,7 @@ tests/test_r1c54_deterministic_rendering.py
|
|
|
127
127
|
tests/test_r1c55_override_grade.py
|
|
128
128
|
tests/test_r1c56_stub_files.py
|
|
129
129
|
tests/test_r1c57_facade_package.py
|
|
130
|
+
tests/test_r1c58_cycle_tangles.py
|
|
130
131
|
tests/test_r1c5_apidiff.py
|
|
131
132
|
tests/test_r1c6_pack.py
|
|
132
133
|
tests/test_r1c7_edge_vocab.py
|
|
@@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta"
|
|
|
9
9
|
# version. README says this plainly, since `pip install codmap` / `import codemap` is a
|
|
10
10
|
# seam a reader will otherwise hit by surprise.
|
|
11
11
|
name = "codmap"
|
|
12
|
-
version = "0.0.
|
|
12
|
+
version = "0.0.20"
|
|
13
13
|
description = "Static analyzer that turns a package's source into a queryable code graph."
|
|
14
14
|
readme = "README.md"
|
|
15
15
|
# M20/D1: measured, not assumed. Each release of the declared range runs the full suite in
|
|
@@ -80,7 +80,10 @@ def test_build_architecture_has_all_sections(q):
|
|
|
80
80
|
# imports each scope contributed). R1-C49 added `type_only_cycles`, the third kind —
|
|
81
81
|
# closed only by an import under `if TYPE_CHECKING:`, i.e. no runtime dependency at all —
|
|
82
82
|
# imports of each scope the map was built from, always, zero included).
|
|
83
|
-
assert set(a) == {"target", "cycles", "lazy_cycles", "type_only_cycles",
|
|
83
|
+
assert set(a) == {"target", "cycles", "lazy_cycles", "type_only_cycles",
|
|
84
|
+
# R1-C58: the tangle is the unit; the `*_cycles` keys stay and carry
|
|
85
|
+
# one example per tangle, because consumers parse them.
|
|
86
|
+
"tangles", "lazy_tangles", "type_only_tangles", "import_map",
|
|
84
87
|
"layers", "coupling", "hotspots"}
|
|
85
88
|
assert a["target"] == "dispatchpkg"
|
|
86
89
|
|
|
@@ -116,16 +116,23 @@ def test_an_eager_cycle_is_still_an_import_cycle(tmp_path):
|
|
|
116
116
|
assert q.lazy_import_cycles() == [], "an eager cycle is not also a lazy one"
|
|
117
117
|
|
|
118
118
|
|
|
119
|
-
def
|
|
119
|
+
def test_two_loops_sharing_a_module_are_two_loops(tmp_path):
|
|
120
120
|
"""Three modules, the lazy import at the far end — the shape both audit scripts lost.
|
|
121
121
|
|
|
122
122
|
The reporter of #11 verified the fix on their tree and found codemap reporting **three**
|
|
123
123
|
cycles where their issue had claimed two. Their scan collected DFS back-edges instead
|
|
124
124
|
of enumerating simple cycles, so a 3-node cycle was swallowed once its nodes were
|
|
125
125
|
coloured; ours mis-anchored relative imports. Two independent scripts written to audit
|
|
126
|
-
a tool, both less careful than the tool, on the same day.
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
a tool, both less careful than the tool, on the same day.
|
|
127
|
+
|
|
128
|
+
**R1-C58 reversed the decision this test used to pin** — "every elementary cycle, not
|
|
129
|
+
one representative per strongly-connected blob" — because enumerating them is
|
|
130
|
+
combinatorial: one 78-module group of a real package has 464 109 of them, and printing
|
|
131
|
+
twenty took ten seconds. What must survive the reversal is the property #11 was about:
|
|
132
|
+
*nothing is swallowed*. Two loops that share a module are still two, and every module
|
|
133
|
+
in a cycle is still named. That is now carried by the tangle's **cycle rank** (its
|
|
134
|
+
independent loops) and its membership, instead of by a list whose length is a
|
|
135
|
+
combinatorial artefact.
|
|
129
136
|
"""
|
|
130
137
|
pkg = _pkg(tmp_path, {
|
|
131
138
|
"a.py": "from pkg.b import beta\n\n\ndef alpha():\n return 1\n",
|
|
@@ -141,11 +148,15 @@ def test_a_longer_cycle_whose_lazy_edge_closes_it_is_enumerated(tmp_path):
|
|
|
141
148
|
" from pkg.e import eps\n return eps()\n")
|
|
142
149
|
q = Query(extract(str(pkg)))
|
|
143
150
|
assert q.import_cycles() == [], "every cycle here is closed by a lazy import"
|
|
144
|
-
|
|
145
|
-
assert
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
"
|
|
151
|
+
tangles = q.lazy_import_tangles()
|
|
152
|
+
assert len(tangles) == 1, "the two loops share pkg.a, so they are one inseparable group"
|
|
153
|
+
tangle = tangles[0]
|
|
154
|
+
assert tangle["modules"] == ["pkg.a", "pkg.b", "pkg.c", "pkg.d", "pkg.e"], (
|
|
155
|
+
"a module of a cycle was swallowed — the failure #11 was about")
|
|
156
|
+
assert tangle["loops"] == 2, (
|
|
157
|
+
"two loops sharing a module are two problems: breaking a → b does not free "
|
|
158
|
+
"a → d → e → a, and a single count of 'one tangle' would hide that")
|
|
159
|
+
assert len(q.lazy_import_cycles()) == 1, "one example cycle per tangle"
|
|
149
160
|
|
|
150
161
|
|
|
151
162
|
def test_import_map_is_emitted_even_when_nothing_is_lazy(tmp_path):
|
|
@@ -176,7 +176,7 @@ def test_the_type_only_rule_gates_the_coupling(tree):
|
|
|
176
176
|
assert arch.check_contract(tree[2], arch.ArchitectureContract(no_lazy_cycles=True)) == []
|
|
177
177
|
v = arch.check_contract(tree[2], arch.ArchitectureContract(no_type_only_cycles=True))
|
|
178
178
|
assert [x.rule for x in v] == ["no_type_only_cycles"]
|
|
179
|
-
assert "
|
|
179
|
+
assert "an import that never runs (`if TYPE_CHECKING:` or a `.pyi`)" in v[0].summary
|
|
180
180
|
|
|
181
181
|
|
|
182
182
|
# -- the mutation ---------------------------------------------------------------------------
|
|
@@ -195,7 +195,7 @@ def test_the_reports_carry_both_kinds_and_say_zero(both, tmp_path):
|
|
|
195
195
|
assert ("Dependency cycles closed only by an import that never runs "
|
|
196
196
|
"(`if TYPE_CHECKING:` or a `.pyi`): 1") in md
|
|
197
197
|
dep = render_dependencies(both)
|
|
198
|
-
assert "1 further
|
|
198
|
+
assert "1 further tangle(s) close through a function-local import" in dep
|
|
199
199
|
assert "1 through an import that never runs" in dep
|
|
200
200
|
assert _flat(build_dependencies(both)["type_only_import_cycles"]) == [["bp.typed_a", "bp.typed_b"]]
|
|
201
201
|
docs = render_docs(both)
|