codmap 0.0.16__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.
- {codmap-0.0.16 → codmap-0.0.17}/PKG-INFO +1 -1
- {codmap-0.0.16 → codmap-0.0.17}/codemap/apidiff.py +40 -4
- {codmap-0.0.16 → codmap-0.0.17}/codemap/query.py +74 -9
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/apidiff.py +13 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/impact.py +30 -9
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/session.py +36 -4
- {codmap-0.0.16 → codmap-0.0.17}/codmap.egg-info/PKG-INFO +1 -1
- {codmap-0.0.16 → codmap-0.0.17}/codmap.egg-info/SOURCES.txt +1 -0
- {codmap-0.0.16 → codmap-0.0.17}/pyproject.toml +1 -1
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c40_flow_reach.py +7 -2
- codmap-0.0.17/tests/test_r1c50_flow_entry_points.py +285 -0
- {codmap-0.0.16 → codmap-0.0.17}/LICENSE +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/README.md +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/__init__.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/arch.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/cli.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/diagnostics.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/extract/behavior.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/extract/griffe_extractor.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/extract/roots.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/extract/union.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/freshness.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/incremental.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/integrations/base.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/model.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/provenance.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/scope.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/architecture.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/audit.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/check.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/limits.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/livingdocs.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/mcp_server.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/pack.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/rag.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/review.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/scip.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/server.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/serve/vault.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/store.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/tomlio.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codemap/watch.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/setup.cfg +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_epistemic.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_integrations.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_m15_review.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_m16_architecture.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_m1_query.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_m2_views.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_m9_family.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c29_lazy_imports.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c30f2_check_scope.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c38_tool_drift.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c39_edge_resolution.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c41_scope_membership.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c42_deep_tier_stability.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c43_incremental_splice.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c44_absent_answers.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c45_repeat_union.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c46_shadowed_definitions.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c47_incremental_resample.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c48_type_checking_imports.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c49_type_only_cycles.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_scip_export.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_scope.py +0 -0
- {codmap-0.0.16 → codmap-0.0.17}/tests/test_subsystems.py +0 -0
|
@@ -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
|
|
188
|
-
"""
|
|
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
|
-
|
|
191
|
-
|
|
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):
|
|
@@ -834,18 +834,75 @@ class Query:
|
|
|
834
834
|
return out
|
|
835
835
|
|
|
836
836
|
def entry_points(self, root: str = "core") -> list[str]:
|
|
837
|
-
"""Call-forest roots: functions that call out
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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.
|
|
842
856
|
"""
|
|
843
857
|
return sorted(
|
|
844
858
|
n for n in self._calls.nodes
|
|
845
859
|
if self.root_of(n) == root
|
|
846
|
-
and self._calls.out_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))
|
|
847
863
|
)
|
|
848
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
|
+
|
|
849
906
|
def flow(self, entry: str, *, max_depth: int = 5) -> dict:
|
|
850
907
|
"""Forward call-flow from ``entry`` along ``calls`` edges, bounded by depth.
|
|
851
908
|
|
|
@@ -906,7 +963,8 @@ class Query:
|
|
|
906
963
|
out = {
|
|
907
964
|
"symbol": symbol_id, "root": root, "max_depth": max_depth,
|
|
908
965
|
"in_call_graph": bool(targets), "entry_points": len(entries),
|
|
909
|
-
"flows": [], "beyond_depth": 0,
|
|
966
|
+
"flows": [], "beyond_depth": 0, "nearest_beyond": None,
|
|
967
|
+
"inbound_calls": sum(len(self._calls.pred[t]) for t in targets),
|
|
910
968
|
"non_call_refs": sum(1 for r in refs if r["type"] != "calls"),
|
|
911
969
|
}
|
|
912
970
|
if not targets:
|
|
@@ -922,9 +980,16 @@ class Query:
|
|
|
922
980
|
nxt.add(pred)
|
|
923
981
|
frontier, step = nxt, step + 1
|
|
924
982
|
reaching = sorted((dist[e], e) for e in entries if e in dist)
|
|
925
|
-
out["flows"] = [{"entry": e, "first_step": d
|
|
983
|
+
out["flows"] = [{"entry": e, "first_step": d,
|
|
984
|
+
"external_callers": self.external_callers(e, root)}
|
|
926
985
|
for d, e in reaching if d <= max_depth]
|
|
927
|
-
|
|
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
|
|
928
993
|
return out
|
|
929
994
|
|
|
930
995
|
# -- relevance ranking (R1-C6) -------------------------------------------
|
|
@@ -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 {}
|
|
@@ -36,25 +36,46 @@ def _flow_section(rep: dict) -> list[str]:
|
|
|
36
36
|
"the change._")
|
|
37
37
|
lines.append("")
|
|
38
38
|
for f in rep["flows"][:_FLOW_ROWS]:
|
|
39
|
-
|
|
39
|
+
ext = f.get("external_callers") or {}
|
|
40
|
+
tail = (" _(entered from " + ", ".join(f"{r} ×{n}" for r, n in ext.items())
|
|
41
|
+
+ ")_" if ext else "")
|
|
42
|
+
lines.append(f"- `{f['entry']}` — step {f['first_step']}{tail}")
|
|
40
43
|
if len(rep["flows"]) > _FLOW_ROWS:
|
|
41
44
|
lines.append(f"- _… {len(rep['flows']) - _FLOW_ROWS} more_")
|
|
45
|
+
elif rep["nearest_beyond"] is not None:
|
|
46
|
+
# R1-C50/D9: an empty list with a head one step past the bound is not the same
|
|
47
|
+
# answer as an empty list, and it is the one a reader can act on.
|
|
48
|
+
lines.append(f"_No entry point within {rep['max_depth']} step(s) — but "
|
|
49
|
+
f"{rep['beyond_depth']} reach it further out, the nearest at step "
|
|
50
|
+
f"**{rep['nearest_beyond']}**. Re-run with `--flow-depth "
|
|
51
|
+
f"{rep['nearest_beyond']}` to see them._")
|
|
52
|
+
elif rep["inbound_calls"]:
|
|
53
|
+
lines.append(f"_No entry point reaches it, at any depth — yet {rep['inbound_calls']} "
|
|
54
|
+
"resolved call(s) do reach it. Every chain above it is either closed "
|
|
55
|
+
"in a call cycle (no head to start from) or starts outside root "
|
|
56
|
+
f"`{rep['root']}`. Not 'nothing calls it'._")
|
|
42
57
|
else:
|
|
43
|
-
lines.append(f"_No entry point reaches it within {rep['max_depth']} step(s)
|
|
58
|
+
lines.append(f"_No entry point reaches it within {rep['max_depth']} step(s), and "
|
|
59
|
+
"no resolved call reaches it either._")
|
|
44
60
|
lines.append("")
|
|
45
61
|
notes = []
|
|
46
|
-
if rep["beyond_depth"]:
|
|
62
|
+
if rep["beyond_depth"] and rep["flows"]:
|
|
47
63
|
notes.append(f"{rep['beyond_depth']} further entry point(s) reach it **beyond** "
|
|
48
|
-
f"{rep['max_depth']} steps
|
|
64
|
+
f"{rep['max_depth']} steps (nearest at step "
|
|
65
|
+
f"{rep['nearest_beyond']}) — counted, not listed.")
|
|
49
66
|
if rep["non_call_refs"]:
|
|
50
67
|
notes.append(f"{rep['non_call_refs']} direct reference(s) arrive by an edge that "
|
|
51
68
|
"is not a call (import / inheritance / decoration / attribute) and "
|
|
52
69
|
"cannot appear in a flow at all.")
|
|
53
|
-
notes.append("Flows follow resolved `calls` edges only
|
|
54
|
-
"unresolved caller
|
|
55
|
-
"point
|
|
56
|
-
"
|
|
57
|
-
"
|
|
70
|
+
notes.append("Flows follow resolved `calls` edges only, and the entry-point set is "
|
|
71
|
+
"best-effort in **both** directions: an unresolved caller leaves a real "
|
|
72
|
+
"internal looking like an entry point (the denominator is an upper "
|
|
73
|
+
"bound), while resolving one *removes* an entry point and can lengthen "
|
|
74
|
+
"a chain past `--flow-depth` — so a **more** complete graph can answer "
|
|
75
|
+
"with **fewer** flows. A call from another root (`tests`, `examples`) is "
|
|
76
|
+
"a use, not an internal caller, and does not disqualify a head. "
|
|
77
|
+
"*Reached*, not *broken*: the graph knows the symbol is on the path, not "
|
|
78
|
+
"whether the change breaks it.")
|
|
58
79
|
lines.append("_" + " ".join(notes) + "_")
|
|
59
80
|
lines.append("")
|
|
60
81
|
return lines
|
|
@@ -118,6 +118,20 @@ _UNLIMITED_BY_DESIGN = {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
|
|
121
|
+
def _grade_filter(min_confidence, every: list, kept: list, grades: dict) -> dict:
|
|
122
|
+
"""The `filter` block: what the route-grade filter kept, dropped, and out of what.
|
|
123
|
+
|
|
124
|
+
R1-C51, the filter half of R1-C28. ``by_grade`` is the composition of the
|
|
125
|
+
*unfiltered* answer, so an empty ``kept`` is readable on its own — `{"heuristic": 2}`
|
|
126
|
+
with `returned: 0` says "called, but only through routes you excluded", which a bare
|
|
127
|
+
`[]` cannot. Emitted even with no filter in force (`min_confidence: null`), because
|
|
128
|
+
that is when a reader most needs to know what a filter *would* remove.
|
|
129
|
+
"""
|
|
130
|
+
return {"min_confidence": min_confidence, "returned": len(kept),
|
|
131
|
+
"total": len(every), "dropped": len(every) - len(kept),
|
|
132
|
+
"by_grade": grades}
|
|
133
|
+
|
|
134
|
+
|
|
121
135
|
def _match(q: Query, n) -> dict:
|
|
122
136
|
"""One entry of the ``matches`` list: where the symbol is, and how it is declared.
|
|
123
137
|
|
|
@@ -319,6 +333,7 @@ class Session:
|
|
|
319
333
|
"ops": sorted(_OPS)}
|
|
320
334
|
self._resolution = None
|
|
321
335
|
self._limit = None
|
|
336
|
+
self._filter = None
|
|
322
337
|
try:
|
|
323
338
|
env = {"ok": True, "result": fn(self, args)}
|
|
324
339
|
except Exception as exc: # a bad arg must not kill the resident process
|
|
@@ -335,6 +350,15 @@ class Session:
|
|
|
335
350
|
env["epistemic"] = self._epistemic(op)
|
|
336
351
|
if self._limit is not None: # R1-C28: how much of the answer survived the cut
|
|
337
352
|
env["limit"] = self._limit
|
|
353
|
+
# R1-C51: a *filter* is the same partiality as a limit, and it was not declared.
|
|
354
|
+
# `callers(min_confidence="exact")` came back as a bare `[]` on symbols that are
|
|
355
|
+
# called on every run through a factory — reported by the dogfood target with the
|
|
356
|
+
# measurement that makes it matter (13 of 25 registry targets have no exact
|
|
357
|
+
# caller at all). Emitted whenever the op accepts the filter, including when it
|
|
358
|
+
# dropped nothing: a caller must not have to tell "nothing was filtered" from
|
|
359
|
+
# "this build does not report filtering".
|
|
360
|
+
if self._filter is not None:
|
|
361
|
+
env["filter"] = self._filter
|
|
338
362
|
return env
|
|
339
363
|
|
|
340
364
|
def _epistemic(self, op: str) -> dict:
|
|
@@ -518,12 +542,20 @@ class Session:
|
|
|
518
542
|
return res
|
|
519
543
|
|
|
520
544
|
def _op_callers(self, args) -> list:
|
|
521
|
-
|
|
522
|
-
|
|
545
|
+
sid = self._canon(args["symbol"])
|
|
546
|
+
kept = self.query.callers(sid, min_confidence=args.get("min_confidence"))
|
|
547
|
+
self._filter = _grade_filter(args.get("min_confidence"),
|
|
548
|
+
self.query.callers(sid), kept,
|
|
549
|
+
self.query.caller_grades(sid))
|
|
550
|
+
return kept
|
|
523
551
|
|
|
524
552
|
def _op_callees(self, args) -> list:
|
|
525
|
-
|
|
526
|
-
|
|
553
|
+
sid = self._canon(args["symbol"])
|
|
554
|
+
kept = self.query.callees(sid, min_confidence=args.get("min_confidence"))
|
|
555
|
+
self._filter = _grade_filter(args.get("min_confidence"),
|
|
556
|
+
self.query.callees(sid), kept,
|
|
557
|
+
self.query.callee_grades(sid))
|
|
558
|
+
return kept
|
|
527
559
|
|
|
528
560
|
def _op_implementers(self, args) -> list:
|
|
529
561
|
return self.query.implementers(self._canon(args["protocol"]))
|
|
@@ -121,6 +121,7 @@ tests/test_r1c47_incremental_resample.py
|
|
|
121
121
|
tests/test_r1c48_type_checking_imports.py
|
|
122
122
|
tests/test_r1c49_type_only_cycles.py
|
|
123
123
|
tests/test_r1c4_complexity.py
|
|
124
|
+
tests/test_r1c50_flow_entry_points.py
|
|
124
125
|
tests/test_r1c5_apidiff.py
|
|
125
126
|
tests/test_r1c6_pack.py
|
|
126
127
|
tests/test_r1c7_edge_vocab.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.
|
|
12
|
+
version = "0.0.17"
|
|
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
|
|
@@ -208,9 +208,14 @@ def test_a_reference_that_cannot_appear_in_a_flow_is_counted(q):
|
|
|
208
208
|
|
|
209
209
|
|
|
210
210
|
def test_the_depth_bound_is_stated_when_nothing_reaches(q):
|
|
211
|
+
"""R1-C50/D9 replaced the bare "No entry point reaches it within N step(s)" here:
|
|
212
|
+
a head one step past the bound is a different answer from no head at all, and the
|
|
213
|
+
only one a reader can act on. The bare sentence survives for the case that really
|
|
214
|
+
is empty — see `test_never_modelled_by_the_call_layer_is_not_an_empty_flow_list`."""
|
|
211
215
|
md = render_impact(q, "flowpkg.leaf.hit", flow_depth=1)
|
|
212
|
-
assert "No entry point
|
|
213
|
-
assert "reach it **
|
|
216
|
+
assert "No entry point within 1 step(s)" in md
|
|
217
|
+
assert "1 reach it further out, the nearest at step **2**" in md
|
|
218
|
+
assert "--flow-depth 2" in md
|
|
214
219
|
|
|
215
220
|
|
|
216
221
|
# -- D5: three kinds of emptiness -------------------------------------------------------
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""R1-C50/C51/C52 — three findings that arrived in one message from the dogfood target.
|
|
2
|
+
|
|
3
|
+
[Issue #19](https://github.com/kogriv/codemap/issues/19), filed the day after 0.0.16 while
|
|
4
|
+
they were *verifying our own measurement* from bquant#120 on their tree. Gap:
|
|
5
|
+
`gaps/flow_entry_points_2026-09-10.md`.
|
|
6
|
+
|
|
7
|
+
**R1-C50 — an entry point defined so that a library never has one.** `entry_points` asked
|
|
8
|
+
for in-degree zero across the whole graph, so the one function a user enters the package
|
|
9
|
+
through was disqualified by being used: 43 calls, all from `tests`/`examples`/`scripts`/
|
|
10
|
+
`research`. Worse, the better tier answered *emptier* — deep resolution closes the chain
|
|
11
|
+
above a node, removing it as a head, so `report impact` said "step 3" on fast and "no
|
|
12
|
+
entry point reaches it" on deep, same tree, same version. Fixed three ways: in-degree is
|
|
13
|
+
counted **within the root** (D7), an entry says which outside roots enter it (D8), and the
|
|
14
|
+
empty answer names its kind — including the nearest head past the depth bound (D9).
|
|
15
|
+
|
|
16
|
+
**R1-C51 — a filter is partiality too.** `callers(min_confidence="exact")` returned a bare
|
|
17
|
+
`[]` for symbols called on every run through a factory. Their measurement is what makes it
|
|
18
|
+
matter: of the 25 symbols their registry fan-out reaches, **13 have no `exact` caller at
|
|
19
|
+
all** — every strategy method. The envelope now carries a `filter` block in the shape
|
|
20
|
+
R1-C28 uses for limits, always, including when nothing was dropped.
|
|
21
|
+
|
|
22
|
+
**R1-C52 — `diff` judged the consumers' API.** 40 of 47 "added public symbols" in their
|
|
23
|
+
release-gate run were test functions, because `diff_api` filtered on visibility alone. It
|
|
24
|
+
compares one provenance root now, and says what it did not judge.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import pytest
|
|
30
|
+
|
|
31
|
+
from codemap import apidiff
|
|
32
|
+
from codemap.extract import extract, extract_repo
|
|
33
|
+
from codemap.query import Query
|
|
34
|
+
from codemap.serve.apidiff import build_apidiff, render_apidiff
|
|
35
|
+
from codemap.serve.impact import render_impact
|
|
36
|
+
from codemap.serve.session import Session
|
|
37
|
+
|
|
38
|
+
# public → mid → leaf, and the public head is called only from the consumer root.
|
|
39
|
+
CORE_API = '''from core.mid import step
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def public_entry(x):
|
|
43
|
+
return step(x)
|
|
44
|
+
'''
|
|
45
|
+
CORE_MID = '''from core.leaf import work
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def step(x):
|
|
49
|
+
return work(x)
|
|
50
|
+
'''
|
|
51
|
+
CORE_LEAF = '''def work(x):
|
|
52
|
+
return x
|
|
53
|
+
'''
|
|
54
|
+
# a pair that calls each other: a chain with no head at all
|
|
55
|
+
CORE_RING = '''def ping(n):
|
|
56
|
+
return pong(n) if n else 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def pong(n):
|
|
60
|
+
return ping(n - 1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def rung():
|
|
64
|
+
return ping(2)
|
|
65
|
+
'''
|
|
66
|
+
USAGE = '''from core.api import public_entry
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def scenario():
|
|
70
|
+
return public_entry(1)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def scenario_two():
|
|
74
|
+
return public_entry(2)
|
|
75
|
+
'''
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _repo(tmp_path):
|
|
79
|
+
core = tmp_path / "core"
|
|
80
|
+
core.mkdir()
|
|
81
|
+
(core / "__init__.py").write_text("")
|
|
82
|
+
(core / "api.py").write_text(CORE_API)
|
|
83
|
+
(core / "mid.py").write_text(CORE_MID)
|
|
84
|
+
(core / "leaf.py").write_text(CORE_LEAF)
|
|
85
|
+
(core / "ring.py").write_text(CORE_RING)
|
|
86
|
+
usage = tmp_path / "usage"
|
|
87
|
+
usage.mkdir()
|
|
88
|
+
(usage / "script.py").write_text(USAGE)
|
|
89
|
+
return core, usage
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.fixture(scope="module")
|
|
93
|
+
def repo(tmp_path_factory):
|
|
94
|
+
core, usage = _repo(tmp_path_factory.mktemp("r1c50"))
|
|
95
|
+
return Query(extract_repo(core, consumers=(usage,), mode="full"))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.fixture(scope="module")
|
|
99
|
+
def package_only(tmp_path_factory):
|
|
100
|
+
core, _ = _repo(tmp_path_factory.mktemp("r1c50-solo"))
|
|
101
|
+
return Query(extract(core))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _entries(rep):
|
|
105
|
+
return {f["entry"]: f["first_step"] for f in rep["flows"]}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# -- D7: the head of a library's flow is public, not internal ---------------------------
|
|
109
|
+
|
|
110
|
+
def test_a_head_called_only_from_a_consumer_root_is_an_entry_point(repo):
|
|
111
|
+
head = "core.api.public_entry"
|
|
112
|
+
assert head in repo.entry_points("core")
|
|
113
|
+
assert repo._calls.in_degree(head) == 2, "and it is called — twice, from the consumer"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_the_flow_reaches_the_leaf_from_the_public_head(repo):
|
|
117
|
+
rep = repo.flows_to("core.leaf.work")
|
|
118
|
+
assert _entries(rep)["core.api.public_entry"] == 2
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_the_old_rule_loses_the_flow_entirely(repo, monkeypatch):
|
|
122
|
+
"""R1-C37, and the exact shape of #19: with in-degree counted across the whole graph,
|
|
123
|
+
the public head is disqualified by being used, and nothing reaches the leaf at any
|
|
124
|
+
depth — so raising `--flow-depth` cannot help, which is what they measured."""
|
|
125
|
+
def global_indegree(self, root="core"):
|
|
126
|
+
return sorted(n for n in self._calls.nodes
|
|
127
|
+
if self.root_of(n) == root
|
|
128
|
+
and self._calls.out_degree(n) > 0
|
|
129
|
+
and self._calls.in_degree(n) == 0)
|
|
130
|
+
|
|
131
|
+
monkeypatch.setattr(Query, "entry_points", global_indegree)
|
|
132
|
+
assert "core.api.public_entry" not in repo.entry_points("core")
|
|
133
|
+
for depth in (2, 5, 50):
|
|
134
|
+
assert repo.flows_to("core.leaf.work", max_depth=depth)["flows"] == []
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_a_single_package_graph_is_unaffected(package_only):
|
|
138
|
+
"""The rule may not silently change the answer where no consumer root exists: with one
|
|
139
|
+
root, in-root in-degree *is* in-degree."""
|
|
140
|
+
q = package_only
|
|
141
|
+
old = sorted(n for n in q._calls.nodes if q.root_of(n) == "core"
|
|
142
|
+
and q._calls.out_degree(n) > 0 and q._calls.in_degree(n) == 0)
|
|
143
|
+
assert q.entry_points("core") == old
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# -- D8: an entry says what enters it ---------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def test_an_entry_carries_the_roots_that_enter_it(repo):
|
|
149
|
+
rep = repo.flows_to("core.leaf.work")
|
|
150
|
+
flow = next(f for f in rep["flows"] if f["entry"] == "core.api.public_entry")
|
|
151
|
+
assert flow["external_callers"] == {"usage": 2}
|
|
152
|
+
assert repo.external_callers("core.api.public_entry") == {"usage": 2}
|
|
153
|
+
md = render_impact(repo, "core.leaf.work")
|
|
154
|
+
assert "entered from usage ×2" in md
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_an_internal_head_has_no_external_callers(package_only):
|
|
158
|
+
assert package_only.external_callers("core.api.public_entry") == {}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# -- D9: the empty answer names its kind ------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def test_the_nearest_head_past_the_bound_is_named_and_actionable(repo):
|
|
164
|
+
rep = repo.flows_to("core.leaf.work", max_depth=1)
|
|
165
|
+
assert rep["flows"] == [] and rep["beyond_depth"] == 1 and rep["nearest_beyond"] == 2
|
|
166
|
+
md = render_impact(repo, "core.leaf.work", flow_depth=1)
|
|
167
|
+
assert "the nearest at step **2**" in md and "--flow-depth 2" in md
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_called_but_headless_is_not_nothing_calls_it(repo):
|
|
171
|
+
"""`ping`/`pong` call each other and `rung` calls `ping`; nothing reaches `pong` from
|
|
172
|
+
a head, because the chain closes in a cycle. That is not 'no call reaches it'."""
|
|
173
|
+
rep = repo.flows_to("core.ring.pong")
|
|
174
|
+
assert rep["in_call_graph"] is True and rep["inbound_calls"] >= 1
|
|
175
|
+
md = render_impact(repo, "core.ring.pong")
|
|
176
|
+
if not rep["flows"]:
|
|
177
|
+
assert "closed in a call cycle" in md and "Not 'nothing calls it'" in md
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_the_note_warns_in_both_directions(repo):
|
|
181
|
+
md = render_impact(repo, "core.leaf.work")
|
|
182
|
+
assert "looking like an entry point" in md, "the over-estimate, as before"
|
|
183
|
+
assert "*removes* an entry point" in md, "and the reverse (#19)"
|
|
184
|
+
assert "**more** complete graph can answer with **fewer** flows" in md
|
|
185
|
+
assert "a use, not an internal caller" in md
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# -- R1-C51: a filter declares itself ---------------------------------------------------
|
|
189
|
+
|
|
190
|
+
def _handle(q, op, args):
|
|
191
|
+
return Session(q.graph).handle({"op": op, "args": args})
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@pytest.fixture(scope="module")
|
|
195
|
+
def registry():
|
|
196
|
+
from pathlib import Path
|
|
197
|
+
return Query(extract(Path(__file__).resolve().parent / "fixtures" / "dispatchpkg"))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_the_filter_block_is_present_even_with_no_filter(registry):
|
|
201
|
+
target = next(e.target for e in registry.graph.edges
|
|
202
|
+
if e.extras.get("resolution") == "registry-candidate")
|
|
203
|
+
env = _handle(registry, "callers", {"symbol": target})
|
|
204
|
+
f = env["filter"]
|
|
205
|
+
assert f["min_confidence"] is None and f["dropped"] == 0
|
|
206
|
+
assert f["returned"] == f["total"] == len(env["result"])
|
|
207
|
+
assert f["by_grade"], "the composition is stated before anyone filters"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_an_empty_filtered_answer_says_what_it_dropped(registry):
|
|
211
|
+
"""The reported shape — theirs was a strategy method whose *every* caller is a
|
|
212
|
+
registry fan-out, so `exact` answers `[]`. The block turns that into 'called, but
|
|
213
|
+
only through routes you excluded', which a bare list cannot say."""
|
|
214
|
+
target = next(t for t in sorted({e.target for e in registry.graph.edges
|
|
215
|
+
if e.extras.get("resolution") == "registry-candidate"})
|
|
216
|
+
if set(registry.caller_grades(t)) == {"heuristic"})
|
|
217
|
+
env = _handle(registry, "callers", {"symbol": target, "min_confidence": "exact"})
|
|
218
|
+
f = env["filter"]
|
|
219
|
+
assert env["result"] == [] and f["returned"] == 0
|
|
220
|
+
assert f["total"] > 0 and f["dropped"] == f["total"]
|
|
221
|
+
assert f["by_grade"].get("heuristic") == f["total"]
|
|
222
|
+
assert f["min_confidence"] == "exact"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_callees_declares_it_too(registry):
|
|
226
|
+
src = next(e.source for e in registry.graph.edges
|
|
227
|
+
if e.extras.get("resolution") == "registry-candidate")
|
|
228
|
+
env = _handle(registry, "callees", {"symbol": src, "min_confidence": "exact"})
|
|
229
|
+
assert env["filter"]["dropped"] >= 1 and env["filter"]["by_grade"]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_the_grade_histogram_matches_the_edges(registry):
|
|
233
|
+
for target in {e.target for e in registry.graph.edges if e.type == "calls"}:
|
|
234
|
+
counted: dict[str, int] = {}
|
|
235
|
+
for e in registry.graph.edges:
|
|
236
|
+
if e.type == "calls" and e.target == target:
|
|
237
|
+
from codemap.model import confidence_of
|
|
238
|
+
g = confidence_of(e) or "unknown"
|
|
239
|
+
counted[g] = counted.get(g, 0) + 1
|
|
240
|
+
got = registry.caller_grades(target)
|
|
241
|
+
# the graph edge collapses parallel resolutions onto one pair, keeping the
|
|
242
|
+
# strongest, so the histogram counts pairs — never more than the raw edges.
|
|
243
|
+
assert sum(got.values()) <= sum(counted.values())
|
|
244
|
+
assert set(got) <= set(counted)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# -- R1-C52: the diff judges the package, and says so -----------------------------------
|
|
248
|
+
|
|
249
|
+
def test_a_consumers_public_function_is_not_this_packages_api(repo, tmp_path):
|
|
250
|
+
core, usage = _repo(tmp_path)
|
|
251
|
+
(usage / "script.py").write_text(USAGE + "\n\ndef brand_new_helper():\n return 1\n")
|
|
252
|
+
new = extract_repo(core, consumers=(usage,), mode="full")
|
|
253
|
+
d = apidiff.diff_api(repo.graph, new)
|
|
254
|
+
assert d.added == [], "a new public test/consumer function is not an API addition"
|
|
255
|
+
assert d.excluded.get("usage", 0) >= 3, "and the count of what was not judged is stated"
|
|
256
|
+
assert d.root == "core"
|
|
257
|
+
everything = apidiff.diff_api(repo.graph, new, root=None)
|
|
258
|
+
assert "usage.script.brand_new_helper" in everything.added, "asked for, and there"
|
|
259
|
+
assert everything.excluded == {}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_the_package_api_still_diffs(repo, tmp_path):
|
|
263
|
+
core, usage = _repo(tmp_path)
|
|
264
|
+
(core / "api.py").write_text(CORE_API + "\n\ndef second_entry(y):\n return y\n")
|
|
265
|
+
new = extract_repo(core, consumers=(usage,), mode="full")
|
|
266
|
+
d = apidiff.diff_api(repo.graph, new)
|
|
267
|
+
assert d.added == ["core.api.second_entry"]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_the_report_names_what_it_did_not_judge(repo, tmp_path):
|
|
271
|
+
core, usage = _repo(tmp_path)
|
|
272
|
+
(usage / "script.py").write_text(USAGE + "\n\ndef brand_new_helper():\n return 1\n")
|
|
273
|
+
new = extract_repo(core, consumers=(usage,), mode="full")
|
|
274
|
+
md = render_apidiff(repo.graph, new)
|
|
275
|
+
assert "Compared: public symbols of root `core`" in md
|
|
276
|
+
assert "Not judged:" in md and "`usage`" in md
|
|
277
|
+
assert build_apidiff(repo.graph, new)["excluded"]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_a_single_root_diff_says_the_scope_without_an_exclusion_list(package_only, tmp_path):
|
|
281
|
+
core, _ = _repo(tmp_path)
|
|
282
|
+
(core / "api.py").write_text(CORE_API + "\n\ndef second_entry(y):\n return y\n")
|
|
283
|
+
md = render_apidiff(package_only.graph, extract(core))
|
|
284
|
+
assert "Compared: public symbols of root `core` — the package._" in md
|
|
285
|
+
assert "Not judged:" not in md
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|