codmap 0.0.17__tar.gz → 0.0.19__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.17 → codmap-0.0.19}/PKG-INFO +2 -2
- {codmap-0.0.17 → codmap-0.0.19}/README.md +1 -1
- {codmap-0.0.17 → codmap-0.0.19}/codemap/extract/griffe_extractor.py +85 -7
- {codmap-0.0.17 → codmap-0.0.19}/codemap/query.py +87 -10
- {codmap-0.0.17 → codmap-0.0.19}/codemap/scope.py +4 -1
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/api_surface.py +45 -2
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/architecture.py +6 -5
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/audit.py +5 -5
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/check.py +4 -3
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/livingdocs.py +3 -3
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/mermaid.py +13 -1
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/session.py +111 -8
- {codmap-0.0.17 → codmap-0.0.19}/codmap.egg-info/PKG-INFO +2 -2
- {codmap-0.0.17 → codmap-0.0.19}/codmap.egg-info/SOURCES.txt +5 -0
- {codmap-0.0.17 → codmap-0.0.19}/pyproject.toml +1 -1
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_m1_query.py +16 -4
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c29_lazy_imports.py +3 -3
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c48_type_checking_imports.py +5 -5
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c49_type_only_cycles.py +5 -4
- codmap-0.0.19/tests/test_r1c53_narrowing_declared.py +199 -0
- codmap-0.0.19/tests/test_r1c54_deterministic_rendering.py +131 -0
- codmap-0.0.19/tests/test_r1c55_override_grade.py +118 -0
- codmap-0.0.19/tests/test_r1c56_stub_files.py +166 -0
- codmap-0.0.19/tests/test_r1c57_facade_package.py +136 -0
- {codmap-0.0.17 → codmap-0.0.19}/LICENSE +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/__init__.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/apidiff.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/arch.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/cli.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/diagnostics.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/extract/behavior.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/extract/roots.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/extract/union.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/freshness.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/incremental.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/integrations/base.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/model.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/provenance.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/impact.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/limits.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/mcp_server.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/pack.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/rag.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/review.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/scip.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/server.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/vault.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/store.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/tomlio.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codemap/watch.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/setup.cfg +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_epistemic.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_integrations.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_m15_review.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_m16_architecture.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_m2_views.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_m9_family.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c30f2_check_scope.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c38_tool_drift.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c39_edge_resolution.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c40_flow_reach.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c41_scope_membership.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c42_deep_tier_stability.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c43_incremental_splice.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c44_absent_answers.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c45_repeat_union.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c46_shadowed_definitions.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c47_incremental_resample.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c50_flow_entry_points.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_scip_export.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/tests/test_scope.py +0 -0
- {codmap-0.0.17 → codmap-0.0.19}/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.19
|
|
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, **961 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, **961 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 &
|
|
@@ -80,6 +80,63 @@ def _assert_is_the_target(loaded, pkg_dir: Path, module_name: str) -> None:
|
|
|
80
80
|
)
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
#: How many sibling packages one load may pull in before we stop trying (R1-C57).
|
|
84
|
+
_MAX_SIBLING_LOADS = 8
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _load_with_siblings(module_name: str, search_path: Path):
|
|
88
|
+
"""``griffe.load`` the target, pulling in siblings its re-exports point at (R1-C57).
|
|
89
|
+
|
|
90
|
+
The facade shape — a public package whose whole job is to re-export a private one,
|
|
91
|
+
as ``pytest``/``_pytest`` and ``attrs``/``attr`` do — used to **crash the build**:
|
|
92
|
+
griffe merges a ``.pyi`` beside ``__init__.py``, that merge resolves aliases, and an
|
|
93
|
+
alias into a package nobody loaded raises ``AliasResolutionError``. Exit code 1, no
|
|
94
|
+
graph, for a six-file package that is valid and published.
|
|
95
|
+
|
|
96
|
+
An unresolvable re-export is partiality, not a crash. So: load the sibling the alias
|
|
97
|
+
names into the *same* collection and retry. The sibling has to be there — it sits on
|
|
98
|
+
the same search path, which is how Python finds it too — and loading it does not put
|
|
99
|
+
it in the graph: the walk starts from the target's own root, and targets outside it
|
|
100
|
+
stay external, exactly as they were before.
|
|
101
|
+
|
|
102
|
+
Returns ``(root, siblings)``; ``siblings`` is what had to be pulled in, and it is
|
|
103
|
+
recorded in the input report so a reader can see the load was not self-contained.
|
|
104
|
+
"""
|
|
105
|
+
collection = griffe.ModulesCollection()
|
|
106
|
+
siblings: list[str] = []
|
|
107
|
+
for _ in range(_MAX_SIBLING_LOADS + 1):
|
|
108
|
+
try:
|
|
109
|
+
return griffe.load(module_name, search_paths=[str(search_path)],
|
|
110
|
+
try_relative_path=False,
|
|
111
|
+
modules_collection=collection), siblings
|
|
112
|
+
except griffe.AliasResolutionError as exc:
|
|
113
|
+
top = exc.alias.target_path.split(".")[0]
|
|
114
|
+
if top == module_name or top in siblings:
|
|
115
|
+
raise # retrying would loop on the same alias
|
|
116
|
+
if not _package_on_path(top, search_path):
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"`{module_name}` re-exports `{exc.alias.target_path}`, and "
|
|
119
|
+
f"`{top}` is not beside it in {search_path}. This is the facade "
|
|
120
|
+
f"layout (a public package re-exporting a private one): build from "
|
|
121
|
+
f"a tree that holds both, or point codemap at the implementation "
|
|
122
|
+
f"package instead."
|
|
123
|
+
) from exc
|
|
124
|
+
griffe.load(top, search_paths=[str(search_path)], try_relative_path=False,
|
|
125
|
+
modules_collection=collection)
|
|
126
|
+
siblings.append(top)
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"`{module_name}` still had unresolved re-exports after loading "
|
|
129
|
+
f"{len(siblings)} sibling package(s): {', '.join(siblings)}."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _package_on_path(name: str, search_path: Path) -> bool:
|
|
134
|
+
"""True when ``name`` is importable as a package/module from ``search_path``."""
|
|
135
|
+
d = search_path / name
|
|
136
|
+
return (d / "__init__.py").is_file() or (d / "__init__.pyi").is_file() \
|
|
137
|
+
or (search_path / f"{name}.py").is_file() or (search_path / f"{name}.pyi").is_file()
|
|
138
|
+
|
|
139
|
+
|
|
83
140
|
def build_structural(package_path: str | Path):
|
|
84
141
|
"""The cheap, deterministic base: griffe load + definition nodes + structural
|
|
85
142
|
edges (contains / imports / inherits / decorated_by / export). No behavioral
|
|
@@ -99,7 +156,7 @@ def build_structural(package_path: str | Path):
|
|
|
99
156
|
# a repo whose root holds `pkg/`, `build /elsewhere/pkg` then silently analysed the
|
|
100
157
|
# local `pkg` — same shape of answer, different code. We always know the directory we
|
|
101
158
|
# were handed, so the name must resolve through `search_paths` and nowhere else.
|
|
102
|
-
root =
|
|
159
|
+
root, siblings = _load_with_siblings(module_name, search_path)
|
|
103
160
|
# Defence in depth: whatever the finder does next (a .pth file, a namespace package,
|
|
104
161
|
# a future default), a graph must describe the directory that was asked for. A wrong
|
|
105
162
|
# answer here is invisible downstream — it is well-formed, complete and about the
|
|
@@ -402,13 +459,18 @@ def _collect(graph, obj, root, target_pkg, walk) -> None:
|
|
|
402
459
|
# carry, or carries under the wrong scope, in one parse.
|
|
403
460
|
nested, eager = _source_import_targets(obj)
|
|
404
461
|
type_checking = {tgt for tgt, scope in nested if scope == "type_checking"}
|
|
462
|
+
# R1-C56/D1: a `.pyi` is a declaration — Python never executes it, so *none* of
|
|
463
|
+
# its imports run, whatever they look like. Recorded as a fourth scope rather
|
|
464
|
+
# than borrowed from `type_checking`: the mechanism is a different one, and the
|
|
465
|
+
# cycle classes are named by mechanism.
|
|
466
|
+
stub = _is_stub(obj)
|
|
405
467
|
for name, tgt in (obj.imports or {}).items():
|
|
406
468
|
# griffe files an import under `if TYPE_CHECKING:` as module-level; it never
|
|
407
469
|
# runs. Demote it unless the same target is also imported eagerly (D2).
|
|
408
470
|
scope = "type_checking" if tgt in type_checking and tgt not in eager else "module"
|
|
409
|
-
walk.imports.append((obj.canonical_path, tgt, scope))
|
|
471
|
+
walk.imports.append((obj.canonical_path, tgt, "stub" if stub else scope))
|
|
410
472
|
for tgt, scope in nested:
|
|
411
|
-
walk.imports.append((obj.canonical_path, tgt, scope))
|
|
473
|
+
walk.imports.append((obj.canonical_path, tgt, "stub" if stub else scope))
|
|
412
474
|
for name, member in obj.members.items():
|
|
413
475
|
if member.is_alias:
|
|
414
476
|
# capture ALL re-exports (public flag kept) — a symbol can be importable
|
|
@@ -487,7 +549,7 @@ def _emit_decorated_by(graph, obj) -> None:
|
|
|
487
549
|
#: import is a **function-local** dependency: it can execute, and labelling it by the
|
|
488
550
|
#: import that cannot would let a tree launder runtime coupling into the type layer
|
|
489
551
|
#: (R1-C49, and the test that says so).
|
|
490
|
-
_SCOPE_RANK = {"module": 0, "function": 1, "type_checking": 2}
|
|
552
|
+
_SCOPE_RANK = {"module": 0, "function": 1, "type_checking": 2, "stub": 3}
|
|
491
553
|
|
|
492
554
|
|
|
493
555
|
def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
@@ -506,7 +568,18 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
|
506
568
|
# no edge at all. Same narrow gate as pass B: only when the head names a
|
|
507
569
|
# module sitting beside the re-exporter, and labelled as the inference it is.
|
|
508
570
|
if _flat_sibling(parent_module, target_path, known_modules) is None:
|
|
509
|
-
|
|
571
|
+
# R1-C57: `import numpy as np` is not this package's API and stays out.
|
|
572
|
+
# A **public** alias to an outside definition is the opposite case: it is
|
|
573
|
+
# exactly what the package exposes, and dropping it made the facade layout
|
|
574
|
+
# (`pytest` re-exporting 90 names from `_pytest`) report a public surface
|
|
575
|
+
# of one symbol. Kept, with the target left as written and marked external
|
|
576
|
+
# — the definition is outside this graph, and the edge says so rather than
|
|
577
|
+
# pretending to resolve it.
|
|
578
|
+
if not is_public:
|
|
579
|
+
continue
|
|
580
|
+
extras["external"] = True
|
|
581
|
+
graph.add_edge(Edge("export", parent_module, target_path, extras=extras))
|
|
582
|
+
continue
|
|
510
583
|
target_path = f"{parent_module.rsplit('.', 1)[0]}.{target_path}"
|
|
511
584
|
extras["resolution"] = "flat"
|
|
512
585
|
graph.add_edge(Edge("export", parent_module, target_path, extras=extras))
|
|
@@ -599,6 +672,12 @@ def _add_node(graph, obj, root) -> None:
|
|
|
599
672
|
)
|
|
600
673
|
|
|
601
674
|
|
|
675
|
+
def _is_stub(obj) -> bool:
|
|
676
|
+
"""True when ``obj`` lives in a ``.pyi`` — a declaration Python never executes."""
|
|
677
|
+
f = module_file(obj)
|
|
678
|
+
return f is not None and f.suffix == ".pyi"
|
|
679
|
+
|
|
680
|
+
|
|
602
681
|
def _stub_marked(extras: dict, obj) -> dict:
|
|
603
682
|
"""Label a symbol that exists only in a ``.pyi`` stub (R1-C23 / design D5).
|
|
604
683
|
|
|
@@ -607,8 +686,7 @@ def _stub_marked(extras: dict, obj) -> dict:
|
|
|
607
686
|
a stubs distribution, and leaving them unmarked presents a function that does not
|
|
608
687
|
exist as if it did. Consumers that reason about execution (dead-code) exclude them.
|
|
609
688
|
"""
|
|
610
|
-
|
|
611
|
-
if f is not None and f.suffix == ".pyi":
|
|
689
|
+
if _is_stub(obj):
|
|
612
690
|
extras = {**extras, "stub": True}
|
|
613
691
|
return extras
|
|
614
692
|
|
|
@@ -21,6 +21,25 @@ def _grade_rank(grade: str | None) -> int:
|
|
|
21
21
|
return CONFIDENCE_ORDER.index(grade) if grade in CONFIDENCE_ORDER else len(CONFIDENCE_ORDER)
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
def _canonical_cycles(cycles) -> list[list[str]]:
|
|
25
|
+
"""Cycles in a stable form: each rotated to start at its smallest node, then sorted.
|
|
26
|
+
|
|
27
|
+
R1-C54, reported by the lab as [issue #20](https://github.com/kogriv/codemap/issues/20).
|
|
28
|
+
``nx.simple_cycles`` yields a cycle starting wherever its traversal happened to enter
|
|
29
|
+
it, and that traversal follows set-iteration order, i.e. string hashes. The chain is
|
|
30
|
+
the *same cycle* either way — but three consumers print it, and they printed three
|
|
31
|
+
different texts for one graph across eight hash seeds.
|
|
32
|
+
|
|
33
|
+
The sort afterwards is the point of the rotation: ``arch.py`` already sorted cycles by
|
|
34
|
+
``(len, c)`` and that looked like canonicalisation, except the key itself moved with
|
|
35
|
+
the rotation — ``["a","b"]`` and ``["b","a"]`` are one cycle and two keys. Rotating
|
|
36
|
+
first makes the existing sort mean what it appeared to mean.
|
|
37
|
+
"""
|
|
38
|
+
out = [c[i:] + c[:i] for c in (list(x) for x in cycles)
|
|
39
|
+
if (i := c.index(min(c))) >= 0]
|
|
40
|
+
return sorted(out, key=lambda c: (len(c), c))
|
|
41
|
+
|
|
42
|
+
|
|
24
43
|
def _check_grade(min_confidence: str | None) -> None:
|
|
25
44
|
if min_confidence is not None and min_confidence not in CONFIDENCE_ORDER:
|
|
26
45
|
raise ValueError(f"min_confidence must be one of {CONFIDENCE_ORDER}, "
|
|
@@ -134,7 +153,10 @@ class Query:
|
|
|
134
153
|
# R1-C49: between the two — everything that can execute. A `TYPE_CHECKING` import
|
|
135
154
|
# never does, so a cycle that needs one is not runtime coupling at all.
|
|
136
155
|
self._imports_runtime = nx.DiGraph()
|
|
137
|
-
|
|
156
|
+
# R1-C56: `stub` is the fourth scope — a `.pyi` is never executed, so none of its
|
|
157
|
+
# imports run. Counted apart from `type_checking` (different mechanism, same
|
|
158
|
+
# consequence) and, like it, kept out of both the eager and the runtime graph.
|
|
159
|
+
self._import_scopes = {"module": 0, "function": 0, "type_checking": 0, "stub": 0}
|
|
138
160
|
for n in graph.nodes.values():
|
|
139
161
|
if n.kind == "module":
|
|
140
162
|
self._imports.add_node(n.id)
|
|
@@ -144,9 +166,9 @@ class Query:
|
|
|
144
166
|
if e.type == "imports":
|
|
145
167
|
self._imports.add_edge(e.source, e.target)
|
|
146
168
|
scope = e.extras.get("scope")
|
|
147
|
-
if scope in ("function", "type_checking"):
|
|
148
|
-
# R1-C29: runs when the function runs; R1-C48: never runs.
|
|
149
|
-
# is an import-time edge — but only the first is a runtime one.
|
|
169
|
+
if scope in ("function", "type_checking", "stub"):
|
|
170
|
+
# R1-C29: runs when the function runs; R1-C48 and R1-C56: never runs.
|
|
171
|
+
# Neither is an import-time edge — but only the first is a runtime one.
|
|
150
172
|
self._import_scopes[scope] += 1
|
|
151
173
|
if scope == "function":
|
|
152
174
|
self._imports_runtime.add_edge(e.source, e.target)
|
|
@@ -646,10 +668,57 @@ class Query:
|
|
|
646
668
|
out = [c for c in out if _CONFIDENCE_RANK[c["confidence"]] >= floor]
|
|
647
669
|
return out
|
|
648
670
|
|
|
671
|
+
def _overridden_base(self, n: Node) -> tuple[str, int] | None:
|
|
672
|
+
"""The ancestor method ``n`` overrides, and that ancestor's inbound calls (R1-C55).
|
|
673
|
+
|
|
674
|
+
An override is not reached by its own name: the base is called and dynamic
|
|
675
|
+
dispatch lands here, so "no inbound calls" is a statement about the *name*, not
|
|
676
|
+
about the body. Measured on Pillow, where 40 of 63 ``high`` candidates were
|
|
677
|
+
``_open`` implementations of a template method the graph itself records as
|
|
678
|
+
called from ``ImageFile.__init__`` — the strongest grade, on the most ordinary
|
|
679
|
+
shape in object-oriented Python.
|
|
680
|
+
|
|
681
|
+
Walks ``inherits`` transitively and weighs **every** ancestor that declares the
|
|
682
|
+
name, not just the nearest: in a three-deep chain the middle link is an override
|
|
683
|
+
too, so it has no inbound call of its own, and stopping there would report "the
|
|
684
|
+
base is itself uncalled" while the call sits one level further up. An ancestor
|
|
685
|
+
with inbound calls therefore wins; failing that, the nearest one is named.
|
|
686
|
+
"""
|
|
687
|
+
cls, _, meth = n.id.rpartition(".")
|
|
688
|
+
if not cls or cls not in self._inherits:
|
|
689
|
+
return None
|
|
690
|
+
nearest: tuple[str, int] | None = None
|
|
691
|
+
seen, queue = {cls}, [cls]
|
|
692
|
+
while queue: # breadth-first, so `nearest` is the closest declaration
|
|
693
|
+
for base in self.bases(queue.pop(0)):
|
|
694
|
+
if base in seen:
|
|
695
|
+
continue
|
|
696
|
+
seen.add(base)
|
|
697
|
+
queue.append(base)
|
|
698
|
+
cand = f"{base}.{meth}"
|
|
699
|
+
if cand not in self.graph.nodes:
|
|
700
|
+
continue
|
|
701
|
+
in_calls = self._calls.in_degree(cand) if cand in self._calls else 0
|
|
702
|
+
if in_calls:
|
|
703
|
+
return cand, in_calls
|
|
704
|
+
nearest = nearest or (cand, 0)
|
|
705
|
+
return nearest
|
|
706
|
+
|
|
649
707
|
def _grade_dead(self, n: Node) -> dict:
|
|
650
708
|
"""Score one uncalled-private candidate → {id, confidence, root, reasons}."""
|
|
651
709
|
refs = self.references_to(n.id) # inbound of every kind, across roots
|
|
652
710
|
registry = n.extras.get("registry")
|
|
711
|
+
override = self._overridden_base(n)
|
|
712
|
+
if override and not refs:
|
|
713
|
+
# R1-C55: the name is uncalled; the body is reachable through the base.
|
|
714
|
+
base, in_calls = override
|
|
715
|
+
if in_calls:
|
|
716
|
+
return {"id": n.id, "confidence": "low", "root": self.root_of(n.id),
|
|
717
|
+
"reasons": [f"overrides {base}, which has {in_calls} inbound "
|
|
718
|
+
f"call(s) — reached by dispatch, not by name"]}
|
|
719
|
+
return {"id": n.id, "confidence": "medium", "root": self.root_of(n.id),
|
|
720
|
+
"reasons": [f"overrides {base}, which is itself uncalled here — "
|
|
721
|
+
f"dead only if the base is"]}
|
|
653
722
|
if refs:
|
|
654
723
|
by: dict[tuple[str, str], int] = {}
|
|
655
724
|
for r in refs:
|
|
@@ -1069,7 +1138,7 @@ class Query:
|
|
|
1069
1138
|
would report someone's fix as their bug. Those cycles are still real coupling
|
|
1070
1139
|
and are returned by :meth:`lazy_import_cycles`.
|
|
1071
1140
|
"""
|
|
1072
|
-
return
|
|
1141
|
+
return _canonical_cycles(nx.simple_cycles(self._imports_eager))
|
|
1073
1142
|
|
|
1074
1143
|
def lazy_import_cycles(self) -> list[list[str]]:
|
|
1075
1144
|
"""Dependency cycles that close **only** through a function-local import.
|
|
@@ -1087,20 +1156,27 @@ class Query:
|
|
|
1087
1156
|
them, or a tree could hide real coupling by adding one.
|
|
1088
1157
|
"""
|
|
1089
1158
|
eager = {frozenset(c) for c in nx.simple_cycles(self._imports_eager)}
|
|
1090
|
-
return
|
|
1091
|
-
|
|
1159
|
+
return _canonical_cycles(c for c in nx.simple_cycles(self._imports_runtime)
|
|
1160
|
+
if frozenset(c) not in eager)
|
|
1092
1161
|
|
|
1093
1162
|
def type_only_import_cycles(self) -> list[list[str]]:
|
|
1094
|
-
"""Dependency cycles that close **only** with an import
|
|
1163
|
+
"""Dependency cycles that close **only** with an import that never executes.
|
|
1095
1164
|
|
|
1096
1165
|
The third kind (R1-C49, issue #18). These modules name each other's types and have
|
|
1097
1166
|
**no runtime dependency whatever**: neither import pulls the other at any moment of
|
|
1098
1167
|
execution. That is why they are not gated by ``no_lazy_cycles`` — which exists
|
|
1099
1168
|
against a lazy import used to walk *around* ``no_cycles`` — and get their own
|
|
1100
1169
|
opt-in rule instead.
|
|
1170
|
+
|
|
1171
|
+
Two mechanisms, one consequence (R1-C56/D2): an import under ``if TYPE_CHECKING:``
|
|
1172
|
+
and an import written in a ``.pyi``, which Python does not execute at all. The
|
|
1173
|
+
class partitions by consequence — what a reader needs is whether the import can
|
|
1174
|
+
break — while the edge keeps the mechanism in ``extras.scope``. Measured on
|
|
1175
|
+
Pillow, whose *only* "hard" cycle was a stub declaring the module that imports it.
|
|
1101
1176
|
"""
|
|
1102
1177
|
runtime = {frozenset(c) for c in nx.simple_cycles(self._imports_runtime)}
|
|
1103
|
-
return
|
|
1178
|
+
return _canonical_cycles(c for c in nx.simple_cycles(self._imports)
|
|
1179
|
+
if frozenset(c) not in runtime)
|
|
1104
1180
|
|
|
1105
1181
|
def import_map(self) -> dict:
|
|
1106
1182
|
"""How much of the import graph each scope contributed (R1-C29).
|
|
@@ -1112,7 +1188,8 @@ class Query:
|
|
|
1112
1188
|
"""
|
|
1113
1189
|
return {"module_level": self._import_scopes["module"],
|
|
1114
1190
|
"function_local": self._import_scopes["function"],
|
|
1115
|
-
"type_checking": self._import_scopes["type_checking"]
|
|
1191
|
+
"type_checking": self._import_scopes["type_checking"],
|
|
1192
|
+
"stub": self._import_scopes["stub"]}
|
|
1116
1193
|
|
|
1117
1194
|
def orphan_modules(self, root: str | None = None) -> list[str]:
|
|
1118
1195
|
"""Modules with no incoming imports (dead-code candidates — heuristic).
|
|
@@ -19,7 +19,10 @@ import subprocess
|
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
21
|
# What codemap actually consumes as input (source + docs it indexes as references).
|
|
22
|
-
|
|
22
|
+
# R1-C56/D3: `.pyi` is here because griffe reads it. Leaving it out made the manifest
|
|
23
|
+
# describe a different input than the graph was built from, so `scope_id` — and with it
|
|
24
|
+
# `--incremental` and `watch` — read as unknown on every tree that ships stubs.
|
|
25
|
+
DEFAULT_INCLUDE = ("*.py", "*.pyi", "*.md")
|
|
23
26
|
# fs-mode default excludes (git mode gets these for free via .gitignore).
|
|
24
27
|
DEFAULT_EXCLUDE_DIRS = frozenset({
|
|
25
28
|
"__pycache__", ".git", ".venv", "venv", "node_modules", "build", "dist",
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from collections import defaultdict
|
|
11
11
|
|
|
12
|
+
from codemap.diagnostics import diagnostics, render_lines
|
|
12
13
|
from codemap.model import Graph
|
|
13
14
|
|
|
14
15
|
_SYMBOL_KINDS = {"class", "function", "attribute"}
|
|
@@ -41,16 +42,39 @@ def build_api_surface(graph: Graph) -> dict:
|
|
|
41
42
|
"doc": _first_line(n.docstring), "file": n.file, "lineno": n.lineno}
|
|
42
43
|
for n in symbols
|
|
43
44
|
]})
|
|
45
|
+
reexported = _reexported_from_outside(graph)
|
|
44
46
|
return {
|
|
45
47
|
"kind": "api-surface",
|
|
46
48
|
"target": graph.target,
|
|
47
49
|
"totals": {"symbols": sum(len(m["symbols"]) for m in modules),
|
|
48
50
|
"modules_with_symbols": len(modules),
|
|
49
|
-
"public_modules": len(public_modules)
|
|
51
|
+
"public_modules": len(public_modules),
|
|
52
|
+
# R1-C57: always present, zero included — a reader must not have to
|
|
53
|
+
# tell "this package re-exports nothing" from "we did not look".
|
|
54
|
+
"reexported_from_outside": len(reexported)},
|
|
50
55
|
"modules": modules,
|
|
56
|
+
"reexported_from_outside": reexported,
|
|
57
|
+
"diagnostics": diagnostics(graph),
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
|
|
61
|
+
def _reexported_from_outside(graph: Graph) -> list[dict]:
|
|
62
|
+
"""Public names this package exposes whose definition lives outside it (R1-C57).
|
|
63
|
+
|
|
64
|
+
The facade layout — `pytest` re-exporting 90 names from `_pytest`, `attrs` from
|
|
65
|
+
`attr` — has almost no symbols of its own, so counting nodes reported a public
|
|
66
|
+
surface of **one** for a package whose whole purpose is its API. These names are
|
|
67
|
+
the surface; the definitions are simply not in this graph, and the answer says so
|
|
68
|
+
instead of omitting them.
|
|
69
|
+
"""
|
|
70
|
+
out = []
|
|
71
|
+
for e in graph.edges:
|
|
72
|
+
if e.type == "export" and e.extras.get("external") and e.extras.get("public"):
|
|
73
|
+
out.append({"name": e.extras.get("as", ""), "module": e.source,
|
|
74
|
+
"defined_at": e.target})
|
|
75
|
+
return sorted(out, key=lambda r: (r["module"], r["name"]))
|
|
76
|
+
|
|
77
|
+
|
|
54
78
|
def render_api_surface(graph: Graph) -> str:
|
|
55
79
|
"""Render the public API surface of ``graph`` as Markdown."""
|
|
56
80
|
by_module: dict[str, list] = defaultdict(list)
|
|
@@ -61,12 +85,24 @@ def render_api_surface(graph: Graph) -> str:
|
|
|
61
85
|
by_module[module].append(node)
|
|
62
86
|
|
|
63
87
|
lines = [f"# API surface — `{graph.target}`", ""]
|
|
88
|
+
# R1-C57: every other report carried the build's diagnostics and this one did not, so
|
|
89
|
+
# a facade package printed its surface with no hint that the import graph behind it was
|
|
90
|
+
# empty. A warning that reaches four reports out of five is a warning the reader can
|
|
91
|
+
# miss by asking the wrong question.
|
|
92
|
+
lines.extend(render_lines(graph))
|
|
64
93
|
public_modules = sorted(
|
|
65
94
|
n.id for n in graph.nodes.values() if n.kind == "module" and n.visibility == "public"
|
|
66
95
|
)
|
|
67
96
|
total = sum(len(v) for v in by_module.values())
|
|
68
|
-
|
|
97
|
+
reexported = _reexported_from_outside(graph)
|
|
98
|
+
lines.append(f"_{total} public symbols across {len(public_modules)} modules; "
|
|
99
|
+
f"{len(reexported)} more re-exported from outside this root._")
|
|
69
100
|
lines.append("")
|
|
101
|
+
if reexported:
|
|
102
|
+
lines.append(f"> ⚠ This package exposes **{len(reexported)}** name(s) it does not "
|
|
103
|
+
f"define — the facade layout. Their definitions are in another root "
|
|
104
|
+
f"and are **not judged here**; build that package to see them.")
|
|
105
|
+
lines.append("")
|
|
70
106
|
|
|
71
107
|
for module in public_modules:
|
|
72
108
|
symbols = sorted(by_module.get(module, []), key=lambda n: n.id)
|
|
@@ -84,6 +120,13 @@ def render_api_surface(graph: Graph) -> str:
|
|
|
84
120
|
lines.append(f" - {doc}")
|
|
85
121
|
lines.append("")
|
|
86
122
|
|
|
123
|
+
if reexported:
|
|
124
|
+
lines.append("## Re-exported from outside this root")
|
|
125
|
+
lines.append("")
|
|
126
|
+
for r in reexported:
|
|
127
|
+
lines.append(f"- **`{r['name']}`** → `{r['defined_at']}` (via `{r['module']}`)")
|
|
128
|
+
lines.append("")
|
|
129
|
+
|
|
87
130
|
return "\n".join(lines).rstrip() + "\n"
|
|
88
131
|
|
|
89
132
|
|
|
@@ -82,9 +82,10 @@ def render_architecture(query: Query) -> str:
|
|
|
82
82
|
or ["_none found in the eager import graph._"])
|
|
83
83
|
out.append("")
|
|
84
84
|
out.append(f"_Read {im['module_level']} module-level, {im['function_local']} "
|
|
85
|
-
f"function-local
|
|
86
|
-
f"module-level imports run at import time, so only
|
|
87
|
-
f"an import under `if TYPE_CHECKING:` never runs
|
|
85
|
+
f"function-local, {im['type_checking']} `TYPE_CHECKING` and {im['stub']} "
|
|
86
|
+
f"`.pyi` import(s). Only module-level imports run at import time, so only "
|
|
87
|
+
f"they can break on import; an import under `if TYPE_CHECKING:` never runs, "
|
|
88
|
+
f"and a `.pyi` is not executed at all._")
|
|
88
89
|
out.append("")
|
|
89
90
|
if a["lazy_cycles"]:
|
|
90
91
|
out.append(f"### Dependency cycles closed only by a function-local import: "
|
|
@@ -103,8 +104,8 @@ def render_architecture(query: Query) -> str:
|
|
|
103
104
|
if a["type_only_cycles"]:
|
|
104
105
|
# R1-C49: the third kind, kept apart from the second because the difference is the
|
|
105
106
|
# whole point — these modules have no runtime dependency on each other at all.
|
|
106
|
-
out.append(f"### Dependency cycles closed only by an import
|
|
107
|
-
f"`if TYPE_CHECKING
|
|
107
|
+
out.append(f"### Dependency cycles closed only by an import that never runs "
|
|
108
|
+
f"(`if TYPE_CHECKING:` or a `.pyi`): {len(a['type_only_cycles'])}")
|
|
108
109
|
out.append("")
|
|
109
110
|
out.append("_Neither module pulls the other at any moment of execution — they name "
|
|
110
111
|
"each other's types. Not an import-time failure and not runtime coupling; "
|
|
@@ -32,11 +32,11 @@ def render_dependencies(query: Query) -> str:
|
|
|
32
32
|
lines.append("_none found in the eager import graph._")
|
|
33
33
|
lines.append("")
|
|
34
34
|
lines.append(f"_Read {im['module_level']} module-level, {im['function_local']} "
|
|
35
|
-
f"function-local
|
|
36
|
-
f"the first run at import time. {len(lazy)} further
|
|
37
|
-
f"a function-local import (runtime coupling, not an
|
|
38
|
-
f"and {len(type_only)} through an import
|
|
39
|
-
f"runtime dependency at all)._")
|
|
35
|
+
f"function-local, {im['type_checking']} `TYPE_CHECKING` and {im['stub']} "
|
|
36
|
+
f"`.pyi` import(s); only the first run at import time. {len(lazy)} further "
|
|
37
|
+
f"cycle(s) close through a function-local import (runtime coupling, not an "
|
|
38
|
+
f"import-time failure) and {len(type_only)} through an import that never "
|
|
39
|
+
f"runs — `if TYPE_CHECKING:` or a `.pyi` (no runtime dependency at all)._")
|
|
40
40
|
lines.append("")
|
|
41
41
|
|
|
42
42
|
lines.append("## Most-depended-on modules (top 15)")
|
|
@@ -47,9 +47,10 @@ def _not_judged(query, contract: ArchitectureContract) -> list[dict]:
|
|
|
47
47
|
"type_checking_imports": query.import_map()["type_checking"],
|
|
48
48
|
"count": len(lazy) + len(type_only),
|
|
49
49
|
"note": ("`lazy` needs a function-local import — runtime coupling, gated by "
|
|
50
|
-
"`no_lazy_cycles = true`. `type_only` needs an import
|
|
51
|
-
"`if TYPE_CHECKING
|
|
52
|
-
"`no_type_only_cycles = true`.
|
|
50
|
+
"`no_lazy_cycles = true`. `type_only` needs an import that never runs "
|
|
51
|
+
"(`if TYPE_CHECKING:`, or a `.pyi`, which Python does not execute) — no "
|
|
52
|
+
"runtime dependency at all, gated by `no_type_only_cycles = true`. "
|
|
53
|
+
"`report architecture` lists both."),
|
|
53
54
|
}]
|
|
54
55
|
|
|
55
56
|
|
|
@@ -160,9 +160,9 @@ def render_docs(query: Query) -> str:
|
|
|
160
160
|
out.append(f"- **{len(lazy)} dependency cycle(s) closed only by a function-local "
|
|
161
161
|
f"import** — deliberate, and still runtime coupling.")
|
|
162
162
|
if type_only:
|
|
163
|
-
out.append(f"- **{len(type_only)} dependency cycle(s) closed only by an import
|
|
164
|
-
f"`if TYPE_CHECKING
|
|
165
|
-
f"runtime dependency.")
|
|
163
|
+
out.append(f"- **{len(type_only)} dependency cycle(s) closed only by an import that "
|
|
164
|
+
f"never runs** (`if TYPE_CHECKING:` or a `.pyi`) — the modules name each "
|
|
165
|
+
f"other's types and have no runtime dependency.")
|
|
166
166
|
if lay["violations"]:
|
|
167
167
|
out.append("- **Layer violations (mutual dependency):** "
|
|
168
168
|
+ ", ".join(f"{a} ↔ {b}" for a, b in lay["violations"]))
|
|
@@ -117,4 +117,16 @@ def render_mermaid(query: Query, kind: str, scope: str | None = None,
|
|
|
117
117
|
return render_call_graph(query, root, depth)
|
|
118
118
|
if kind not in _KINDS:
|
|
119
119
|
raise ValueError(f"unknown mermaid kind: {kind}")
|
|
120
|
-
|
|
120
|
+
out = _KINDS[kind](query, scope)
|
|
121
|
+
if scope:
|
|
122
|
+
# R1-C53: `--scope` cut this diagram from 144 lines to 47 on the dogfood tree with
|
|
123
|
+
# no marker of any kind, so the picture read as "the class diagram of this package".
|
|
124
|
+
# A diagram is an answer too. Mermaid ignores `%%` lines, so the note costs nothing
|
|
125
|
+
# rendered and is visible in the source a reader diffs or pastes.
|
|
126
|
+
hidden = len(_KINDS[kind](query, None).splitlines()) - len(out.splitlines())
|
|
127
|
+
note = (f"%% scope: {scope} — {hidden} further line(s) exist in the unscoped "
|
|
128
|
+
"diagram of this graph")
|
|
129
|
+
lines = out.splitlines()
|
|
130
|
+
at = 1 if lines and lines[0].startswith("```") else 0
|
|
131
|
+
out = "\n".join(lines[:at] + [note] + lines[at:]) + "\n"
|
|
132
|
+
return out
|