codmap 0.0.23__tar.gz → 0.0.24__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 (156) hide show
  1. {codmap-0.0.23/codmap.egg-info → codmap-0.0.24}/PKG-INFO +2 -2
  2. {codmap-0.0.23 → codmap-0.0.24}/README.md +1 -1
  3. {codmap-0.0.23 → codmap-0.0.24}/codemap/cli.py +33 -0
  4. {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/behavior.py +130 -22
  5. {codmap-0.0.23 → codmap-0.0.24}/codemap/model.py +5 -1
  6. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/audit.py +44 -10
  7. {codmap-0.0.23 → codmap-0.0.24/codmap.egg-info}/PKG-INFO +2 -2
  8. {codmap-0.0.23 → codmap-0.0.24}/codmap.egg-info/SOURCES.txt +2 -0
  9. {codmap-0.0.23 → codmap-0.0.24}/pyproject.toml +1 -1
  10. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c63_answer_format.py +2 -1
  11. codmap-0.0.24/tests/test_r1c73_cli_version.py +72 -0
  12. codmap-0.0.24/tests/test_r1c74_call_site_conservation.py +169 -0
  13. {codmap-0.0.23 → codmap-0.0.24}/LICENSE +0 -0
  14. {codmap-0.0.23 → codmap-0.0.24}/codemap/__init__.py +0 -0
  15. {codmap-0.0.23 → codmap-0.0.24}/codemap/apidiff.py +0 -0
  16. {codmap-0.0.23 → codmap-0.0.24}/codemap/arch.py +0 -0
  17. {codmap-0.0.23 → codmap-0.0.24}/codemap/diagnostics.py +0 -0
  18. {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/__init__.py +0 -0
  19. {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/attrflow.py +0 -0
  20. {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/dataflow.py +0 -0
  21. {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/dispatch.py +0 -0
  22. {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/griffe_extractor.py +0 -0
  23. {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/gsource.py +0 -0
  24. {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/probe.py +0 -0
  25. {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/roots.py +0 -0
  26. {codmap-0.0.23 → codmap-0.0.24}/codemap/extract/union.py +0 -0
  27. {codmap-0.0.23 → codmap-0.0.24}/codemap/freshness.py +0 -0
  28. {codmap-0.0.23 → codmap-0.0.24}/codemap/incremental.py +0 -0
  29. {codmap-0.0.23 → codmap-0.0.24}/codemap/integrations/__init__.py +0 -0
  30. {codmap-0.0.23 → codmap-0.0.24}/codemap/integrations/base.py +0 -0
  31. {codmap-0.0.23 → codmap-0.0.24}/codemap/integrations/cocoindex.py +0 -0
  32. {codmap-0.0.23 → codmap-0.0.24}/codemap/integrations/gate.py +0 -0
  33. {codmap-0.0.23 → codmap-0.0.24}/codemap/integrations/gitnexus.py +0 -0
  34. {codmap-0.0.23 → codmap-0.0.24}/codemap/integrations/registry.py +0 -0
  35. {codmap-0.0.23 → codmap-0.0.24}/codemap/integrations/transport.py +0 -0
  36. {codmap-0.0.23 → codmap-0.0.24}/codemap/provenance.py +0 -0
  37. {codmap-0.0.23 → codmap-0.0.24}/codemap/query.py +0 -0
  38. {codmap-0.0.23 → codmap-0.0.24}/codemap/scope.py +0 -0
  39. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/__init__.py +0 -0
  40. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/_scip_pb2.py +0 -0
  41. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/api_surface.py +0 -0
  42. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/apidiff.py +0 -0
  43. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/architecture.py +0 -0
  44. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/check.py +0 -0
  45. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/ctags.py +0 -0
  46. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/currency.py +0 -0
  47. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/impact.py +0 -0
  48. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/limits.py +0 -0
  49. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/livingdocs.py +0 -0
  50. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/mcp_server.py +0 -0
  51. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/mermaid.py +0 -0
  52. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/pack.py +0 -0
  53. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/rag.py +0 -0
  54. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/review.py +0 -0
  55. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/scip.py +0 -0
  56. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/semantic.py +0 -0
  57. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/server.py +0 -0
  58. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/session.py +0 -0
  59. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/subsystems.py +0 -0
  60. {codmap-0.0.23 → codmap-0.0.24}/codemap/serve/vault.py +0 -0
  61. {codmap-0.0.23 → codmap-0.0.24}/codemap/store.py +0 -0
  62. {codmap-0.0.23 → codmap-0.0.24}/codemap/tomlio.py +0 -0
  63. {codmap-0.0.23 → codmap-0.0.24}/codemap/watch.py +0 -0
  64. {codmap-0.0.23 → codmap-0.0.24}/codmap.egg-info/dependency_links.txt +0 -0
  65. {codmap-0.0.23 → codmap-0.0.24}/codmap.egg-info/entry_points.txt +0 -0
  66. {codmap-0.0.23 → codmap-0.0.24}/codmap.egg-info/requires.txt +0 -0
  67. {codmap-0.0.23 → codmap-0.0.24}/codmap.egg-info/top_level.txt +0 -0
  68. {codmap-0.0.23 → codmap-0.0.24}/setup.cfg +0 -0
  69. {codmap-0.0.23 → codmap-0.0.24}/tests/test_epistemic.py +0 -0
  70. {codmap-0.0.23 → codmap-0.0.24}/tests/test_gitnexus_router.py +0 -0
  71. {codmap-0.0.23 → codmap-0.0.24}/tests/test_impact_depth.py +0 -0
  72. {codmap-0.0.23 → codmap-0.0.24}/tests/test_integrations.py +0 -0
  73. {codmap-0.0.23 → codmap-0.0.24}/tests/test_issue3_serve_freshness.py +0 -0
  74. {codmap-0.0.23 → codmap-0.0.24}/tests/test_livingdocs.py +0 -0
  75. {codmap-0.0.23 → codmap-0.0.24}/tests/test_m0_api_surface.py +0 -0
  76. {codmap-0.0.23 → codmap-0.0.24}/tests/test_m11_argcontract.py +0 -0
  77. {codmap-0.0.23 → codmap-0.0.24}/tests/test_m12_dataflow.py +0 -0
  78. {codmap-0.0.23 → codmap-0.0.24}/tests/test_m15_review.py +0 -0
  79. {codmap-0.0.23 → codmap-0.0.24}/tests/test_m16_architecture.py +0 -0
  80. {codmap-0.0.23 → codmap-0.0.24}/tests/test_m17_mcp.py +0 -0
  81. {codmap-0.0.23 → codmap-0.0.24}/tests/test_m18_freshness.py +0 -0
  82. {codmap-0.0.23 → codmap-0.0.24}/tests/test_m1_5_semantics.py +0 -0
  83. {codmap-0.0.23 → codmap-0.0.24}/tests/test_m1_query.py +0 -0
  84. {codmap-0.0.23 → codmap-0.0.24}/tests/test_m2_views.py +0 -0
  85. {codmap-0.0.23 → codmap-0.0.24}/tests/test_m32_watch.py +0 -0
  86. {codmap-0.0.23 → codmap-0.0.24}/tests/test_m3_serve.py +0 -0
  87. {codmap-0.0.23 → codmap-0.0.24}/tests/test_m4_behavior.py +0 -0
  88. {codmap-0.0.23 → codmap-0.0.24}/tests/test_m5_deep.py +0 -0
  89. {codmap-0.0.23 → codmap-0.0.24}/tests/test_m6_repo_scope.py +0 -0
  90. {codmap-0.0.23 → codmap-0.0.24}/tests/test_m7_dispatch.py +0 -0
  91. {codmap-0.0.23 → codmap-0.0.24}/tests/test_m9_family.py +0 -0
  92. {codmap-0.0.23 → codmap-0.0.24}/tests/test_pack_honesty.py +0 -0
  93. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  94. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c13_grep_vs_graph.py +0 -0
  95. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c16_semantic.py +0 -0
  96. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c20_attribute_edges.py +0 -0
  97. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c21_flat_layout.py +0 -0
  98. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c22_source_visible_refs.py +0 -0
  99. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c23_hard_python.py +0 -0
  100. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c24_test_mapping.py +0 -0
  101. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c25_provenance.py +0 -0
  102. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c26_deep_union.py +0 -0
  103. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c27_config_honesty.py +0 -0
  104. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c28_limit_envelope.py +0 -0
  105. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c29_lazy_imports.py +0 -0
  106. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c2_ctags.py +0 -0
  107. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c30_local_import_calls.py +0 -0
  108. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c30f1_reexport_calls.py +0 -0
  109. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c30f2_check_scope.py +0 -0
  110. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c31_path_origin.py +0 -0
  111. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c32_report_json.py +0 -0
  112. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c33_query_signature.py +0 -0
  113. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c34_signature_kinds.py +0 -0
  114. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c35_contract_location.py +0 -0
  115. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c36_target_resolution.py +0 -0
  116. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c37_rule_mutation.py +0 -0
  117. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c38_tool_drift.py +0 -0
  118. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c39_edge_resolution.py +0 -0
  119. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c3_arch_contract.py +0 -0
  120. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c3_dogfood.py +0 -0
  121. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c40_flow_reach.py +0 -0
  122. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c41_scope_membership.py +0 -0
  123. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c42_deep_tier_stability.py +0 -0
  124. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c43_incremental_splice.py +0 -0
  125. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c44_absent_answers.py +0 -0
  126. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c45_repeat_union.py +0 -0
  127. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c46_shadowed_definitions.py +0 -0
  128. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c47_incremental_resample.py +0 -0
  129. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c48_type_checking_imports.py +0 -0
  130. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c49_type_only_cycles.py +0 -0
  131. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c4_complexity.py +0 -0
  132. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c50_flow_entry_points.py +0 -0
  133. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c53_narrowing_declared.py +0 -0
  134. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c54_deterministic_rendering.py +0 -0
  135. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c55_override_grade.py +0 -0
  136. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c56_stub_files.py +0 -0
  137. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c57_facade_package.py +0 -0
  138. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c58_cycle_tangles.py +0 -0
  139. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c59_completeness_drop.py +0 -0
  140. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c5_apidiff.py +0 -0
  141. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c60_nested_definitions.py +0 -0
  142. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c62_phantom_rules.py +0 -0
  143. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c64_dependency_probe.py +0 -0
  144. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c66_pack_direction.py +0 -0
  145. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c68_dotted_imports.py +0 -0
  146. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c69_contract_validation.py +0 -0
  147. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c6_pack.py +0 -0
  148. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c70_check_graph_currency.py +0 -0
  149. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c71_attribute_chain_calls.py +0 -0
  150. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c72_flat_inference_in_packages.py +0 -0
  151. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c7_edge_vocab.py +0 -0
  152. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c8_deadcode.py +0 -0
  153. {codmap-0.0.23 → codmap-0.0.24}/tests/test_r1c9_incremental.py +0 -0
  154. {codmap-0.0.23 → codmap-0.0.24}/tests/test_scip_export.py +0 -0
  155. {codmap-0.0.23 → codmap-0.0.24}/tests/test_scope.py +0 -0
  156. {codmap-0.0.23 → codmap-0.0.24}/tests/test_subsystems.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.23
3
+ Version: 0.0.24
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, **1115 tests with no failures on
47
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **1128 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, **1115 tests with no failures on
11
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **1128 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 &
@@ -982,8 +982,41 @@ def _add_source(p) -> None:
982
982
  help="Deep call resolution via jedi (richer, ~1 min; default fast).")
983
983
 
984
984
 
985
+ def version_line() -> str:
986
+ """``codemap 0.0.24 @abc1234 · answer format 4 · schema 0.13`` (R1-C73).
987
+
988
+ Built from ``provenance.tool_identity()`` — the identity every graph records as
989
+ ``provenance.tool`` — so the CLI and the artifact cannot disagree. Commit and dirtiness
990
+ appear only when known, as there; a missing distribution is said, never invented.
991
+ """
992
+ from codemap.model import ANSWER_FORMAT, SCHEMA_VERSION
993
+ from codemap.provenance import tool_identity
994
+ t = tool_identity()
995
+ head = f"codemap {t['version']}" if t.get("version") else \
996
+ "codemap version unknown (not installed as a distribution)"
997
+ if t.get("commit"):
998
+ head += f" @{t['commit']}"
999
+ if t.get("dirty"):
1000
+ head += " (dirty)"
1001
+ return f"{head} · answer format {ANSWER_FORMAT} · schema {SCHEMA_VERSION}"
1002
+
1003
+
1004
+ class _VersionAction(argparse.Action):
1005
+ """``--version``, computed only when asked: ``tool_identity()`` calls git (design D1)."""
1006
+
1007
+ def __init__(self, option_strings, dest=argparse.SUPPRESS, default=argparse.SUPPRESS,
1008
+ help="Print the installed version, the answer format and the graph schema, then exit."):
1009
+ super().__init__(option_strings=option_strings, dest=dest, default=default, nargs=0,
1010
+ help=help)
1011
+
1012
+ def __call__(self, parser, namespace, values, option_string=None):
1013
+ print(version_line())
1014
+ parser.exit(0)
1015
+
1016
+
985
1017
  def build_parser() -> argparse.ArgumentParser:
986
1018
  p = argparse.ArgumentParser(prog="codemap", description="Static code-graph builder.")
1019
+ p.add_argument("--version", action=_VersionAction)
987
1020
  sub = p.add_subparsers(dest="command", required=True)
988
1021
 
989
1022
  b = sub.add_parser("build", help="Build the canonical graph from a package path.")
@@ -83,11 +83,18 @@ def add_behavior(graph, griffe_root, target_pkg: str, *, deep: bool = False,
83
83
  # R1-C46: the surviving definition says which earlier bodies it replaced. Stamped
84
84
  # here because this pass holds the AST; the walkers below skip those bodies.
85
85
  _stamp_shadows(graph, tree, modpath)
86
+ walked_here: set[str] = set() # R1-C74: nodes that already took a body in this module
87
+ shadow_skip = _shadow_map(tree)[0]
86
88
  for fnode, class_stack, scope in _named_functions_scoped(tree):
87
89
  node_id = _node_id(modpath, class_stack, fnode.name)
88
90
  local = local_imports.get(id(fnode))
89
91
  fn_imports = {**imports, **local} if local else imports
90
- if node_id not in graph.nodes:
92
+ # R1-C74: a def nested in a function is never a definition node, whatever its
93
+ # name. `_node_id` sees only the class chain, so `def getfixturedefs` inside a method
94
+ # of `FixtureManager` used to *become* the method `FixtureManager.getfixturedefs` —
95
+ # its calls and edges attributed to a namesake (found by the conservation check).
96
+ nested_in_function = len(scope) > len(class_stack)
97
+ if node_id not in graph.nodes or nested_in_function:
91
98
  # R1-C22 D3: a closure / dynamically-built class body is not a definition
92
99
  # node, but the calls inside it are real. Attribute them to the innermost
93
100
  # definition that *does* exist instead of discarding them.
@@ -103,7 +110,9 @@ def add_behavior(graph, griffe_root, target_pkg: str, *, deep: bool = False,
103
110
  return _resolve(call, modpath, _cp, _im, _mm, _me, reexports, nodes)
104
111
  resolve = (_deep_then_fast(graph, script, target_pkg, fast, modpath, known_modules)
105
112
  if script else fast)
106
- _process_function(graph, node_id, fnode, resolve)
113
+ _process_function(graph, node_id, fnode, resolve,
114
+ accumulate=node_id in walked_here)
115
+ walked_here.add(node_id)
107
116
  # R1-C22 D1: functions/classes named as *values* here (dispatch tables,
108
117
  # `default=` callbacks) — a use the call layer cannot see.
109
118
  _emit_name_references(graph, node_id, fnode, modpath, fn_imports, modmembers,
@@ -115,7 +124,11 @@ def add_behavior(graph, griffe_root, target_pkg: str, *, deep: bool = False,
115
124
  mod_resolve = (_deep_then_fast(graph, script, target_pkg, mod_fast, modpath, known_modules)
116
125
  if script else mod_fast)
117
126
  _process_module_level(graph, modpath, tree, mod_resolve, imports, modmembers,
118
- reexports)
127
+ reexports, shadow_skip=shadow_skip)
128
+ # R1-C74: class bodies run at import time and were walked by nobody.
129
+ _process_class_bodies(graph, modpath, tree, mod_resolve, imports, modmembers,
130
+ script, target_pkg, nested, known_modules, reexports,
131
+ skip=shadow_skip)
119
132
  _emit_nested_calls(graph, nested)
120
133
 
121
134
 
@@ -525,30 +538,49 @@ def _deep_then_fast(graph, script, target_pkg, fast, modpath, known_modules):
525
538
  return resolve
526
539
 
527
540
 
528
- def _process_function(graph, node_id, fnode, resolve) -> None:
529
- counts = {"out": 0, "resolved": 0, "external": 0, "unresolved": 0, "dynamic": 0}
541
+ def _new_counts() -> dict:
542
+ return {"out": 0, "resolved": 0, "external": 0, "unresolved": 0, "dynamic": 0}
543
+
544
+
545
+ def _tally(counts: dict, graph, target, resolution) -> bool:
546
+ """Count one call site by how it resolved; True when it becomes an internal edge.
547
+
548
+ One rule for every scope (R1-C74): a function body, a nested function, module-level
549
+ code and a class body are classified the same way, so their counters can be summed
550
+ and checked against the source.
551
+ """
552
+ counts["out"] += 1
553
+ if resolution in _EDGE_RESOLUTIONS and target and target in graph.nodes:
554
+ counts["resolved"] += 1
555
+ return True
556
+ if resolution == "external":
557
+ counts["external"] += 1
558
+ elif resolution == "dynamic":
559
+ counts["dynamic"] += 1
560
+ else:
561
+ counts["unresolved"] += 1
562
+ return False
563
+
564
+
565
+ def _process_function(graph, node_id, fnode, resolve, accumulate: bool = False) -> None:
566
+ """``accumulate``: this node already took a body in this pass (R1-C74). A name defined in
567
+ both branches of an ``if``/``try``, or a legitimate rebinding (a property's setter), keeps
568
+ *both* bodies walked (R1-C46) — their counts add up instead of the last overwriting."""
569
+ counts = _new_counts()
530
570
  # F7: edges are deduped caller->callee, so aggregate the per-call-site argument
531
571
  # contract before emitting — a refactorer needs "how is it called", and the
532
572
  # collapse itself (2 sites -> 1 edge) must stay visible via `callsites`.
533
573
  by_target: dict[str, dict] = {}
534
574
  for call in _own_calls(fnode):
535
- counts["out"] += 1
536
575
  target, resolution = resolve(call)
537
576
  # Soundness (R1-C13-f1/f2): an internal call edge must point at a real
538
577
  # graph node. A resolver can name a non-node — a local variable jedi typed
539
578
  # to its own scope-path, or a nested/closure function that is not a
540
579
  # definition node — so downgrade any such target to unresolved rather than
541
580
  # emit an edge to nothing (which would poison callers/callees/impact).
542
- if resolution in _EDGE_RESOLUTIONS and target and target in graph.nodes:
543
- counts["resolved"] += 1
581
+ if _tally(counts, graph, target, resolution):
544
582
  agg = by_target.setdefault(target, {"resolution": resolution, "shapes": []})
545
583
  agg["shapes"].append(_arg_shape(call))
546
- elif resolution == "external":
547
- counts["external"] += 1
548
- elif resolution == "dynamic":
549
- counts["dynamic"] += 1
550
- else:
551
- counts["unresolved"] += 1
552
584
 
553
585
  for target in sorted(by_target):
554
586
  agg = by_target[target]
@@ -556,7 +588,11 @@ def _process_function(graph, node_id, fnode, resolve) -> None:
556
588
  graph.add_edge(Edge("calls", node_id, target, extras=extras))
557
589
 
558
590
  node = graph.nodes[node_id]
559
- node.extras["calls"] = counts
591
+ if accumulate and "calls" in node.extras:
592
+ for k, v in counts.items():
593
+ node.extras["calls"][k] = node.extras["calls"].get(k, 0) + v
594
+ else:
595
+ node.extras["calls"] = counts
560
596
  node.extras["control"] = _control(fnode)
561
597
  node.extras["complexity"] = _complexity(fnode)
562
598
 
@@ -941,14 +977,30 @@ def _emit_name_references(graph, src_id, scope, modpath, imports, modmembers,
941
977
 
942
978
 
943
979
  def _process_module_level(graph, modpath, tree, resolve, imports, modmembers,
944
- reexports=None) -> None:
945
- """Calls and value-references in module-level code, sourced from the module (D1/D2)."""
980
+ reexports=None, shadow_skip=None) -> None:
981
+ """Calls and value-references in module-level code, sourced from the module (D1/D2).
982
+
983
+ R1-C74: the module node also carries ``extras.calls`` — its sites used to make edges
984
+ and appear in no resolution figure.
985
+ """
946
986
  by_target: dict[str, dict] = {}
987
+ counts = _new_counts()
947
988
  for call in (n for n in _module_level_nodes(tree) if isinstance(n, ast.Call)):
948
989
  target, resolution = resolve(call)
949
- if resolution in _EDGE_RESOLUTIONS and target and target in graph.nodes:
990
+ if _tally(counts, graph, target, resolution):
950
991
  agg = by_target.setdefault(target, {"resolution": resolution, "shapes": []})
951
992
  agg["shapes"].append(_arg_shape(call))
993
+ if modpath in graph.nodes:
994
+ graph.nodes[modpath].extras["calls"] = counts
995
+ # R1-C74 D1: a replaced body is not walked (R1-C46) — said, not absorbed.
996
+ skipped = _shadow_map(tree)[0] if shadow_skip is None else shadow_skip
997
+ if skipped:
998
+ seen: set[int] = set()
999
+ for node in ast.walk(tree):
1000
+ if id(node) in skipped:
1001
+ seen.update(id(c) for c in ast.walk(node) if isinstance(c, ast.Call))
1002
+ if seen:
1003
+ graph.nodes[modpath].extras["calls_shadowed"] = len(seen)
952
1004
  for target in sorted(by_target):
953
1005
  agg = by_target[target]
954
1006
  graph.add_edge(Edge("calls", modpath, target,
@@ -958,6 +1010,57 @@ def _process_module_level(graph, modpath, tree, resolve, imports, modmembers,
958
1010
  reexports=reexports)
959
1011
 
960
1012
 
1013
+ def _process_class_bodies(graph, modpath, tree, resolve, imports, modmembers, script,
1014
+ target_pkg, nested, known_modules=frozenset(), reexports=None,
1015
+ skip=None) -> None:
1016
+ """Calls in class bodies outside their methods — bases, keywords, decorators, statements.
1017
+
1018
+ R1-C74 (design ``call_site_conservation.md`` D1). A class body runs at import time, so a
1019
+ class that is a graph node is the source of its own ``calls`` edges and carries
1020
+ ``extras.calls``, resolved like module-level code. A class nested in a function runs
1021
+ when the function does: its sites go to the owner as nested, like a closure's. Shadowed
1022
+ definitions are skipped, as everywhere (R1-C46).
1023
+ """
1024
+ skip = _shadow_map(tree)[0] if skip is None else skip
1025
+
1026
+ def visit(node, scope, in_function):
1027
+ for child in ast.iter_child_nodes(node):
1028
+ if id(child) in skip:
1029
+ continue
1030
+ if isinstance(child, ast.ClassDef):
1031
+ cscope = scope + [child.name]
1032
+ calls = [n for n in _own_nodes(child) if isinstance(n, ast.Call)]
1033
+ cid = ".".join([modpath, *cscope])
1034
+ if not in_function and cid in graph.nodes:
1035
+ counts = _new_counts()
1036
+ by_target: dict[str, dict] = {}
1037
+ for call in calls:
1038
+ target, resolution = resolve(call)
1039
+ if _tally(counts, graph, target, resolution) and target != cid:
1040
+ agg = by_target.setdefault(target, {"resolution": resolution,
1041
+ "shapes": []})
1042
+ agg["shapes"].append(_arg_shape(call))
1043
+ graph.nodes[cid].extras["calls"] = counts
1044
+ for target in sorted(by_target):
1045
+ agg = by_target[target]
1046
+ graph.add_edge(Edge("calls", cid, target,
1047
+ extras={"resolution": agg["resolution"],
1048
+ **_arg_contract(agg["shapes"])}))
1049
+ elif calls:
1050
+ owner = _nearest_owner(graph, modpath, scope)
1051
+ if owner is not None:
1052
+ _collect_nested_calls(graph, owner, child, modpath, imports, modmembers,
1053
+ script, target_pkg, nested, known_modules,
1054
+ reexports, calls=calls)
1055
+ visit(child, cscope, in_function)
1056
+ elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
1057
+ visit(child, scope + [child.name], True)
1058
+ else:
1059
+ visit(child, scope, in_function)
1060
+
1061
+ visit(tree, [], False)
1062
+
1063
+
961
1064
  def _named_functions_scoped(tree):
962
1065
  """Like :func:`_named_functions`, plus the **full** enclosing scope names.
963
1066
 
@@ -1001,17 +1104,22 @@ def _nearest_owner(graph, modpath, scope) -> str | None:
1001
1104
 
1002
1105
  def _collect_nested_calls(graph, owner, fnode, modpath, imports, modmembers,
1003
1106
  script, target_pkg, out, known_modules=frozenset(),
1004
- reexports=None) -> None:
1005
- """Resolve a non-node function's own calls and stage them under ``owner`` (D3)."""
1107
+ reexports=None, calls=None) -> None:
1108
+ """Resolve a non-node function's own calls and stage them under ``owner`` (D3).
1109
+
1110
+ ``fnode`` may also be a class nested in a function (R1-C74): its body runs inside the
1111
+ owner, like a closure's. Every site is counted into the owner's ``extras.calls_nested``.
1112
+ """
1006
1113
  def fast(call):
1007
1114
  return _resolve(call, modpath, "", imports, modmembers, {}, reexports, graph.nodes)
1008
1115
  # R1-C26: the same union the named-function path uses — a closure's calls must not be
1009
1116
  # resolved by a weaker rule than its neighbours'.
1010
1117
  resolve = (_deep_then_fast(graph, script, target_pkg, fast, modpath, known_modules)
1011
1118
  if script is not None else fast)
1012
- for call in _own_calls(fnode):
1119
+ counts = graph.nodes[owner].extras.setdefault("calls_nested", _new_counts())
1120
+ for call in (calls if calls is not None else _own_calls(fnode)):
1013
1121
  target, resolution = resolve(call)
1014
- if resolution not in _EDGE_RESOLUTIONS or not target or target not in graph.nodes:
1122
+ if not _tally(counts, graph, target, resolution):
1015
1123
  continue
1016
1124
  if target == owner:
1017
1125
  continue # a closure calling its own enclosing function — recursion, not a dep
@@ -109,7 +109,11 @@ SCHEMA_VERSION = "0.13"
109
109
  # `check --graph` carries a currency line, and its structured answer a `graph_currency`
110
110
  # block, with a graph behind its source tree failing the gate (R1-C70,
111
111
  # docs/design/check_graph_currency.md).
112
- ANSWER_FORMAT = 3
112
+ # 4 — `report behavior` states the whole and its parts: call sites in function bodies,
113
+ # nested functions, module-level code and class bodies, plus sites in shadowed
114
+ # bodies that are not walked; the structured answer gains `by_scope` and
115
+ # `shadowed_not_walked` (R1-C74, docs/design/call_site_conservation.md).
116
+ ANSWER_FORMAT = 4
113
117
 
114
118
  # Closed vocabulary of edge types (R1-C7). Node ``kind`` is deliberately an OPEN set
115
119
  # (DESIGN §2 — new entity kinds may appear), but edges are TYPED: every relationship
@@ -103,15 +103,37 @@ def build_dead_code(query: Query, *, whitelist: tuple[str, ...] = (),
103
103
  }
104
104
 
105
105
 
106
+ def call_sites_by_scope(graph) -> dict:
107
+ """Call-site counters summed by the scope that runs them (R1-C74, design D2).
108
+
109
+ ``function`` — own bodies of function nodes (attribute nodes are properties: functions too);
110
+ ``nested`` — closures and classes inside them, carried by their owner; ``module`` —
111
+ module-level code; ``class_body`` — class bodies outside methods. Plus the sites in
112
+ shadowed bodies, which are counted but never walked.
113
+ """
114
+ keys = ("out", "resolved", "external", "unresolved", "dynamic")
115
+ by = {s: dict.fromkeys(keys, 0) for s in ("function", "nested", "module", "class_body")}
116
+ shadowed = 0
117
+ for n in graph.nodes.values():
118
+ scope = {"function": "function", "attribute": "function", "module": "module",
119
+ "class": "class_body"}.get(n.kind)
120
+ for key, bucket in (("calls", scope), ("calls_nested", "nested")):
121
+ c = n.extras.get(key)
122
+ if c and bucket:
123
+ for k in keys:
124
+ by[bucket][k] += c.get(k, 0)
125
+ shadowed += n.extras.get("calls_shadowed", 0)
126
+ every = {k: sum(by[s][k] for s in by) for k in keys}
127
+ return {"all": every, "by_scope": by, "shadowed_not_walked": shadowed}
128
+
129
+
106
130
  def build_behavior(query: Query) -> dict:
107
131
  """:func:`render_behavior`, structured (R1-C32)."""
108
132
  graph = query.graph
109
133
  funcs = [n for n in graph.nodes.values() if n.kind == "function"]
110
134
  with_cov = [n for n in funcs if "calls" in n.extras]
111
- agg = {"out": 0, "resolved": 0, "external": 0, "unresolved": 0, "dynamic": 0}
112
- for n in with_cov:
113
- for k, v in n.extras["calls"].items():
114
- agg[k] = agg.get(k, 0) + v
135
+ sites = call_sites_by_scope(graph)
136
+ agg = sites["all"]
115
137
  by_res: dict[str, int] = {}
116
138
  for e in graph.edges:
117
139
  if e.type == "calls":
@@ -122,7 +144,11 @@ def build_behavior(query: Query) -> dict:
122
144
  return {
123
145
  "kind": "behavior",
124
146
  "target": graph.target,
125
- "call_sites": {**agg, "functions_with_coverage": len(with_cov)},
147
+ "call_sites": {**agg, "functions_with_coverage": len(with_cov),
148
+ # R1-C74: the whole and its parts; they sum to every call in the source
149
+ # that can run (shadowed bodies are named, not walked).
150
+ "by_scope": sites["by_scope"],
151
+ "shadowed_not_walked": sites["shadowed_not_walked"]},
126
152
  "calls_edges": {"total": sum(by_res.values()), "by_resolution": dict(sorted(by_res.items()))},
127
153
  "complexity": {
128
154
  "functions_scored": len(scored),
@@ -236,10 +262,8 @@ def render_behavior(query: Query) -> str:
236
262
  graph = query.graph
237
263
  funcs = [n for n in graph.nodes.values() if n.kind == "function"]
238
264
  with_cov = [n for n in funcs if "calls" in n.extras]
239
- agg = {"out": 0, "resolved": 0, "external": 0, "unresolved": 0, "dynamic": 0}
240
- for n in with_cov:
241
- for k, v in n.extras["calls"].items():
242
- agg[k] += v
265
+ sites = call_sites_by_scope(graph)
266
+ agg, by = sites["all"], sites["by_scope"]
243
267
  total = agg["out"] or 1
244
268
  lines = [f"# Behavioral layer — `{graph.target}`", ""]
245
269
  lines.append(
@@ -247,7 +271,17 @@ def render_behavior(query: Query) -> str:
247
271
  "type inference and are left unresolved on purpose (gaps/ CM-09/10)._"
248
272
  )
249
273
  lines.append("")
250
- lines.append(f"## Call-site resolution ({agg['out']} sites in {len(with_cov)} functions)")
274
+ lines.append(f"## Call-site resolution ({agg['out']} call sites)")
275
+ lines.append("")
276
+ # R1-C74: name the parts. The heading used to count function bodies only and read as the
277
+ # whole package; module-level code, nested functions and class bodies were outside it.
278
+ shadow = sites["shadowed_not_walked"]
279
+ lines.append(
280
+ f"_{by['function']['out']} in function bodies, {by['nested']['out']} in nested functions, "
281
+ f"{by['module']['out']} at module level, {by['class_body']['out']} in class bodies"
282
+ + (f"; {shadow} more sit in bodies replaced by a later definition and are not walked "
283
+ "(they cannot run)" if shadow else "")
284
+ + "._")
251
285
  lines.append("")
252
286
  lines.append(f"- resolved to internal edges: **{agg['resolved']}** ({100*agg['resolved']/total:.1f}%)")
253
287
  lines.append(f"- external / builtin (flagged): {agg['external']} ({100*agg['external']/total:.1f}%)")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.23
3
+ Version: 0.0.24
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, **1115 tests with no failures on
47
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **1128 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 &
@@ -144,6 +144,8 @@ tests/test_r1c6_pack.py
144
144
  tests/test_r1c70_check_graph_currency.py
145
145
  tests/test_r1c71_attribute_chain_calls.py
146
146
  tests/test_r1c72_flat_inference_in_packages.py
147
+ tests/test_r1c73_cli_version.py
148
+ tests/test_r1c74_call_site_conservation.py
147
149
  tests/test_r1c7_edge_vocab.py
148
150
  tests/test_r1c8_deadcode.py
149
151
  tests/test_r1c9_incremental.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.23"
12
+ version = "0.0.24"
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
@@ -43,7 +43,8 @@ from codemap.serve.session import Session
43
43
  # blind-spot line — wording inside an existing note, not a change of structure.
44
44
  # Re-pinned with a bump to 3 (2026-11-03, R1-C69/R1-C70): the refusal of an invalid contract and
45
45
  # the three states of the `check --graph` currency line join the pinned surfaces.
46
- RENDERED_SHA = "395f32ca271c12cc0ad26bee19e66e1eae23ee1826098dd97e0ce8c5437f2d35"
46
+ # Re-pinned with a bump to 4 (2026-11-03, R1-C74): `report behavior` names its scopes.
47
+ RENDERED_SHA = "32a160c89e31af252873c0703d0e903397c6d470013fad5889b9194e2a3a2eb8"
47
48
 
48
49
  FILES = {
49
50
  "__init__.py": "",
@@ -0,0 +1,72 @@
1
+ """R1-C73 — `codemap --version`, from the identity the graph records.
2
+
3
+ Design `docs/design/cli_version.md`. Pinned: V1 (agrees with `importlib.metadata` and with
4
+ `provenance.tool`), V3 (the flag wins over a subcommand), V4 (no arguments is still an error), V5 (no other
5
+ command pays for `tool_identity()`), and V6 (a fixed string goes red). V2, the wheel outside the source
6
+ tree, is the CI wheel job's ground and was measured by hand at acceptance.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.metadata
12
+ from unittest import mock
13
+
14
+ import pytest
15
+
16
+ from codemap import cli, provenance
17
+ from codemap.model import ANSWER_FORMAT, SCHEMA_VERSION
18
+
19
+
20
+ def _version_out(capsys, *argv) -> tuple[int, str]:
21
+ with pytest.raises(SystemExit) as exc:
22
+ cli.main(["--version", *argv])
23
+ return exc.value.code, capsys.readouterr().out.strip()
24
+
25
+
26
+ def test_it_agrees_with_the_metadata_and_with_provenance(capsys):
27
+ rc, out = _version_out(capsys)
28
+ assert rc == 0
29
+ ident = provenance.tool_identity()
30
+ assert out.startswith(f"codemap {importlib.metadata.version('codmap')}")
31
+ if ident.get("commit"):
32
+ assert f"@{ident['commit']}" in out
33
+ assert out.endswith(f"· answer format {ANSWER_FORMAT} · schema {SCHEMA_VERSION}")
34
+ assert ("(dirty)" in out) == bool(ident.get("dirty"))
35
+
36
+
37
+ def test_the_flag_wins_over_a_subcommand(capsys):
38
+ rc, out = _version_out(capsys, "build", "x")
39
+ assert rc == 0 and out.startswith("codemap ")
40
+
41
+
42
+ def test_no_arguments_is_still_an_error(capsys):
43
+ with pytest.raises(SystemExit) as exc:
44
+ cli.main([])
45
+ assert exc.value.code == 2
46
+
47
+
48
+ def test_an_uninstalled_tool_says_so_rather_than_inventing_a_number():
49
+ with mock.patch.object(provenance, "tool_identity", lambda: {"name": "codemap"}):
50
+ line = cli.version_line()
51
+ assert line.startswith("codemap version unknown (not installed as a distribution)")
52
+
53
+
54
+ def test_no_other_command_pays_for_the_identity(tmp_path, capsys):
55
+ """V5: building the parser must not call git; only the flag does."""
56
+ pkg = tmp_path / "vpkg"
57
+ pkg.mkdir()
58
+ (pkg / "__init__.py").write_text("")
59
+ g = tmp_path / "g.json"
60
+ assert cli.main(["build", str(pkg), "-o", str(g)]) == 0
61
+ with mock.patch.object(provenance, "tool_identity", side_effect=AssertionError("called")):
62
+ assert cli.main(["query", "vpkg", "--graph", str(g)]) in (0, 1)
63
+ assert cli.main(["report", "dependencies", "--graph", str(g)]) in (0, None)
64
+
65
+
66
+ def test_mutation_a_fixed_string_goes_red(capsys):
67
+ """V6: an action that prints a frozen version disagrees with the metadata."""
68
+ with mock.patch.object(cli, "version_line", lambda: "codemap 9.9.9 · answer format 1 · schema 0.1"):
69
+ _, out = _version_out(capsys)
70
+ assert not out.startswith(f"codemap {importlib.metadata.version('codmap')}")
71
+ _, out = _version_out(capsys)
72
+ assert out.startswith(f"codemap {importlib.metadata.version('codmap')}")