codmap 0.0.12__tar.gz → 0.0.14__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.12/codmap.egg-info → codmap-0.0.14}/PKG-INFO +1 -1
- {codmap-0.0.12 → codmap-0.0.14}/codemap/arch.py +24 -2
- {codmap-0.0.12 → codmap-0.0.14}/codemap/extract/griffe_extractor.py +76 -15
- {codmap-0.0.12 → codmap-0.0.14}/codemap/extract/union.py +22 -9
- {codmap-0.0.12 → codmap-0.0.14}/codemap/query.py +39 -8
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/architecture.py +27 -7
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/audit.py +9 -5
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/check.py +31 -15
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/livingdocs.py +6 -1
- {codmap-0.0.12 → codmap-0.0.14/codmap.egg-info}/PKG-INFO +1 -1
- {codmap-0.0.12 → codmap-0.0.14}/codmap.egg-info/SOURCES.txt +2 -0
- {codmap-0.0.12 → codmap-0.0.14}/pyproject.toml +1 -1
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_m16_architecture.py +3 -1
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_m1_query.py +11 -4
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c29_lazy_imports.py +4 -4
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c30f2_check_scope.py +11 -3
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c45_repeat_union.py +49 -0
- codmap-0.0.14/tests/test_r1c48_type_checking_imports.py +193 -0
- codmap-0.0.14/tests/test_r1c49_type_only_cycles.py +206 -0
- {codmap-0.0.12 → codmap-0.0.14}/LICENSE +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/README.md +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/__init__.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/apidiff.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/cli.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/diagnostics.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/extract/behavior.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/extract/roots.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/freshness.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/incremental.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/integrations/base.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/model.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/provenance.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/scope.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/impact.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/limits.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/mcp_server.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/pack.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/rag.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/review.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/scip.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/server.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/session.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/vault.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/store.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/tomlio.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codemap/watch.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/setup.cfg +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_epistemic.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_integrations.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_m15_review.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_m2_views.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_m9_family.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c38_tool_drift.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c41_scope_membership.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c42_deep_tier_stability.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c43_incremental_splice.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c44_absent_answers.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c46_shadowed_definitions.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c47_incremental_resample.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_scip_export.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_scope.py +0 -0
- {codmap-0.0.12 → codmap-0.0.14}/tests/test_subsystems.py +0 -0
|
@@ -21,6 +21,7 @@ integration gate reads). All rules operate on the **core** module import graph
|
|
|
21
21
|
# also gate the coupling a lazy import hides: cycles closed only by an import
|
|
22
22
|
# written inside a function. Off by default — see below.
|
|
23
23
|
no_lazy_cycles = false
|
|
24
|
+
no_type_only_cycles = false
|
|
24
25
|
# every core module's layer must appear in `layers` (catches a new,
|
|
25
26
|
# undeclared top-level package slipping in).
|
|
26
27
|
exhaustive = false
|
|
@@ -54,6 +55,7 @@ class ArchitectureContract:
|
|
|
54
55
|
forbidden: tuple[tuple[str, str], ...] = ()
|
|
55
56
|
no_cycles: bool = False
|
|
56
57
|
no_lazy_cycles: bool = False
|
|
58
|
+
no_type_only_cycles: bool = False
|
|
57
59
|
exhaustive: bool = False
|
|
58
60
|
error: str | None = None
|
|
59
61
|
# R1-C35: the file this contract was looked for in. "No contract found" is only
|
|
@@ -63,7 +65,8 @@ class ArchitectureContract:
|
|
|
63
65
|
|
|
64
66
|
def is_empty(self) -> bool:
|
|
65
67
|
return not (self.layers or self.independent or self.forbidden
|
|
66
|
-
or self.no_cycles or self.no_lazy_cycles
|
|
68
|
+
or self.no_cycles or self.no_lazy_cycles
|
|
69
|
+
or self.no_type_only_cycles or self.exhaustive)
|
|
67
70
|
|
|
68
71
|
|
|
69
72
|
@dataclass(frozen=True)
|
|
@@ -71,7 +74,7 @@ class Violation:
|
|
|
71
74
|
"""One broken rule, with the concrete import edges (or cycle) that break it."""
|
|
72
75
|
|
|
73
76
|
rule: str # layered | independent | forbidden | no_cycles | no_lazy_cycles
|
|
74
|
-
# | exhaustive
|
|
77
|
+
# | no_type_only_cycles | exhaustive
|
|
75
78
|
summary: str # human one-liner
|
|
76
79
|
edges: tuple[tuple[str, str], ...] = field(default=()) # offending (importer, imported)
|
|
77
80
|
modules: tuple[str, ...] = field(default=()) # for exhaustive / cycles
|
|
@@ -112,6 +115,7 @@ def parse_contract(section: dict) -> ArchitectureContract:
|
|
|
112
115
|
forbidden=tuple(forbidden),
|
|
113
116
|
no_cycles=bool(section.get("no_cycles", False)),
|
|
114
117
|
no_lazy_cycles=bool(section.get("no_lazy_cycles", False)),
|
|
118
|
+
no_type_only_cycles=bool(section.get("no_type_only_cycles", False)),
|
|
115
119
|
exhaustive=bool(section.get("exhaustive", False)),
|
|
116
120
|
)
|
|
117
121
|
|
|
@@ -211,6 +215,24 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
|
|
|
211
215
|
modules=tuple(" → ".join(c) + " → " + c[0] for c in worst),
|
|
212
216
|
))
|
|
213
217
|
|
|
218
|
+
# -- no_type_only_cycles: the third kind, and the one with no runtime dependency ----
|
|
219
|
+
# R1-C49 (issue #18): a cycle that needs an import under `if TYPE_CHECKING:` cannot
|
|
220
|
+
# break at import time and does not couple the modules at run time either — the two
|
|
221
|
+
# only name each other's types. Gating it is a style choice about the type layer, so
|
|
222
|
+
# it is opt-in and separate: the consumer who asked for this had `no_lazy_cycles` on
|
|
223
|
+
# against lazy imports used as a way around `no_cycles`, and the standard typing
|
|
224
|
+
# idiom is not that.
|
|
225
|
+
if contract.no_type_only_cycles:
|
|
226
|
+
type_only = query.type_only_import_cycles()
|
|
227
|
+
if type_only:
|
|
228
|
+
worst = sorted(type_only, key=lambda c: (len(c), c))
|
|
229
|
+
violations.append(Violation(
|
|
230
|
+
"no_type_only_cycles",
|
|
231
|
+
f"{len(type_only)} dependency cycle(s) closed only by an import under "
|
|
232
|
+
f"`if TYPE_CHECKING:`",
|
|
233
|
+
modules=tuple(" → ".join(c) + " → " + c[0] for c in worst),
|
|
234
|
+
))
|
|
235
|
+
|
|
214
236
|
# -- exhaustive: every core module's layer must be declared -----------------
|
|
215
237
|
if contract.exhaustive and contract.layers:
|
|
216
238
|
declared = set(contract.layers)
|
|
@@ -265,8 +265,9 @@ def _input_report(graph, pkg_dir: Path, root: Path, walk) -> dict:
|
|
|
265
265
|
return report
|
|
266
266
|
|
|
267
267
|
|
|
268
|
-
def _source_import_targets(module) -> list[tuple[str, str]]:
|
|
269
|
-
"""``(target, scope)``
|
|
268
|
+
def _source_import_targets(module) -> tuple[list[tuple[str, str]], set[str]]:
|
|
269
|
+
"""``([(target, scope)], eager targets)`` — the imports griffe's module-level map does
|
|
270
|
+
not carry, and the targets it carries correctly.
|
|
270
271
|
|
|
271
272
|
Two families, one traversal, one parse — griffe records neither, and both used to be
|
|
272
273
|
invisible to the import graph:
|
|
@@ -282,6 +283,11 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
|
|
|
282
283
|
- **imports written in a class body** → scope ``"module"``. They run at
|
|
283
284
|
class-definition time, i.e. at import time, so they are ordinary eager dependencies
|
|
284
285
|
and *can* close a real import cycle. griffe does not record them either (measured).
|
|
286
|
+
- **imports under `if TYPE_CHECKING:`** (R1-C48 / issue #18) → scope
|
|
287
|
+
``"type_checking"``. griffe *does* record these, as module-level — it has no notion
|
|
288
|
+
of a condition that is always false at run time — so the second value returned is
|
|
289
|
+
the set of targets imported at plain module level: the caller demotes griffe's entry
|
|
290
|
+
for a target that appears only under ``TYPE_CHECKING``.
|
|
285
291
|
|
|
286
292
|
Cost discipline, and it is not theoretical: the first version of this walked every
|
|
287
293
|
function's subtree separately, which is quadratic in nesting and cost the dogfood
|
|
@@ -293,13 +299,13 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
|
|
|
293
299
|
try:
|
|
294
300
|
src = module.source
|
|
295
301
|
except Exception: # no source (namespace dir, synthetic)
|
|
296
|
-
return []
|
|
302
|
+
return [], set()
|
|
297
303
|
if not NESTED_IMPORT_HINT.search(src):
|
|
298
|
-
return []
|
|
304
|
+
return [], set()
|
|
299
305
|
try:
|
|
300
306
|
tree = ast.parse(src)
|
|
301
307
|
except (SyntaxError, ValueError):
|
|
302
|
-
return []
|
|
308
|
+
return [], set() # unreadable: D2's report owns this file
|
|
303
309
|
modpath = module.canonical_path
|
|
304
310
|
f = module_file(module)
|
|
305
311
|
is_pkg = f is not None and f.name == "__init__.py"
|
|
@@ -312,9 +318,11 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
|
|
|
312
318
|
return node.module or ""
|
|
313
319
|
|
|
314
320
|
out: list[tuple[str, str]] = []
|
|
321
|
+
eager: set[str] = set() # targets imported at plain module level (griffe has them)
|
|
315
322
|
|
|
316
323
|
def visit(node, scope: str | None) -> None:
|
|
317
|
-
"""``scope`` is None at module level (griffe has those), else
|
|
324
|
+
"""``scope`` is None at module level (griffe has those), else
|
|
325
|
+
module|function|type_checking."""
|
|
318
326
|
for child in ast.iter_child_nodes(node):
|
|
319
327
|
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
320
328
|
visit(child, "function")
|
|
@@ -322,9 +330,21 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
|
|
|
322
330
|
# A class body runs at import time — unless we are already inside a
|
|
323
331
|
# function, in which case the whole thing does not.
|
|
324
332
|
visit(child, "function" if scope == "function" else "module")
|
|
333
|
+
elif isinstance(child, ast.If) and scope in (None, "type_checking") \
|
|
334
|
+
and (tc := _type_checking_branches(child)) is not None:
|
|
335
|
+
# R1-C48 (issue #18): `if TYPE_CHECKING:` never runs. Its body is a third
|
|
336
|
+
# scope; its `else` is ordinary module level. Inside a function the whole
|
|
337
|
+
# `if` is function-local already and is not looked at.
|
|
338
|
+
body_scope, else_scope = tc
|
|
339
|
+
for stmt in child.body:
|
|
340
|
+
visit_stmt(stmt, body_scope if body_scope else scope)
|
|
341
|
+
for stmt in child.orelse:
|
|
342
|
+
visit_stmt(stmt, else_scope if else_scope else scope)
|
|
325
343
|
elif isinstance(child, ast.Import):
|
|
326
344
|
if scope is not None:
|
|
327
345
|
out.extend((a.name, scope) for a in child.names)
|
|
346
|
+
else:
|
|
347
|
+
eager.update(a.name for a in child.names)
|
|
328
348
|
elif isinstance(child, ast.ImportFrom):
|
|
329
349
|
target = resolve(child)
|
|
330
350
|
if not target:
|
|
@@ -333,6 +353,8 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
|
|
|
333
353
|
if scope is None:
|
|
334
354
|
if star: # the D3 case: griffe records nothing
|
|
335
355
|
out.append((target, "module"))
|
|
356
|
+
eager.add(target)
|
|
357
|
+
eager.update(f"{target}.{a.name}" for a in child.names if a.name != "*")
|
|
336
358
|
continue
|
|
337
359
|
# `from pkg.mod import name` → keep the member paths so the resolver walks
|
|
338
360
|
# down to the containing module exactly as it does for the module-level
|
|
@@ -343,19 +365,49 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
|
|
|
343
365
|
else:
|
|
344
366
|
visit(child, scope)
|
|
345
367
|
|
|
368
|
+
def visit_stmt(stmt, scope: str | None) -> None:
|
|
369
|
+
"""Route one statement through ``visit`` as if it were the only child."""
|
|
370
|
+
holder = ast.Module(body=[stmt], type_ignores=[])
|
|
371
|
+
visit(holder, scope)
|
|
372
|
+
|
|
346
373
|
visit(tree, None)
|
|
347
|
-
return out
|
|
374
|
+
return out, eager
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _type_checking_branches(node: ast.If) -> tuple[str | None, str | None] | None:
|
|
378
|
+
"""``(scope of body, scope of else)`` for a recognised ``TYPE_CHECKING`` test, else None.
|
|
379
|
+
|
|
380
|
+
Recognised, narrowly (design D3): the bare name ``TYPE_CHECKING``, any attribute named
|
|
381
|
+
``TYPE_CHECKING`` (``typing.TYPE_CHECKING``, ``t.TYPE_CHECKING``), and ``not`` of either,
|
|
382
|
+
which swaps the branches. A compound test (``TYPE_CHECKING or X``) is *not* recognised
|
|
383
|
+
and stays eager: a condition the tool cannot read is judged strictly, never leniently.
|
|
384
|
+
``None`` in a slot means "the enclosing scope" (module level).
|
|
385
|
+
"""
|
|
386
|
+
test, negated = node.test, False
|
|
387
|
+
if isinstance(test, ast.UnaryOp) and isinstance(test.op, ast.Not):
|
|
388
|
+
test, negated = test.operand, True
|
|
389
|
+
is_tc = ((isinstance(test, ast.Name) and test.id == "TYPE_CHECKING")
|
|
390
|
+
or (isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING"))
|
|
391
|
+
if not is_tc:
|
|
392
|
+
return None
|
|
393
|
+
return (None, "type_checking") if negated else ("type_checking", None)
|
|
348
394
|
|
|
349
395
|
|
|
350
396
|
def _collect(graph, obj, root, target_pkg, walk) -> None:
|
|
351
397
|
if obj.kind.value == "module":
|
|
352
398
|
_claim(obj, walk) # the root claims its own path before any member is walked
|
|
353
399
|
_add_node(graph, obj, root)
|
|
400
|
+
# R1-C23/D3 (star imports) + R1-C29 (function-local and class-body imports) +
|
|
401
|
+
# R1-C48 (`if TYPE_CHECKING:`): everything griffe's module-level map does not
|
|
402
|
+
# carry, or carries under the wrong scope, in one parse.
|
|
403
|
+
nested, eager = _source_import_targets(obj)
|
|
404
|
+
type_checking = {tgt for tgt, scope in nested if scope == "type_checking"}
|
|
354
405
|
for name, tgt in (obj.imports or {}).items():
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
406
|
+
# griffe files an import under `if TYPE_CHECKING:` as module-level; it never
|
|
407
|
+
# runs. Demote it unless the same target is also imported eagerly (D2).
|
|
408
|
+
scope = "type_checking" if tgt in type_checking and tgt not in eager else "module"
|
|
409
|
+
walk.imports.append((obj.canonical_path, tgt, scope))
|
|
410
|
+
for tgt, scope in nested:
|
|
359
411
|
walk.imports.append((obj.canonical_path, tgt, scope))
|
|
360
412
|
for name, member in obj.members.items():
|
|
361
413
|
if member.is_alias:
|
|
@@ -429,6 +481,15 @@ def _emit_decorated_by(graph, obj) -> None:
|
|
|
429
481
|
|
|
430
482
|
# -- pass 2: resolve export + import edges against known nodes ----------------
|
|
431
483
|
|
|
484
|
+
#: How strongly an import scope reaches its target: an edge carries the strongest (R1-C29
|
|
485
|
+
#: D2, R1-C48 D2). ``module`` runs at import time, ``function`` when the function runs,
|
|
486
|
+
#: ``type_checking`` never. A pair reached by both a function-local and a ``TYPE_CHECKING``
|
|
487
|
+
#: import is a **function-local** dependency: it can execute, and labelling it by the
|
|
488
|
+
#: import that cannot would let a tree launder runtime coupling into the type layer
|
|
489
|
+
#: (R1-C49, and the test that says so).
|
|
490
|
+
_SCOPE_RANK = {"module": 0, "function": 1, "type_checking": 2}
|
|
491
|
+
|
|
492
|
+
|
|
432
493
|
def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
433
494
|
module_ids = sorted(
|
|
434
495
|
(n.id for n in graph.nodes.values() if n.kind == "module"), key=len, reverse=True
|
|
@@ -457,7 +518,7 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
|
457
518
|
# the eager import it is. `scope` only ever *weakens* to "function" for a pair that
|
|
458
519
|
# has no module-level import at all — the edge says how the dependency is reached at
|
|
459
520
|
# its earliest, never how it happens to appear last in the walk.
|
|
460
|
-
ordered = sorted(imports, key=lambda t: t[2]
|
|
521
|
+
ordered = sorted(imports, key=lambda t: _SCOPE_RANK.get(t[2], 0))
|
|
461
522
|
|
|
462
523
|
# pass A — package-qualified targets. Exact, and run first so that a pair reachable
|
|
463
524
|
# both ways is recorded as exact rather than inferred.
|
|
@@ -472,7 +533,7 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
|
472
533
|
if key in seen:
|
|
473
534
|
continue
|
|
474
535
|
seen.add(key)
|
|
475
|
-
extras = {"scope":
|
|
536
|
+
extras = {"scope": scope} if scope != "module" else {}
|
|
476
537
|
graph.add_edge(Edge("imports", src_module, tgt_module, extras=extras))
|
|
477
538
|
|
|
478
539
|
# pass B — flat layout (R1-C21): sibling modules importing each other by bare name
|
|
@@ -489,8 +550,8 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
|
489
550
|
continue
|
|
490
551
|
seen.add(key)
|
|
491
552
|
extras = {"resolution": "flat"}
|
|
492
|
-
if scope
|
|
493
|
-
extras["scope"] =
|
|
553
|
+
if scope != "module":
|
|
554
|
+
extras["scope"] = scope
|
|
494
555
|
graph.add_edge(Edge("imports", src_module, tgt_module, extras=extras))
|
|
495
556
|
|
|
496
557
|
|
|
@@ -36,7 +36,10 @@ An edge seen in fewer than N runs carries ``extras.seen: k``; nothing is written
|
|
|
36
36
|
``k == N``, so a single-sample build's edges are byte-identical to what they were. Node
|
|
37
37
|
counters (``calls`` / ``attr_access``) count *sites*, which dedupe into edges — the
|
|
38
38
|
measured node had three counter variants behind one flapping edge — so per node the
|
|
39
|
-
variant with the fewest ``unresolved`` is kept (tie: most ``resolved``, tie:
|
|
39
|
+
variant with the fewest ``unresolved`` is kept (tie: most ``resolved``, tie: the serialized
|
|
40
|
+
counter). Every choice between variants of one key is a **total order** over the candidates
|
|
41
|
+
(:func:`_rank`, :func:`_counter_rank`): the merged graph is a function of the set of
|
|
42
|
+
samples, and the same samples in any order produce the same bytes.
|
|
40
43
|
|
|
41
44
|
**Every sample is a fresh process.** The share above was measured across processes —
|
|
42
45
|
the consumer's 175 builds and the eight here. Repeating the behavioural layer *inside*
|
|
@@ -74,20 +77,30 @@ def _identity(e: Edge) -> tuple:
|
|
|
74
77
|
json.dumps(e.extras, sort_keys=True, ensure_ascii=False))
|
|
75
78
|
|
|
76
79
|
|
|
77
|
-
def
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
def _rank(e: Edge) -> tuple:
|
|
81
|
+
"""Total order among the variants of one identity key: ``deep`` beats any other
|
|
82
|
+
resolution, more call sites beat fewer, and the serialized ``extras`` break what is
|
|
83
|
+
left — so the winner is a function of the *set* of samples, never of the order they
|
|
84
|
+
arrived in. The consumer's hint on codemap#17: where two candidates are equally
|
|
85
|
+
legitimate, 'first one wins' is a dependence on arrival order, and one that a probe
|
|
86
|
+
permuting the *output* can never see."""
|
|
87
|
+
ex = e.extras
|
|
88
|
+
return (ex.get("resolution") == "deep", ex.get("callsites", 0),
|
|
89
|
+
json.dumps(ex, sort_keys=True, ensure_ascii=False))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _counter_rank(c: dict) -> tuple:
|
|
93
|
+
return (-c.get("unresolved", 0), c.get("resolved", 0), json.dumps(c, sort_keys=True))
|
|
80
94
|
|
|
81
95
|
|
|
82
96
|
def _better_counter(a: dict | None, b: dict | None) -> dict | None:
|
|
83
|
-
"""The counter variant that resolved more sites (``None`` never beats a value)
|
|
97
|
+
"""The counter variant that resolved more sites (``None`` never beats a value);
|
|
98
|
+
a tie is broken by the serialized counter, not by which sample came first."""
|
|
84
99
|
if a is None:
|
|
85
100
|
return b
|
|
86
101
|
if b is None:
|
|
87
102
|
return a
|
|
88
|
-
|
|
89
|
-
kb = (-b.get("unresolved", 0), b.get("resolved", 0))
|
|
90
|
-
return b if kb > ka else a
|
|
103
|
+
return b if _counter_rank(b) > _counter_rank(a) else a
|
|
91
104
|
|
|
92
105
|
|
|
93
106
|
def merge_samples(samples: list[Graph]) -> tuple[Graph, dict]:
|
|
@@ -116,7 +129,7 @@ def merge_samples(samples: list[Graph]) -> tuple[Graph, dict]:
|
|
|
116
129
|
else:
|
|
117
130
|
if key not in seen_this_run:
|
|
118
131
|
entry[0] += 1
|
|
119
|
-
if
|
|
132
|
+
if _rank(e) > _rank(entry[1]):
|
|
120
133
|
entry[1] = e
|
|
121
134
|
seen_this_run.add(key)
|
|
122
135
|
|
|
@@ -119,17 +119,27 @@ class Query:
|
|
|
119
119
|
# made an incomplete map read as a safety property.
|
|
120
120
|
self._imports = nx.DiGraph()
|
|
121
121
|
self._imports_eager = nx.DiGraph()
|
|
122
|
-
|
|
122
|
+
# R1-C49: between the two — everything that can execute. A `TYPE_CHECKING` import
|
|
123
|
+
# never does, so a cycle that needs one is not runtime coupling at all.
|
|
124
|
+
self._imports_runtime = nx.DiGraph()
|
|
125
|
+
self._import_scopes = {"module": 0, "function": 0, "type_checking": 0}
|
|
123
126
|
for n in graph.nodes.values():
|
|
124
127
|
if n.kind == "module":
|
|
125
128
|
self._imports.add_node(n.id)
|
|
126
129
|
self._imports_eager.add_node(n.id)
|
|
130
|
+
self._imports_runtime.add_node(n.id)
|
|
127
131
|
for e in graph.edges:
|
|
128
132
|
if e.type == "imports":
|
|
129
133
|
self._imports.add_edge(e.source, e.target)
|
|
130
|
-
|
|
131
|
-
|
|
134
|
+
scope = e.extras.get("scope")
|
|
135
|
+
if scope in ("function", "type_checking"):
|
|
136
|
+
# R1-C29: runs when the function runs; R1-C48: never runs. Neither
|
|
137
|
+
# is an import-time edge — but only the first is a runtime one.
|
|
138
|
+
self._import_scopes[scope] += 1
|
|
139
|
+
if scope == "function":
|
|
140
|
+
self._imports_runtime.add_edge(e.source, e.target)
|
|
132
141
|
else:
|
|
142
|
+
self._imports_runtime.add_edge(e.source, e.target)
|
|
133
143
|
self._imports_eager.add_edge(e.source, e.target)
|
|
134
144
|
self._import_scopes["module"] += 1
|
|
135
145
|
# export edges: name -> [target definition paths]
|
|
@@ -888,12 +898,32 @@ class Query:
|
|
|
888
898
|
"""Dependency cycles that close **only** through a function-local import.
|
|
889
899
|
|
|
890
900
|
Not an import-time failure, and not nothing: the modules still cannot be
|
|
891
|
-
separated, and the lazy import is the evidence someone already hit
|
|
892
|
-
R1-C29 these were invisible — on the target this project benchmarks
|
|
893
|
-
the nine cycles present were of this kind and the report said "1".
|
|
901
|
+
separated at run time, and the lazy import is the evidence someone already hit
|
|
902
|
+
this. Before R1-C29 these were invisible — on the target this project benchmarks
|
|
903
|
+
on, eight of the nine cycles present were of this kind and the report said "1".
|
|
904
|
+
|
|
905
|
+
R1-C49 narrowed this back after R1-C48 briefly widened it: a cycle that needs an
|
|
906
|
+
import under ``if TYPE_CHECKING:`` is not runtime coupling at all and is returned
|
|
907
|
+
by :meth:`type_only_import_cycles`. The partition is by the **weakest scope that
|
|
908
|
+
closes the cycle**, not by which scopes appear in it — a pair coupled through a
|
|
909
|
+
function-local import stays *lazy* however many type imports also run between
|
|
910
|
+
them, or a tree could hide real coupling by adding one.
|
|
894
911
|
"""
|
|
895
912
|
eager = {frozenset(c) for c in nx.simple_cycles(self._imports_eager)}
|
|
896
|
-
return [c for c in nx.simple_cycles(self.
|
|
913
|
+
return [c for c in nx.simple_cycles(self._imports_runtime)
|
|
914
|
+
if frozenset(c) not in eager]
|
|
915
|
+
|
|
916
|
+
def type_only_import_cycles(self) -> list[list[str]]:
|
|
917
|
+
"""Dependency cycles that close **only** with an import under ``if TYPE_CHECKING:``.
|
|
918
|
+
|
|
919
|
+
The third kind (R1-C49, issue #18). These modules name each other's types and have
|
|
920
|
+
**no runtime dependency whatever**: neither import pulls the other at any moment of
|
|
921
|
+
execution. That is why they are not gated by ``no_lazy_cycles`` — which exists
|
|
922
|
+
against a lazy import used to walk *around* ``no_cycles`` — and get their own
|
|
923
|
+
opt-in rule instead.
|
|
924
|
+
"""
|
|
925
|
+
runtime = {frozenset(c) for c in nx.simple_cycles(self._imports_runtime)}
|
|
926
|
+
return [c for c in nx.simple_cycles(self._imports) if frozenset(c) not in runtime]
|
|
897
927
|
|
|
898
928
|
def import_map(self) -> dict:
|
|
899
929
|
"""How much of the import graph each scope contributed (R1-C29).
|
|
@@ -904,7 +934,8 @@ class Query:
|
|
|
904
934
|
did not look for them".
|
|
905
935
|
"""
|
|
906
936
|
return {"module_level": self._import_scopes["module"],
|
|
907
|
-
"function_local": self._import_scopes["function"]
|
|
937
|
+
"function_local": self._import_scopes["function"],
|
|
938
|
+
"type_checking": self._import_scopes["type_checking"]}
|
|
908
939
|
|
|
909
940
|
def orphan_modules(self, root: str | None = None) -> list[str]:
|
|
910
941
|
"""Modules with no incoming imports (dead-code candidates — heuristic).
|
|
@@ -18,8 +18,10 @@ from codemap.query import Query
|
|
|
18
18
|
def build_architecture(query: Query) -> dict:
|
|
19
19
|
"""Structured whole-system overview (cycles + layers + coupling + hotspots).
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
Three kinds, by the weakest scope that closes the cycle (R1-C49): ``cycles`` break at
|
|
22
|
+
import time, ``lazy_cycles`` need a function-local import (runtime coupling), and
|
|
23
|
+
``type_only_cycles`` need one under ``if TYPE_CHECKING:`` (no runtime dependency at
|
|
24
|
+
all). Splitting them is the point — a lazy
|
|
23
25
|
import is how a developer *fixes* an import cycle, so folding the two together would
|
|
24
26
|
report someone's fix as their bug, while dropping the second (what this tool did
|
|
25
27
|
until issue #11) hides that the modules are still inseparable. ``import_map`` is
|
|
@@ -30,6 +32,7 @@ def build_architecture(query: Query) -> dict:
|
|
|
30
32
|
"target": query.graph.target,
|
|
31
33
|
"cycles": query.import_cycles(),
|
|
32
34
|
"lazy_cycles": query.lazy_import_cycles(),
|
|
35
|
+
"type_only_cycles": query.type_only_import_cycles(),
|
|
33
36
|
"import_map": query.import_map(),
|
|
34
37
|
"layers": query.layers(),
|
|
35
38
|
"coupling": query.coupling(),
|
|
@@ -78,9 +81,10 @@ def render_architecture(query: Query) -> str:
|
|
|
78
81
|
sorted(a["cycles"], key=lambda c: (len(c), c))]
|
|
79
82
|
or ["_none found in the eager import graph._"])
|
|
80
83
|
out.append("")
|
|
81
|
-
out.append(f"_Read {im['module_level']} module-level
|
|
82
|
-
f"function-local import(s). Only
|
|
83
|
-
f"so only they can break on import
|
|
84
|
+
out.append(f"_Read {im['module_level']} module-level, {im['function_local']} "
|
|
85
|
+
f"function-local and {im['type_checking']} `TYPE_CHECKING` import(s). Only "
|
|
86
|
+
f"module-level imports run at import time, so only they can break on import; "
|
|
87
|
+
f"an import under `if TYPE_CHECKING:` never runs._")
|
|
84
88
|
out.append("")
|
|
85
89
|
if a["lazy_cycles"]:
|
|
86
90
|
out.append(f"### Dependency cycles closed only by a function-local import: "
|
|
@@ -88,14 +92,30 @@ def render_architecture(query: Query) -> str:
|
|
|
88
92
|
out.append("")
|
|
89
93
|
out.append("_These do **not** break at import time — the lazy import is what "
|
|
90
94
|
"prevents that, and is usually deliberate. They are listed because "
|
|
91
|
-
"the modules are still mutually dependent: neither can be
|
|
92
|
-
"without the other._")
|
|
95
|
+
"the modules are still mutually dependent at run time: neither can be "
|
|
96
|
+
"extracted without the other._")
|
|
93
97
|
out.append("")
|
|
94
98
|
out.extend(f"- {' → '.join(c)} → {c[0]}" for c in
|
|
95
99
|
sorted(a["lazy_cycles"], key=lambda c: (len(c), c))[:20])
|
|
96
100
|
if len(a["lazy_cycles"]) > 20:
|
|
97
101
|
out.append(f"- _… {len(a['lazy_cycles']) - 20} more_")
|
|
98
102
|
out.append("")
|
|
103
|
+
if a["type_only_cycles"]:
|
|
104
|
+
# R1-C49: the third kind, kept apart from the second because the difference is the
|
|
105
|
+
# whole point — these modules have no runtime dependency on each other at all.
|
|
106
|
+
out.append(f"### Dependency cycles closed only by an import under "
|
|
107
|
+
f"`if TYPE_CHECKING:`: {len(a['type_only_cycles'])}")
|
|
108
|
+
out.append("")
|
|
109
|
+
out.append("_Neither module pulls the other at any moment of execution — they name "
|
|
110
|
+
"each other's types. Not an import-time failure and not runtime coupling; "
|
|
111
|
+
"`no_type_only_cycles = true` gates them if the type layer must not close "
|
|
112
|
+
"a cycle either._")
|
|
113
|
+
out.append("")
|
|
114
|
+
out.extend(f"- {' → '.join(c)} → {c[0]}" for c in
|
|
115
|
+
sorted(a["type_only_cycles"], key=lambda c: (len(c), c))[:20])
|
|
116
|
+
if len(a["type_only_cycles"]) > 20:
|
|
117
|
+
out.append(f"- _… {len(a['type_only_cycles']) - 20} more_")
|
|
118
|
+
out.append("")
|
|
99
119
|
|
|
100
120
|
# -- coupling -----------------------------------------------------------
|
|
101
121
|
out.append("## Coupling (top by afferent Ca)")
|
|
@@ -18,6 +18,7 @@ def render_dependencies(query: Query) -> str:
|
|
|
18
18
|
|
|
19
19
|
cycles = query.import_cycles()
|
|
20
20
|
lazy = query.lazy_import_cycles()
|
|
21
|
+
type_only = query.type_only_import_cycles()
|
|
21
22
|
im = query.import_map()
|
|
22
23
|
lines.append(f"## Import cycles: {len(cycles)}")
|
|
23
24
|
lines.append("")
|
|
@@ -29,11 +30,12 @@ def render_dependencies(query: Query) -> str:
|
|
|
29
30
|
# map that would have to be complete to support it demonstrably is not.
|
|
30
31
|
lines.append("_none found in the eager import graph._")
|
|
31
32
|
lines.append("")
|
|
32
|
-
lines.append(f"_Read {im['module_level']} module-level
|
|
33
|
-
f"function-local import(s); only
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
lines.append(f"_Read {im['module_level']} module-level, {im['function_local']} "
|
|
34
|
+
f"function-local and {im['type_checking']} `TYPE_CHECKING` import(s); only "
|
|
35
|
+
f"the first run at import time. {len(lazy)} further cycle(s) close through "
|
|
36
|
+
f"a function-local import (runtime coupling, not an import-time failure) "
|
|
37
|
+
f"and {len(type_only)} through an import under `if TYPE_CHECKING:` (no "
|
|
38
|
+
f"runtime dependency at all)._")
|
|
37
39
|
lines.append("")
|
|
38
40
|
|
|
39
41
|
lines.append("## Most-depended-on modules (top 15)")
|
|
@@ -61,6 +63,8 @@ def build_dependencies(query: Query) -> dict:
|
|
|
61
63
|
"import_cycles": [list(c) for c in sorted(query.import_cycles(), key=lambda c: (len(c), c))],
|
|
62
64
|
"lazy_import_cycles": [list(c) for c in
|
|
63
65
|
sorted(query.lazy_import_cycles(), key=lambda c: (len(c), c))],
|
|
66
|
+
"type_only_import_cycles": [list(c) for c in
|
|
67
|
+
sorted(query.type_only_import_cycles(), key=lambda c: (len(c), c))],
|
|
64
68
|
"most_depended_on": [{"module": m, "imported_by": g.in_degree(m)}
|
|
65
69
|
for m in ranked[:15] if g.in_degree(m)],
|
|
66
70
|
"diagnostics": diagnostics(query.graph),
|
|
@@ -27,17 +27,29 @@ def _not_judged(query, contract: ArchitectureContract) -> list[dict]:
|
|
|
27
27
|
a field that appears only when there is something to say forces a machine consumer to
|
|
28
28
|
tell "nothing was skipped" from "this build does not report skips", and it cannot.
|
|
29
29
|
"""
|
|
30
|
-
if not contract.no_cycles
|
|
31
|
-
return []
|
|
32
|
-
|
|
30
|
+
if not contract.no_cycles:
|
|
31
|
+
return [] # not enforced
|
|
32
|
+
if contract.no_lazy_cycles and contract.no_type_only_cycles:
|
|
33
|
+
return [] # the contract judges all three kinds
|
|
34
|
+
# R1-C49: two kinds may be left out, and they are different things. Whichever of them
|
|
35
|
+
# this contract does not gate is what the reader must be told about — reporting one
|
|
36
|
+
# sum over both is the defect the third consumer filed.
|
|
37
|
+
lazy = [] if contract.no_lazy_cycles else query.lazy_import_cycles()
|
|
38
|
+
type_only = [] if contract.no_type_only_cycles else query.type_only_import_cycles()
|
|
33
39
|
return [{
|
|
34
40
|
"rule": "no_cycles",
|
|
35
41
|
"judged": "the eager import graph — imports that run at import time",
|
|
36
|
-
"not_judged": "dependency cycles
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
"not_judged": "dependency cycles that do not close at import time",
|
|
43
|
+
"lazy": len(lazy),
|
|
44
|
+
"type_only": len(type_only),
|
|
45
|
+
"lazy_gated": contract.no_lazy_cycles,
|
|
46
|
+
"type_only_gated": contract.no_type_only_cycles,
|
|
47
|
+
"type_checking_imports": query.import_map()["type_checking"],
|
|
48
|
+
"count": len(lazy) + len(type_only),
|
|
49
|
+
"note": ("`lazy` needs a function-local import — runtime coupling, gated by "
|
|
50
|
+
"`no_lazy_cycles = true`. `type_only` needs an import under "
|
|
51
|
+
"`if TYPE_CHECKING:` — no runtime dependency at all, gated by "
|
|
52
|
+
"`no_type_only_cycles = true`. `report architecture` lists both."),
|
|
41
53
|
}]
|
|
42
54
|
|
|
43
55
|
|
|
@@ -131,11 +143,15 @@ def _render_scope(query, contract: ArchitectureContract) -> str:
|
|
|
131
143
|
if not scope:
|
|
132
144
|
return ""
|
|
133
145
|
s = scope[0]
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
146
|
+
parts = []
|
|
147
|
+
if not s["lazy_gated"]:
|
|
148
|
+
parts.append(f"**{s['lazy']}** cycle(s) closed only by a function-local import "
|
|
149
|
+
f"(runtime coupling — `no_lazy_cycles = true` gates them)")
|
|
150
|
+
if not s["type_only_gated"]:
|
|
151
|
+
parts.append(f"**{s['type_only']}** closed only by an import under "
|
|
152
|
+
f"`if TYPE_CHECKING:` (no runtime dependency at all — "
|
|
153
|
+
f"`no_type_only_cycles = true` gates them)")
|
|
137
154
|
return (f"\n_`no_cycles` judged **the eager import graph only** — imports that run at "
|
|
138
|
-
f"import time.
|
|
139
|
-
f"
|
|
140
|
-
f"
|
|
141
|
-
f"`no_lazy_cycles = true` gates them._\n")
|
|
155
|
+
f"import time. Not judged: {' and '.join(parts)}. `report architecture` lists "
|
|
156
|
+
f"them. {s['type_checking_imports']} import(s) under `TYPE_CHECKING` read as "
|
|
157
|
+
f"never running._\n")
|
|
@@ -144,6 +144,7 @@ def render_docs(query: Query) -> str:
|
|
|
144
144
|
# -- architecture caveats (the honest health section) -------------------
|
|
145
145
|
cycles = query.import_cycles()
|
|
146
146
|
lazy = query.lazy_import_cycles()
|
|
147
|
+
type_only = query.type_only_import_cycles()
|
|
147
148
|
lay = query.layers()
|
|
148
149
|
gods = query.hotspots()["god_classes"]
|
|
149
150
|
out.append("## Architecture notes")
|
|
@@ -157,7 +158,11 @@ def render_docs(query: Query) -> str:
|
|
|
157
158
|
out.append("- No import cycle found in the eager import graph.")
|
|
158
159
|
if lazy:
|
|
159
160
|
out.append(f"- **{len(lazy)} dependency cycle(s) closed only by a function-local "
|
|
160
|
-
f"import** — deliberate, and still
|
|
161
|
+
f"import** — deliberate, and still runtime coupling.")
|
|
162
|
+
if type_only:
|
|
163
|
+
out.append(f"- **{len(type_only)} dependency cycle(s) closed only by an import under "
|
|
164
|
+
f"`if TYPE_CHECKING:`** — the modules name each other's types and have no "
|
|
165
|
+
f"runtime dependency.")
|
|
161
166
|
if lay["violations"]:
|
|
162
167
|
out.append("- **Layer violations (mutual dependency):** "
|
|
163
168
|
+ ", ".join(f"{a} ↔ {b}" for a, b in lay["violations"]))
|
|
@@ -116,6 +116,8 @@ tests/test_r1c44_absent_answers.py
|
|
|
116
116
|
tests/test_r1c45_repeat_union.py
|
|
117
117
|
tests/test_r1c46_shadowed_definitions.py
|
|
118
118
|
tests/test_r1c47_incremental_resample.py
|
|
119
|
+
tests/test_r1c48_type_checking_imports.py
|
|
120
|
+
tests/test_r1c49_type_only_cycles.py
|
|
119
121
|
tests/test_r1c4_complexity.py
|
|
120
122
|
tests/test_r1c5_apidiff.py
|
|
121
123
|
tests/test_r1c6_pack.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.14"
|
|
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
|
|
@@ -77,8 +77,10 @@ def test_build_architecture_has_all_sections(q):
|
|
|
77
77
|
a = build_architecture(q)
|
|
78
78
|
# R1-C29 added two: `lazy_cycles` (dependency cycles closed only by a function-local
|
|
79
79
|
# import — real coupling, not an import-time failure) and `import_map` (how many
|
|
80
|
+
# imports each scope contributed). R1-C49 added `type_only_cycles`, the third kind —
|
|
81
|
+
# closed only by an import under `if TYPE_CHECKING:`, i.e. no runtime dependency at all —
|
|
80
82
|
# imports of each scope the map was built from, always, zero included).
|
|
81
|
-
assert set(a) == {"target", "cycles", "lazy_cycles", "import_map",
|
|
83
|
+
assert set(a) == {"target", "cycles", "lazy_cycles", "type_only_cycles", "import_map",
|
|
82
84
|
"layers", "coupling", "hotspots"}
|
|
83
85
|
assert a["target"] == "dispatchpkg"
|
|
84
86
|
|