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.
Files changed (139) hide show
  1. {codmap-0.0.17 → codmap-0.0.19}/PKG-INFO +2 -2
  2. {codmap-0.0.17 → codmap-0.0.19}/README.md +1 -1
  3. {codmap-0.0.17 → codmap-0.0.19}/codemap/extract/griffe_extractor.py +85 -7
  4. {codmap-0.0.17 → codmap-0.0.19}/codemap/query.py +87 -10
  5. {codmap-0.0.17 → codmap-0.0.19}/codemap/scope.py +4 -1
  6. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/api_surface.py +45 -2
  7. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/architecture.py +6 -5
  8. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/audit.py +5 -5
  9. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/check.py +4 -3
  10. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/livingdocs.py +3 -3
  11. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/mermaid.py +13 -1
  12. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/session.py +111 -8
  13. {codmap-0.0.17 → codmap-0.0.19}/codmap.egg-info/PKG-INFO +2 -2
  14. {codmap-0.0.17 → codmap-0.0.19}/codmap.egg-info/SOURCES.txt +5 -0
  15. {codmap-0.0.17 → codmap-0.0.19}/pyproject.toml +1 -1
  16. {codmap-0.0.17 → codmap-0.0.19}/tests/test_m1_query.py +16 -4
  17. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c29_lazy_imports.py +3 -3
  18. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c48_type_checking_imports.py +5 -5
  19. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c49_type_only_cycles.py +5 -4
  20. codmap-0.0.19/tests/test_r1c53_narrowing_declared.py +199 -0
  21. codmap-0.0.19/tests/test_r1c54_deterministic_rendering.py +131 -0
  22. codmap-0.0.19/tests/test_r1c55_override_grade.py +118 -0
  23. codmap-0.0.19/tests/test_r1c56_stub_files.py +166 -0
  24. codmap-0.0.19/tests/test_r1c57_facade_package.py +136 -0
  25. {codmap-0.0.17 → codmap-0.0.19}/LICENSE +0 -0
  26. {codmap-0.0.17 → codmap-0.0.19}/codemap/__init__.py +0 -0
  27. {codmap-0.0.17 → codmap-0.0.19}/codemap/apidiff.py +0 -0
  28. {codmap-0.0.17 → codmap-0.0.19}/codemap/arch.py +0 -0
  29. {codmap-0.0.17 → codmap-0.0.19}/codemap/cli.py +0 -0
  30. {codmap-0.0.17 → codmap-0.0.19}/codemap/diagnostics.py +0 -0
  31. {codmap-0.0.17 → codmap-0.0.19}/codemap/extract/__init__.py +0 -0
  32. {codmap-0.0.17 → codmap-0.0.19}/codemap/extract/attrflow.py +0 -0
  33. {codmap-0.0.17 → codmap-0.0.19}/codemap/extract/behavior.py +0 -0
  34. {codmap-0.0.17 → codmap-0.0.19}/codemap/extract/dataflow.py +0 -0
  35. {codmap-0.0.17 → codmap-0.0.19}/codemap/extract/dispatch.py +0 -0
  36. {codmap-0.0.17 → codmap-0.0.19}/codemap/extract/gsource.py +0 -0
  37. {codmap-0.0.17 → codmap-0.0.19}/codemap/extract/roots.py +0 -0
  38. {codmap-0.0.17 → codmap-0.0.19}/codemap/extract/union.py +0 -0
  39. {codmap-0.0.17 → codmap-0.0.19}/codemap/freshness.py +0 -0
  40. {codmap-0.0.17 → codmap-0.0.19}/codemap/incremental.py +0 -0
  41. {codmap-0.0.17 → codmap-0.0.19}/codemap/integrations/__init__.py +0 -0
  42. {codmap-0.0.17 → codmap-0.0.19}/codemap/integrations/base.py +0 -0
  43. {codmap-0.0.17 → codmap-0.0.19}/codemap/integrations/cocoindex.py +0 -0
  44. {codmap-0.0.17 → codmap-0.0.19}/codemap/integrations/gate.py +0 -0
  45. {codmap-0.0.17 → codmap-0.0.19}/codemap/integrations/gitnexus.py +0 -0
  46. {codmap-0.0.17 → codmap-0.0.19}/codemap/integrations/registry.py +0 -0
  47. {codmap-0.0.17 → codmap-0.0.19}/codemap/integrations/transport.py +0 -0
  48. {codmap-0.0.17 → codmap-0.0.19}/codemap/model.py +0 -0
  49. {codmap-0.0.17 → codmap-0.0.19}/codemap/provenance.py +0 -0
  50. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/__init__.py +0 -0
  51. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/_scip_pb2.py +0 -0
  52. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/apidiff.py +0 -0
  53. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/ctags.py +0 -0
  54. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/impact.py +0 -0
  55. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/limits.py +0 -0
  56. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/mcp_server.py +0 -0
  57. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/pack.py +0 -0
  58. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/rag.py +0 -0
  59. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/review.py +0 -0
  60. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/scip.py +0 -0
  61. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/semantic.py +0 -0
  62. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/server.py +0 -0
  63. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/subsystems.py +0 -0
  64. {codmap-0.0.17 → codmap-0.0.19}/codemap/serve/vault.py +0 -0
  65. {codmap-0.0.17 → codmap-0.0.19}/codemap/store.py +0 -0
  66. {codmap-0.0.17 → codmap-0.0.19}/codemap/tomlio.py +0 -0
  67. {codmap-0.0.17 → codmap-0.0.19}/codemap/watch.py +0 -0
  68. {codmap-0.0.17 → codmap-0.0.19}/codmap.egg-info/dependency_links.txt +0 -0
  69. {codmap-0.0.17 → codmap-0.0.19}/codmap.egg-info/entry_points.txt +0 -0
  70. {codmap-0.0.17 → codmap-0.0.19}/codmap.egg-info/requires.txt +0 -0
  71. {codmap-0.0.17 → codmap-0.0.19}/codmap.egg-info/top_level.txt +0 -0
  72. {codmap-0.0.17 → codmap-0.0.19}/setup.cfg +0 -0
  73. {codmap-0.0.17 → codmap-0.0.19}/tests/test_epistemic.py +0 -0
  74. {codmap-0.0.17 → codmap-0.0.19}/tests/test_gitnexus_router.py +0 -0
  75. {codmap-0.0.17 → codmap-0.0.19}/tests/test_impact_depth.py +0 -0
  76. {codmap-0.0.17 → codmap-0.0.19}/tests/test_integrations.py +0 -0
  77. {codmap-0.0.17 → codmap-0.0.19}/tests/test_issue3_serve_freshness.py +0 -0
  78. {codmap-0.0.17 → codmap-0.0.19}/tests/test_livingdocs.py +0 -0
  79. {codmap-0.0.17 → codmap-0.0.19}/tests/test_m0_api_surface.py +0 -0
  80. {codmap-0.0.17 → codmap-0.0.19}/tests/test_m11_argcontract.py +0 -0
  81. {codmap-0.0.17 → codmap-0.0.19}/tests/test_m12_dataflow.py +0 -0
  82. {codmap-0.0.17 → codmap-0.0.19}/tests/test_m15_review.py +0 -0
  83. {codmap-0.0.17 → codmap-0.0.19}/tests/test_m16_architecture.py +0 -0
  84. {codmap-0.0.17 → codmap-0.0.19}/tests/test_m17_mcp.py +0 -0
  85. {codmap-0.0.17 → codmap-0.0.19}/tests/test_m18_freshness.py +0 -0
  86. {codmap-0.0.17 → codmap-0.0.19}/tests/test_m1_5_semantics.py +0 -0
  87. {codmap-0.0.17 → codmap-0.0.19}/tests/test_m2_views.py +0 -0
  88. {codmap-0.0.17 → codmap-0.0.19}/tests/test_m32_watch.py +0 -0
  89. {codmap-0.0.17 → codmap-0.0.19}/tests/test_m3_serve.py +0 -0
  90. {codmap-0.0.17 → codmap-0.0.19}/tests/test_m4_behavior.py +0 -0
  91. {codmap-0.0.17 → codmap-0.0.19}/tests/test_m5_deep.py +0 -0
  92. {codmap-0.0.17 → codmap-0.0.19}/tests/test_m6_repo_scope.py +0 -0
  93. {codmap-0.0.17 → codmap-0.0.19}/tests/test_m7_dispatch.py +0 -0
  94. {codmap-0.0.17 → codmap-0.0.19}/tests/test_m9_family.py +0 -0
  95. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  96. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c13_grep_vs_graph.py +0 -0
  97. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c16_semantic.py +0 -0
  98. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c20_attribute_edges.py +0 -0
  99. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c21_flat_layout.py +0 -0
  100. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c22_source_visible_refs.py +0 -0
  101. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c23_hard_python.py +0 -0
  102. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c24_test_mapping.py +0 -0
  103. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c25_provenance.py +0 -0
  104. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c26_deep_union.py +0 -0
  105. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c27_config_honesty.py +0 -0
  106. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c28_limit_envelope.py +0 -0
  107. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c2_ctags.py +0 -0
  108. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c30_local_import_calls.py +0 -0
  109. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c30f1_reexport_calls.py +0 -0
  110. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c30f2_check_scope.py +0 -0
  111. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c31_path_origin.py +0 -0
  112. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c32_report_json.py +0 -0
  113. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c33_query_signature.py +0 -0
  114. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c34_signature_kinds.py +0 -0
  115. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c35_contract_location.py +0 -0
  116. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c36_target_resolution.py +0 -0
  117. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c37_rule_mutation.py +0 -0
  118. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c38_tool_drift.py +0 -0
  119. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c39_edge_resolution.py +0 -0
  120. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c3_arch_contract.py +0 -0
  121. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c3_dogfood.py +0 -0
  122. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c40_flow_reach.py +0 -0
  123. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c41_scope_membership.py +0 -0
  124. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c42_deep_tier_stability.py +0 -0
  125. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c43_incremental_splice.py +0 -0
  126. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c44_absent_answers.py +0 -0
  127. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c45_repeat_union.py +0 -0
  128. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c46_shadowed_definitions.py +0 -0
  129. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c47_incremental_resample.py +0 -0
  130. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c4_complexity.py +0 -0
  131. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c50_flow_entry_points.py +0 -0
  132. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c5_apidiff.py +0 -0
  133. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c6_pack.py +0 -0
  134. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c7_edge_vocab.py +0 -0
  135. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c8_deadcode.py +0 -0
  136. {codmap-0.0.17 → codmap-0.0.19}/tests/test_r1c9_incremental.py +0 -0
  137. {codmap-0.0.17 → codmap-0.0.19}/tests/test_scip_export.py +0 -0
  138. {codmap-0.0.17 → codmap-0.0.19}/tests/test_scope.py +0 -0
  139. {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.17
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
  [![CI](https://github.com/kogriv/codemap/actions/workflows/ci.yml/badge.svg)](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
46
46
 
47
- **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **765 tests with no failures on
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
  [![CI](https://github.com/kogriv/codemap/actions/workflows/ci.yml/badge.svg)](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
10
10
 
11
- **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **765 tests with no failures on
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 = griffe.load(module_name, search_paths=[str(search_path)], try_relative_path=False)
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
- continue # external re-export (e.g. `import numpy as np`) — out of scope
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
- f = module_file(obj)
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
- self._import_scopes = {"module": 0, "function": 0, "type_checking": 0}
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. Neither
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 [c for c in nx.simple_cycles(self._imports_eager)]
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 [c for c in nx.simple_cycles(self._imports_runtime)
1091
- if frozenset(c) not in eager]
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 under ``if TYPE_CHECKING:``.
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 [c for c in nx.simple_cycles(self._imports) if frozenset(c) not in runtime]
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
- DEFAULT_INCLUDE = ("*.py", "*.md")
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
- lines.append(f"_{total} public symbols across {len(public_modules)} modules._")
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 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._")
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 under "
107
- f"`if TYPE_CHECKING:`: {len(a['type_only_cycles'])}")
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 and {im['type_checking']} `TYPE_CHECKING` import(s); only "
36
- f"the first run at import time. {len(lazy)} further cycle(s) close through "
37
- f"a function-local import (runtime coupling, not an import-time failure) "
38
- f"and {len(type_only)} through an import under `if TYPE_CHECKING:` (no "
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 under "
51
- "`if TYPE_CHECKING:` — no runtime dependency at all, gated by "
52
- "`no_type_only_cycles = true`. `report architecture` lists both."),
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 under "
164
- f"`if TYPE_CHECKING:`** — the modules name each other's types and have no "
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
- return _KINDS[kind](query, scope)
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