codmap 0.0.17__tar.gz → 0.0.18__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.17 → codmap-0.0.18}/PKG-INFO +2 -2
- {codmap-0.0.17 → codmap-0.0.18}/README.md +1 -1
- {codmap-0.0.17 → codmap-0.0.18}/codemap/query.py +24 -4
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/mermaid.py +13 -1
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/session.py +106 -8
- {codmap-0.0.17 → codmap-0.0.18}/codmap.egg-info/PKG-INFO +2 -2
- {codmap-0.0.17 → codmap-0.0.18}/codmap.egg-info/SOURCES.txt +2 -0
- {codmap-0.0.17 → codmap-0.0.18}/pyproject.toml +1 -1
- codmap-0.0.18/tests/test_r1c53_narrowing_declared.py +199 -0
- codmap-0.0.18/tests/test_r1c54_deterministic_rendering.py +131 -0
- {codmap-0.0.17 → codmap-0.0.18}/LICENSE +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/__init__.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/apidiff.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/arch.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/cli.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/diagnostics.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/extract/behavior.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/extract/griffe_extractor.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/extract/roots.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/extract/union.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/freshness.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/incremental.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/integrations/base.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/model.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/provenance.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/scope.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/architecture.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/audit.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/check.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/impact.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/limits.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/livingdocs.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/mcp_server.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/pack.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/rag.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/review.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/scip.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/server.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/serve/vault.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/store.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/tomlio.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codemap/watch.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/setup.cfg +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_epistemic.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_integrations.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_m15_review.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_m16_architecture.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_m1_query.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_m2_views.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_m9_family.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c29_lazy_imports.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c30f2_check_scope.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c38_tool_drift.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c39_edge_resolution.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c40_flow_reach.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c41_scope_membership.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c42_deep_tier_stability.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c43_incremental_splice.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c44_absent_answers.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c45_repeat_union.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c46_shadowed_definitions.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c47_incremental_resample.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c48_type_checking_imports.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c49_type_only_cycles.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c50_flow_entry_points.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_scip_export.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_scope.py +0 -0
- {codmap-0.0.17 → codmap-0.0.18}/tests/test_subsystems.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codmap
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.18
|
|
4
4
|
Summary: Static analyzer that turns a package's source into a queryable code graph.
|
|
5
5
|
Author-email: kogriv <kogriv@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -44,7 +44,7 @@ and a **SCIP index** for interop with Sourcegraph / Glean and other precise-code
|
|
|
44
44
|
|
|
45
45
|
[](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
|
|
46
46
|
|
|
47
|
-
**Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **
|
|
47
|
+
**Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **938 tests with no failures on
|
|
48
48
|
Python 3.11–3.14** ([in CI](docs/ci.md): the full suite including the dogfood pass, a determinism check, a
|
|
49
49
|
wheel smoke test, and ctags/SCIP interop against the real CLIs), warm serve surface with 31 ops (28 exposed
|
|
50
50
|
as MCP tools), and SCIP export. See **[DESIGN.md](DESIGN.md)** (product design &
|
|
@@ -8,7 +8,7 @@ and a **SCIP index** for interop with Sourcegraph / Glean and other precise-code
|
|
|
8
8
|
|
|
9
9
|
[](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
|
|
10
10
|
|
|
11
|
-
**Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **
|
|
11
|
+
**Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **938 tests with no failures on
|
|
12
12
|
Python 3.11–3.14** ([in CI](docs/ci.md): the full suite including the dogfood pass, a determinism check, a
|
|
13
13
|
wheel smoke test, and ctags/SCIP interop against the real CLIs), warm serve surface with 31 ops (28 exposed
|
|
14
14
|
as MCP tools), and SCIP export. See **[DESIGN.md](DESIGN.md)** (product design &
|
|
@@ -21,6 +21,25 @@ def _grade_rank(grade: str | None) -> int:
|
|
|
21
21
|
return CONFIDENCE_ORDER.index(grade) if grade in CONFIDENCE_ORDER else len(CONFIDENCE_ORDER)
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
def _canonical_cycles(cycles) -> list[list[str]]:
|
|
25
|
+
"""Cycles in a stable form: each rotated to start at its smallest node, then sorted.
|
|
26
|
+
|
|
27
|
+
R1-C54, reported by the lab as [issue #20](https://github.com/kogriv/codemap/issues/20).
|
|
28
|
+
``nx.simple_cycles`` yields a cycle starting wherever its traversal happened to enter
|
|
29
|
+
it, and that traversal follows set-iteration order, i.e. string hashes. The chain is
|
|
30
|
+
the *same cycle* either way — but three consumers print it, and they printed three
|
|
31
|
+
different texts for one graph across eight hash seeds.
|
|
32
|
+
|
|
33
|
+
The sort afterwards is the point of the rotation: ``arch.py`` already sorted cycles by
|
|
34
|
+
``(len, c)`` and that looked like canonicalisation, except the key itself moved with
|
|
35
|
+
the rotation — ``["a","b"]`` and ``["b","a"]`` are one cycle and two keys. Rotating
|
|
36
|
+
first makes the existing sort mean what it appeared to mean.
|
|
37
|
+
"""
|
|
38
|
+
out = [c[i:] + c[:i] for c in (list(x) for x in cycles)
|
|
39
|
+
if (i := c.index(min(c))) >= 0]
|
|
40
|
+
return sorted(out, key=lambda c: (len(c), c))
|
|
41
|
+
|
|
42
|
+
|
|
24
43
|
def _check_grade(min_confidence: str | None) -> None:
|
|
25
44
|
if min_confidence is not None and min_confidence not in CONFIDENCE_ORDER:
|
|
26
45
|
raise ValueError(f"min_confidence must be one of {CONFIDENCE_ORDER}, "
|
|
@@ -1069,7 +1088,7 @@ class Query:
|
|
|
1069
1088
|
would report someone's fix as their bug. Those cycles are still real coupling
|
|
1070
1089
|
and are returned by :meth:`lazy_import_cycles`.
|
|
1071
1090
|
"""
|
|
1072
|
-
return
|
|
1091
|
+
return _canonical_cycles(nx.simple_cycles(self._imports_eager))
|
|
1073
1092
|
|
|
1074
1093
|
def lazy_import_cycles(self) -> list[list[str]]:
|
|
1075
1094
|
"""Dependency cycles that close **only** through a function-local import.
|
|
@@ -1087,8 +1106,8 @@ class Query:
|
|
|
1087
1106
|
them, or a tree could hide real coupling by adding one.
|
|
1088
1107
|
"""
|
|
1089
1108
|
eager = {frozenset(c) for c in nx.simple_cycles(self._imports_eager)}
|
|
1090
|
-
return
|
|
1091
|
-
|
|
1109
|
+
return _canonical_cycles(c for c in nx.simple_cycles(self._imports_runtime)
|
|
1110
|
+
if frozenset(c) not in eager)
|
|
1092
1111
|
|
|
1093
1112
|
def type_only_import_cycles(self) -> list[list[str]]:
|
|
1094
1113
|
"""Dependency cycles that close **only** with an import under ``if TYPE_CHECKING:``.
|
|
@@ -1100,7 +1119,8 @@ class Query:
|
|
|
1100
1119
|
opt-in rule instead.
|
|
1101
1120
|
"""
|
|
1102
1121
|
runtime = {frozenset(c) for c in nx.simple_cycles(self._imports_runtime)}
|
|
1103
|
-
return
|
|
1122
|
+
return _canonical_cycles(c for c in nx.simple_cycles(self._imports)
|
|
1123
|
+
if frozenset(c) not in runtime)
|
|
1104
1124
|
|
|
1105
1125
|
def import_map(self) -> dict:
|
|
1106
1126
|
"""How much of the import graph each scope contributed (R1-C29).
|
|
@@ -117,4 +117,16 @@ def render_mermaid(query: Query, kind: str, scope: str | None = None,
|
|
|
117
117
|
return render_call_graph(query, root, depth)
|
|
118
118
|
if kind not in _KINDS:
|
|
119
119
|
raise ValueError(f"unknown mermaid kind: {kind}")
|
|
120
|
-
|
|
120
|
+
out = _KINDS[kind](query, scope)
|
|
121
|
+
if scope:
|
|
122
|
+
# R1-C53: `--scope` cut this diagram from 144 lines to 47 on the dogfood tree with
|
|
123
|
+
# no marker of any kind, so the picture read as "the class diagram of this package".
|
|
124
|
+
# A diagram is an answer too. Mermaid ignores `%%` lines, so the note costs nothing
|
|
125
|
+
# rendered and is visible in the source a reader diffs or pastes.
|
|
126
|
+
hidden = len(_KINDS[kind](query, None).splitlines()) - len(out.splitlines())
|
|
127
|
+
note = (f"%% scope: {scope} — {hidden} further line(s) exist in the unscoped "
|
|
128
|
+
"diagram of this graph")
|
|
129
|
+
lines = out.splitlines()
|
|
130
|
+
at = 1 if lines and lines[0].startswith("```") else 0
|
|
131
|
+
out = "\n".join(lines[:at] + [note] + lines[at:]) + "\n"
|
|
132
|
+
return out
|
|
@@ -50,6 +50,15 @@ _REPORTS = {
|
|
|
50
50
|
# jedi sample (R1-C43).
|
|
51
51
|
# `accessors` used to be in neither — it reads `accesses`, modelled best-effort, and
|
|
52
52
|
# carried no `epistemic` at all (found by the R1-C44 measurement).
|
|
53
|
+
# R1-C53 added the three column ops: they read `reads`/`writes`, which are an **over-set**
|
|
54
|
+
# rather than a lower bound — a different partiality, so it gets its own reason below.
|
|
55
|
+
#
|
|
56
|
+
# `query` was considered here and deliberately left out. Its dossier *does* carry
|
|
57
|
+
# call-derived fields, but most of it — where the symbol is defined, its signature, its
|
|
58
|
+
# file — is exact, and one envelope-wide "this is a lower bound" would say the definition
|
|
59
|
+
# is uncertain too. The rule this file already states is "absence of the label means
|
|
60
|
+
# exact", so a **mixed** answer declares per field (see `_op_query`), not per envelope.
|
|
61
|
+
# Over-claiming partiality is the same defect as hiding it, pointed the other way.
|
|
53
62
|
_OP_EDGE_CLASSES = {
|
|
54
63
|
"callers": frozenset({"calls"}),
|
|
55
64
|
"callees": frozenset({"calls"}),
|
|
@@ -59,10 +68,19 @@ _OP_EDGE_CLASSES = {
|
|
|
59
68
|
"accessors": frozenset({"accesses"}),
|
|
60
69
|
"tests": frozenset({"calls", "references"}),
|
|
61
70
|
"covers": frozenset({"calls", "references"}),
|
|
71
|
+
"column": frozenset({"reads", "writes"}),
|
|
72
|
+
"columns": frozenset({"reads", "writes"}),
|
|
73
|
+
"columns_of": frozenset({"reads", "writes"}),
|
|
62
74
|
}
|
|
63
75
|
_BEST_EFFORT_CLASSES = frozenset({"calls", "accesses"})
|
|
76
|
+
#: Classes that are an over-set, not a lower bound: a literal subscript key cannot be told
|
|
77
|
+
#: from a dict key, so the column set contains more than the DataFrame columns (F15/M12).
|
|
78
|
+
#: The opposite error from `_BEST_EFFORT_CLASSES`, and it must not borrow its wording.
|
|
79
|
+
_OVER_SET_CLASSES = frozenset({"reads", "writes"})
|
|
64
80
|
_PARTIAL_OPS = frozenset(op for op, cls in _OP_EDGE_CLASSES.items()
|
|
65
|
-
if cls & _BEST_EFFORT_CLASSES)
|
|
81
|
+
if cls & (_BEST_EFFORT_CLASSES | _OVER_SET_CLASSES))
|
|
82
|
+
_OVER_SET_OPS = frozenset(op for op, cls in _OP_EDGE_CLASSES.items()
|
|
83
|
+
if cls & _OVER_SET_CLASSES)
|
|
66
84
|
_SPLICE_OPS = frozenset(op for op, cls in _OP_EDGE_CLASSES.items()
|
|
67
85
|
if cls & _SPLICED_EDGE_TYPES)
|
|
68
86
|
_EPISTEMIC_PARTIAL = {
|
|
@@ -70,6 +88,15 @@ _EPISTEMIC_PARTIAL = {
|
|
|
70
88
|
"reason": "leans on static resolution of calls and attribute accesses (partial "
|
|
71
89
|
"for Python) — a lower bound; pair with grep/tests before acting.",
|
|
72
90
|
}
|
|
91
|
+
# R1-C53: the over-set is partial in the *other* direction, and saying "a lower bound"
|
|
92
|
+
# about it would be worse than saying nothing. A caller that trims by this set would trim
|
|
93
|
+
# too little, not too much.
|
|
94
|
+
_EPISTEMIC_OVER_SET = {
|
|
95
|
+
"epistemic": "partial",
|
|
96
|
+
"reason": "string-keyed dataflow is an **over-set**, not a lower bound: a literal "
|
|
97
|
+
"subscript key cannot be told from a dict key, so this set contains more "
|
|
98
|
+
"than the DataFrame columns. Confirm a key before acting on it.",
|
|
99
|
+
}
|
|
73
100
|
# D4 — the second reason, named separately: "a lower bound by static resolution" and
|
|
74
101
|
# "part of this graph was carried over, not recomputed" are different facts.
|
|
75
102
|
_EPISTEMIC_SPLICE = (
|
|
@@ -118,6 +145,24 @@ _UNLIMITED_BY_DESIGN = {
|
|
|
118
145
|
}
|
|
119
146
|
|
|
120
147
|
|
|
148
|
+
def _root_scope(query: Query, root: str, judged: list) -> dict:
|
|
149
|
+
"""The `scope` block: which provenance root the answer covers, and what it leaves out.
|
|
150
|
+
|
|
151
|
+
R1-C53. `communities` judges the package and is silent about it; on a repo-scoped graph
|
|
152
|
+
a caller reads "9 subsystems" without learning that `tests` was never considered. Same
|
|
153
|
+
shape as `check`'s "not judged" line and `diff`'s root filter, now stated in one place.
|
|
154
|
+
"""
|
|
155
|
+
others: dict[str, int] = {}
|
|
156
|
+
for n in query.graph.nodes.values():
|
|
157
|
+
if n.kind != "module":
|
|
158
|
+
continue
|
|
159
|
+
r = query.root_of(n.id)
|
|
160
|
+
if r != root:
|
|
161
|
+
others[r] = others.get(r, 0) + 1
|
|
162
|
+
return {"root": root, "judged": len(judged),
|
|
163
|
+
"not_judged": dict(sorted(others.items()))}
|
|
164
|
+
|
|
165
|
+
|
|
121
166
|
def _grade_filter(min_confidence, every: list, kept: list, grades: dict) -> dict:
|
|
122
167
|
"""The `filter` block: what the route-grade filter kept, dropped, and out of what.
|
|
123
168
|
|
|
@@ -257,6 +302,17 @@ def build_query_result(q: Query, name: str) -> dict:
|
|
|
257
302
|
used_by[n.id] = by_root
|
|
258
303
|
if used_by:
|
|
259
304
|
result["used_by"] = used_by
|
|
305
|
+
# R1-C53: the dossier is *mixed* — `defined_at`, `matches` and the signatures
|
|
306
|
+
# are exact, while `used_by` comes from the same best-effort call/reference
|
|
307
|
+
# layer that makes `callers` a lower bound. An envelope-wide `epistemic` would
|
|
308
|
+
# have said the definition is uncertain too, and over-claiming partiality is
|
|
309
|
+
# the same defect as hiding it. So it is declared on the field, here — where
|
|
310
|
+
# the field is built — so the CLI dossier carries it as well as the op.
|
|
311
|
+
result["used_by_epistemic"] = (
|
|
312
|
+
"lower bound — `used_by` comes from statically resolved calls and "
|
|
313
|
+
"references (partial for Python); `defined_at` and the signatures are "
|
|
314
|
+
"exact. `callers`/`impact` carry the same caveat for a whole answer."
|
|
315
|
+
)
|
|
260
316
|
col = q.column(name)
|
|
261
317
|
if col and (col["writes"] or col["reads"]):
|
|
262
318
|
result["column"] = col
|
|
@@ -334,6 +390,7 @@ class Session:
|
|
|
334
390
|
self._resolution = None
|
|
335
391
|
self._limit = None
|
|
336
392
|
self._filter = None
|
|
393
|
+
self._scope = None
|
|
337
394
|
try:
|
|
338
395
|
env = {"ok": True, "result": fn(self, args)}
|
|
339
396
|
except Exception as exc: # a bad arg must not kill the resident process
|
|
@@ -359,6 +416,12 @@ class Session:
|
|
|
359
416
|
# "this build does not report filtering".
|
|
360
417
|
if self._filter is not None:
|
|
361
418
|
env["filter"] = self._filter
|
|
419
|
+
# R1-C53: the third class. A `scope` narrowing is not a cut of a computed list and
|
|
420
|
+
# not a predicate over it — it is the answer covering **one provenance root** while
|
|
421
|
+
# the graph holds several, which is how `diff` came to count 40 test functions as
|
|
422
|
+
# API and how `communities` judges only the package while saying nothing.
|
|
423
|
+
if self._scope is not None:
|
|
424
|
+
env["scope"] = self._scope
|
|
362
425
|
return env
|
|
363
426
|
|
|
364
427
|
def _epistemic(self, op: str) -> dict:
|
|
@@ -368,7 +431,7 @@ class Session:
|
|
|
368
431
|
sample, and only an op reading a spliced class reads it. On a full deep graph
|
|
369
432
|
and on the fast tier the block is exactly the R1-C13 one.
|
|
370
433
|
"""
|
|
371
|
-
block = dict(_EPISTEMIC_PARTIAL)
|
|
434
|
+
block = dict(_EPISTEMIC_OVER_SET if op in _OVER_SET_OPS else _EPISTEMIC_PARTIAL)
|
|
372
435
|
prov = self.graph.provenance or {}
|
|
373
436
|
if op in _SPLICE_OPS and prov.get("tier") == "deep":
|
|
374
437
|
if prov.get("incremental") is True:
|
|
@@ -467,7 +530,7 @@ class Session:
|
|
|
467
530
|
result = build_query_result(self.query, args["name"])
|
|
468
531
|
if not result["matches"]: # R1-C44: an empty dossier says why it is empty
|
|
469
532
|
self._resolution = _not_found(args["name"])
|
|
470
|
-
return result
|
|
533
|
+
return result # the mixed-answer note rides on the field (see build_query_result)
|
|
471
534
|
|
|
472
535
|
def _op_impact(self, args) -> dict:
|
|
473
536
|
sym = args["symbol"]
|
|
@@ -512,7 +575,18 @@ class Session:
|
|
|
512
575
|
def _op_columns(self, args) -> list:
|
|
513
576
|
# F15: default to subscript-accessed keys (the real column-like set);
|
|
514
577
|
# pass all=true for the full over-set incl. dict-literal payload keys.
|
|
515
|
-
|
|
578
|
+
narrow = not args.get("all", False)
|
|
579
|
+
kept = self.query.columns(subscripted_only=narrow)
|
|
580
|
+
# R1-C53: the default hides the larger half and said nothing — measured on the
|
|
581
|
+
# dogfood tree, 331 of 1057. A deliberate narrowing still has to be declared, or
|
|
582
|
+
# the caller reads 331 as the answer to "which columns exist".
|
|
583
|
+
every = self.query.columns(subscripted_only=False) if narrow else kept
|
|
584
|
+
self._filter = {"basis": "subscripted_only" if narrow else None,
|
|
585
|
+
"returned": len(kept), "total": len(every),
|
|
586
|
+
"dropped": len(every) - len(kept),
|
|
587
|
+
"note": "a key reached only through a dict literal is not a "
|
|
588
|
+
"subscript access; pass all=true for the full over-set"}
|
|
589
|
+
return kept
|
|
516
590
|
|
|
517
591
|
def _op_columns_of(self, args) -> dict:
|
|
518
592
|
return self.query.columns_of(self._canon(args["symbol"]))
|
|
@@ -615,14 +689,38 @@ class Session:
|
|
|
615
689
|
return build_check(self.query, contract, check_contract(self.query, contract))
|
|
616
690
|
|
|
617
691
|
def _op_communities(self, args) -> list:
|
|
618
|
-
"""R1-C18: data-driven module subsystems (greedy modularity).
|
|
619
|
-
|
|
692
|
+
"""R1-C18: data-driven module subsystems (greedy modularity).
|
|
693
|
+
|
|
694
|
+
R1-C53: subsystems are computed over the **package** only — a consumer root is not
|
|
695
|
+
a subsystem of it — and that was true in the code and absent from the answer.
|
|
696
|
+
"""
|
|
697
|
+
out = self.query.communities()
|
|
698
|
+
self._scope = _root_scope(self.query, "core",
|
|
699
|
+
[m for c in out for m in c["modules"]])
|
|
700
|
+
return out
|
|
620
701
|
|
|
621
702
|
def _op_flows(self, args) -> dict:
|
|
622
|
-
"""R1-C18: forward call-flow from a symbol, or entry points if none given.
|
|
703
|
+
"""R1-C18: forward call-flow from a symbol, or entry points if none given.
|
|
704
|
+
|
|
705
|
+
R1-C53: the entry-point list is shaped by a *definition* — "calls out, and nothing
|
|
706
|
+
in its own root calls it" — which R1-C50 had to change after it disqualified a
|
|
707
|
+
library's public API for being used. A definition that can be wrong for a target
|
|
708
|
+
belongs in the answer, not only in a docstring.
|
|
709
|
+
"""
|
|
623
710
|
sym = args.get("symbol")
|
|
624
711
|
if not sym:
|
|
625
|
-
|
|
712
|
+
entries = self.query.entry_points()
|
|
713
|
+
self._scope = _root_scope(self.query, "core", entries)
|
|
714
|
+
return {
|
|
715
|
+
"entry_points": entries,
|
|
716
|
+
"definition": "a function that calls out and is called by nothing in its "
|
|
717
|
+
"own provenance root; a call from `tests`/`examples` is a "
|
|
718
|
+
"use, not an internal caller (R1-C50)",
|
|
719
|
+
"best_effort_both_ways": "an unresolved caller leaves a real internal "
|
|
720
|
+
"looking like an entry point, and resolving one "
|
|
721
|
+
"removes an entry point — so a more complete "
|
|
722
|
+
"graph can list fewer",
|
|
723
|
+
}
|
|
626
724
|
return self.query.flow(self._canon(sym), max_depth=int(args.get("depth", 5)))
|
|
627
725
|
|
|
628
726
|
def _op_semantic(self, args) -> dict:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codmap
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.18
|
|
4
4
|
Summary: Static analyzer that turns a package's source into a queryable code graph.
|
|
5
5
|
Author-email: kogriv <kogriv@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -44,7 +44,7 @@ and a **SCIP index** for interop with Sourcegraph / Glean and other precise-code
|
|
|
44
44
|
|
|
45
45
|
[](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
|
|
46
46
|
|
|
47
|
-
**Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **
|
|
47
|
+
**Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **938 tests with no failures on
|
|
48
48
|
Python 3.11–3.14** ([in CI](docs/ci.md): the full suite including the dogfood pass, a determinism check, a
|
|
49
49
|
wheel smoke test, and ctags/SCIP interop against the real CLIs), warm serve surface with 31 ops (28 exposed
|
|
50
50
|
as MCP tools), and SCIP export. See **[DESIGN.md](DESIGN.md)** (product design &
|
|
@@ -122,6 +122,8 @@ tests/test_r1c48_type_checking_imports.py
|
|
|
122
122
|
tests/test_r1c49_type_only_cycles.py
|
|
123
123
|
tests/test_r1c4_complexity.py
|
|
124
124
|
tests/test_r1c50_flow_entry_points.py
|
|
125
|
+
tests/test_r1c53_narrowing_declared.py
|
|
126
|
+
tests/test_r1c54_deterministic_rendering.py
|
|
125
127
|
tests/test_r1c5_apidiff.py
|
|
126
128
|
tests/test_r1c6_pack.py
|
|
127
129
|
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.18"
|
|
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
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""R1-C53 — every narrowing declares itself, and the classes are enumerated.
|
|
2
|
+
|
|
3
|
+
Not a bug report: a **count**. Eight of the twelve fixes R1-C41…R1-C52 were one defect —
|
|
4
|
+
an answer narrower than it looks, silent about it — and four of the last five were found by
|
|
5
|
+
a consumer rather than by us. So this file is about the mechanism, not the instances.
|
|
6
|
+
|
|
7
|
+
Seven classes of narrowing, and what declares each:
|
|
8
|
+
|
|
9
|
+
| class | what narrows | declared by |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `limit` | a computed list is cut | `limit` block, always (R1-C28) |
|
|
12
|
+
| `filter` | a predicate drops entries | `filter` block, always (R1-C51, R1-C53) |
|
|
13
|
+
| `scope` | one provenance root is judged | `scope` block (R1-C53), `diff`'s `excluded` (R1-C52) |
|
|
14
|
+
| `bound` | a walk stops at a depth | in-result (`by_distance`, `beyond_depth`, `nearest_beyond`) |
|
|
15
|
+
| `tier` | the answer is a lower bound | `epistemic` (R1-C13), splice reason (R1-C43) |
|
|
16
|
+
| `edge-class` | only some edge classes are read | `epistemic`, and the over-set variant (R1-C53) |
|
|
17
|
+
| `definition` | the answer's shape depends on a definition that may not fit | in-result (`definition`) |
|
|
18
|
+
|
|
19
|
+
The two tests that matter in a year are the guards at the bottom: one fails when an op
|
|
20
|
+
learns a narrowing argument without joining a class, the other when a class's declaration
|
|
21
|
+
stops being emitted. Everything above them is the evidence that each class is real.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import inspect
|
|
27
|
+
import re
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import pytest
|
|
31
|
+
|
|
32
|
+
from codemap.extract import extract, extract_repo
|
|
33
|
+
from codemap.query import Query
|
|
34
|
+
from codemap.serve import session as session_mod
|
|
35
|
+
from codemap.serve.mermaid import render_mermaid
|
|
36
|
+
from codemap.serve.session import (_LIMITED_OPS, _OVER_SET_OPS, _PARTIAL_OPS,
|
|
37
|
+
_UNLIMITED_BY_DESIGN, Session)
|
|
38
|
+
|
|
39
|
+
FIX = Path(__file__).resolve().parent / "fixtures"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.fixture(scope="module")
|
|
43
|
+
def flowgraph():
|
|
44
|
+
return Query(extract(FIX / "flowpkg"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture(scope="module")
|
|
48
|
+
def repo():
|
|
49
|
+
return Query(extract_repo(FIX / "reporoot" / "core",
|
|
50
|
+
consumers=(FIX / "reporoot" / "usage",),
|
|
51
|
+
docs=(FIX / "reporoot" / "docs",), mode="full"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _handle(q, op, args=None):
|
|
55
|
+
return Session(q.graph).handle({"op": op, "args": args or {}})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# -- filter: the narrowing that hid the larger half -------------------------------------
|
|
59
|
+
|
|
60
|
+
def test_columns_declares_the_half_it_hides(flowgraph):
|
|
61
|
+
"""Measured on the dogfood tree before the fix: 331 returned of 1057, and the answer
|
|
62
|
+
was a bare list. The default narrowing is deliberate (F15) and was undeclared, which
|
|
63
|
+
is a different thing from being wrong."""
|
|
64
|
+
env = _handle(flowgraph, "columns")
|
|
65
|
+
f = env["filter"]
|
|
66
|
+
assert f["basis"] == "subscripted_only"
|
|
67
|
+
assert f["returned"] == len(env["result"])
|
|
68
|
+
assert f["total"] >= f["returned"] and f["dropped"] == f["total"] - f["returned"]
|
|
69
|
+
wide = _handle(flowgraph, "columns", {"all": True})
|
|
70
|
+
assert wide["filter"]["basis"] is None and wide["filter"]["dropped"] == 0
|
|
71
|
+
assert len(wide["result"]) == f["total"], "and the two agree on the total"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_the_over_set_is_not_called_a_lower_bound(flowgraph):
|
|
75
|
+
"""`reads`/`writes` are partial in the *other* direction. Borrowing the lower-bound
|
|
76
|
+
wording would tell a caller to widen when it must verify instead."""
|
|
77
|
+
for op, args in (("columns", {}), ("columns_of", {"symbol": "flowpkg.producer.make"})):
|
|
78
|
+
env = _handle(flowgraph, op, args)
|
|
79
|
+
reason = env.get("epistemic", {}).get("reason", "")
|
|
80
|
+
assert "over-set" in reason, (op, reason)
|
|
81
|
+
assert "lower bound" not in reason.replace("not a lower bound", ""), op
|
|
82
|
+
assert _OVER_SET_OPS == {"column", "columns", "columns_of"}
|
|
83
|
+
assert _OVER_SET_OPS <= _PARTIAL_OPS, "an over-set is still a partiality"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# -- scope: one root judged, the others named -------------------------------------------
|
|
87
|
+
|
|
88
|
+
def test_communities_names_the_root_it_judges(repo):
|
|
89
|
+
env = _handle(repo, "communities")
|
|
90
|
+
s = env["scope"]
|
|
91
|
+
assert s["root"] == "core"
|
|
92
|
+
assert s["not_judged"] == {"usage": 1}, "the consumer root is named, not silently cut"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_entry_points_name_the_root_and_the_definition(repo):
|
|
96
|
+
env = _handle(repo, "flows")
|
|
97
|
+
assert env["scope"]["not_judged"] == {"usage": 1}
|
|
98
|
+
d = env["result"]
|
|
99
|
+
assert "use, not an internal caller" in d["definition"], "R1-C50, in the answer"
|
|
100
|
+
assert "fewer" in d["best_effort_both_ways"], "and the reverse direction too"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_a_single_root_graph_says_so_without_inventing_exclusions(flowgraph):
|
|
104
|
+
env = _handle(flowgraph, "communities")
|
|
105
|
+
assert env["scope"]["root"] == "core" and env["scope"]["not_judged"] == {}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# -- definition / mixed answers ----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def test_a_mixed_dossier_declares_per_field_not_per_envelope(flowgraph):
|
|
111
|
+
"""The rule this file does **not** break: absence of `epistemic` means exact. A dossier
|
|
112
|
+
whose definition is exact and whose `used_by` is a lower bound must not be stamped
|
|
113
|
+
wholesale — over-claiming partiality is the same defect pointed the other way."""
|
|
114
|
+
q = Query(extract(FIX / "argpkg"))
|
|
115
|
+
env = _handle(q, "query", {"name": "configure"})
|
|
116
|
+
assert env["result"]["used_by"], "the fixture must carry the field being declared"
|
|
117
|
+
assert "epistemic" not in env, "the dossier as a whole is not a lower bound"
|
|
118
|
+
assert "lower bound" in env["result"]["used_by_epistemic"]
|
|
119
|
+
assert "are exact" in env["result"]["used_by_epistemic"]
|
|
120
|
+
# and the note lives where the field is built, so the CLI dossier carries it too
|
|
121
|
+
from codemap.serve.session import build_query_result
|
|
122
|
+
assert "used_by_epistemic" in build_query_result(q, "configure")
|
|
123
|
+
assert "used_by_epistemic" not in build_query_result(q, "nope_missing")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_a_scoped_diagram_says_what_it_left_out(flowgraph):
|
|
127
|
+
scoped = render_mermaid(flowgraph, "class", scope="flowpkg.klass")
|
|
128
|
+
assert "%% scope: flowpkg.klass" in scoped and "further line(s) exist" in scoped
|
|
129
|
+
assert "%% scope" not in render_mermaid(flowgraph, "class")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# -- the guards --------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
_NARROWING_ARG = re.compile(
|
|
135
|
+
r"""args\.get\(\s*["'](?:\w+_)?(limit|cap|budget|max|top|min_confidence|all|scope|root)(?:_\w+)?["']""")
|
|
136
|
+
|
|
137
|
+
#: Ops that read a narrowing argument and declare it, with the block that does the
|
|
138
|
+
#: declaring. Hand-written on purpose: the guard below compares this list against the
|
|
139
|
+
#: source, so a new narrowing forces an entry here rather than passing unnoticed.
|
|
140
|
+
_DECLARED = {
|
|
141
|
+
"search": "limit", "semantic": "limit", "tests": "limit", "covers": "limit",
|
|
142
|
+
"callers": "filter", "callees": "filter", "columns": "filter",
|
|
143
|
+
"communities": "scope", "flows": "scope",
|
|
144
|
+
}
|
|
145
|
+
#: Read a narrowing-ish argument and deliberately declare nothing, with the reason.
|
|
146
|
+
_NOT_A_NARROWING = {
|
|
147
|
+
"check": "`root` says where codemap.toml is read from — it does not narrow the answer; "
|
|
148
|
+
"what the contract left unjudged is already declared by the R1-C30-f2 block",
|
|
149
|
+
"export": "`root`/`scope` select *which view* to render rather than cutting an answer, "
|
|
150
|
+
"and the scoped mermaid diagram carries its own `%% scope:` line (R1-C53)",
|
|
151
|
+
"report": "a markdown report declares its own narrowing in prose — dead-code prints "
|
|
152
|
+
"`(min-confidence: …)` and impact prints the flow note; there is no "
|
|
153
|
+
"structured answer here for a block to describe",
|
|
154
|
+
"pack": _UNLIMITED_BY_DESIGN["pack"],
|
|
155
|
+
"impact": _UNLIMITED_BY_DESIGN["impact"],
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_every_op_reading_a_narrowing_arg_is_classified():
|
|
160
|
+
"""Fails when an op learns a narrowing without joining a class. The list it checks is
|
|
161
|
+
the ops' own source, because a hand-kept list is what gets forgotten — and the two
|
|
162
|
+
hand-kept sets here exist so that "exempt, deliberately" is distinguishable from
|
|
163
|
+
"nobody noticed"."""
|
|
164
|
+
for op, fn in session_mod._OPS.items():
|
|
165
|
+
if not _NARROWING_ARG.search(inspect.getsource(fn)):
|
|
166
|
+
continue
|
|
167
|
+
assert op in _DECLARED or op in _NOT_A_NARROWING, (
|
|
168
|
+
f"op {op!r} reads a narrowing argument and neither declares it nor records "
|
|
169
|
+
f"why it does not")
|
|
170
|
+
for op, reason in _NOT_A_NARROWING.items():
|
|
171
|
+
assert op in session_mod._OPS and len(reason) > 40, op
|
|
172
|
+
assert set(_DECLARED) & _LIMITED_OPS == {"search", "semantic", "tests", "covers"}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_each_declared_op_actually_emits_its_block(flowgraph, repo):
|
|
176
|
+
calls = {
|
|
177
|
+
"search": (flowgraph, {"term": "hit"}), "tests": (flowgraph, {"symbol": "hit"}),
|
|
178
|
+
"covers": (flowgraph, {"test": "t"}),
|
|
179
|
+
"callers": (flowgraph, {"symbol": "flowpkg.leaf.hit"}),
|
|
180
|
+
"callees": (flowgraph, {"symbol": "flowpkg.entry.main"}),
|
|
181
|
+
"columns": (flowgraph, {}), "communities": (repo, {}), "flows": (repo, {}),
|
|
182
|
+
}
|
|
183
|
+
assert set(calls) | {"semantic"} == set(_DECLARED), "a declared op has no coverage here"
|
|
184
|
+
for op, (q, args) in calls.items():
|
|
185
|
+
env = _handle(q, op, args)
|
|
186
|
+
assert env["ok"], (op, env)
|
|
187
|
+
block = _DECLARED[op]
|
|
188
|
+
assert block in env, f"{op} promised a {block!r} block and emitted {sorted(env)}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_the_guard_catches_a_narrowing_name_it_has_not_seen():
|
|
192
|
+
"""R1-C37 on the guard itself: shown the shapes it must catch, including the ones that
|
|
193
|
+
slipped past earlier versions of the pattern."""
|
|
194
|
+
for src in ('args.get("min_confidence")', 'args.get("all", False)',
|
|
195
|
+
'args.get("flow_limit", 5)', "args.get('max_results')",
|
|
196
|
+
'args.get("scope")', 'args.get("root")'):
|
|
197
|
+
assert _NARROWING_ARG.search(src), src
|
|
198
|
+
for src in ('args.get("symbol")', 'args.get("depth", 2)', 'args.get("base")'):
|
|
199
|
+
assert not _NARROWING_ARG.search(src), f"{src} is not a narrowing of the answer"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""R1-C54 — the same graph renders the same text, whatever the hash seed.
|
|
2
|
+
|
|
3
|
+
[Issue #20](https://github.com/kogriv/codemap/issues/20), filed by the lab while raising
|
|
4
|
+
their pin 0.0.16 → 0.0.17. The graph was inert exactly as promised — 1904 nodes, 4652
|
|
5
|
+
edges, the whole JSON byte-identical but for `provenance.version` — and the *printed cycle
|
|
6
|
+
chains* differed between runs. They nearly recorded it as a behavioural change in the
|
|
7
|
+
release, and did not only because they re-measured within one version first.
|
|
8
|
+
|
|
9
|
+
`nx.simple_cycles` enters a cycle wherever its traversal happens to, and that follows
|
|
10
|
+
set-iteration order, i.e. string hashes. `arch.py` sorted the cycles by `(len, c)`, which
|
|
11
|
+
looked like canonicalisation but could not be: the key moves with the rotation.
|
|
12
|
+
|
|
13
|
+
Gap: `gaps/cycle_rotation_nondeterminism_2026-09-12.md`. The perimeter measured there is
|
|
14
|
+
wider than the report: three consumers of one source, one of them the **structured** MCP
|
|
15
|
+
`architecture` answer, while `report dependencies` and the living docs were already stable
|
|
16
|
+
because they print counts rather than chains.
|
|
17
|
+
|
|
18
|
+
The guard has to spawn processes — `PYTHONHASHSEED` is read once at interpreter start, so
|
|
19
|
+
an in-process monkeypatch cannot produce the condition. Its positive control is twofold:
|
|
20
|
+
the rotation unit test below fails if canonicalisation is removed, and the cross-seed test
|
|
21
|
+
was measured red on the pre-fix code (3 distinct outputs for each of three surfaces across
|
|
22
|
+
8 seeds — recorded in the gap with the numbers).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import textwrap
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
import pytest
|
|
34
|
+
|
|
35
|
+
from codemap import store
|
|
36
|
+
from codemap.extract import extract
|
|
37
|
+
from codemap.query import Query, _canonical_cycles
|
|
38
|
+
|
|
39
|
+
PKG = {
|
|
40
|
+
"__init__.py": "",
|
|
41
|
+
# one lazy cycle over three modules: a → b → c → a, each closed in a function
|
|
42
|
+
"a.py": "def f():\n from .b import g\n return g()\n",
|
|
43
|
+
"b.py": "def g():\n from .c import h\n return h()\n",
|
|
44
|
+
"c.py": "def h():\n from .a import f\n return f()\n",
|
|
45
|
+
# a second, shorter cycle so the *order of cycles* is exercised too
|
|
46
|
+
"d.py": "def p():\n from .e import q\n return q()\n",
|
|
47
|
+
"e.py": "def q():\n from .d import p\n return p()\n",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_PROBE = textwrap.dedent('''
|
|
51
|
+
import hashlib, json, sys
|
|
52
|
+
from codemap import arch, store
|
|
53
|
+
from codemap.query import Query
|
|
54
|
+
from codemap.serve.architecture import build_architecture, render_architecture
|
|
55
|
+
from codemap.serve.audit import render_dependencies
|
|
56
|
+
from codemap.serve.livingdocs import render_docs
|
|
57
|
+
|
|
58
|
+
q = Query(store.load(sys.argv[1]))
|
|
59
|
+
h = lambda s: hashlib.md5(s.encode()).hexdigest()
|
|
60
|
+
c = arch.ArchitectureContract(no_cycles=True, no_lazy_cycles=True,
|
|
61
|
+
no_type_only_cycles=True)
|
|
62
|
+
print(json.dumps({
|
|
63
|
+
"architecture_md": h(render_architecture(q)),
|
|
64
|
+
"architecture_json": h(json.dumps(build_architecture(q))),
|
|
65
|
+
"dependencies_md": h(render_dependencies(q)),
|
|
66
|
+
"docs_md": h(render_docs(q)),
|
|
67
|
+
"check": h(json.dumps([[v.rule, v.summary, list(v.modules)]
|
|
68
|
+
for v in arch.check_contract(q, c)])),
|
|
69
|
+
"graph_bytes": h(store.dumps(q.graph)),
|
|
70
|
+
"cycles": h(json.dumps(q.lazy_import_cycles())),
|
|
71
|
+
}))
|
|
72
|
+
''')
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.fixture(scope="module")
|
|
76
|
+
def graph_file(tmp_path_factory):
|
|
77
|
+
root = tmp_path_factory.mktemp("r1c54")
|
|
78
|
+
pkg = root / "cycpkg"
|
|
79
|
+
pkg.mkdir()
|
|
80
|
+
for name, body in PKG.items():
|
|
81
|
+
(pkg / name).write_text(body)
|
|
82
|
+
path = root / "g.json"
|
|
83
|
+
store.save(extract(pkg), path)
|
|
84
|
+
return path
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# -- the unit control: rotation in, one form out -----------------------------------------
|
|
88
|
+
|
|
89
|
+
def test_every_rotation_of_a_cycle_canonicalises_to_one_form():
|
|
90
|
+
rotations = [["a", "b", "c"], ["b", "c", "a"], ["c", "a", "b"]]
|
|
91
|
+
assert {tuple(_canonical_cycles([r])[0]) for r in rotations} == {("a", "b", "c")}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_the_order_of_cycles_is_canonical_too(graph_file):
|
|
95
|
+
"""D2: rotating without sorting would leave the *list* order on the traversal."""
|
|
96
|
+
q = Query(store.load(graph_file))
|
|
97
|
+
cycles = q.lazy_import_cycles()
|
|
98
|
+
assert cycles == sorted(cycles, key=lambda c: (len(c), c))
|
|
99
|
+
assert all(c[0] == min(c) for c in cycles), "each starts at its smallest node"
|
|
100
|
+
assert len(cycles) == 2, "the fixture carries both a 3-cycle and a 2-cycle"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_without_canonicalisation_the_rotations_stay_three_answers():
|
|
104
|
+
"""R1-C37: the thing the fix must reject, shown to the check. This is what all three
|
|
105
|
+
consumers were printing — one cycle, three texts."""
|
|
106
|
+
raw = [["b", "c", "a"], ["c", "a", "b"], ["a", "b", "c"]]
|
|
107
|
+
assert len({tuple(c) for c in raw}) == 3, "three renderings of one cycle"
|
|
108
|
+
assert len({tuple(c) for c in (_canonical_cycles([c])[0] for c in raw)}) == 1
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# -- the integration guard: across hash seeds, in separate processes ----------------------
|
|
112
|
+
|
|
113
|
+
def _run(graph_file, seed: int) -> dict:
|
|
114
|
+
probe = Path(graph_file).parent / "probe.py"
|
|
115
|
+
probe.write_text(_PROBE)
|
|
116
|
+
out = subprocess.run([sys.executable, str(probe), str(graph_file)],
|
|
117
|
+
capture_output=True, text=True, check=True,
|
|
118
|
+
env={"PYTHONHASHSEED": str(seed), "PATH": "/usr/bin:/bin",
|
|
119
|
+
"PYTHONPATH": str(Path(__file__).resolve().parents[1])})
|
|
120
|
+
return json.loads(out.stdout)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.mark.parametrize("surface", ["architecture_md", "architecture_json", "check",
|
|
124
|
+
"dependencies_md", "docs_md", "graph_bytes", "cycles"])
|
|
125
|
+
def test_one_graph_one_rendering_across_hash_seeds(graph_file, surface):
|
|
126
|
+
"""Six surfaces, eight seeds, one answer each. The three that were already stable are
|
|
127
|
+
in here on purpose: a regression in them must fail too, and leaving them out would make
|
|
128
|
+
this guard narrower than the claim it defends (README: "deterministic ... diffable").
|
|
129
|
+
"""
|
|
130
|
+
seen = {_run(graph_file, seed)[surface] for seed in range(8)}
|
|
131
|
+
assert len(seen) == 1, f"{surface} rendered {len(seen)} different ways across 8 seeds"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|