codmap 0.0.12__tar.gz → 0.0.13__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.13}/PKG-INFO +2 -2
- {codmap-0.0.12 → codmap-0.0.13}/README.md +1 -1
- {codmap-0.0.12 → codmap-0.0.13}/codemap/arch.py +2 -1
- {codmap-0.0.12 → codmap-0.0.13}/codemap/extract/griffe_extractor.py +74 -15
- {codmap-0.0.12 → codmap-0.0.13}/codemap/extract/union.py +22 -9
- {codmap-0.0.12 → codmap-0.0.13}/codemap/query.py +10 -5
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/architecture.py +9 -7
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/audit.py +7 -5
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/check.py +12 -7
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/livingdocs.py +3 -2
- {codmap-0.0.12 → codmap-0.0.13/codmap.egg-info}/PKG-INFO +2 -2
- {codmap-0.0.12 → codmap-0.0.13}/codmap.egg-info/SOURCES.txt +1 -0
- {codmap-0.0.12 → codmap-0.0.13}/pyproject.toml +1 -1
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_m1_query.py +9 -4
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c29_lazy_imports.py +5 -5
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c30f2_check_scope.py +1 -1
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c45_repeat_union.py +49 -0
- codmap-0.0.13/tests/test_r1c48_type_checking_imports.py +187 -0
- {codmap-0.0.12 → codmap-0.0.13}/LICENSE +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/__init__.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/apidiff.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/cli.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/diagnostics.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/extract/behavior.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/extract/roots.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/freshness.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/incremental.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/integrations/base.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/model.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/provenance.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/scope.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/impact.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/limits.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/mcp_server.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/pack.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/rag.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/review.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/scip.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/server.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/session.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/vault.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/store.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/tomlio.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codemap/watch.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/setup.cfg +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_epistemic.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_integrations.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_m15_review.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_m16_architecture.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_m2_views.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_m9_family.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c38_tool_drift.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c41_scope_membership.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c42_deep_tier_stability.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c43_incremental_splice.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c44_absent_answers.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c46_shadowed_definitions.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c47_incremental_resample.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_scip_export.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/tests/test_scope.py +0 -0
- {codmap-0.0.12 → codmap-0.0.13}/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.13
|
|
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
|
|
@@ -85,7 +85,7 @@ layers core 9 · data 12 · indicators 16 · analysis 42 · visualizatio
|
|
|
85
85
|
analysis → core 38 edges · indicators → core 22 · data → core 13
|
|
86
86
|
violation ⚠ analysis ↔ core — one backward edge, written inside a function
|
|
87
87
|
cycle pipeline → cache → pipeline — the classic Python import-order landmine
|
|
88
|
-
lazy cycles 40 more, closed only by a function-local
|
|
88
|
+
lazy cycles 40 more, closed only by a non-eager import (function-local or `TYPE_CHECKING`) — not import-time failures,
|
|
89
89
|
still mutual coupling: neither module can be extracted without the other
|
|
90
90
|
coupling core.logging_config Ca 96 — a breaking change here reaches 96 modules
|
|
91
91
|
concentration ZoneVisualizer 35 methods, worst function CC 66 / MI 12.5
|
|
@@ -49,7 +49,7 @@ layers core 9 · data 12 · indicators 16 · analysis 42 · visualizatio
|
|
|
49
49
|
analysis → core 38 edges · indicators → core 22 · data → core 13
|
|
50
50
|
violation ⚠ analysis ↔ core — one backward edge, written inside a function
|
|
51
51
|
cycle pipeline → cache → pipeline — the classic Python import-order landmine
|
|
52
|
-
lazy cycles 40 more, closed only by a function-local
|
|
52
|
+
lazy cycles 40 more, closed only by a non-eager import (function-local or `TYPE_CHECKING`) — not import-time failures,
|
|
53
53
|
still mutual coupling: neither module can be extracted without the other
|
|
54
54
|
coupling core.logging_config Ca 96 — a breaking change here reaches 96 modules
|
|
55
55
|
concentration ZoneVisualizer 35 methods, worst function CC 66 / MI 12.5
|
|
@@ -207,7 +207,8 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
|
|
|
207
207
|
worst = sorted(lazy, key=lambda c: (len(c), c))
|
|
208
208
|
violations.append(Violation(
|
|
209
209
|
"no_lazy_cycles",
|
|
210
|
-
f"{len(lazy)} dependency cycle(s) closed only by a
|
|
210
|
+
f"{len(lazy)} dependency cycle(s) closed only by a non-eager import "
|
|
211
|
+
f"(function-local, or under `if TYPE_CHECKING:`)",
|
|
211
212
|
modules=tuple(" → ".join(c) + " → " + c[0] for c in worst),
|
|
212
213
|
))
|
|
213
214
|
|
|
@@ -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,13 @@ def _emit_decorated_by(graph, obj) -> None:
|
|
|
429
481
|
|
|
430
482
|
# -- pass 2: resolve export + import edges against known nodes ----------------
|
|
431
483
|
|
|
484
|
+
#: How early an import scope reaches its target: an edge carries the earliest (R1-C29 D2,
|
|
485
|
+
#: R1-C48 D2). ``module`` runs at import time, ``type_checking`` never, ``function`` when
|
|
486
|
+
#: the function runs — the middle one is ordered before ``function`` only so the label
|
|
487
|
+
#: names the construct actually written at module level.
|
|
488
|
+
_SCOPE_RANK = {"module": 0, "type_checking": 1, "function": 2}
|
|
489
|
+
|
|
490
|
+
|
|
432
491
|
def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
433
492
|
module_ids = sorted(
|
|
434
493
|
(n.id for n in graph.nodes.values() if n.kind == "module"), key=len, reverse=True
|
|
@@ -457,7 +516,7 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
|
457
516
|
# the eager import it is. `scope` only ever *weakens* to "function" for a pair that
|
|
458
517
|
# has no module-level import at all — the edge says how the dependency is reached at
|
|
459
518
|
# its earliest, never how it happens to appear last in the walk.
|
|
460
|
-
ordered = sorted(imports, key=lambda t: t[2]
|
|
519
|
+
ordered = sorted(imports, key=lambda t: _SCOPE_RANK.get(t[2], 0))
|
|
461
520
|
|
|
462
521
|
# pass A — package-qualified targets. Exact, and run first so that a pair reachable
|
|
463
522
|
# both ways is recorded as exact rather than inferred.
|
|
@@ -472,7 +531,7 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
|
472
531
|
if key in seen:
|
|
473
532
|
continue
|
|
474
533
|
seen.add(key)
|
|
475
|
-
extras = {"scope":
|
|
534
|
+
extras = {"scope": scope} if scope != "module" else {}
|
|
476
535
|
graph.add_edge(Edge("imports", src_module, tgt_module, extras=extras))
|
|
477
536
|
|
|
478
537
|
# pass B — flat layout (R1-C21): sibling modules importing each other by bare name
|
|
@@ -489,8 +548,8 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
|
489
548
|
continue
|
|
490
549
|
seen.add(key)
|
|
491
550
|
extras = {"resolution": "flat"}
|
|
492
|
-
if scope
|
|
493
|
-
extras["scope"] =
|
|
551
|
+
if scope != "module":
|
|
552
|
+
extras["scope"] = scope
|
|
494
553
|
graph.add_edge(Edge("imports", src_module, tgt_module, extras=extras))
|
|
495
554
|
|
|
496
555
|
|
|
@@ -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,7 +119,7 @@ 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
|
-
self._import_scopes = {"module": 0, "function": 0}
|
|
122
|
+
self._import_scopes = {"module": 0, "function": 0, "type_checking": 0}
|
|
123
123
|
for n in graph.nodes.values():
|
|
124
124
|
if n.kind == "module":
|
|
125
125
|
self._imports.add_node(n.id)
|
|
@@ -127,8 +127,11 @@ class Query:
|
|
|
127
127
|
for e in graph.edges:
|
|
128
128
|
if e.type == "imports":
|
|
129
129
|
self._imports.add_edge(e.source, e.target)
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
scope = e.extras.get("scope")
|
|
131
|
+
if scope in ("function", "type_checking"):
|
|
132
|
+
# R1-C29: runs when the function runs; R1-C48: never runs. Neither
|
|
133
|
+
# is an import-time edge.
|
|
134
|
+
self._import_scopes[scope] += 1
|
|
132
135
|
else:
|
|
133
136
|
self._imports_eager.add_edge(e.source, e.target)
|
|
134
137
|
self._import_scopes["module"] += 1
|
|
@@ -885,7 +888,8 @@ class Query:
|
|
|
885
888
|
return [c for c in nx.simple_cycles(self._imports_eager)]
|
|
886
889
|
|
|
887
890
|
def lazy_import_cycles(self) -> list[list[str]]:
|
|
888
|
-
"""Dependency cycles that close **only** through a
|
|
891
|
+
"""Dependency cycles that close **only** through a non-eager import — one
|
|
892
|
+
written inside a function (R1-C29) or under ``if TYPE_CHECKING:`` (R1-C48).
|
|
889
893
|
|
|
890
894
|
Not an import-time failure, and not nothing: the modules still cannot be
|
|
891
895
|
separated, and the lazy import is the evidence someone already hit this. Before
|
|
@@ -904,7 +908,8 @@ class Query:
|
|
|
904
908
|
did not look for them".
|
|
905
909
|
"""
|
|
906
910
|
return {"module_level": self._import_scopes["module"],
|
|
907
|
-
"function_local": self._import_scopes["function"]
|
|
911
|
+
"function_local": self._import_scopes["function"],
|
|
912
|
+
"type_checking": self._import_scopes["type_checking"]}
|
|
908
913
|
|
|
909
914
|
def orphan_modules(self, root: str | None = None) -> list[str]:
|
|
910
915
|
"""Modules with no incoming imports (dead-code candidates — heuristic).
|
|
@@ -19,7 +19,8 @@ def build_architecture(query: Query) -> dict:
|
|
|
19
19
|
"""Structured whole-system overview (cycles + layers + coupling + hotspots).
|
|
20
20
|
|
|
21
21
|
R1-C29: ``cycles`` are the **import-time** ones and ``lazy_cycles`` the dependency
|
|
22
|
-
cycles closed only by a
|
|
22
|
+
cycles closed only by a non-eager import — function-local (R1-C29) or under
|
|
23
|
+
``if TYPE_CHECKING:`` (R1-C48). Splitting them is the point — a lazy
|
|
23
24
|
import is how a developer *fixes* an import cycle, so folding the two together would
|
|
24
25
|
report someone's fix as their bug, while dropping the second (what this tool did
|
|
25
26
|
until issue #11) hides that the modules are still inseparable. ``import_map`` is
|
|
@@ -78,15 +79,16 @@ def render_architecture(query: Query) -> str:
|
|
|
78
79
|
sorted(a["cycles"], key=lambda c: (len(c), c))]
|
|
79
80
|
or ["_none found in the eager import graph._"])
|
|
80
81
|
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
|
|
82
|
+
out.append(f"_Read {im['module_level']} module-level, {im['function_local']} "
|
|
83
|
+
f"function-local and {im['type_checking']} `TYPE_CHECKING` import(s). Only "
|
|
84
|
+
f"module-level imports run at import time, so only they can break on import; "
|
|
85
|
+
f"an import under `if TYPE_CHECKING:` never runs._")
|
|
84
86
|
out.append("")
|
|
85
87
|
if a["lazy_cycles"]:
|
|
86
|
-
out.append(f"### Dependency cycles closed only by a
|
|
87
|
-
f"{len(a['lazy_cycles'])}")
|
|
88
|
+
out.append(f"### Dependency cycles closed only by a non-eager import "
|
|
89
|
+
f"(function-local, or under `if TYPE_CHECKING:`): {len(a['lazy_cycles'])}")
|
|
88
90
|
out.append("")
|
|
89
|
-
out.append("_These do **not** break at import time — the
|
|
91
|
+
out.append("_These do **not** break at import time — the non-eager import is what "
|
|
90
92
|
"prevents that, and is usually deliberate. They are listed because "
|
|
91
93
|
"the modules are still mutually dependent: neither can be extracted "
|
|
92
94
|
"without the other._")
|
|
@@ -29,11 +29,13 @@ def render_dependencies(query: Query) -> str:
|
|
|
29
29
|
# map that would have to be complete to support it demonstrably is not.
|
|
30
30
|
lines.append("_none found in the eager import graph._")
|
|
31
31
|
lines.append("")
|
|
32
|
-
lines.append(f"_Read {im['module_level']} module-level
|
|
33
|
-
f"function-local import(s); only
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
lines.append(f"_Read {im['module_level']} module-level, {im['function_local']} "
|
|
33
|
+
f"function-local and {im['type_checking']} `TYPE_CHECKING` import(s); only "
|
|
34
|
+
f"the first run at import time. "
|
|
35
|
+
+ (f"{len(lazy)} further cycle(s) close through a non-eager import "
|
|
36
|
+
f"(function-local, or under `if TYPE_CHECKING:`) — real coupling, not "
|
|
37
|
+
f"an import-time failure._" if lazy
|
|
38
|
+
else "No cycle closes through a non-eager import._"))
|
|
37
39
|
lines.append("")
|
|
38
40
|
|
|
39
41
|
lines.append("## Most-depended-on modules (top 15)")
|
|
@@ -33,9 +33,10 @@ def _not_judged(query, contract: ArchitectureContract) -> list[dict]:
|
|
|
33
33
|
return [{
|
|
34
34
|
"rule": "no_cycles",
|
|
35
35
|
"judged": "the eager import graph — imports that run at import time",
|
|
36
|
-
"not_judged": "dependency cycles closed only by a function-local
|
|
36
|
+
"not_judged": "dependency cycles closed only by a non-eager import (function-local, or under `if TYPE_CHECKING:`)",
|
|
37
|
+
"type_checking_imports": query.import_map()["type_checking"],
|
|
37
38
|
"count": len(lazy),
|
|
38
|
-
"note": ("a
|
|
39
|
+
"note": ("a non-eager import does not run at import time, so such a cycle cannot break "
|
|
39
40
|
"on import; it is still mutual coupling. Set `no_lazy_cycles = true` to "
|
|
40
41
|
"gate these as well, or see `report architecture` for the list."),
|
|
41
42
|
}]
|
|
@@ -132,10 +133,14 @@ def _render_scope(query, contract: ArchitectureContract) -> str:
|
|
|
132
133
|
return ""
|
|
133
134
|
s = scope[0]
|
|
134
135
|
if not s["count"]:
|
|
135
|
-
return ("\n_`no_cycles` judged the eager import graph; no dependency cycle is closed "
|
|
136
|
-
"only by a function-local
|
|
136
|
+
return (f"\n_`no_cycles` judged the eager import graph; no dependency cycle is closed "
|
|
137
|
+
f"only by a non-eager import (function-local, or under `if TYPE_CHECKING:`) "
|
|
138
|
+
f"either. {s['type_checking_imports']} import(s) under `TYPE_CHECKING` read "
|
|
139
|
+
f"as never running._\n")
|
|
137
140
|
return (f"\n_`no_cycles` judged **the eager import graph only** — imports that run at "
|
|
138
141
|
f"import time. **{s['count']}** dependency cycle(s) closed only by a "
|
|
139
|
-
f"function-local
|
|
140
|
-
f"import, but the coupling is real.
|
|
141
|
-
f"`no_lazy_cycles = true` gates them.
|
|
142
|
+
f"non-eager import (function-local, or under `if TYPE_CHECKING:`) were **not** "
|
|
143
|
+
f"judged: such a cycle cannot break on import, but the coupling is real. "
|
|
144
|
+
f"`report architecture` lists them; `no_lazy_cycles = true` gates them. "
|
|
145
|
+
f"{s['type_checking_imports']} import(s) under `TYPE_CHECKING` read as never "
|
|
146
|
+
f"running._\n")
|
|
@@ -156,8 +156,9 @@ def render_docs(query: Query) -> str:
|
|
|
156
156
|
# proof of acyclicity, which this map cannot support.
|
|
157
157
|
out.append("- No import cycle found in the eager import graph.")
|
|
158
158
|
if lazy:
|
|
159
|
-
out.append(f"- **{len(lazy)} dependency cycle(s) closed only by a
|
|
160
|
-
f"import** — deliberate,
|
|
159
|
+
out.append(f"- **{len(lazy)} dependency cycle(s) closed only by a non-eager "
|
|
160
|
+
f"import** (function-local, or under `if TYPE_CHECKING:`) — deliberate, "
|
|
161
|
+
f"and still mutual coupling.")
|
|
161
162
|
if lay["violations"]:
|
|
162
163
|
out.append("- **Layer violations (mutual dependency):** "
|
|
163
164
|
+ ", ".join(f"{a} ↔ {b}" for a, b in lay["violations"]))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codmap
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.13
|
|
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
|
|
@@ -85,7 +85,7 @@ layers core 9 · data 12 · indicators 16 · analysis 42 · visualizatio
|
|
|
85
85
|
analysis → core 38 edges · indicators → core 22 · data → core 13
|
|
86
86
|
violation ⚠ analysis ↔ core — one backward edge, written inside a function
|
|
87
87
|
cycle pipeline → cache → pipeline — the classic Python import-order landmine
|
|
88
|
-
lazy cycles 40 more, closed only by a function-local
|
|
88
|
+
lazy cycles 40 more, closed only by a non-eager import (function-local or `TYPE_CHECKING`) — not import-time failures,
|
|
89
89
|
still mutual coupling: neither module can be extracted without the other
|
|
90
90
|
coupling core.logging_config Ca 96 — a breaking change here reaches 96 modules
|
|
91
91
|
concentration ZoneVisualizer 35 methods, worst function CC 66 / MI 12.5
|
|
@@ -116,6 +116,7 @@ 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
|
|
119
120
|
tests/test_r1c4_complexity.py
|
|
120
121
|
tests/test_r1c5_apidiff.py
|
|
121
122
|
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.13"
|
|
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
|
|
@@ -63,10 +63,15 @@ def test_dependencies_both_ways(q):
|
|
|
63
63
|
|
|
64
64
|
|
|
65
65
|
def test_cycle_detection(q):
|
|
66
|
-
|
|
67
|
-
#
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
# bquant's pipeline<->cache pair is mutually dependent, and the cache side reaches
|
|
67
|
+
# pipeline only through an import under `if TYPE_CHECKING:` — which never runs. Until
|
|
68
|
+
# R1-C48 (issue #18) that edge was counted as eager and this test pinned the defect:
|
|
69
|
+
# the pair is a *dependency* cycle, and not an import-time one.
|
|
70
|
+
pair = frozenset({PIPELINE, "bquant.analysis.zones.cache"})
|
|
71
|
+
assert pair not in {frozenset(c) for c in q.import_cycles()}, \
|
|
72
|
+
"an import under TYPE_CHECKING must not close an eager cycle"
|
|
73
|
+
assert pair in {frozenset(c) for c in q.lazy_import_cycles()}
|
|
74
|
+
assert q.import_map()["type_checking"] >= 1
|
|
70
75
|
|
|
71
76
|
|
|
72
77
|
def test_orphan_modules(q):
|
|
@@ -153,7 +153,7 @@ def test_import_map_is_emitted_even_when_nothing_is_lazy(tmp_path):
|
|
|
153
153
|
to tell "no lazy imports here" from "this build did not look for them"."""
|
|
154
154
|
pkg = _pkg(tmp_path, {"a.py": "x = 1\n", "b.py": "from pkg.a import x\n"})
|
|
155
155
|
im = Query(extract(str(pkg))).import_map()
|
|
156
|
-
assert im == {"module_level": 1, "function_local": 0}
|
|
156
|
+
assert im == {"module_level": 1, "function_local": 0, "type_checking": 0}
|
|
157
157
|
|
|
158
158
|
|
|
159
159
|
# -- and no consumer states acyclicity as a property -------------------------
|
|
@@ -162,21 +162,21 @@ def test_architecture_never_claims_acyclic(lazy_cycle):
|
|
|
162
162
|
md = render_architecture(Query(extract(str(lazy_cycle))))
|
|
163
163
|
assert "acyclic" not in md, "a partial map cannot support a property claim"
|
|
164
164
|
assert "none found in the eager import graph" in md
|
|
165
|
-
assert "function-local import(s)" in md
|
|
166
|
-
assert "Dependency cycles closed only by a function-local
|
|
165
|
+
assert "function-local and 0 `TYPE_CHECKING` import(s)" in md
|
|
166
|
+
assert "Dependency cycles closed only by a non-eager import (function-local, or under `if TYPE_CHECKING:`): 1" in md
|
|
167
167
|
|
|
168
168
|
|
|
169
169
|
def test_dependencies_report_never_claims_acyclic(lazy_cycle):
|
|
170
170
|
md = render_dependencies(Query(extract(str(lazy_cycle))))
|
|
171
171
|
assert "acyclic" not in md
|
|
172
|
-
assert "close through a
|
|
172
|
+
assert "close through a non-eager import" in md
|
|
173
173
|
|
|
174
174
|
|
|
175
175
|
def test_architecture_payload_carries_both_kinds_and_the_counts(lazy_cycle):
|
|
176
176
|
a = build_architecture(Query(extract(str(lazy_cycle))))
|
|
177
177
|
assert a["cycles"] == []
|
|
178
178
|
assert [sorted(c) for c in a["lazy_cycles"]] == [["pkg.a", "pkg.b"]]
|
|
179
|
-
assert a["import_map"] == {"module_level": 1, "function_local": 1}
|
|
179
|
+
assert a["import_map"] == {"module_level": 1, "function_local": 1, "type_checking": 0}
|
|
180
180
|
|
|
181
181
|
|
|
182
182
|
def test_no_renderer_states_acyclicity_anywhere(tmp_path):
|
|
@@ -92,7 +92,7 @@ def test_it_is_declared_even_when_there_is_nothing_to_declare(eager_only):
|
|
|
92
92
|
contract, violations = _run(eager_only, {"no_cycles": True})
|
|
93
93
|
scope = build_check(eager_only, contract, violations)["scope"]
|
|
94
94
|
assert len(scope) == 1 and scope[0]["count"] == 0
|
|
95
|
-
assert "no dependency cycle is closed only by a
|
|
95
|
+
assert "no dependency cycle is closed only by a non-eager import" \
|
|
96
96
|
in render_check(eager_only, contract, violations)
|
|
97
97
|
|
|
98
98
|
|
|
@@ -72,6 +72,31 @@ def test_the_order_of_runs_does_not_change_the_result():
|
|
|
72
72
|
assert _edges(merge_samples([shallow, deep])[0]) == _edges(merge_samples([deep, shallow])[0])
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def test_the_same_samples_in_any_order_merge_to_the_same_bytes():
|
|
76
|
+
"""codemap#17, the consumer's hint: where two candidates are equally legitimate, the
|
|
77
|
+
choice must not depend on the order the samples arrived in. The three places the
|
|
78
|
+
rule 'deep wins, else first run' could see a tie are fed here — two `deep` variants
|
|
79
|
+
of one call, two non-deep variants of one call, and a counter tie — and every
|
|
80
|
+
permutation of the same samples must serialize to the same bytes (provenance is
|
|
81
|
+
the first sample's by contract and is left out)."""
|
|
82
|
+
import itertools
|
|
83
|
+
a = _graph([("calls", "pkg.a", "pkg.B", {"resolution": "deep", "callsites": 1, "via": "B"}),
|
|
84
|
+
("calls", "pkg.a", "pkg.B.m", {"resolution": "imported", "callsites": 1, "via": "m"})],
|
|
85
|
+
{"attr_access": {"out": 4, "resolved": 3, "unresolved": 1, "external": 0}})
|
|
86
|
+
b = _graph([("calls", "pkg.a", "pkg.B", {"resolution": "deep", "callsites": 2, "via": "B"}),
|
|
87
|
+
("calls", "pkg.a", "pkg.B.m", {"resolution": "imported", "callsites": 2, "via": "m"})],
|
|
88
|
+
{"attr_access": {"out": 4, "resolved": 3, "unresolved": 1, "external": 1}})
|
|
89
|
+
c = _graph([("calls", "pkg.a", "pkg.B", {"resolution": "deep", "callsites": 1, "via": "B"})])
|
|
90
|
+
|
|
91
|
+
def bytes_of(samples):
|
|
92
|
+
d = json.loads(store.dumps(merge_samples(list(samples))[0]))
|
|
93
|
+
d.pop("provenance", None)
|
|
94
|
+
return json.dumps(d, sort_keys=True)
|
|
95
|
+
|
|
96
|
+
outcomes = {bytes_of(p) for p in itertools.permutations([a, b, c])}
|
|
97
|
+
assert len(outcomes) == 1, f"{len(outcomes)} distinct graphs from the same three samples"
|
|
98
|
+
|
|
99
|
+
|
|
75
100
|
def test_a_read_and_a_write_of_one_attribute_are_two_edges_not_one():
|
|
76
101
|
"""`(type, source, target)` would collapse these; 96 such pairs exist in one bquant build."""
|
|
77
102
|
pair = [("accesses", "pkg.a", "pkg.B.f", {"access": "read", "resolution": "self"}),
|
|
@@ -82,6 +107,30 @@ def test_a_read_and_a_write_of_one_attribute_are_two_edges_not_one():
|
|
|
82
107
|
assert stats == {"runs": 2, "unstable": 0}
|
|
83
108
|
|
|
84
109
|
|
|
110
|
+
def test_the_third_trees_three_pairs_survive_the_merge_and_deep_wins_only_by_resolution():
|
|
111
|
+
"""codemap#17: on the consumer's 219-file tree 17 logical triples carry two records on
|
|
112
|
+
the deep tier — a read and a write, a name as annotation and as value, a dict-literal
|
|
113
|
+
and a subscript write of one column. Fed literally: every pair must come out as two
|
|
114
|
+
edges, and only an `imported`/`deep` pair on a call may collapse — by `resolution`,
|
|
115
|
+
not by the rest of `extras`."""
|
|
116
|
+
pairs = [
|
|
117
|
+
("accesses", "pkg.a", "pkg.B.by_k", {"access": "read", "resolution": "self"}),
|
|
118
|
+
("accesses", "pkg.a", "pkg.B.by_k", {"access": "write", "resolution": "self"}),
|
|
119
|
+
("references", "pkg.a", "pkg.B", {"resolution": "annotation", "sites": 1}),
|
|
120
|
+
("references", "pkg.a", "pkg.B", {"resolution": "name", "sites": 1}),
|
|
121
|
+
("writes", "pkg.a", "column:n_zones", {"access": "dict-literal", "resolution": "string-key"}),
|
|
122
|
+
("writes", "pkg.a", "column:n_zones", {"access": "subscript", "resolution": "string-key"}),
|
|
123
|
+
("calls", "pkg.a", "pkg.B.m", {"resolution": "imported", "via": "B.m", "callsites": 1}),
|
|
124
|
+
]
|
|
125
|
+
deeper = pairs[:-1] + [("calls", "pkg.a", "pkg.B.m", {"resolution": "deep", "via": "B.m", "callsites": 2})]
|
|
126
|
+
merged, stats = merge_samples([_graph(pairs), _graph(deeper), _graph(pairs)])
|
|
127
|
+
assert len(merged.edges) == 7, "six facts in three pairs plus one call — nothing collapsed"
|
|
128
|
+
assert all("seen" not in e.extras for e in merged.edges), "every fact was in every run"
|
|
129
|
+
call = next(e for e in merged.edges if e.type == "calls")
|
|
130
|
+
assert call.extras == {"resolution": "deep", "via": "B.m", "callsites": 2}
|
|
131
|
+
assert stats == {"runs": 3, "unstable": 0}
|
|
132
|
+
|
|
133
|
+
|
|
85
134
|
def test_a_construct_write_and_a_deep_write_are_two_sites_and_both_survive():
|
|
86
135
|
pair = [("accesses", "pkg.a", "pkg.B.f", {"access": "write", "resolution": "construct"}),
|
|
87
136
|
("accesses", "pkg.a", "pkg.B.f", {"access": "write", "resolution": "deep"})]
|