codmap 0.0.15__tar.gz → 0.0.17__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 (135) hide show
  1. {codmap-0.0.15 → codmap-0.0.17}/PKG-INFO +3 -2
  2. {codmap-0.0.15 → codmap-0.0.17}/README.md +2 -1
  3. {codmap-0.0.15 → codmap-0.0.17}/codemap/apidiff.py +40 -4
  4. {codmap-0.0.15 → codmap-0.0.17}/codemap/cli.py +10 -2
  5. {codmap-0.0.15 → codmap-0.0.17}/codemap/model.py +101 -0
  6. {codmap-0.0.15 → codmap-0.0.17}/codemap/query.py +193 -16
  7. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/apidiff.py +13 -0
  8. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/audit.py +17 -0
  9. codmap-0.0.17/codemap/serve/impact.py +160 -0
  10. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/mcp_server.py +39 -16
  11. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/session.py +52 -5
  12. {codmap-0.0.15 → codmap-0.0.17}/codmap.egg-info/PKG-INFO +3 -2
  13. {codmap-0.0.15 → codmap-0.0.17}/codmap.egg-info/SOURCES.txt +3 -0
  14. {codmap-0.0.15 → codmap-0.0.17}/pyproject.toml +1 -1
  15. {codmap-0.0.15 → codmap-0.0.17}/tests/test_m1_query.py +33 -12
  16. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c28_limit_envelope.py +14 -1
  17. codmap-0.0.17/tests/test_r1c39_edge_resolution.py +215 -0
  18. codmap-0.0.17/tests/test_r1c40_flow_reach.py +296 -0
  19. codmap-0.0.17/tests/test_r1c50_flow_entry_points.py +285 -0
  20. codmap-0.0.15/codemap/serve/impact.py +0 -86
  21. {codmap-0.0.15 → codmap-0.0.17}/LICENSE +0 -0
  22. {codmap-0.0.15 → codmap-0.0.17}/codemap/__init__.py +0 -0
  23. {codmap-0.0.15 → codmap-0.0.17}/codemap/arch.py +0 -0
  24. {codmap-0.0.15 → codmap-0.0.17}/codemap/diagnostics.py +0 -0
  25. {codmap-0.0.15 → codmap-0.0.17}/codemap/extract/__init__.py +0 -0
  26. {codmap-0.0.15 → codmap-0.0.17}/codemap/extract/attrflow.py +0 -0
  27. {codmap-0.0.15 → codmap-0.0.17}/codemap/extract/behavior.py +0 -0
  28. {codmap-0.0.15 → codmap-0.0.17}/codemap/extract/dataflow.py +0 -0
  29. {codmap-0.0.15 → codmap-0.0.17}/codemap/extract/dispatch.py +0 -0
  30. {codmap-0.0.15 → codmap-0.0.17}/codemap/extract/griffe_extractor.py +0 -0
  31. {codmap-0.0.15 → codmap-0.0.17}/codemap/extract/gsource.py +0 -0
  32. {codmap-0.0.15 → codmap-0.0.17}/codemap/extract/roots.py +0 -0
  33. {codmap-0.0.15 → codmap-0.0.17}/codemap/extract/union.py +0 -0
  34. {codmap-0.0.15 → codmap-0.0.17}/codemap/freshness.py +0 -0
  35. {codmap-0.0.15 → codmap-0.0.17}/codemap/incremental.py +0 -0
  36. {codmap-0.0.15 → codmap-0.0.17}/codemap/integrations/__init__.py +0 -0
  37. {codmap-0.0.15 → codmap-0.0.17}/codemap/integrations/base.py +0 -0
  38. {codmap-0.0.15 → codmap-0.0.17}/codemap/integrations/cocoindex.py +0 -0
  39. {codmap-0.0.15 → codmap-0.0.17}/codemap/integrations/gate.py +0 -0
  40. {codmap-0.0.15 → codmap-0.0.17}/codemap/integrations/gitnexus.py +0 -0
  41. {codmap-0.0.15 → codmap-0.0.17}/codemap/integrations/registry.py +0 -0
  42. {codmap-0.0.15 → codmap-0.0.17}/codemap/integrations/transport.py +0 -0
  43. {codmap-0.0.15 → codmap-0.0.17}/codemap/provenance.py +0 -0
  44. {codmap-0.0.15 → codmap-0.0.17}/codemap/scope.py +0 -0
  45. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/__init__.py +0 -0
  46. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/_scip_pb2.py +0 -0
  47. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/api_surface.py +0 -0
  48. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/architecture.py +0 -0
  49. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/check.py +0 -0
  50. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/ctags.py +0 -0
  51. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/limits.py +0 -0
  52. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/livingdocs.py +0 -0
  53. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/mermaid.py +0 -0
  54. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/pack.py +0 -0
  55. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/rag.py +0 -0
  56. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/review.py +0 -0
  57. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/scip.py +0 -0
  58. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/semantic.py +0 -0
  59. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/server.py +0 -0
  60. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/subsystems.py +0 -0
  61. {codmap-0.0.15 → codmap-0.0.17}/codemap/serve/vault.py +0 -0
  62. {codmap-0.0.15 → codmap-0.0.17}/codemap/store.py +0 -0
  63. {codmap-0.0.15 → codmap-0.0.17}/codemap/tomlio.py +0 -0
  64. {codmap-0.0.15 → codmap-0.0.17}/codemap/watch.py +0 -0
  65. {codmap-0.0.15 → codmap-0.0.17}/codmap.egg-info/dependency_links.txt +0 -0
  66. {codmap-0.0.15 → codmap-0.0.17}/codmap.egg-info/entry_points.txt +0 -0
  67. {codmap-0.0.15 → codmap-0.0.17}/codmap.egg-info/requires.txt +0 -0
  68. {codmap-0.0.15 → codmap-0.0.17}/codmap.egg-info/top_level.txt +0 -0
  69. {codmap-0.0.15 → codmap-0.0.17}/setup.cfg +0 -0
  70. {codmap-0.0.15 → codmap-0.0.17}/tests/test_epistemic.py +0 -0
  71. {codmap-0.0.15 → codmap-0.0.17}/tests/test_gitnexus_router.py +0 -0
  72. {codmap-0.0.15 → codmap-0.0.17}/tests/test_impact_depth.py +0 -0
  73. {codmap-0.0.15 → codmap-0.0.17}/tests/test_integrations.py +0 -0
  74. {codmap-0.0.15 → codmap-0.0.17}/tests/test_issue3_serve_freshness.py +0 -0
  75. {codmap-0.0.15 → codmap-0.0.17}/tests/test_livingdocs.py +0 -0
  76. {codmap-0.0.15 → codmap-0.0.17}/tests/test_m0_api_surface.py +0 -0
  77. {codmap-0.0.15 → codmap-0.0.17}/tests/test_m11_argcontract.py +0 -0
  78. {codmap-0.0.15 → codmap-0.0.17}/tests/test_m12_dataflow.py +0 -0
  79. {codmap-0.0.15 → codmap-0.0.17}/tests/test_m15_review.py +0 -0
  80. {codmap-0.0.15 → codmap-0.0.17}/tests/test_m16_architecture.py +0 -0
  81. {codmap-0.0.15 → codmap-0.0.17}/tests/test_m17_mcp.py +0 -0
  82. {codmap-0.0.15 → codmap-0.0.17}/tests/test_m18_freshness.py +0 -0
  83. {codmap-0.0.15 → codmap-0.0.17}/tests/test_m1_5_semantics.py +0 -0
  84. {codmap-0.0.15 → codmap-0.0.17}/tests/test_m2_views.py +0 -0
  85. {codmap-0.0.15 → codmap-0.0.17}/tests/test_m32_watch.py +0 -0
  86. {codmap-0.0.15 → codmap-0.0.17}/tests/test_m3_serve.py +0 -0
  87. {codmap-0.0.15 → codmap-0.0.17}/tests/test_m4_behavior.py +0 -0
  88. {codmap-0.0.15 → codmap-0.0.17}/tests/test_m5_deep.py +0 -0
  89. {codmap-0.0.15 → codmap-0.0.17}/tests/test_m6_repo_scope.py +0 -0
  90. {codmap-0.0.15 → codmap-0.0.17}/tests/test_m7_dispatch.py +0 -0
  91. {codmap-0.0.15 → codmap-0.0.17}/tests/test_m9_family.py +0 -0
  92. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  93. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c13_grep_vs_graph.py +0 -0
  94. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c16_semantic.py +0 -0
  95. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c20_attribute_edges.py +0 -0
  96. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c21_flat_layout.py +0 -0
  97. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c22_source_visible_refs.py +0 -0
  98. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c23_hard_python.py +0 -0
  99. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c24_test_mapping.py +0 -0
  100. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c25_provenance.py +0 -0
  101. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c26_deep_union.py +0 -0
  102. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c27_config_honesty.py +0 -0
  103. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c29_lazy_imports.py +0 -0
  104. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c2_ctags.py +0 -0
  105. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c30_local_import_calls.py +0 -0
  106. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c30f1_reexport_calls.py +0 -0
  107. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c30f2_check_scope.py +0 -0
  108. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c31_path_origin.py +0 -0
  109. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c32_report_json.py +0 -0
  110. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c33_query_signature.py +0 -0
  111. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c34_signature_kinds.py +0 -0
  112. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c35_contract_location.py +0 -0
  113. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c36_target_resolution.py +0 -0
  114. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c37_rule_mutation.py +0 -0
  115. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c38_tool_drift.py +0 -0
  116. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c3_arch_contract.py +0 -0
  117. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c3_dogfood.py +0 -0
  118. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c41_scope_membership.py +0 -0
  119. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c42_deep_tier_stability.py +0 -0
  120. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c43_incremental_splice.py +0 -0
  121. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c44_absent_answers.py +0 -0
  122. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c45_repeat_union.py +0 -0
  123. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c46_shadowed_definitions.py +0 -0
  124. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c47_incremental_resample.py +0 -0
  125. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c48_type_checking_imports.py +0 -0
  126. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c49_type_only_cycles.py +0 -0
  127. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c4_complexity.py +0 -0
  128. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c5_apidiff.py +0 -0
  129. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c6_pack.py +0 -0
  130. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c7_edge_vocab.py +0 -0
  131. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c8_deadcode.py +0 -0
  132. {codmap-0.0.15 → codmap-0.0.17}/tests/test_r1c9_incremental.py +0 -0
  133. {codmap-0.0.15 → codmap-0.0.17}/tests/test_scip_export.py +0 -0
  134. {codmap-0.0.15 → codmap-0.0.17}/tests/test_scope.py +0 -0
  135. {codmap-0.0.15 → codmap-0.0.17}/tests/test_subsystems.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.15
3
+ Version: 0.0.17
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
@@ -177,7 +177,8 @@ codemap query analyze_zones --graph graph.json
177
177
  codemap report architecture --graph graph.json # layers, coupling, god-objects, cycles
178
178
  codemap report dependencies --graph graph.json
179
179
  codemap report dead-code --graph graph.json
180
- codemap report impact --symbol MyClass --graph graph.json
180
+ codemap report impact --symbol MyClass --graph graph.json # who references it, and which
181
+ # flows it lands on — at which step
181
182
  codemap report api-surface --graph graph.json --format json # every kind has a json form
182
183
 
183
184
  # change-set review straight from a diff → risk-sorted dossier
@@ -141,7 +141,8 @@ codemap query analyze_zones --graph graph.json
141
141
  codemap report architecture --graph graph.json # layers, coupling, god-objects, cycles
142
142
  codemap report dependencies --graph graph.json
143
143
  codemap report dead-code --graph graph.json
144
- codemap report impact --symbol MyClass --graph graph.json
144
+ codemap report impact --symbol MyClass --graph graph.json # who references it, and which
145
+ # flows it lands on — at which step
145
146
  codemap report api-surface --graph graph.json --format json # every kind has a json form
146
147
 
147
148
  # change-set review straight from a diff → risk-sorted dossier
@@ -45,6 +45,8 @@ class ApiDiff:
45
45
  added: list[str] = field(default_factory=list) # new public symbols
46
46
  removed: list[str] = field(default_factory=list) # deleted public symbols (each breaking)
47
47
  changes: list[Change] = field(default_factory=list) # per-symbol classified changes
48
+ root: str | None = "core" # the provenance root compared (R1-C52)
49
+ excluded: dict[str, int] = field(default_factory=dict) # public symbols left unjudged, by root
48
50
 
49
51
  @property
50
52
  def breaking(self) -> list[Change]:
@@ -52,6 +54,8 @@ class ApiDiff:
52
54
 
53
55
  def to_dict(self) -> dict:
54
56
  return {
57
+ "root": self.root,
58
+ "excluded": dict(self.excluded),
55
59
  "added": sorted(self.added),
56
60
  "removed": sorted(self.removed),
57
61
  "changes": [
@@ -184,11 +188,43 @@ def _is_public(node: Node) -> bool:
184
188
  return node.visibility == "public"
185
189
 
186
190
 
187
- def diff_api(old: Graph, new: Graph) -> ApiDiff:
188
- """Diff the public API surface of two graphs (old → new)."""
191
+ def _root_of(node: Node) -> str:
192
+ """Provenance root of a node; an untagged graph is all ``core`` by construction."""
193
+ return node.extras.get("root", "core")
194
+
195
+
196
+ def diff_api(old: Graph, new: Graph, *, root: str | None = "core") -> ApiDiff:
197
+ """Diff the public API surface of two graphs (old → new).
198
+
199
+ ``root`` restricts the comparison to one provenance root, default ``core`` — the
200
+ package. R1-C52: this used to filter on visibility alone, so on a repo-scoped graph
201
+ (built with ``--consumer``) every public *test* function counted as added API. The
202
+ dogfood target measured it on their own release gate: **40 of 47** "added public
203
+ symbols" were test functions, which means `--exit-code` fired on test churn rather
204
+ than on the package. A public function in `tests/` is not this package's API.
205
+
206
+ Pass ``root=None`` to compare every root, and read ``excluded`` for what a root
207
+ filter left out — a gate must name what it did not judge (R1-C30-f2).
208
+ """
189
209
  diff = ApiDiff()
190
- old_nodes = {i: n for i, n in old.nodes.items() if n.kind in ("function", "class", "attribute")}
191
- new_nodes = {i: n for i, n in new.nodes.items() if n.kind in ("function", "class", "attribute")}
210
+
211
+ def _pick(graph: Graph) -> tuple[dict, dict[str, int]]:
212
+ keep, skipped = {}, {}
213
+ for i, n in graph.nodes.items():
214
+ if n.kind not in ("function", "class", "attribute"):
215
+ continue
216
+ r = _root_of(n)
217
+ if root is not None and r != root:
218
+ if _is_public(n):
219
+ skipped[r] = skipped.get(r, 0) + 1
220
+ continue
221
+ keep[i] = n
222
+ return keep, skipped
223
+
224
+ old_nodes, _ = _pick(old)
225
+ new_nodes, skipped = _pick(new)
226
+ diff.root = root
227
+ diff.excluded = dict(sorted(skipped.items()))
192
228
 
193
229
  for sid, n in new_nodes.items():
194
230
  if sid not in old_nodes and _is_public(n):
@@ -507,7 +507,8 @@ def _cmd_report(args) -> int:
507
507
  if args.kind == "impact":
508
508
  if not args.symbol:
509
509
  raise SystemExit("error: report impact needs --symbol <name>")
510
- print(render_impact(Query(graph), args.symbol, depth=args.depth), end="")
510
+ print(render_impact(Query(graph), args.symbol, depth=args.depth,
511
+ flow_depth=args.flow_depth), end="")
511
512
  return 0
512
513
  if args.kind in ("communities", "flows"):
513
514
  from codemap.serve.subsystems import render_communities, render_flows
@@ -542,7 +543,11 @@ def _report_json(graph, args) -> dict:
542
543
  ids = q.impact_targets(args.symbol)
543
544
  return {"kind": "impact", "target": graph.target, "symbol": args.symbol,
544
545
  "matched": ids,
545
- "reports": [{"id": sid, **q.impact(sid, depth=args.depth)} for sid in ids]}
546
+ # R1-C40: the flows the change lands on ride next to the blast radius,
547
+ # not inside it — two answers with two different partialities.
548
+ "reports": [{"id": sid, **q.impact(sid, depth=args.depth),
549
+ "flows": q.flows_to(sid, max_depth=args.flow_depth)}
550
+ for sid in ids]}
546
551
  if args.kind == "communities":
547
552
  return {"kind": "communities", "target": graph.target,
548
553
  "communities": Query(graph).communities()}
@@ -962,6 +967,9 @@ def build_parser() -> argparse.ArgumentParser:
962
967
  r.add_argument("--symbol", help="Symbol for `report impact` (short or full name).")
963
968
  r.add_argument("--depth", type=int, default=2,
964
969
  help="report impact: transitive BFS depth (default 2).")
970
+ r.add_argument("--flow-depth", type=int, default=5,
971
+ help="report impact: how many call steps a flow may take to reach "
972
+ "the symbol (default 5, same as `report flows`).")
965
973
  r.add_argument("--min-confidence", choices=["low", "medium", "high"], default=None,
966
974
  help="report dead-code: only show candidates at/above this confidence.")
967
975
  r.add_argument("--format", choices=["markdown", "json"], default="markdown")
@@ -97,6 +97,107 @@ EDGE_TYPES = frozenset({
97
97
  "accesses", # function → attribute node it reads/writes (extras.access, R1-C20)
98
98
  })
99
99
 
100
+ # Closed vocabulary of ``extras.resolution`` (R1-C39), keyed by (edge type, value).
101
+ #
102
+ # The route was already on the edge before this table existed — `calls` alone carries six
103
+ # distinguishable values — but five modules emitted them and nothing enumerated them, so a
104
+ # new or mistyped value shipped in silence. The guard is the R1-C7 shape: a pair absent
105
+ # here fails, and a row that stops appearing in any build fails too.
106
+ #
107
+ # ``means`` is the honest part. On `calls`/`accesses` the value says **how the target was
108
+ # found** (route); on `references` three of four values say **what kind of site** it was
109
+ # (an annotation, a name used as a value, a doc mention) and only `imported` is a route.
110
+ # One field, two questions — stated rather than papered over (design D4).
111
+ #
112
+ # ``confidence`` is a *grade*, never a probability: `exact` (the target came from a binding
113
+ # read in the source), `inferred` (a type-inference engine produced it — precise but a
114
+ # sample: one deep build misses a live edge ~1 time in 4, R1-C42), `heuristic` (a name
115
+ # match, not a binding — an honest over-approximation). A computed float would read as a
116
+ # precision that is not there. It is derived, never stored: writing it on the edge would be
117
+ # a second source of truth for a pure function of the first (design D2/D3).
118
+ RESOLUTIONS: dict[tuple[str, str], dict[str, str]] = {
119
+ ("calls", "self"): {"means": "route", "confidence": "exact",
120
+ "how": "receiver is `self`; target is a member of the enclosing class"},
121
+ ("calls", "module"): {"means": "route", "confidence": "exact",
122
+ "how": "the name is defined at this module's level"},
123
+ ("calls", "imported"): {"means": "route", "confidence": "exact",
124
+ "how": "the name is bound by an import statement in this file"},
125
+ ("calls", "deep"): {"means": "route", "confidence": "inferred",
126
+ "how": "receiver type inferred by jedi (deep tier only)"},
127
+ ("calls", "registry"): {"means": "route", "confidence": "exact",
128
+ "how": "a literal registry key resolved to the registered impl (M7)"},
129
+ ("calls", "registry-candidate"): {
130
+ "means": "route", "confidence": "heuristic",
131
+ "how": "a factory/getter call fanned out to every member of the family — an "
132
+ "honest over-approximation, not a resolved target"},
133
+ ("accesses", "self"): {"means": "route", "confidence": "exact",
134
+ "how": "`self.attr` inside the class that declares the attribute"},
135
+ ("accesses", "class"): {"means": "route", "confidence": "exact",
136
+ "how": "`Class.attr` through an import or module member"},
137
+ ("accesses", "construct"): {"means": "route", "confidence": "exact",
138
+ "how": "`Class(...).attr` — the constructor names the owner"},
139
+ ("accesses", "deep"): {"means": "route", "confidence": "inferred",
140
+ "how": "owner type inferred by jedi (deep tier only)"},
141
+ ("references", "annotation"): {"means": "site", "confidence": "exact",
142
+ "how": "the symbol appears in a type annotation"},
143
+ ("references", "name"): {"means": "site", "confidence": "exact",
144
+ "how": "the symbol is named as a value (dict entry, default=…)"},
145
+ ("references", "doc"): {"means": "site", "confidence": "exact",
146
+ "how": "a documentation file names the symbol"},
147
+ ("references", "imported"): {"means": "route", "confidence": "exact",
148
+ "how": "a consumer root names an imported core symbol"},
149
+ ("reads", "string-key"): {"means": "route", "confidence": "exact",
150
+ "how": "a literal subscript key — the column set is an "
151
+ "over-set, dict access lands here too"},
152
+ ("writes", "string-key"): {"means": "route", "confidence": "exact",
153
+ "how": "a literal subscript key (see `reads`)"},
154
+ ("imports", "flat"): {"means": "route", "confidence": "exact",
155
+ "how": "sibling import under a flat layout (R1-C21)"},
156
+ ("export", "flat"): {"means": "route", "confidence": "exact",
157
+ "how": "re-export inferred under a flat layout (R1-C21)"},
158
+ }
159
+
160
+ #: Edge types that carry no ``resolution``: pure syntax, with no route to record.
161
+ UNRESOLVED_EDGE_TYPES = frozenset({"contains", "inherits", "decorated_by", "implements"})
162
+
163
+ #: Grades, strongest first — the order `min_confidence` filters by.
164
+ CONFIDENCE_ORDER = ("exact", "inferred", "heuristic")
165
+
166
+ #: A value this build's table does not know. Reachable only from an artifact built by
167
+ #: another version of the tool — never from a graph this build produced (the guard test
168
+ #: is what keeps that true), so it is reported, not raised, when a graph is merely read.
169
+ UNKNOWN_CONFIDENCE = "unknown"
170
+
171
+
172
+ def resolution_of(edge, *, strict: bool = True) -> dict[str, str] | None:
173
+ """The table row for ``edge``, or ``None`` when it carries no ``resolution``.
174
+
175
+ ``None`` is a legitimate answer (``contains`` has no route), and it is distinct from
176
+ a value the table does not know. Under ``strict`` that one raises — a silent unknown
177
+ is how an open vocabulary pretends to be closed — and this is the form the guard test
178
+ and the extractor use. Reading a *foreign* graph passes ``strict=False`` and gets an
179
+ ``unknown`` grade instead: refusing to open an artifact from another version is a
180
+ worse answer than saying which part of it this build cannot grade.
181
+ """
182
+ value = edge.extras.get("resolution")
183
+ if value is None:
184
+ return None
185
+ row = RESOLUTIONS.get((edge.type, value))
186
+ if row is None:
187
+ if not strict:
188
+ return {"means": "route", "confidence": UNKNOWN_CONFIDENCE,
189
+ "how": f"{value!r} is not in this build's table"}
190
+ raise KeyError(f"unknown resolution {value!r} on a {edge.type!r} edge — add it to "
191
+ f"RESOLUTIONS (codemap/model.py) with its meaning and grade")
192
+ return row
193
+
194
+
195
+ def confidence_of(edge, *, strict: bool = True) -> str | None:
196
+ """Grade of ``edge``: exact | inferred | heuristic, or ``None`` when it has no route."""
197
+ row = resolution_of(edge, strict=strict)
198
+ return row["confidence"] if row else None
199
+
200
+
100
201
  # The subset of EDGE_TYPES an incremental build splices from the old graph for modules
101
202
  # it did not recompute (``incremental.py``) — and therefore the classes a deep+incremental
102
203
  # graph answers from an *earlier* build's jedi sample (R1-C43). Lives with the vocabulary
@@ -13,7 +13,19 @@ import re
13
13
 
14
14
  import networkx as nx
15
15
 
16
- from codemap.model import Graph, Node
16
+ from codemap.model import CONFIDENCE_ORDER, Graph, Node, confidence_of
17
+
18
+
19
+ def _grade_rank(grade: str | None) -> int:
20
+ """Route grades ordered strongest-first (R1-C39); an unknown one sorts last."""
21
+ return CONFIDENCE_ORDER.index(grade) if grade in CONFIDENCE_ORDER else len(CONFIDENCE_ORDER)
22
+
23
+
24
+ def _check_grade(min_confidence: str | None) -> None:
25
+ if min_confidence is not None and min_confidence not in CONFIDENCE_ORDER:
26
+ raise ValueError(f"min_confidence must be one of {CONFIDENCE_ORDER}, "
27
+ f"got {min_confidence!r}")
28
+
17
29
 
18
30
  # Dead-code confidence, most-certain first (R1-C8). "high" = no inbound edge of any
19
31
  # kind and no decorator/registry hook; "medium" = an implicit-use hook (decorator /
@@ -162,7 +174,16 @@ class Query:
162
174
  self._call_in: dict[str, list[tuple[str, dict]]] = {}
163
175
  for e in graph.edges:
164
176
  if e.type == "calls":
165
- self._calls.add_edge(e.source, e.target)
177
+ # R1-C39: the grade of the route rides on the graph edge, so `callers` /
178
+ # `callees` can answer "only what a binding found". Two edges may collapse
179
+ # onto one pair (a call resolved exactly at one site, fanned out from a
180
+ # registry at another) — the *strongest* wins: the pair is genuinely
181
+ # connected by a binding, whatever else also points that way.
182
+ grade = confidence_of(e, strict=False)
183
+ prev = self._calls.edges.get((e.source, e.target), {}).get("confidence")
184
+ if prev is not None and _grade_rank(prev) < _grade_rank(grade):
185
+ grade = prev
186
+ self._calls.add_edge(e.source, e.target, confidence=grade)
166
187
  self._call_in.setdefault(e.target, []).append((e.source, e.extras))
167
188
  # implements edges (M9/F4): concrete impl -> Protocol (structural typing,
168
189
  # synthesised via the registry family since it's never inherited).
@@ -412,17 +433,53 @@ class Query:
412
433
 
413
434
  # -- call graph (M4, best-effort — see gaps/ CM-09) ----------------------
414
435
 
415
- def callers(self, symbol_id: str) -> list[str]:
416
- """Functions that statically call ``symbol_id`` (resolved calls only)."""
417
- if symbol_id not in self._calls:
418
- return []
419
- return sorted(self._calls.predecessors(symbol_id))
436
+ def callers(self, symbol_id: str, *, min_confidence: str | None = None) -> list[str]:
437
+ """Functions that statically call ``symbol_id`` (resolved calls only).
420
438
 
421
- def callees(self, symbol_id: str) -> list[str]:
422
- """Internal symbols ``symbol_id`` statically calls."""
439
+ ``min_confidence`` (R1-C39) keeps only edges whose route grades at least that
440
+ strong — ``"exact"`` asks for calls a *binding* found and drops the honest
441
+ over-approximations (a factory fanned out across a registry family). Default:
442
+ every resolved edge, whatever found it.
443
+ """
444
+ _check_grade(min_confidence) # before the membership test: a bad argument must
445
+ if symbol_id not in self._calls: # not pass silently just because the symbol is
446
+ return [] # absent from the call graph
447
+ return sorted(src for src in self._calls.predecessors(symbol_id)
448
+ if self._passes(src, symbol_id, min_confidence))
449
+
450
+ def callees(self, symbol_id: str, *, min_confidence: str | None = None) -> list[str]:
451
+ """Internal symbols ``symbol_id`` statically calls (see :meth:`callers`)."""
452
+ _check_grade(min_confidence)
423
453
  if symbol_id not in self._calls:
424
454
  return []
425
- return sorted(self._calls.successors(symbol_id))
455
+ return sorted(tgt for tgt in self._calls.successors(symbol_id)
456
+ if self._passes(symbol_id, tgt, min_confidence))
457
+
458
+ def _passes(self, source: str, target: str, min_confidence: str | None) -> bool:
459
+ if min_confidence is None:
460
+ return True
461
+ grade = self._calls.edges[source, target].get("confidence")
462
+ return _grade_rank(grade) <= _grade_rank(min_confidence)
463
+
464
+ def confidence_map(self) -> dict[str, dict[str, int]]:
465
+ """Edge counts by route grade, and by (edge type, resolution) pair — R1-C39.
466
+
467
+ The answer to "how much of this graph was found by a binding, and how much by a
468
+ name match", which no field of the artifact stated before: the route was on every
469
+ edge, but nothing said what a route is worth. Edges with no route at all
470
+ (``contains``, ``inherits``, …) are counted under ``none`` rather than dropped —
471
+ absence is a number here too (R1-C28).
472
+ """
473
+ by_grade: dict[str, int] = {}
474
+ by_pair: dict[str, int] = {}
475
+ for e in self.graph.edges:
476
+ grade = confidence_of(e, strict=False) or "none"
477
+ by_grade[grade] = by_grade.get(grade, 0) + 1
478
+ if e.extras.get("resolution") is not None:
479
+ key = f"{e.type}:{e.extras['resolution']}"
480
+ by_pair[key] = by_pair.get(key, 0) + 1
481
+ return {"by_grade": dict(sorted(by_grade.items())),
482
+ "by_pair": dict(sorted(by_pair.items()))}
426
483
 
427
484
  def call_contract(self, symbol_id: str) -> list[dict]:
428
485
  """Per-caller argument contract of calls into ``symbol_id`` (+ members) — F7.
@@ -777,18 +834,75 @@ class Query:
777
834
  return out
778
835
 
779
836
  def entry_points(self, root: str = "core") -> list[str]:
780
- """Call-forest roots: functions that call out but are never called (resolved).
781
-
782
- Where behaviour starts — public API / mains / not-yet-triggered. Restricted
783
- to one provenance ``root`` (default core). Best-effort: call resolution is
784
- partial, so an unresolved caller can leave a real internal as an entry point.
837
+ """Call-forest roots: functions that call out and are called by nothing *in
838
+ their own root* (resolved calls only).
839
+
840
+ Where behaviour starts — public API / mains / not-yet-triggered. Restricted to
841
+ one provenance ``root`` (default core).
842
+
843
+ R1-C50: "called by nothing" used to mean in-degree zero **across the whole
844
+ graph**, which on a library means the public API is never an entry point — the
845
+ one function a user enters the package through is called from `tests`,
846
+ `examples` and `scripts`, and those 43 calls were what disqualified it. A call
847
+ from a consumer root is a *use*, not an internal caller; being used is not
848
+ evidence of being reachable-from-elsewhere. Reported by the dogfood target as
849
+ [issue #19](https://github.com/kogriv/codemap/issues/19). On a single-package
850
+ graph every node is `core`, so this is the same set as before by construction.
851
+
852
+ Best-effort in **both** directions: an unresolved caller leaves a real internal
853
+ looking like an entry point (the set is an over-estimate), and resolving one
854
+ *removes* an entry point (it is an under-estimate of heads whose chain the
855
+ resolver cannot close). The second direction is what #19 measured.
785
856
  """
786
857
  return sorted(
787
858
  n for n in self._calls.nodes
788
859
  if self.root_of(n) == root
789
- and self._calls.out_degree(n) > 0 and self._calls.in_degree(n) == 0
860
+ and self._calls.out_degree(n) > 0
861
+ and not any(self.root_of(p) == root
862
+ for p in self._calls.predecessors(n))
790
863
  )
791
864
 
865
+ def external_callers(self, symbol_id: str, root: str = "core") -> dict[str, int]:
866
+ """Resolved callers of ``symbol_id`` living **outside** ``root``, by root.
867
+
868
+ R1-C50/D8: on an entry point this is the evidence that the head is public —
869
+ `{"tests": 43}` says the package is entered here, which is exactly what the
870
+ in-degree rule used to read as a disqualification.
871
+ """
872
+ out: dict[str, int] = {}
873
+ if symbol_id in self._calls:
874
+ for p in self._calls.predecessors(symbol_id):
875
+ r = self.root_of(p)
876
+ if r != root:
877
+ out[r] = out.get(r, 0) + 1
878
+ return dict(sorted(out.items()))
879
+
880
+ def caller_grades(self, symbol_id: str) -> dict[str, int]:
881
+ """Histogram of the route grades of the resolved calls *into* ``symbol_id``.
882
+
883
+ R1-C51: what `callers(min_confidence=…)` would drop, so a filtered answer can
884
+ say so instead of coming back as a bare empty list. Reported by the dogfood
885
+ target with the measurement that makes it matter: of the 25 symbols their
886
+ registry fan-out reaches, **13 have no `exact` caller at all** — every strategy
887
+ method — so `min_confidence="exact"` answers `[]` about symbols that are called
888
+ on every run, through an object a factory returned for a string key.
889
+ """
890
+ out: dict[str, int] = {}
891
+ if symbol_id in self._calls:
892
+ for p in self._calls.predecessors(symbol_id):
893
+ g = self._calls.edges[p, symbol_id].get("confidence") or "unknown"
894
+ out[g] = out.get(g, 0) + 1
895
+ return dict(sorted(out.items(), key=lambda kv: _grade_rank(kv[0])))
896
+
897
+ def callee_grades(self, symbol_id: str) -> dict[str, int]:
898
+ """Histogram of the route grades of the resolved calls *out of* ``symbol_id``."""
899
+ out: dict[str, int] = {}
900
+ if symbol_id in self._calls:
901
+ for t in self._calls.successors(symbol_id):
902
+ g = self._calls.edges[symbol_id, t].get("confidence") or "unknown"
903
+ out[g] = out.get(g, 0) + 1
904
+ return dict(sorted(out.items(), key=lambda kv: _grade_rank(kv[0])))
905
+
792
906
  def flow(self, entry: str, *, max_depth: int = 5) -> dict:
793
907
  """Forward call-flow from ``entry`` along ``calls`` edges, bounded by depth.
794
908
 
@@ -815,6 +929,69 @@ class Query:
815
929
  return {"entry": entry, "edges": edges, "reached": len(seen) - 1,
816
930
  "max_depth": max((e["distance"] for e in edges), default=0)}
817
931
 
932
+ def flows_to(self, symbol_id: str, *, max_depth: int = 5,
933
+ root: str = "core") -> dict:
934
+ """Which flows a change to ``symbol_id`` lands on, and at which step (R1-C40).
935
+
936
+ The join of the two ends codemap already had: :meth:`impact` walks inbound
937
+ ("who references"), :meth:`flow` walks outbound ("what a call sets in
938
+ motion"), and neither answers the question asked *before* a change — "what
939
+ stops working, and where in the scenario". A flow here is what ``flows``
940
+ already calls one: an entry point of ``root`` (:meth:`entry_points`).
941
+ ``first_step`` is the shortest distance in ``calls`` edges from that entry
942
+ to the symbol **or one of its members** — shortest because "first" is what
943
+ was asked, and a BFS gives the minimum by construction.
944
+
945
+ Computed as one reverse BFS from the targets rather than a forward walk per
946
+ entry: the distance is the same measured from either end, and the cost drops
947
+ from O(entries × graph) to O(graph). The equivalence is what the acceptance
948
+ test checks against :meth:`flow`.
949
+
950
+ Says "reached", never "broken" (design D3): the graph knows the symbol lies
951
+ on the path, not whether the edit breaks it. Three separate partialities are
952
+ named rather than folded into the list — ``non_call_refs`` (a reference that
953
+ arrives by import / inheritance / decoration / attribute access cannot appear
954
+ in a flow at all), ``beyond_depth`` (entries that do reach, further than
955
+ ``max_depth`` — counted, not silently dropped), and the standing note that
956
+ call resolution is a lower bound. ``in_call_graph: False`` is the honest
957
+ "nothing to say about flows" for a symbol the call layer never modelled — not
958
+ the same answer as an empty list (R1-C44).
959
+ """
960
+ targets = {i for i in self._member_ids(symbol_id) if i in self._calls}
961
+ entries = self.entry_points(root)
962
+ refs = self.references_to(symbol_id)
963
+ out = {
964
+ "symbol": symbol_id, "root": root, "max_depth": max_depth,
965
+ "in_call_graph": bool(targets), "entry_points": len(entries),
966
+ "flows": [], "beyond_depth": 0, "nearest_beyond": None,
967
+ "inbound_calls": sum(len(self._calls.pred[t]) for t in targets),
968
+ "non_call_refs": sum(1 for r in refs if r["type"] != "calls"),
969
+ }
970
+ if not targets:
971
+ return out
972
+ dist = {t: 0 for t in targets}
973
+ frontier, step = set(targets), 0
974
+ while frontier:
975
+ nxt: set[str] = set()
976
+ for node in sorted(frontier):
977
+ for pred in self._calls.predecessors(node):
978
+ if pred not in dist:
979
+ dist[pred] = step + 1
980
+ nxt.add(pred)
981
+ frontier, step = nxt, step + 1
982
+ reaching = sorted((dist[e], e) for e in entries if e in dist)
983
+ out["flows"] = [{"entry": e, "first_step": d,
984
+ "external_callers": self.external_callers(e, root)}
985
+ for d, e in reaching if d <= max_depth]
986
+ beyond = [d for d, _ in reaching if d > max_depth]
987
+ # R1-C50/D9: "0 flows" and "0 flows, and the nearest head is one step past the
988
+ # bound" are different answers, and the second one is actionable. Naming only
989
+ # the count made the headline read as unreachable — measured on a deep graph
990
+ # where four real heads sat at step 6 under a bound of 5.
991
+ out["beyond_depth"] = len(beyond)
992
+ out["nearest_beyond"] = min(beyond) if beyond else None
993
+ return out
994
+
818
995
  # -- relevance ranking (R1-C6) -------------------------------------------
819
996
 
820
997
  def _expand_seeds(self, seeds) -> set[str]:
@@ -44,6 +44,19 @@ def render_apidiff(old: Graph, new: Graph) -> str:
44
44
  out.append(f"{verdict} {s['added']} added, {s['removed']} removed, "
45
45
  f"{s['changed_symbols']} changed.")
46
46
  out.append("")
47
+ # R1-C52: a gate must name what it did not judge. The verdict covers one root — the
48
+ # package — and on a repo-scoped graph that leaves the public symbols of `tests`,
49
+ # `examples` and friends outside it. Silence here is what let 40 test functions read
50
+ # as 40 additions to the API.
51
+ if d.get("root") is not None:
52
+ excluded = d.get("excluded") or {}
53
+ out.append(f"_Compared: public symbols of root `{d['root']}` — the package._"
54
+ if not excluded else
55
+ f"_Compared: public symbols of root `{d['root']}` — the package. "
56
+ "**Not judged:** "
57
+ + ", ".join(f"{n} public symbol(s) in `{r}`" for r, n in excluded.items())
58
+ + " — a consumer root is not this package's API._")
59
+ out.append("")
47
60
  # R1-C25/D4: a verdict about the code is only a verdict about the code when both
48
61
  # graphs came from the same tool. Say so above the verdict, not in a footnote.
49
62
  prov = d.get("provenance") or {}
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from codemap.diagnostics import diagnostics, render_lines
6
+ from codemap.model import CONFIDENCE_ORDER, RESOLUTIONS, UNKNOWN_CONFIDENCE
6
7
  from codemap.query import Query
7
8
  from codemap.tomlio import read_toml
8
9
 
@@ -250,6 +251,22 @@ def render_behavior(query: Query) -> str:
250
251
  by_res[e.extras.get("resolution", "?")] = by_res.get(e.extras.get("resolution", "?"), 0) + 1
251
252
  calls_edges = sum(by_res.values())
252
253
  lines.append(f"_Emitted {calls_edges} `calls` edges (deduped caller→callee)._")
254
+ # R1-C39: the route was already on every edge; what a route is worth was not.
255
+ grades: dict[str, int] = {}
256
+ for value, count in by_res.items():
257
+ row = RESOLUTIONS.get(("calls", value))
258
+ grade = row["confidence"] if row else UNKNOWN_CONFIDENCE
259
+ grades[grade] = grades.get(grade, 0) + count
260
+ if grades:
261
+ lines.append("")
262
+ lines.append("- by route grade: " + ", ".join(
263
+ f"**{g}** {grades[g]}" for g in CONFIDENCE_ORDER if g in grades)
264
+ + (f", unknown {grades[UNKNOWN_CONFIDENCE]}"
265
+ if UNKNOWN_CONFIDENCE in grades else ""))
266
+ lines.append("")
267
+ lines.append("_`exact` — a binding read from the source found the target; "
268
+ "`inferred` — a type-inference engine did (precise, but one deep "
269
+ "build is a sample); `heuristic` — a name match, not a binding._")
253
270
  bridged = by_res.get("registry", 0) + by_res.get("registry-candidate", 0)
254
271
  if bridged:
255
272
  lines.append("")