codmap 0.0.19__tar.gz → 0.0.21__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.21}/PKG-INFO +2 -2
- {codmap-0.0.19 → codmap-0.0.21}/README.md +1 -1
- {codmap-0.0.19 → codmap-0.0.21}/codemap/arch.py +89 -15
- {codmap-0.0.19 → codmap-0.0.21}/codemap/cli.py +26 -9
- {codmap-0.0.19 → codmap-0.0.21}/codemap/model.py +18 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/query.py +91 -8
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/architecture.py +65 -17
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/audit.py +12 -8
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/check.py +56 -8
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/livingdocs.py +8 -7
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/session.py +7 -2
- {codmap-0.0.19 → codmap-0.0.21}/codmap.egg-info/PKG-INFO +2 -2
- {codmap-0.0.19 → codmap-0.0.21}/codmap.egg-info/SOURCES.txt +3 -0
- {codmap-0.0.19 → codmap-0.0.21}/pyproject.toml +1 -1
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_m16_architecture.py +4 -1
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c29_lazy_imports.py +20 -9
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c48_type_checking_imports.py +1 -1
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c49_type_only_cycles.py +1 -1
- codmap-0.0.21/tests/test_r1c58_cycle_tangles.py +151 -0
- codmap-0.0.21/tests/test_r1c62_phantom_rules.py +167 -0
- codmap-0.0.21/tests/test_r1c63_answer_format.py +148 -0
- {codmap-0.0.19 → codmap-0.0.21}/LICENSE +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/__init__.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/apidiff.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/diagnostics.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/extract/behavior.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/extract/griffe_extractor.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/extract/roots.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/extract/union.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/freshness.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/incremental.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/integrations/base.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/provenance.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/scope.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/impact.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/limits.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/mcp_server.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/pack.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/rag.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/review.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/scip.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/server.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/vault.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/store.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/tomlio.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codemap/watch.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/setup.cfg +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_epistemic.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_integrations.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_m15_review.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_m1_query.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_m2_views.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_m9_family.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c30f2_check_scope.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c38_tool_drift.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c39_edge_resolution.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c40_flow_reach.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c41_scope_membership.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c42_deep_tier_stability.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c43_incremental_splice.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c44_absent_answers.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c45_repeat_union.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c46_shadowed_definitions.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c47_incremental_resample.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c50_flow_entry_points.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c53_narrowing_declared.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c54_deterministic_rendering.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c55_override_grade.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c56_stub_files.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c57_facade_package.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_scip_export.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/tests/test_scope.py +0 -0
- {codmap-0.0.19 → codmap-0.0.21}/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.21
|
|
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, **990 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, **990 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 &
|
|
@@ -57,6 +57,11 @@ class ArchitectureContract:
|
|
|
57
57
|
no_lazy_cycles: bool = False
|
|
58
58
|
no_type_only_cycles: bool = False
|
|
59
59
|
exhaustive: bool = False
|
|
60
|
+
# R1-C62: the mirror of `exhaustive`. That one asks "is every layer of the code
|
|
61
|
+
# declared?"; this asks "does every declared name exist?". Opt-in, because a contract
|
|
62
|
+
# written ahead of the code is a documented, legitimate use (see the module docstring)
|
|
63
|
+
# and failing on it by default would turn that practice red on somebody else's tree.
|
|
64
|
+
no_phantom_rules: bool = False
|
|
60
65
|
error: str | None = None
|
|
61
66
|
# R1-C35: the file this contract was looked for in. "No contract found" is only
|
|
62
67
|
# actionable next to *where* we looked — a reader in the wrong directory cannot tell
|
|
@@ -66,7 +71,8 @@ class ArchitectureContract:
|
|
|
66
71
|
def is_empty(self) -> bool:
|
|
67
72
|
return not (self.layers or self.independent or self.forbidden
|
|
68
73
|
or self.no_cycles or self.no_lazy_cycles
|
|
69
|
-
or self.no_type_only_cycles or self.exhaustive
|
|
74
|
+
or self.no_type_only_cycles or self.exhaustive
|
|
75
|
+
or self.no_phantom_rules)
|
|
70
76
|
|
|
71
77
|
|
|
72
78
|
@dataclass(frozen=True)
|
|
@@ -117,9 +123,46 @@ def parse_contract(section: dict) -> ArchitectureContract:
|
|
|
117
123
|
no_lazy_cycles=bool(section.get("no_lazy_cycles", False)),
|
|
118
124
|
no_type_only_cycles=bool(section.get("no_type_only_cycles", False)),
|
|
119
125
|
exhaustive=bool(section.get("exhaustive", False)),
|
|
126
|
+
no_phantom_rules=bool(section.get("no_phantom_rules", False)),
|
|
120
127
|
)
|
|
121
128
|
|
|
122
129
|
|
|
130
|
+
def _graph_layers(query) -> set[str]:
|
|
131
|
+
"""Layer names that exist in the core import graph."""
|
|
132
|
+
return {query._layer_of(m) for m in query.import_graph.nodes
|
|
133
|
+
if query.root_of(m) == "core"}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def applicability(query, contract: ArchitectureContract) -> dict:
|
|
137
|
+
"""Per-rule *declared* vs *applicable* counts, plus the names that do not exist (R1-C62).
|
|
138
|
+
|
|
139
|
+
A rule naming a layer the graph does not contain is **inert by design** — no module, no
|
|
140
|
+
edge, nothing to break — which is what lets a contract be written ahead of the code. The
|
|
141
|
+
defect that made this function necessary is not the inertness: it is that the gate
|
|
142
|
+
counted such a rule as *enforced*, so a renamed layer left a green tick over a rule that
|
|
143
|
+
could no longer fire.
|
|
144
|
+
|
|
145
|
+
Applicability is presence of the **name**, nothing cleverer: `layers` needs the layer,
|
|
146
|
+
`independent` needs at least two members of the group (one cannot import itself), and
|
|
147
|
+
`forbidden` needs both ends. Whether a violation is even possible between two existing
|
|
148
|
+
layers is not asked, and should not be.
|
|
149
|
+
"""
|
|
150
|
+
present = _graph_layers(query)
|
|
151
|
+
named = set(contract.layers) | {m for grp in contract.independent for m in grp}
|
|
152
|
+
named |= {n for pair in contract.forbidden for n in pair}
|
|
153
|
+
return {
|
|
154
|
+
"layers": {"declared": len(contract.layers),
|
|
155
|
+
"applicable": sum(1 for x in contract.layers if x in present)},
|
|
156
|
+
"independent": {"declared": len(contract.independent),
|
|
157
|
+
"applicable": sum(1 for g in contract.independent
|
|
158
|
+
if len([x for x in g if x in present]) >= 2)},
|
|
159
|
+
"forbidden": {"declared": len(contract.forbidden),
|
|
160
|
+
"applicable": sum(1 for (a, b) in contract.forbidden
|
|
161
|
+
if a in present and b in present)},
|
|
162
|
+
"absent_names": tuple(sorted(n for n in named if n not in present)),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
123
166
|
def _core_layer_edges(query) -> list[tuple[str, str, str, str]]:
|
|
124
167
|
"""Cross-layer core→core import edges as (importer, imported, layer_i, layer_j)."""
|
|
125
168
|
ig = query.import_graph
|
|
@@ -133,6 +176,20 @@ def _core_layer_edges(query) -> list[tuple[str, str, str, str]]:
|
|
|
133
176
|
return out
|
|
134
177
|
|
|
135
178
|
|
|
179
|
+
|
|
180
|
+
def _tangle_line(tangle: dict) -> str:
|
|
181
|
+
"""One gate line per tangle: the example cycle, then the members (R1-C58).
|
|
182
|
+
|
|
183
|
+
The example comes first because it is what a reader acts on; the membership follows
|
|
184
|
+
because the tangle, not the cycle, is what has to be broken.
|
|
185
|
+
"""
|
|
186
|
+
ex = tangle["example"]
|
|
187
|
+
head = " → ".join(ex) + " → " + ex[0]
|
|
188
|
+
if tangle["size"] <= len(ex):
|
|
189
|
+
return head
|
|
190
|
+
return f"{head} (tangle of {tangle['size']}: {', '.join(tangle['modules'])})"
|
|
191
|
+
|
|
192
|
+
|
|
136
193
|
def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
|
|
137
194
|
"""Evaluate every rule against the graph; return the violations (empty = clean)."""
|
|
138
195
|
if contract.is_empty():
|
|
@@ -192,13 +249,16 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
|
|
|
192
249
|
# migrated into the gate. So: the gate stays eager, the *disclosure* is mandatory (see
|
|
193
250
|
# `build_check`), and a contract that wants the coupling gated says so.
|
|
194
251
|
if contract.no_cycles:
|
|
195
|
-
|
|
196
|
-
if
|
|
197
|
-
|
|
252
|
+
tangles = query.import_tangles()
|
|
253
|
+
if tangles:
|
|
254
|
+
# R1-C58: one violation per **tangle**, not per simple cycle. A tangle of 19
|
|
255
|
+
# modules used to produce 1080 violations that were all the same problem, and
|
|
256
|
+
# the gate's own output then took a thousand lines to say it once.
|
|
198
257
|
violations.append(Violation(
|
|
199
258
|
"no_cycles",
|
|
200
|
-
f"{len(
|
|
201
|
-
|
|
259
|
+
f"{len(tangles)} import tangle(s), "
|
|
260
|
+
f"{sum(t['size'] for t in tangles)} module(s)",
|
|
261
|
+
modules=tuple(_tangle_line(t) for t in tangles),
|
|
202
262
|
))
|
|
203
263
|
|
|
204
264
|
# -- no_lazy_cycles: opt in to gating the coupling a lazy import hides -------
|
|
@@ -206,13 +266,13 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
|
|
|
206
266
|
# not a gate, and a lazy import is the accepted way to break an import cycle — so this
|
|
207
267
|
# is the contract owner's call to state, not a default to pick on their behalf.
|
|
208
268
|
if contract.no_lazy_cycles:
|
|
209
|
-
lazy = query.
|
|
269
|
+
lazy = query.lazy_import_tangles()
|
|
210
270
|
if lazy:
|
|
211
|
-
worst = sorted(lazy, key=lambda c: (len(c), c))
|
|
212
271
|
violations.append(Violation(
|
|
213
272
|
"no_lazy_cycles",
|
|
214
|
-
f"{len(lazy)}
|
|
215
|
-
|
|
273
|
+
f"{len(lazy)} tangle(s) closed only by a function-local import, "
|
|
274
|
+
f"{sum(t['size'] for t in lazy)} module(s)",
|
|
275
|
+
modules=tuple(_tangle_line(t) for t in lazy),
|
|
216
276
|
))
|
|
217
277
|
|
|
218
278
|
# -- no_type_only_cycles: the third kind, and the one with no runtime dependency ----
|
|
@@ -223,14 +283,14 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
|
|
|
223
283
|
# against lazy imports used as a way around `no_cycles`, and the standard typing
|
|
224
284
|
# idiom is not that.
|
|
225
285
|
if contract.no_type_only_cycles:
|
|
226
|
-
type_only = query.
|
|
286
|
+
type_only = query.type_only_import_tangles()
|
|
227
287
|
if type_only:
|
|
228
|
-
worst = sorted(type_only, key=lambda c: (len(c), c))
|
|
229
288
|
violations.append(Violation(
|
|
230
289
|
"no_type_only_cycles",
|
|
231
|
-
f"{len(type_only)}
|
|
232
|
-
f"`if TYPE_CHECKING:`"
|
|
233
|
-
|
|
290
|
+
f"{len(type_only)} tangle(s) closed only by an import that never runs "
|
|
291
|
+
f"(`if TYPE_CHECKING:` or a `.pyi`), "
|
|
292
|
+
f"{sum(t['size'] for t in type_only)} module(s)",
|
|
293
|
+
modules=tuple(_tangle_line(t) for t in type_only),
|
|
234
294
|
))
|
|
235
295
|
|
|
236
296
|
# -- exhaustive: every core module's layer must be declared -----------------
|
|
@@ -249,4 +309,18 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
|
|
|
249
309
|
modules=tuple(undeclared),
|
|
250
310
|
))
|
|
251
311
|
|
|
312
|
+
# -- no_phantom_rules: every name the contract mentions must exist -----------
|
|
313
|
+
# R1-C62, the mirror of `exhaustive`. Off by default on purpose: inert rules are how a
|
|
314
|
+
# contract gets written ahead of the code. On, it catches the drift the count alone
|
|
315
|
+
# only *discloses* — a layer renamed while the contract kept guarding the old name.
|
|
316
|
+
if contract.no_phantom_rules:
|
|
317
|
+
absent = applicability(query, contract)["absent_names"]
|
|
318
|
+
if absent:
|
|
319
|
+
violations.append(Violation(
|
|
320
|
+
"no_phantom_rules",
|
|
321
|
+
f"{len(absent)} name(s) in the contract are absent from the graph, "
|
|
322
|
+
"so the rules naming them cannot apply",
|
|
323
|
+
modules=absent,
|
|
324
|
+
))
|
|
325
|
+
|
|
252
326
|
return violations
|
|
@@ -73,6 +73,23 @@ _REPORTS = {
|
|
|
73
73
|
_REPORT_KINDS = sorted(_REPORTS) + ["impact", "communities", "flows"] # extra args
|
|
74
74
|
|
|
75
75
|
|
|
76
|
+
def _emit_answer(text: str) -> None:
|
|
77
|
+
"""Print a markdown answer with the answer-format trailer (R1-C63).
|
|
78
|
+
|
|
79
|
+
One seam rather than a line in every renderer: the renderers stay pure functions of the
|
|
80
|
+
graph, and there is one place to forget rather than nine. The trailer is what a consumer
|
|
81
|
+
diffing our output sees when the *shape* of an answer changes deliberately — 0.0.20
|
|
82
|
+
changed the gate's lines while the graph stayed byte-identical, and on a text diff that
|
|
83
|
+
looked exactly like the #20 defect, where the text moved between runs of one version. The
|
|
84
|
+
only thing that told them apart was a message written by hand.
|
|
85
|
+
"""
|
|
86
|
+
from codemap.model import ANSWER_FORMAT, SCHEMA_VERSION
|
|
87
|
+
print(text, end="")
|
|
88
|
+
if not text.endswith("\n"):
|
|
89
|
+
print()
|
|
90
|
+
print(f"_answer format {ANSWER_FORMAT} · schema {SCHEMA_VERSION}_")
|
|
91
|
+
|
|
92
|
+
|
|
76
93
|
def _graph_from(args):
|
|
77
94
|
if getattr(args, "build", None):
|
|
78
95
|
return extract(args.build, deep=getattr(args, "deep", False))
|
|
@@ -507,28 +524,28 @@ def _cmd_report(args) -> int:
|
|
|
507
524
|
if args.kind == "impact":
|
|
508
525
|
if not args.symbol:
|
|
509
526
|
raise SystemExit("error: report impact needs --symbol <name>")
|
|
510
|
-
|
|
511
|
-
|
|
527
|
+
_emit_answer(render_impact(Query(graph), args.symbol, depth=args.depth,
|
|
528
|
+
flow_depth=args.flow_depth))
|
|
512
529
|
return 0
|
|
513
530
|
if args.kind in ("communities", "flows"):
|
|
514
531
|
from codemap.serve.subsystems import render_communities, render_flows
|
|
515
532
|
q = Query(graph)
|
|
516
533
|
out = (render_communities(q) if args.kind == "communities"
|
|
517
534
|
else render_flows(q, args.symbol, depth=args.depth))
|
|
518
|
-
|
|
535
|
+
_emit_answer(out)
|
|
519
536
|
return 0
|
|
520
537
|
if args.kind == "dead-code":
|
|
521
538
|
from codemap.serve.audit import load_dead_code_whitelist
|
|
522
539
|
root = getattr(args, "source_root", None) or os.getcwd()
|
|
523
540
|
whitelist, wl_error = load_dead_code_whitelist(root)
|
|
524
|
-
|
|
541
|
+
_emit_answer(render_dead_code(Query(graph),
|
|
525
542
|
whitelist=whitelist,
|
|
526
543
|
min_confidence=args.min_confidence,
|
|
527
|
-
whitelist_error=wl_error)
|
|
544
|
+
whitelist_error=wl_error))
|
|
528
545
|
return 0
|
|
529
546
|
renderer = _REPORTS[args.kind]
|
|
530
547
|
payload = renderer(graph) if args.kind == "api-surface" else renderer(Query(graph))
|
|
531
|
-
|
|
548
|
+
_emit_answer(payload)
|
|
532
549
|
return 0
|
|
533
550
|
|
|
534
551
|
|
|
@@ -755,13 +772,13 @@ def _cmd_check(args) -> int:
|
|
|
755
772
|
# would sort an unreadable contract into the success branch of every `if rc == 2` that
|
|
756
773
|
# already exists in someone's pipeline.
|
|
757
774
|
if contract.error:
|
|
758
|
-
|
|
775
|
+
_emit_answer(render_check(q, contract, []))
|
|
759
776
|
return 2
|
|
760
777
|
if contract.is_empty() and args.require_contract:
|
|
761
|
-
|
|
778
|
+
_emit_answer(render_check(q, contract, []))
|
|
762
779
|
raise SystemExit("error: no [architecture] contract found (--require-contract)")
|
|
763
780
|
violations = check_contract(q, contract)
|
|
764
|
-
|
|
781
|
+
_emit_answer(render_check(q, contract, violations))
|
|
765
782
|
return 2 if violations else 0
|
|
766
783
|
|
|
767
784
|
|
|
@@ -77,6 +77,24 @@ from typing import Any
|
|
|
77
77
|
# (they had ``lineno`` alone, so `search` answered a line number with no file).
|
|
78
78
|
SCHEMA_VERSION = "0.13"
|
|
79
79
|
|
|
80
|
+
# R1-C63: the *answer* has a shape too, and consumers diff it. `SCHEMA_VERSION` versions the
|
|
81
|
+
# artifact; 0.0.20 left the graph byte-identical and changed the architecture report and the
|
|
82
|
+
# gate's lines completely (a cycle answer became a tangle), which on a text diff is
|
|
83
|
+
# indistinguishable from the #20 defect — where the text moved between runs of one version,
|
|
84
|
+
# without anyone deciding it. The only thing that told the consumer apart was a message I
|
|
85
|
+
# wrote by hand.
|
|
86
|
+
#
|
|
87
|
+
# Bump on a change to the **structure a consumer sees**: a block appears or vanishes, a
|
|
88
|
+
# counted unit changes meaning, a verdict or heading changes shape, a field in a structured
|
|
89
|
+
# answer changes type or semantics. Do **not** bump for wording, typos or added prose inside
|
|
90
|
+
# an existing note — a version that ticks on whitespace stops being read, which is worse than
|
|
91
|
+
# not having one. Independent of SCHEMA_VERSION by design: the graph can be identical while
|
|
92
|
+
# this moves, and that is the case it exists for.
|
|
93
|
+
#
|
|
94
|
+
# Starts at 1 with 0.0.21 and makes no claim about earlier releases: the answer format changed
|
|
95
|
+
# many times before it, and numbering that retroactively would be inventing a history.
|
|
96
|
+
ANSWER_FORMAT = 1
|
|
97
|
+
|
|
80
98
|
# Closed vocabulary of edge types (R1-C7). Node ``kind`` is deliberately an OPEN set
|
|
81
99
|
# (DESIGN §2 — new entity kinds may appear), but edges are TYPED: every relationship
|
|
82
100
|
# codemap emits is one of these, each with a fixed meaning. This is the machine-
|
|
@@ -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("")
|