codmap 0.0.20__tar.gz → 0.0.21__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.20 → codmap-0.0.21}/PKG-INFO +2 -2
- {codmap-0.0.20 → codmap-0.0.21}/README.md +1 -1
- {codmap-0.0.20 → codmap-0.0.21}/codemap/arch.py +58 -1
- {codmap-0.0.20 → codmap-0.0.21}/codemap/cli.py +26 -9
- {codmap-0.0.20 → codmap-0.0.21}/codemap/model.py +18 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/check.py +54 -6
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/session.py +7 -2
- {codmap-0.0.20 → codmap-0.0.21}/codmap.egg-info/PKG-INFO +2 -2
- {codmap-0.0.20 → codmap-0.0.21}/codmap.egg-info/SOURCES.txt +2 -0
- {codmap-0.0.20 → codmap-0.0.21}/pyproject.toml +1 -1
- codmap-0.0.21/tests/test_r1c62_phantom_rules.py +167 -0
- codmap-0.0.21/tests/test_r1c63_answer_format.py +148 -0
- {codmap-0.0.20 → codmap-0.0.21}/LICENSE +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/__init__.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/apidiff.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/diagnostics.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/extract/behavior.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/extract/griffe_extractor.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/extract/roots.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/extract/union.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/freshness.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/incremental.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/integrations/base.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/provenance.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/query.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/scope.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/architecture.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/audit.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/impact.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/limits.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/livingdocs.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/mcp_server.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/pack.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/rag.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/review.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/scip.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/server.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/vault.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/store.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/tomlio.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codemap/watch.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/setup.cfg +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_epistemic.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_integrations.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_m15_review.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_m16_architecture.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_m1_query.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_m2_views.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_m9_family.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c29_lazy_imports.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c30f2_check_scope.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c38_tool_drift.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c39_edge_resolution.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c40_flow_reach.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c41_scope_membership.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c42_deep_tier_stability.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c43_incremental_splice.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c44_absent_answers.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c45_repeat_union.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c46_shadowed_definitions.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c47_incremental_resample.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c48_type_checking_imports.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c49_type_only_cycles.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c50_flow_entry_points.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c53_narrowing_declared.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c54_deterministic_rendering.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c55_override_grade.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c56_stub_files.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c57_facade_package.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c58_cycle_tangles.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_scip_export.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/tests/test_scope.py +0 -0
- {codmap-0.0.20 → codmap-0.0.21}/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.21
|
|
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, **990 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, **990 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 &
|
|
@@ -57,6 +57,11 @@ class ArchitectureContract:
|
|
|
57
57
|
no_lazy_cycles: bool = False
|
|
58
58
|
no_type_only_cycles: bool = False
|
|
59
59
|
exhaustive: bool = False
|
|
60
|
+
# R1-C62: the mirror of `exhaustive`. That one asks "is every layer of the code
|
|
61
|
+
# declared?"; this asks "does every declared name exist?". Opt-in, because a contract
|
|
62
|
+
# written ahead of the code is a documented, legitimate use (see the module docstring)
|
|
63
|
+
# and failing on it by default would turn that practice red on somebody else's tree.
|
|
64
|
+
no_phantom_rules: bool = False
|
|
60
65
|
error: str | None = None
|
|
61
66
|
# R1-C35: the file this contract was looked for in. "No contract found" is only
|
|
62
67
|
# actionable next to *where* we looked — a reader in the wrong directory cannot tell
|
|
@@ -66,7 +71,8 @@ class ArchitectureContract:
|
|
|
66
71
|
def is_empty(self) -> bool:
|
|
67
72
|
return not (self.layers or self.independent or self.forbidden
|
|
68
73
|
or self.no_cycles or self.no_lazy_cycles
|
|
69
|
-
or self.no_type_only_cycles or self.exhaustive
|
|
74
|
+
or self.no_type_only_cycles or self.exhaustive
|
|
75
|
+
or self.no_phantom_rules)
|
|
70
76
|
|
|
71
77
|
|
|
72
78
|
@dataclass(frozen=True)
|
|
@@ -117,9 +123,46 @@ def parse_contract(section: dict) -> ArchitectureContract:
|
|
|
117
123
|
no_lazy_cycles=bool(section.get("no_lazy_cycles", False)),
|
|
118
124
|
no_type_only_cycles=bool(section.get("no_type_only_cycles", False)),
|
|
119
125
|
exhaustive=bool(section.get("exhaustive", False)),
|
|
126
|
+
no_phantom_rules=bool(section.get("no_phantom_rules", False)),
|
|
120
127
|
)
|
|
121
128
|
|
|
122
129
|
|
|
130
|
+
def _graph_layers(query) -> set[str]:
|
|
131
|
+
"""Layer names that exist in the core import graph."""
|
|
132
|
+
return {query._layer_of(m) for m in query.import_graph.nodes
|
|
133
|
+
if query.root_of(m) == "core"}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def applicability(query, contract: ArchitectureContract) -> dict:
|
|
137
|
+
"""Per-rule *declared* vs *applicable* counts, plus the names that do not exist (R1-C62).
|
|
138
|
+
|
|
139
|
+
A rule naming a layer the graph does not contain is **inert by design** — no module, no
|
|
140
|
+
edge, nothing to break — which is what lets a contract be written ahead of the code. The
|
|
141
|
+
defect that made this function necessary is not the inertness: it is that the gate
|
|
142
|
+
counted such a rule as *enforced*, so a renamed layer left a green tick over a rule that
|
|
143
|
+
could no longer fire.
|
|
144
|
+
|
|
145
|
+
Applicability is presence of the **name**, nothing cleverer: `layers` needs the layer,
|
|
146
|
+
`independent` needs at least two members of the group (one cannot import itself), and
|
|
147
|
+
`forbidden` needs both ends. Whether a violation is even possible between two existing
|
|
148
|
+
layers is not asked, and should not be.
|
|
149
|
+
"""
|
|
150
|
+
present = _graph_layers(query)
|
|
151
|
+
named = set(contract.layers) | {m for grp in contract.independent for m in grp}
|
|
152
|
+
named |= {n for pair in contract.forbidden for n in pair}
|
|
153
|
+
return {
|
|
154
|
+
"layers": {"declared": len(contract.layers),
|
|
155
|
+
"applicable": sum(1 for x in contract.layers if x in present)},
|
|
156
|
+
"independent": {"declared": len(contract.independent),
|
|
157
|
+
"applicable": sum(1 for g in contract.independent
|
|
158
|
+
if len([x for x in g if x in present]) >= 2)},
|
|
159
|
+
"forbidden": {"declared": len(contract.forbidden),
|
|
160
|
+
"applicable": sum(1 for (a, b) in contract.forbidden
|
|
161
|
+
if a in present and b in present)},
|
|
162
|
+
"absent_names": tuple(sorted(n for n in named if n not in present)),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
123
166
|
def _core_layer_edges(query) -> list[tuple[str, str, str, str]]:
|
|
124
167
|
"""Cross-layer core→core import edges as (importer, imported, layer_i, layer_j)."""
|
|
125
168
|
ig = query.import_graph
|
|
@@ -266,4 +309,18 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
|
|
|
266
309
|
modules=tuple(undeclared),
|
|
267
310
|
))
|
|
268
311
|
|
|
312
|
+
# -- no_phantom_rules: every name the contract mentions must exist -----------
|
|
313
|
+
# R1-C62, the mirror of `exhaustive`. Off by default on purpose: inert rules are how a
|
|
314
|
+
# contract gets written ahead of the code. On, it catches the drift the count alone
|
|
315
|
+
# only *discloses* — a layer renamed while the contract kept guarding the old name.
|
|
316
|
+
if contract.no_phantom_rules:
|
|
317
|
+
absent = applicability(query, contract)["absent_names"]
|
|
318
|
+
if absent:
|
|
319
|
+
violations.append(Violation(
|
|
320
|
+
"no_phantom_rules",
|
|
321
|
+
f"{len(absent)} name(s) in the contract are absent from the graph, "
|
|
322
|
+
"so the rules naming them cannot apply",
|
|
323
|
+
modules=absent,
|
|
324
|
+
))
|
|
325
|
+
|
|
269
326
|
return violations
|
|
@@ -73,6 +73,23 @@ _REPORTS = {
|
|
|
73
73
|
_REPORT_KINDS = sorted(_REPORTS) + ["impact", "communities", "flows"] # extra args
|
|
74
74
|
|
|
75
75
|
|
|
76
|
+
def _emit_answer(text: str) -> None:
|
|
77
|
+
"""Print a markdown answer with the answer-format trailer (R1-C63).
|
|
78
|
+
|
|
79
|
+
One seam rather than a line in every renderer: the renderers stay pure functions of the
|
|
80
|
+
graph, and there is one place to forget rather than nine. The trailer is what a consumer
|
|
81
|
+
diffing our output sees when the *shape* of an answer changes deliberately — 0.0.20
|
|
82
|
+
changed the gate's lines while the graph stayed byte-identical, and on a text diff that
|
|
83
|
+
looked exactly like the #20 defect, where the text moved between runs of one version. The
|
|
84
|
+
only thing that told them apart was a message written by hand.
|
|
85
|
+
"""
|
|
86
|
+
from codemap.model import ANSWER_FORMAT, SCHEMA_VERSION
|
|
87
|
+
print(text, end="")
|
|
88
|
+
if not text.endswith("\n"):
|
|
89
|
+
print()
|
|
90
|
+
print(f"_answer format {ANSWER_FORMAT} · schema {SCHEMA_VERSION}_")
|
|
91
|
+
|
|
92
|
+
|
|
76
93
|
def _graph_from(args):
|
|
77
94
|
if getattr(args, "build", None):
|
|
78
95
|
return extract(args.build, deep=getattr(args, "deep", False))
|
|
@@ -507,28 +524,28 @@ def _cmd_report(args) -> int:
|
|
|
507
524
|
if args.kind == "impact":
|
|
508
525
|
if not args.symbol:
|
|
509
526
|
raise SystemExit("error: report impact needs --symbol <name>")
|
|
510
|
-
|
|
511
|
-
|
|
527
|
+
_emit_answer(render_impact(Query(graph), args.symbol, depth=args.depth,
|
|
528
|
+
flow_depth=args.flow_depth))
|
|
512
529
|
return 0
|
|
513
530
|
if args.kind in ("communities", "flows"):
|
|
514
531
|
from codemap.serve.subsystems import render_communities, render_flows
|
|
515
532
|
q = Query(graph)
|
|
516
533
|
out = (render_communities(q) if args.kind == "communities"
|
|
517
534
|
else render_flows(q, args.symbol, depth=args.depth))
|
|
518
|
-
|
|
535
|
+
_emit_answer(out)
|
|
519
536
|
return 0
|
|
520
537
|
if args.kind == "dead-code":
|
|
521
538
|
from codemap.serve.audit import load_dead_code_whitelist
|
|
522
539
|
root = getattr(args, "source_root", None) or os.getcwd()
|
|
523
540
|
whitelist, wl_error = load_dead_code_whitelist(root)
|
|
524
|
-
|
|
541
|
+
_emit_answer(render_dead_code(Query(graph),
|
|
525
542
|
whitelist=whitelist,
|
|
526
543
|
min_confidence=args.min_confidence,
|
|
527
|
-
whitelist_error=wl_error)
|
|
544
|
+
whitelist_error=wl_error))
|
|
528
545
|
return 0
|
|
529
546
|
renderer = _REPORTS[args.kind]
|
|
530
547
|
payload = renderer(graph) if args.kind == "api-surface" else renderer(Query(graph))
|
|
531
|
-
|
|
548
|
+
_emit_answer(payload)
|
|
532
549
|
return 0
|
|
533
550
|
|
|
534
551
|
|
|
@@ -755,13 +772,13 @@ def _cmd_check(args) -> int:
|
|
|
755
772
|
# would sort an unreadable contract into the success branch of every `if rc == 2` that
|
|
756
773
|
# already exists in someone's pipeline.
|
|
757
774
|
if contract.error:
|
|
758
|
-
|
|
775
|
+
_emit_answer(render_check(q, contract, []))
|
|
759
776
|
return 2
|
|
760
777
|
if contract.is_empty() and args.require_contract:
|
|
761
|
-
|
|
778
|
+
_emit_answer(render_check(q, contract, []))
|
|
762
779
|
raise SystemExit("error: no [architecture] contract found (--require-contract)")
|
|
763
780
|
violations = check_contract(q, contract)
|
|
764
|
-
|
|
781
|
+
_emit_answer(render_check(q, contract, violations))
|
|
765
782
|
return 2 if violations else 0
|
|
766
783
|
|
|
767
784
|
|
|
@@ -77,6 +77,24 @@ from typing import Any
|
|
|
77
77
|
# (they had ``lineno`` alone, so `search` answered a line number with no file).
|
|
78
78
|
SCHEMA_VERSION = "0.13"
|
|
79
79
|
|
|
80
|
+
# R1-C63: the *answer* has a shape too, and consumers diff it. `SCHEMA_VERSION` versions the
|
|
81
|
+
# artifact; 0.0.20 left the graph byte-identical and changed the architecture report and the
|
|
82
|
+
# gate's lines completely (a cycle answer became a tangle), which on a text diff is
|
|
83
|
+
# indistinguishable from the #20 defect — where the text moved between runs of one version,
|
|
84
|
+
# without anyone deciding it. The only thing that told the consumer apart was a message I
|
|
85
|
+
# wrote by hand.
|
|
86
|
+
#
|
|
87
|
+
# Bump on a change to the **structure a consumer sees**: a block appears or vanishes, a
|
|
88
|
+
# counted unit changes meaning, a verdict or heading changes shape, a field in a structured
|
|
89
|
+
# answer changes type or semantics. Do **not** bump for wording, typos or added prose inside
|
|
90
|
+
# an existing note — a version that ticks on whitespace stops being read, which is worse than
|
|
91
|
+
# not having one. Independent of SCHEMA_VERSION by design: the graph can be identical while
|
|
92
|
+
# this moves, and that is the case it exists for.
|
|
93
|
+
#
|
|
94
|
+
# Starts at 1 with 0.0.21 and makes no claim about earlier releases: the answer format changed
|
|
95
|
+
# many times before it, and numbering that retroactively would be inventing a history.
|
|
96
|
+
ANSWER_FORMAT = 1
|
|
97
|
+
|
|
80
98
|
# Closed vocabulary of edge types (R1-C7). Node ``kind`` is deliberately an OPEN set
|
|
81
99
|
# (DESIGN §2 — new entity kinds may appear), but edges are TYPED: every relationship
|
|
82
100
|
# codemap emits is one of these, each with a fixed meaning. This is the machine-
|
|
@@ -8,7 +8,7 @@ mechanical.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
from codemap.arch import ArchitectureContract, Violation
|
|
11
|
+
from codemap.arch import ArchitectureContract, Violation, applicability
|
|
12
12
|
|
|
13
13
|
_EDGE_CAP = 25 # offending edges listed per rule before "+N more"
|
|
14
14
|
|
|
@@ -54,6 +54,44 @@ def _not_judged(query, contract: ArchitectureContract) -> list[dict]:
|
|
|
54
54
|
}]
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
def _applicability_payload(query, contract: ArchitectureContract) -> dict:
|
|
58
|
+
"""``applicability`` with its list of absent names as a plain list (JSON-friendly)."""
|
|
59
|
+
a = applicability(query, contract)
|
|
60
|
+
return {**{k: v for k, v in a.items() if k != "absent_names"},
|
|
61
|
+
"absent_names": list(a["absent_names"])}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _phantom_note(a: dict, contract: ArchitectureContract) -> str:
|
|
65
|
+
"""Name the contract names the graph does not have (R1-C62), or say nothing.
|
|
66
|
+
|
|
67
|
+
Silent when there are none — a note on every clean run is noise, and this text is
|
|
68
|
+
diffed by consumers. Silent too when ``no_phantom_rules`` is enforced: then the
|
|
69
|
+
violation has already said it, and saying it twice reads as two findings.
|
|
70
|
+
"""
|
|
71
|
+
absent = a["absent_names"]
|
|
72
|
+
if not absent or contract.no_phantom_rules:
|
|
73
|
+
return ""
|
|
74
|
+
names = ", ".join(f"`{n}`" for n in absent)
|
|
75
|
+
return (f"\n_{len(absent)} name(s) in this contract are absent from the graph — {names} — "
|
|
76
|
+
"so the rules naming them could not apply; they are counted as *declared*, not as "
|
|
77
|
+
"*applicable*, above. A contract may legitimately be written ahead of the code; "
|
|
78
|
+
"if a missing name means drift instead, `no_phantom_rules = true` makes it a "
|
|
79
|
+
"failure._\n")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _rule_count(name: str, a: dict) -> str:
|
|
83
|
+
"""``layered (7)`` when every declared name exists, ``layered (7 declared, 5 applicable)``
|
|
84
|
+
when it does not (R1-C62).
|
|
85
|
+
|
|
86
|
+
The count only grows a second half where there is something to disclose. Printing both
|
|
87
|
+
numbers always would move the text of every green run on every tree for zero
|
|
88
|
+
information — and this line is diffed by consumers.
|
|
89
|
+
"""
|
|
90
|
+
key = {"layered": "layers"}.get(name, name)
|
|
91
|
+
dec, app = a[key]["declared"], a[key]["applicable"]
|
|
92
|
+
return f"{name} ({dec})" if dec == app else f"{name} ({dec} declared, {app} applicable)"
|
|
93
|
+
|
|
94
|
+
|
|
57
95
|
def build_check(query, contract: ArchitectureContract, violations: list[Violation]) -> dict:
|
|
58
96
|
"""Structured result: ok flag + violations with their concrete edges.
|
|
59
97
|
|
|
@@ -73,6 +111,11 @@ def build_check(query, contract: ArchitectureContract, violations: list[Violatio
|
|
|
73
111
|
# has the same "absent or mislocated?" question a human does.
|
|
74
112
|
"contract_path": contract.path,
|
|
75
113
|
"ok": not violations and contract.error is None,
|
|
114
|
+
# R1-C62: declared vs applicable per rule, and the names that do not exist. Always
|
|
115
|
+
# present while a contract is readable — a consumer cannot tell "nothing phantom"
|
|
116
|
+
# from "this build does not report it" if the field comes and goes (R1-C28).
|
|
117
|
+
"applicability": (None if contract.is_empty() or contract.error
|
|
118
|
+
else _applicability_payload(query, contract)),
|
|
76
119
|
"violations": [
|
|
77
120
|
{"rule": v.rule, "summary": v.summary,
|
|
78
121
|
"edges": [list(e) for e in v.edges],
|
|
@@ -109,13 +152,14 @@ def render_check(query, contract: ArchitectureContract, violations: list[Violati
|
|
|
109
152
|
# rule that ran and stayed silent is the R1-C30-f2 defect from the other side —
|
|
110
153
|
# there the reader concluded more than was checked, here less. `tests/
|
|
111
154
|
# test_r1c49_type_only_cycles.py` fails if a new rule is added and not listed.
|
|
155
|
+
appl = applicability(query, contract)
|
|
112
156
|
rules = []
|
|
113
157
|
if contract.layers:
|
|
114
|
-
rules.append(
|
|
158
|
+
rules.append(_rule_count("layered", appl))
|
|
115
159
|
if contract.independent:
|
|
116
|
-
rules.append(
|
|
160
|
+
rules.append(_rule_count("independent", appl))
|
|
117
161
|
if contract.forbidden:
|
|
118
|
-
rules.append(
|
|
162
|
+
rules.append(_rule_count("forbidden", appl))
|
|
119
163
|
if contract.no_cycles:
|
|
120
164
|
rules.append("no_cycles")
|
|
121
165
|
if contract.no_lazy_cycles:
|
|
@@ -124,9 +168,11 @@ def render_check(query, contract: ArchitectureContract, violations: list[Violati
|
|
|
124
168
|
rules.append("no_type_only_cycles")
|
|
125
169
|
if contract.exhaustive:
|
|
126
170
|
rules.append("exhaustive")
|
|
171
|
+
if contract.no_phantom_rules:
|
|
172
|
+
rules.append("no_phantom_rules")
|
|
127
173
|
return (f"# Architecture check — `{target}`\n\n"
|
|
128
174
|
f"✅ **Contract satisfied.** Rules enforced: {', '.join(rules)}.\n"
|
|
129
|
-
+ _render_scope(query, contract))
|
|
175
|
+
+ _phantom_note(appl, contract) + _render_scope(query, contract))
|
|
130
176
|
|
|
131
177
|
out = [f"# Architecture check — `{target}`", "",
|
|
132
178
|
f"❌ **{len(violations)} rule(s) broken.**", ""]
|
|
@@ -141,7 +187,9 @@ def render_check(query, contract: ArchitectureContract, violations: list[Violati
|
|
|
141
187
|
for m in v.modules:
|
|
142
188
|
out.append(f"- {m}")
|
|
143
189
|
out.append("")
|
|
144
|
-
return "\n".join(out).rstrip() + "\n"
|
|
190
|
+
return ("\n".join(out).rstrip() + "\n"
|
|
191
|
+
+ _phantom_note(applicability(query, contract), contract)
|
|
192
|
+
+ _render_scope(query, contract))
|
|
145
193
|
|
|
146
194
|
|
|
147
195
|
def _render_scope(query, contract: ArchitectureContract) -> str:
|
|
@@ -14,7 +14,8 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
from collections import Counter
|
|
16
16
|
|
|
17
|
-
from codemap.model import
|
|
17
|
+
from codemap.model import (ANSWER_FORMAT, SCHEMA_VERSION,
|
|
18
|
+
SPLICED_EDGE_TYPES as _SPLICED_EDGE_TYPES, Graph)
|
|
18
19
|
from codemap.query import Query
|
|
19
20
|
from codemap.serve.api_surface import render_api_surface
|
|
20
21
|
from codemap.serve.architecture import build_architecture, render_architecture
|
|
@@ -397,7 +398,11 @@ class Session:
|
|
|
397
398
|
self._filter = None
|
|
398
399
|
self._scope = None
|
|
399
400
|
try:
|
|
400
|
-
env = {"ok": True, "result": fn(self, args)
|
|
401
|
+
env = {"ok": True, "result": fn(self, args),
|
|
402
|
+
# R1-C63: the shape of this answer, versioned independently of the
|
|
403
|
+
# graph's schema. Always present — a field that appears only when
|
|
404
|
+
# something changed cannot be read by a machine (R1-C28).
|
|
405
|
+
"answer_format": ANSWER_FORMAT}
|
|
401
406
|
except Exception as exc: # a bad arg must not kill the resident process
|
|
402
407
|
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
403
408
|
r = self._resolution
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codmap
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.21
|
|
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, **990 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 &
|
|
@@ -129,6 +129,8 @@ tests/test_r1c56_stub_files.py
|
|
|
129
129
|
tests/test_r1c57_facade_package.py
|
|
130
130
|
tests/test_r1c58_cycle_tangles.py
|
|
131
131
|
tests/test_r1c5_apidiff.py
|
|
132
|
+
tests/test_r1c62_phantom_rules.py
|
|
133
|
+
tests/test_r1c63_answer_format.py
|
|
132
134
|
tests/test_r1c6_pack.py
|
|
133
135
|
tests/test_r1c7_edge_vocab.py
|
|
134
136
|
tests/test_r1c8_deadcode.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.21"
|
|
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,167 @@
|
|
|
1
|
+
"""R1-C62 — a rule with nothing to cover was counted as enforced.
|
|
2
|
+
|
|
3
|
+
Measured on a two-layer package (`api`, `core`) with a contract in which every name is
|
|
4
|
+
fictional:
|
|
5
|
+
|
|
6
|
+
layers = ["frontend", "middleware", "persistence"]
|
|
7
|
+
independent = [["frontend", "middleware"]]
|
|
8
|
+
forbidden = [{ from = "middleware", to = "persistence" }]
|
|
9
|
+
|
|
10
|
+
✅ Contract satisfied. Rules enforced: layered (3), independent (1), forbidden (1), no_cycles.
|
|
11
|
+
exit 0
|
|
12
|
+
|
|
13
|
+
Six rules reported as enforced; five of them cannot fire. The inertness itself is **not** the
|
|
14
|
+
defect — it is a documented decision (`codemap/arch.py`: rules naming an absent layer are
|
|
15
|
+
inert, so a contract can be written ahead of the code), and breaking it would turn a
|
|
16
|
+
legitimate practice red on other people's trees. The defect is the report: "Rules enforced:
|
|
17
|
+
layered (3)" is a claim of fact and it is false, and the green tick reads as coverage. R1-C28's
|
|
18
|
+
family — a rule covering nothing is partiality, and partiality must be declared.
|
|
19
|
+
|
|
20
|
+
The question came from outside: a third-party repo about requirements that stop being true
|
|
21
|
+
(`research/05_curated_sources.md` §4a). Design: `docs/design/vacuous_contract_rules.md`.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import pytest
|
|
27
|
+
|
|
28
|
+
from codemap.arch import applicability, check_contract, parse_contract
|
|
29
|
+
from codemap.extract import extract
|
|
30
|
+
from codemap.query import Query
|
|
31
|
+
from codemap.serve.check import build_check, render_check
|
|
32
|
+
|
|
33
|
+
PHANTOM = {
|
|
34
|
+
"layers": ["frontend", "middleware", "persistence"],
|
|
35
|
+
"independent": [["frontend", "middleware"]],
|
|
36
|
+
"forbidden": [{"from": "middleware", "to": "persistence"}],
|
|
37
|
+
"no_cycles": True,
|
|
38
|
+
}
|
|
39
|
+
HEALTHY = {
|
|
40
|
+
"layers": ["api", "core"],
|
|
41
|
+
"forbidden": [{"from": "core", "to": "api"}],
|
|
42
|
+
"no_cycles": True,
|
|
43
|
+
"exhaustive": True,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture(scope="module")
|
|
48
|
+
def two_layers(tmp_path_factory):
|
|
49
|
+
"""`api` imports `core`; nothing else. Layers present: exactly `api` and `core`."""
|
|
50
|
+
pkg = tmp_path_factory.mktemp("r1c62") / "pkg"
|
|
51
|
+
(pkg / "core").mkdir(parents=True)
|
|
52
|
+
(pkg / "api").mkdir()
|
|
53
|
+
(pkg / "__init__.py").write_text("")
|
|
54
|
+
(pkg / "core" / "__init__.py").write_text("")
|
|
55
|
+
(pkg / "core" / "util.py").write_text("def helper():\n return 1\n")
|
|
56
|
+
(pkg / "api" / "__init__.py").write_text("")
|
|
57
|
+
(pkg / "api" / "route.py").write_text(
|
|
58
|
+
"from pkg.core.util import helper\n\n\ndef handler():\n return helper()\n")
|
|
59
|
+
return Query(extract(str(pkg)))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _render(query, section):
|
|
63
|
+
contract = parse_contract(section)
|
|
64
|
+
return contract, render_check(query, contract, check_contract(query, contract))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# -- D1: applicability is counted, and disclosed only when it differs --------------------
|
|
68
|
+
|
|
69
|
+
def test_a_phantom_rule_is_not_counted_as_applicable(two_layers):
|
|
70
|
+
_, md = _render(two_layers, PHANTOM)
|
|
71
|
+
assert "layered (3 declared, 0 applicable)" in md
|
|
72
|
+
assert "independent (1 declared, 0 applicable)" in md
|
|
73
|
+
assert "forbidden (1 declared, 0 applicable)" in md
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_the_absent_names_are_named(two_layers):
|
|
77
|
+
_, md = _render(two_layers, PHANTOM)
|
|
78
|
+
for name in ("frontend", "middleware", "persistence"):
|
|
79
|
+
assert f"`{name}`" in md
|
|
80
|
+
assert "could not apply" in md
|
|
81
|
+
assert "no_phantom_rules = true" in md, "the note points at the opt-in"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_a_healthy_contract_does_not_move_a_byte(two_layers):
|
|
85
|
+
"""The control, and the reason the second half is conditional: this text is diffed by
|
|
86
|
+
consumers, so a green run on a sound contract must read exactly as before."""
|
|
87
|
+
_, md = _render(two_layers, HEALTHY)
|
|
88
|
+
assert "Rules enforced: layered (2), forbidden (1), no_cycles, exhaustive." in md
|
|
89
|
+
assert "applicable" not in md
|
|
90
|
+
assert "absent from the graph" not in md
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_applicability_is_precise_about_partial_names(two_layers):
|
|
94
|
+
"""One end present is not enough: `independent` needs two members, `forbidden` two ends."""
|
|
95
|
+
a = applicability(two_layers, parse_contract({
|
|
96
|
+
"layers": ["api", "core", "ghost"],
|
|
97
|
+
"independent": [["api", "ghost"], ["api", "core"]],
|
|
98
|
+
"forbidden": [{"from": "api", "to": "ghost"}, {"from": "api", "to": "core"}],
|
|
99
|
+
}))
|
|
100
|
+
assert a["layers"] == {"declared": 3, "applicable": 2}
|
|
101
|
+
assert a["independent"] == {"declared": 2, "applicable": 1}
|
|
102
|
+
assert a["forbidden"] == {"declared": 2, "applicable": 1}
|
|
103
|
+
assert a["absent_names"] == ("ghost",)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# -- D2: turning a phantom into a failure is opt-in --------------------------------------
|
|
107
|
+
|
|
108
|
+
def test_the_opt_in_makes_it_a_violation(two_layers):
|
|
109
|
+
contract = parse_contract({**PHANTOM, "no_phantom_rules": True})
|
|
110
|
+
violations = check_contract(two_layers, contract)
|
|
111
|
+
assert [v.rule for v in violations] == ["no_phantom_rules"]
|
|
112
|
+
assert violations[0].modules == ("frontend", "middleware", "persistence")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_the_opt_in_is_silent_on_a_sound_contract(two_layers):
|
|
116
|
+
"""Positive control: the rule must not fire where every name exists."""
|
|
117
|
+
contract = parse_contract({**HEALTHY, "no_phantom_rules": True})
|
|
118
|
+
assert check_contract(two_layers, contract) == []
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_the_note_is_not_repeated_when_the_rule_is_enforced(two_layers):
|
|
122
|
+
_, md = _render(two_layers, {**PHANTOM, "no_phantom_rules": True})
|
|
123
|
+
assert "`no_phantom_rules`" in md, "the violation says it"
|
|
124
|
+
assert "could not apply; they are counted" not in md, "and the note does not say it again"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_off_by_default(two_layers):
|
|
128
|
+
"""A contract that does not ask for it keeps exiting 0 on phantoms — the documented
|
|
129
|
+
write-ahead use must not become red on an upgrade."""
|
|
130
|
+
contract = parse_contract(PHANTOM)
|
|
131
|
+
assert contract.no_phantom_rules is False
|
|
132
|
+
assert check_contract(two_layers, contract) == []
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# -- D3: the structured answer carries it too --------------------------------------------
|
|
136
|
+
|
|
137
|
+
def test_the_payload_carries_applicability(two_layers):
|
|
138
|
+
contract = parse_contract(PHANTOM)
|
|
139
|
+
payload = build_check(two_layers, contract, check_contract(two_layers, contract))
|
|
140
|
+
a = payload["applicability"]
|
|
141
|
+
assert a["layers"] == {"declared": 3, "applicable": 0}
|
|
142
|
+
assert a["absent_names"] == ["frontend", "middleware", "persistence"]
|
|
143
|
+
assert payload["ok"] is True, "disclosure is not a failure by itself"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_the_payload_declares_it_on_a_sound_contract_too(two_layers):
|
|
147
|
+
"""R1-C28: a field that appears only when there is something to say cannot be read —
|
|
148
|
+
a machine consumer could not tell "nothing phantom" from "this build does not report it"."""
|
|
149
|
+
contract = parse_contract(HEALTHY)
|
|
150
|
+
a = build_check(two_layers, contract, check_contract(two_layers, contract))["applicability"]
|
|
151
|
+
assert a is not None and a["absent_names"] == []
|
|
152
|
+
assert a["layers"] == {"declared": 2, "applicable": 2}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# -- the semantics did not change --------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def test_a_real_violation_still_fires_next_to_phantoms(two_layers):
|
|
158
|
+
"""Phantom names must not shadow a rule that *can* fire: `core` importing `api` is
|
|
159
|
+
still caught while three fictional layers sit in the same contract."""
|
|
160
|
+
contract = parse_contract({
|
|
161
|
+
"layers": ["core", "api", "ghost"], # deliberately inverted: api → core is "up"
|
|
162
|
+
"forbidden": [{"from": "ghost", "to": "api"}],
|
|
163
|
+
"no_cycles": True,
|
|
164
|
+
})
|
|
165
|
+
violations = check_contract(two_layers, contract)
|
|
166
|
+
assert [v.rule for v in violations] == ["layered"]
|
|
167
|
+
assert violations[0].edges == (("pkg.api.route", "pkg.core.util"),)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""R1-C63 — the artifact is versioned; the answer, which is what consumers diff, was not.
|
|
2
|
+
|
|
3
|
+
`SCHEMA_VERSION` versions `graph.json`. In 0.0.20 the graph stayed byte-identical and the gate's
|
|
4
|
+
output and the architecture report changed completely — a cycle answer became a tangle. On a text
|
|
5
|
+
diff that is indistinguishable from the #20 defect, where the rendered text moved *between runs of
|
|
6
|
+
one version* because of hash order, and nobody had decided anything. One was a deliberate change
|
|
7
|
+
of shape, the other a bug; the only thing that told the consumer apart was a message written by
|
|
8
|
+
hand.
|
|
9
|
+
|
|
10
|
+
So the answer gets its own version, independent of the schema: bumped when the structure a
|
|
11
|
+
consumer sees changes, not when prose is edited. Two surfaces, one per consumer — a one-line
|
|
12
|
+
trailer on CLI markdown (what a text diff shows) and `answer_format` in the serve/MCP envelope
|
|
13
|
+
(what a machine reads).
|
|
14
|
+
|
|
15
|
+
The pinned hash below is the other half: it does not know whether a text change is structural —
|
|
16
|
+
that judgement cannot be automated — it only refuses to let one pass **silently**. Design:
|
|
17
|
+
`docs/design/answer_format_version.md`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
from codemap.arch import check_contract, parse_contract
|
|
28
|
+
from codemap.cli import main
|
|
29
|
+
from codemap.extract import extract
|
|
30
|
+
from codemap.model import ANSWER_FORMAT, SCHEMA_VERSION
|
|
31
|
+
from codemap.query import Query
|
|
32
|
+
from codemap.serve.api_surface import render_api_surface
|
|
33
|
+
from codemap.serve.architecture import render_architecture
|
|
34
|
+
from codemap.serve.audit import render_behavior, render_dead_code, render_dependencies
|
|
35
|
+
from codemap.serve.check import render_check
|
|
36
|
+
from codemap.serve.session import Session
|
|
37
|
+
|
|
38
|
+
# Bump together with ANSWER_FORMAT when a change of answer *structure* is intended.
|
|
39
|
+
# The fixture is owned by this test on purpose: pinned against the live dogfood tree the hash
|
|
40
|
+
# would move whenever somebody edits that tree, and the test would be measuring the target
|
|
41
|
+
# instead of the tool (R1-C25 — the mistake `tests/frozen.py` exists to prevent).
|
|
42
|
+
RENDERED_SHA = "980ee1b8a07b8b0f8d6f08d707035f4af9b3b568a1526493719c0e57d9c28ba0"
|
|
43
|
+
|
|
44
|
+
FILES = {
|
|
45
|
+
"__init__.py": "",
|
|
46
|
+
"core/__init__.py": "",
|
|
47
|
+
"core/util.py": "def helper():\n return 1\n\n\ndef unused():\n return 2\n",
|
|
48
|
+
"api/__init__.py": "",
|
|
49
|
+
"api/route.py": ("from pkg.core.util import helper\n\n\n"
|
|
50
|
+
"class Handler:\n"
|
|
51
|
+
" def run(self, n: int) -> int:\n"
|
|
52
|
+
" return helper() + n\n"),
|
|
53
|
+
}
|
|
54
|
+
CONTRACT = {"layers": ["api", "core"], "no_cycles": True, "exhaustive": True}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.fixture(scope="module")
|
|
58
|
+
def pkg(tmp_path_factory):
|
|
59
|
+
root = tmp_path_factory.mktemp("r1c63") / "pkg"
|
|
60
|
+
for rel, body in FILES.items():
|
|
61
|
+
path = root / rel
|
|
62
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
path.write_text(body)
|
|
64
|
+
return Query(extract(str(root)))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _rendered(query) -> str:
|
|
68
|
+
"""Every markdown surface a consumer reads, concatenated in a fixed order."""
|
|
69
|
+
contract = parse_contract(CONTRACT)
|
|
70
|
+
return "\n@@\n".join([
|
|
71
|
+
render_check(query, contract, check_contract(query, contract)),
|
|
72
|
+
render_architecture(query),
|
|
73
|
+
render_api_surface(query.graph),
|
|
74
|
+
render_dead_code(query),
|
|
75
|
+
render_dependencies(query),
|
|
76
|
+
render_behavior(query),
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# -- the version itself ------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def test_the_answer_format_is_its_own_version(pkg):
|
|
83
|
+
assert isinstance(ANSWER_FORMAT, int) and ANSWER_FORMAT >= 1
|
|
84
|
+
assert ANSWER_FORMAT != SCHEMA_VERSION, "two different facts, two different fields"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_the_envelope_carries_it_on_every_answer(pkg):
|
|
88
|
+
"""Machine-readable half, and always present: a field that appears only when something
|
|
89
|
+
changed cannot be read (R1-C28)."""
|
|
90
|
+
session = Session(pkg.graph)
|
|
91
|
+
for op, args in (("stats", {}), ("architecture", {}), ("query", {"name": "helper"})):
|
|
92
|
+
env = session.handle({"op": op, "args": args})
|
|
93
|
+
assert env["answer_format"] == ANSWER_FORMAT, op
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# -- the CLI trailer ---------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def test_the_cli_trailer_names_both_versions(pkg, tmp_path, capsys, monkeypatch):
|
|
99
|
+
from codemap import store
|
|
100
|
+
out = tmp_path / "g.json"
|
|
101
|
+
store.save(pkg.graph, str(out))
|
|
102
|
+
monkeypatch.chdir(tmp_path) # no codemap.toml here: empty contract
|
|
103
|
+
assert main(["check", "--graph", str(out)]) == 0
|
|
104
|
+
tail = capsys.readouterr().out.rstrip().splitlines()[-1]
|
|
105
|
+
assert tail == f"_answer format {ANSWER_FORMAT} · schema {SCHEMA_VERSION}_"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_the_trailer_is_on_reports_too(pkg, tmp_path, capsys):
|
|
109
|
+
from codemap import store
|
|
110
|
+
out = tmp_path / "g.json"
|
|
111
|
+
store.save(pkg.graph, str(out))
|
|
112
|
+
main(["report", "architecture", "--graph", str(out)])
|
|
113
|
+
assert capsys.readouterr().out.rstrip().endswith(
|
|
114
|
+
f"_answer format {ANSWER_FORMAT} · schema {SCHEMA_VERSION}_")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_json_output_has_no_trailer(pkg, tmp_path, capsys):
|
|
118
|
+
"""The trailer is presentation. A `--format json` answer must stay parseable."""
|
|
119
|
+
from codemap import store
|
|
120
|
+
out = tmp_path / "g.json"
|
|
121
|
+
store.save(pkg.graph, str(out))
|
|
122
|
+
main(["report", "architecture", "--graph", str(out), "--format", "json"])
|
|
123
|
+
json.loads(capsys.readouterr().out) # raises if a trailer leaked in
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# -- the guard that makes a silent change impossible --------------------------------------
|
|
127
|
+
|
|
128
|
+
def test_rendered_answers_match_the_pin(pkg):
|
|
129
|
+
"""Six markdown surfaces over a fixture this test owns, hashed.
|
|
130
|
+
|
|
131
|
+
This does not decide whether a change is structural — that judgement is a human's, and
|
|
132
|
+
pretending to automate it would be the R1-C37 defect in a new place. It only refuses to let
|
|
133
|
+
the shape of an answer move **silently**.
|
|
134
|
+
"""
|
|
135
|
+
got = hashlib.sha256(_rendered(pkg).encode()).hexdigest()
|
|
136
|
+
assert got == RENDERED_SHA, (
|
|
137
|
+
"the rendered answers changed.\n"
|
|
138
|
+
" • intended change of structure? bump ANSWER_FORMAT in codemap/model.py, update\n"
|
|
139
|
+
" RENDERED_SHA here in the same commit, and say so in the release note\n"
|
|
140
|
+
" • only wording? update RENDERED_SHA alone — the version is about structure\n"
|
|
141
|
+
" • neither? this is a regression, and the pin just caught it\n"
|
|
142
|
+
f" expected {RENDERED_SHA}\n got {got}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_the_pin_is_over_a_fixture_this_test_owns(pkg):
|
|
146
|
+
"""Positive control for the pin: it must not be hashing the live dogfood tree."""
|
|
147
|
+
assert pkg.graph.target == "pkg"
|
|
148
|
+
assert len(pkg.graph.nodes) < 40, "a fixture, not a real package"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|