codmap 0.0.15__tar.gz → 0.0.16__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 (133) hide show
  1. {codmap-0.0.15 → codmap-0.0.16}/PKG-INFO +3 -2
  2. {codmap-0.0.15 → codmap-0.0.16}/README.md +2 -1
  3. {codmap-0.0.15 → codmap-0.0.16}/codemap/cli.py +10 -2
  4. {codmap-0.0.15 → codmap-0.0.16}/codemap/model.py +101 -0
  5. {codmap-0.0.15 → codmap-0.0.16}/codemap/query.py +122 -10
  6. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/audit.py +17 -0
  7. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/impact.py +54 -1
  8. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/mcp_server.py +39 -16
  9. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/session.py +20 -5
  10. {codmap-0.0.15 → codmap-0.0.16}/codmap.egg-info/PKG-INFO +3 -2
  11. {codmap-0.0.15 → codmap-0.0.16}/codmap.egg-info/SOURCES.txt +2 -0
  12. {codmap-0.0.15 → codmap-0.0.16}/pyproject.toml +1 -1
  13. {codmap-0.0.15 → codmap-0.0.16}/tests/test_m1_query.py +33 -12
  14. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c28_limit_envelope.py +14 -1
  15. codmap-0.0.16/tests/test_r1c39_edge_resolution.py +215 -0
  16. codmap-0.0.16/tests/test_r1c40_flow_reach.py +291 -0
  17. {codmap-0.0.15 → codmap-0.0.16}/LICENSE +0 -0
  18. {codmap-0.0.15 → codmap-0.0.16}/codemap/__init__.py +0 -0
  19. {codmap-0.0.15 → codmap-0.0.16}/codemap/apidiff.py +0 -0
  20. {codmap-0.0.15 → codmap-0.0.16}/codemap/arch.py +0 -0
  21. {codmap-0.0.15 → codmap-0.0.16}/codemap/diagnostics.py +0 -0
  22. {codmap-0.0.15 → codmap-0.0.16}/codemap/extract/__init__.py +0 -0
  23. {codmap-0.0.15 → codmap-0.0.16}/codemap/extract/attrflow.py +0 -0
  24. {codmap-0.0.15 → codmap-0.0.16}/codemap/extract/behavior.py +0 -0
  25. {codmap-0.0.15 → codmap-0.0.16}/codemap/extract/dataflow.py +0 -0
  26. {codmap-0.0.15 → codmap-0.0.16}/codemap/extract/dispatch.py +0 -0
  27. {codmap-0.0.15 → codmap-0.0.16}/codemap/extract/griffe_extractor.py +0 -0
  28. {codmap-0.0.15 → codmap-0.0.16}/codemap/extract/gsource.py +0 -0
  29. {codmap-0.0.15 → codmap-0.0.16}/codemap/extract/roots.py +0 -0
  30. {codmap-0.0.15 → codmap-0.0.16}/codemap/extract/union.py +0 -0
  31. {codmap-0.0.15 → codmap-0.0.16}/codemap/freshness.py +0 -0
  32. {codmap-0.0.15 → codmap-0.0.16}/codemap/incremental.py +0 -0
  33. {codmap-0.0.15 → codmap-0.0.16}/codemap/integrations/__init__.py +0 -0
  34. {codmap-0.0.15 → codmap-0.0.16}/codemap/integrations/base.py +0 -0
  35. {codmap-0.0.15 → codmap-0.0.16}/codemap/integrations/cocoindex.py +0 -0
  36. {codmap-0.0.15 → codmap-0.0.16}/codemap/integrations/gate.py +0 -0
  37. {codmap-0.0.15 → codmap-0.0.16}/codemap/integrations/gitnexus.py +0 -0
  38. {codmap-0.0.15 → codmap-0.0.16}/codemap/integrations/registry.py +0 -0
  39. {codmap-0.0.15 → codmap-0.0.16}/codemap/integrations/transport.py +0 -0
  40. {codmap-0.0.15 → codmap-0.0.16}/codemap/provenance.py +0 -0
  41. {codmap-0.0.15 → codmap-0.0.16}/codemap/scope.py +0 -0
  42. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/__init__.py +0 -0
  43. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/_scip_pb2.py +0 -0
  44. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/api_surface.py +0 -0
  45. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/apidiff.py +0 -0
  46. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/architecture.py +0 -0
  47. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/check.py +0 -0
  48. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/ctags.py +0 -0
  49. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/limits.py +0 -0
  50. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/livingdocs.py +0 -0
  51. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/mermaid.py +0 -0
  52. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/pack.py +0 -0
  53. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/rag.py +0 -0
  54. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/review.py +0 -0
  55. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/scip.py +0 -0
  56. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/semantic.py +0 -0
  57. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/server.py +0 -0
  58. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/subsystems.py +0 -0
  59. {codmap-0.0.15 → codmap-0.0.16}/codemap/serve/vault.py +0 -0
  60. {codmap-0.0.15 → codmap-0.0.16}/codemap/store.py +0 -0
  61. {codmap-0.0.15 → codmap-0.0.16}/codemap/tomlio.py +0 -0
  62. {codmap-0.0.15 → codmap-0.0.16}/codemap/watch.py +0 -0
  63. {codmap-0.0.15 → codmap-0.0.16}/codmap.egg-info/dependency_links.txt +0 -0
  64. {codmap-0.0.15 → codmap-0.0.16}/codmap.egg-info/entry_points.txt +0 -0
  65. {codmap-0.0.15 → codmap-0.0.16}/codmap.egg-info/requires.txt +0 -0
  66. {codmap-0.0.15 → codmap-0.0.16}/codmap.egg-info/top_level.txt +0 -0
  67. {codmap-0.0.15 → codmap-0.0.16}/setup.cfg +0 -0
  68. {codmap-0.0.15 → codmap-0.0.16}/tests/test_epistemic.py +0 -0
  69. {codmap-0.0.15 → codmap-0.0.16}/tests/test_gitnexus_router.py +0 -0
  70. {codmap-0.0.15 → codmap-0.0.16}/tests/test_impact_depth.py +0 -0
  71. {codmap-0.0.15 → codmap-0.0.16}/tests/test_integrations.py +0 -0
  72. {codmap-0.0.15 → codmap-0.0.16}/tests/test_issue3_serve_freshness.py +0 -0
  73. {codmap-0.0.15 → codmap-0.0.16}/tests/test_livingdocs.py +0 -0
  74. {codmap-0.0.15 → codmap-0.0.16}/tests/test_m0_api_surface.py +0 -0
  75. {codmap-0.0.15 → codmap-0.0.16}/tests/test_m11_argcontract.py +0 -0
  76. {codmap-0.0.15 → codmap-0.0.16}/tests/test_m12_dataflow.py +0 -0
  77. {codmap-0.0.15 → codmap-0.0.16}/tests/test_m15_review.py +0 -0
  78. {codmap-0.0.15 → codmap-0.0.16}/tests/test_m16_architecture.py +0 -0
  79. {codmap-0.0.15 → codmap-0.0.16}/tests/test_m17_mcp.py +0 -0
  80. {codmap-0.0.15 → codmap-0.0.16}/tests/test_m18_freshness.py +0 -0
  81. {codmap-0.0.15 → codmap-0.0.16}/tests/test_m1_5_semantics.py +0 -0
  82. {codmap-0.0.15 → codmap-0.0.16}/tests/test_m2_views.py +0 -0
  83. {codmap-0.0.15 → codmap-0.0.16}/tests/test_m32_watch.py +0 -0
  84. {codmap-0.0.15 → codmap-0.0.16}/tests/test_m3_serve.py +0 -0
  85. {codmap-0.0.15 → codmap-0.0.16}/tests/test_m4_behavior.py +0 -0
  86. {codmap-0.0.15 → codmap-0.0.16}/tests/test_m5_deep.py +0 -0
  87. {codmap-0.0.15 → codmap-0.0.16}/tests/test_m6_repo_scope.py +0 -0
  88. {codmap-0.0.15 → codmap-0.0.16}/tests/test_m7_dispatch.py +0 -0
  89. {codmap-0.0.15 → codmap-0.0.16}/tests/test_m9_family.py +0 -0
  90. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  91. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c13_grep_vs_graph.py +0 -0
  92. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c16_semantic.py +0 -0
  93. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c20_attribute_edges.py +0 -0
  94. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c21_flat_layout.py +0 -0
  95. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c22_source_visible_refs.py +0 -0
  96. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c23_hard_python.py +0 -0
  97. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c24_test_mapping.py +0 -0
  98. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c25_provenance.py +0 -0
  99. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c26_deep_union.py +0 -0
  100. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c27_config_honesty.py +0 -0
  101. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c29_lazy_imports.py +0 -0
  102. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c2_ctags.py +0 -0
  103. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c30_local_import_calls.py +0 -0
  104. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c30f1_reexport_calls.py +0 -0
  105. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c30f2_check_scope.py +0 -0
  106. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c31_path_origin.py +0 -0
  107. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c32_report_json.py +0 -0
  108. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c33_query_signature.py +0 -0
  109. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c34_signature_kinds.py +0 -0
  110. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c35_contract_location.py +0 -0
  111. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c36_target_resolution.py +0 -0
  112. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c37_rule_mutation.py +0 -0
  113. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c38_tool_drift.py +0 -0
  114. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c3_arch_contract.py +0 -0
  115. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c3_dogfood.py +0 -0
  116. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c41_scope_membership.py +0 -0
  117. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c42_deep_tier_stability.py +0 -0
  118. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c43_incremental_splice.py +0 -0
  119. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c44_absent_answers.py +0 -0
  120. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c45_repeat_union.py +0 -0
  121. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c46_shadowed_definitions.py +0 -0
  122. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c47_incremental_resample.py +0 -0
  123. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c48_type_checking_imports.py +0 -0
  124. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c49_type_only_cycles.py +0 -0
  125. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c4_complexity.py +0 -0
  126. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c5_apidiff.py +0 -0
  127. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c6_pack.py +0 -0
  128. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c7_edge_vocab.py +0 -0
  129. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c8_deadcode.py +0 -0
  130. {codmap-0.0.15 → codmap-0.0.16}/tests/test_r1c9_incremental.py +0 -0
  131. {codmap-0.0.15 → codmap-0.0.16}/tests/test_scip_export.py +0 -0
  132. {codmap-0.0.15 → codmap-0.0.16}/tests/test_scope.py +0 -0
  133. {codmap-0.0.15 → codmap-0.0.16}/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.16
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
@@ -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.
@@ -815,6 +872,61 @@ class Query:
815
872
  return {"entry": entry, "edges": edges, "reached": len(seen) - 1,
816
873
  "max_depth": max((e["distance"] for e in edges), default=0)}
817
874
 
875
+ def flows_to(self, symbol_id: str, *, max_depth: int = 5,
876
+ root: str = "core") -> dict:
877
+ """Which flows a change to ``symbol_id`` lands on, and at which step (R1-C40).
878
+
879
+ The join of the two ends codemap already had: :meth:`impact` walks inbound
880
+ ("who references"), :meth:`flow` walks outbound ("what a call sets in
881
+ motion"), and neither answers the question asked *before* a change — "what
882
+ stops working, and where in the scenario". A flow here is what ``flows``
883
+ already calls one: an entry point of ``root`` (:meth:`entry_points`).
884
+ ``first_step`` is the shortest distance in ``calls`` edges from that entry
885
+ to the symbol **or one of its members** — shortest because "first" is what
886
+ was asked, and a BFS gives the minimum by construction.
887
+
888
+ Computed as one reverse BFS from the targets rather than a forward walk per
889
+ entry: the distance is the same measured from either end, and the cost drops
890
+ from O(entries × graph) to O(graph). The equivalence is what the acceptance
891
+ test checks against :meth:`flow`.
892
+
893
+ Says "reached", never "broken" (design D3): the graph knows the symbol lies
894
+ on the path, not whether the edit breaks it. Three separate partialities are
895
+ named rather than folded into the list — ``non_call_refs`` (a reference that
896
+ arrives by import / inheritance / decoration / attribute access cannot appear
897
+ in a flow at all), ``beyond_depth`` (entries that do reach, further than
898
+ ``max_depth`` — counted, not silently dropped), and the standing note that
899
+ call resolution is a lower bound. ``in_call_graph: False`` is the honest
900
+ "nothing to say about flows" for a symbol the call layer never modelled — not
901
+ the same answer as an empty list (R1-C44).
902
+ """
903
+ targets = {i for i in self._member_ids(symbol_id) if i in self._calls}
904
+ entries = self.entry_points(root)
905
+ refs = self.references_to(symbol_id)
906
+ out = {
907
+ "symbol": symbol_id, "root": root, "max_depth": max_depth,
908
+ "in_call_graph": bool(targets), "entry_points": len(entries),
909
+ "flows": [], "beyond_depth": 0,
910
+ "non_call_refs": sum(1 for r in refs if r["type"] != "calls"),
911
+ }
912
+ if not targets:
913
+ return out
914
+ dist = {t: 0 for t in targets}
915
+ frontier, step = set(targets), 0
916
+ while frontier:
917
+ nxt: set[str] = set()
918
+ for node in sorted(frontier):
919
+ for pred in self._calls.predecessors(node):
920
+ if pred not in dist:
921
+ dist[pred] = step + 1
922
+ nxt.add(pred)
923
+ frontier, step = nxt, step + 1
924
+ reaching = sorted((dist[e], e) for e in entries if e in dist)
925
+ out["flows"] = [{"entry": e, "first_step": d}
926
+ for d, e in reaching if d <= max_depth]
927
+ out["beyond_depth"] = sum(1 for d, _ in reaching if d > max_depth)
928
+ return out
929
+
818
930
  # -- relevance ranking (R1-C6) -------------------------------------------
819
931
 
820
932
  def _expand_seeds(self, seeds) -> set[str]:
@@ -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("")
@@ -11,7 +11,57 @@ from __future__ import annotations
11
11
  from codemap.query import Query
12
12
 
13
13
 
14
- def render_impact(query: Query, symbol: str, *, depth: int = 2) -> str:
14
+ _FLOW_ROWS = 40
15
+
16
+
17
+ def _flow_section(rep: dict) -> list[str]:
18
+ """R1-C40: which scenarios the change lands on, and at which step.
19
+
20
+ The three partialities of `Query.flows_to` are rendered as words, not left for the
21
+ reader to infer from a short list: an empty flow list next to twenty references is
22
+ the exact shape that reads as "nothing will break" when it means "the flow layer
23
+ cannot see this kind of edge".
24
+ """
25
+ lines = [f"### Flows reached ({len(rep['flows'])} of {rep['entry_points']} "
26
+ f"entry point(s) in root `{rep['root']}`)", ""]
27
+ if not rep["in_call_graph"]:
28
+ lines += ["_The call layer never modelled this symbol — no resolved call reaches "
29
+ "it or leaves it — so there is nothing to say about flows here. This is "
30
+ "not 'no flow reaches it'._", ""]
31
+ return lines
32
+ if rep["flows"]:
33
+ lines.append("_`step` — where in the flow the change first lands: 1 means the "
34
+ "entry point calls it directly, 0 that the entry point is the "
35
+ "symbol itself (or one of its members), so the flow starts inside "
36
+ "the change._")
37
+ lines.append("")
38
+ for f in rep["flows"][:_FLOW_ROWS]:
39
+ lines.append(f"- `{f['entry']}` — step {f['first_step']}")
40
+ if len(rep["flows"]) > _FLOW_ROWS:
41
+ lines.append(f"- _… {len(rep['flows']) - _FLOW_ROWS} more_")
42
+ else:
43
+ lines.append(f"_No entry point reaches it within {rep['max_depth']} step(s)._")
44
+ lines.append("")
45
+ notes = []
46
+ if rep["beyond_depth"]:
47
+ notes.append(f"{rep['beyond_depth']} further entry point(s) reach it **beyond** "
48
+ f"{rep['max_depth']} steps — counted, not listed.")
49
+ if rep["non_call_refs"]:
50
+ notes.append(f"{rep['non_call_refs']} direct reference(s) arrive by an edge that "
51
+ "is not a call (import / inheritance / decoration / attribute) and "
52
+ "cannot appear in a flow at all.")
53
+ notes.append("Flows follow resolved `calls` edges only — a lower bound both ways: an "
54
+ "unresolved caller also leaves a real internal looking like an entry "
55
+ "point, so the denominator is an upper bound. *Reached*, not *broken*: "
56
+ "the graph knows the symbol is on the path, not whether the change "
57
+ "breaks it.")
58
+ lines.append("_" + " ".join(notes) + "_")
59
+ lines.append("")
60
+ return lines
61
+
62
+
63
+ def render_impact(query: Query, symbol: str, *, depth: int = 2,
64
+ flow_depth: int = 5) -> str:
15
65
  """Markdown blast-radius for the symbol matching ``symbol`` (short or full)."""
16
66
  ids = query.impact_targets(symbol) # F23: short name / full id / re-export
17
67
  lines = [f"# Impact — `{symbol}`", ""]
@@ -64,6 +114,9 @@ def render_impact(query: Query, symbol: str, *, depth: int = 2) -> str:
64
114
  lines.append(f"- _+{len(indirect)} transitive (distance >1)_")
65
115
  lines.append("")
66
116
 
117
+ # R1-C40: from "who references" to "what stops working, and where".
118
+ lines += _flow_section(query.flows_to(sid, max_depth=flow_depth))
119
+
67
120
  # F7: argument contract of the call-sites — what a signature change touches.
68
121
  contract = query.call_contract(sid)
69
122
  if contract:
@@ -55,15 +55,27 @@ def _compact_impact(env: dict, limit: int) -> dict:
55
55
  for e in result.get("impact", []):
56
56
  refs = e.get("refs", [])
57
57
  shown = min(limit, len(refs))
58
- entries.append({**e, "refs": refs[:limit],
59
- "refs_shown": shown, "refs_total": len(refs)})
58
+ entry = {**e, "refs": refs[:limit],
59
+ "refs_shown": shown, "refs_total": len(refs)}
60
+ # R1-C40: the flows section is a second computed list, cut here as well — in its
61
+ # own vocabulary, so a reader never has to guess which list the envelope's summed
62
+ # block describes (it is the refs one).
63
+ flows = e.get("flows")
64
+ if isinstance(flows, dict):
65
+ listed = flows.get("flows", [])
66
+ entry["flows"] = {**flows, "flows": listed[:limit],
67
+ "flows_shown": min(limit, len(listed)),
68
+ "flows_total": len(listed)}
69
+ entries.append(entry)
60
70
  shown_total += shown
61
71
  refs_total += len(refs)
62
72
  result["impact"] = entries
63
73
  env["result"] = result
64
74
  env["limit"] = limit_block(limit, shown_total, refs_total,
65
- note="applied per impact entry, not across the answer; "
66
- "the by_root counts are complete regardless")
75
+ note="counts the refs list, applied per impact entry, not "
76
+ "across the answer; the by_root counts are complete "
77
+ "regardless, and the flows list carries its own "
78
+ "flows_shown / flows_total")
67
79
  return env
68
80
 
69
81
 
@@ -139,14 +151,20 @@ def build_mcp_server(session: "Session", name: str = "codemap") -> Any:
139
151
  return op("resolve", {"name": name})
140
152
 
141
153
  @server.tool()
142
- def callers(symbol: str) -> dict:
143
- """Functions that statically call `symbol` (resolved calls only)."""
144
- return op("callers", {"symbol": symbol})
154
+ def callers(symbol: str, min_confidence: str | None = None) -> dict:
155
+ """Functions that statically call `symbol` (resolved calls only).
156
+ `min_confidence` keeps only edges whose route grades at least that strong:
157
+ `exact` (a binding in the source found the target), `inferred` (a type-inference
158
+ engine did — precise, but one deep build samples ~3 of 4 live edges), `heuristic`
159
+ (a name match, e.g. a factory fanned out across a registry family). Omit for
160
+ every resolved edge. `stats` reports the whole graph's grade mix."""
161
+ return op("callers", {"symbol": symbol, "min_confidence": min_confidence})
145
162
 
146
163
  @server.tool()
147
- def callees(symbol: str) -> dict:
148
- """Internal symbols that `symbol` statically calls."""
149
- return op("callees", {"symbol": symbol})
164
+ def callees(symbol: str, min_confidence: str | None = None) -> dict:
165
+ """Internal symbols that `symbol` statically calls. `min_confidence` as in
166
+ `callers`: exact | inferred | heuristic."""
167
+ return op("callees", {"symbol": symbol, "min_confidence": min_confidence})
150
168
 
151
169
  @server.tool()
152
170
  def tests(symbol: str, depth: int = 3, cap: int = 25) -> dict:
@@ -164,13 +182,18 @@ def build_mcp_server(session: "Session", name: str = "codemap") -> Any:
164
182
  return op("covers", {"test": test, "depth": depth, "cap": cap})
165
183
 
166
184
  @server.tool()
167
- def impact(symbol: str, depth: int = 2, limit: int = 40, full: bool = False) -> dict:
185
+ def impact(symbol: str, depth: int = 2, limit: int = 40, full: bool = False,
186
+ flow_depth: int = 5) -> dict:
168
187
  """Blast radius of changing `symbol`: inbound references up to `depth`, counted
169
- by provenance root (core/tests/docs/…). Compact by default (F22): omits the
170
- duplicate markdown and caps the flat ref list at `limit` (by_root counts stay
171
- complete, and every entry carries refs_shown/refs_total). Pass full=true for
172
- the entire payload including markdown."""
173
- env = op("impact", {"symbol": symbol, "depth": depth})
188
+ by provenance root (core/tests/docs/…), plus `flows` — which entry points reach
189
+ it and at which step (`first_step`), within `flow_depth`. Compact by default
190
+ (F22): omits the duplicate markdown and caps each list at `limit` (by_root counts
191
+ stay complete; entries carry refs_shown/refs_total and flows_shown/flows_total).
192
+ Flows follow resolved `calls` edges only: `non_call_refs` says how many
193
+ references cannot appear there, `beyond_depth` how many entries reach further,
194
+ and `in_call_graph: false` means the call layer never modelled the symbol — not
195
+ that no flow reaches it. Pass full=true for the entire payload with markdown."""
196
+ env = op("impact", {"symbol": symbol, "depth": depth, "flow_depth": flow_depth})
174
197
  return env if full else _compact_impact(env, limit)
175
198
 
176
199
  @server.tool()
@@ -110,7 +110,10 @@ _UNLIMITED_BY_DESIGN = {
110
110
  "pack": "budget is a token budget the caller sets on purpose, and the result is "
111
111
  "explicitly a *pack* — self-describing by construction",
112
112
  "impact": "depth bounds the walk, it does not slice a computed list; the reach is "
113
- "already echoed back as by_distance / max_distance",
113
+ "already echoed back as by_distance / max_distance, and flow_depth "
114
+ "likewise bounds the flow walk while counting what lies past it "
115
+ "(beyond_depth). Both lists come back whole here; the transport cut "
116
+ "lives in _compact_impact and declares itself there (R1-C40)",
114
117
  "flows": "depth likewise bounds the walk, and the answer carries its own depth",
115
118
  }
116
119
 
@@ -367,6 +370,9 @@ class Session:
367
370
  "edges": len(self.graph.edges),
368
371
  "node_kinds": dict(Counter(n.kind for n in self.graph.nodes.values())),
369
372
  "edge_types": dict(Counter(e.type for e in self.graph.edges)),
373
+ # R1-C39: how much of this graph a binding found, and how much a name match.
374
+ # The route was always on the edge; what it is worth was never stated.
375
+ "confidence": self.query.confidence_map(),
370
376
  }
371
377
  # M18 + #3: age of the graph WE SERVE (not the on-disk file), with an explicit
372
378
  # stale flag when the artifact was rebuilt after we loaded it.
@@ -442,13 +448,20 @@ class Session:
442
448
  def _op_impact(self, args) -> dict:
443
449
  sym = args["symbol"]
444
450
  depth = int(args.get("depth", 2))
451
+ flow_depth = int(args.get("flow_depth", 5))
445
452
  ids = self.query.impact_targets(sym) # F23: accept full id / re-export too
446
453
  if not ids: # R1-C44: this op resolves on its own, so it records on its own
447
454
  self._resolution = _not_found(sym)
448
455
  return {
449
456
  "symbol": sym,
450
- "impact": [self.query.impact(sid, depth=depth) for sid in ids],
451
- "markdown": render_impact(self.query, sym, depth=depth),
457
+ # R1-C40: blast radius and the flows it lands on are two answers kept side by
458
+ # side, not merged — "who references" and "where in a scenario the change
459
+ # first bites" have different partialities, and each states its own.
460
+ "impact": [{**self.query.impact(sid, depth=depth),
461
+ "flows": self.query.flows_to(sid, max_depth=flow_depth)}
462
+ for sid in ids],
463
+ "markdown": render_impact(self.query, sym, depth=depth,
464
+ flow_depth=flow_depth),
452
465
  }
453
466
 
454
467
  def _op_resolve(self, args) -> dict | None:
@@ -505,10 +518,12 @@ class Session:
505
518
  return res
506
519
 
507
520
  def _op_callers(self, args) -> list:
508
- return self.query.callers(self._canon(args["symbol"]))
521
+ return self.query.callers(self._canon(args["symbol"]),
522
+ min_confidence=args.get("min_confidence"))
509
523
 
510
524
  def _op_callees(self, args) -> list:
511
- return self.query.callees(self._canon(args["symbol"]))
525
+ return self.query.callees(self._canon(args["symbol"]),
526
+ min_confidence=args.get("min_confidence"))
512
527
 
513
528
  def _op_implementers(self, args) -> list:
514
529
  return self.query.implementers(self._canon(args["protocol"]))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.15
3
+ Version: 0.0.16
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
@@ -107,8 +107,10 @@ tests/test_r1c35_contract_location.py
107
107
  tests/test_r1c36_target_resolution.py
108
108
  tests/test_r1c37_rule_mutation.py
109
109
  tests/test_r1c38_tool_drift.py
110
+ tests/test_r1c39_edge_resolution.py
110
111
  tests/test_r1c3_arch_contract.py
111
112
  tests/test_r1c3_dogfood.py
113
+ tests/test_r1c40_flow_reach.py
112
114
  tests/test_r1c41_scope_membership.py
113
115
  tests/test_r1c42_deep_tier_stability.py
114
116
  tests/test_r1c43_incremental_splice.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.15"
12
+ version = "0.0.16"
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