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.
Files changed (142) hide show
  1. {codmap-0.0.20 → codmap-0.0.21}/PKG-INFO +2 -2
  2. {codmap-0.0.20 → codmap-0.0.21}/README.md +1 -1
  3. {codmap-0.0.20 → codmap-0.0.21}/codemap/arch.py +58 -1
  4. {codmap-0.0.20 → codmap-0.0.21}/codemap/cli.py +26 -9
  5. {codmap-0.0.20 → codmap-0.0.21}/codemap/model.py +18 -0
  6. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/check.py +54 -6
  7. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/session.py +7 -2
  8. {codmap-0.0.20 → codmap-0.0.21}/codmap.egg-info/PKG-INFO +2 -2
  9. {codmap-0.0.20 → codmap-0.0.21}/codmap.egg-info/SOURCES.txt +2 -0
  10. {codmap-0.0.20 → codmap-0.0.21}/pyproject.toml +1 -1
  11. codmap-0.0.21/tests/test_r1c62_phantom_rules.py +167 -0
  12. codmap-0.0.21/tests/test_r1c63_answer_format.py +148 -0
  13. {codmap-0.0.20 → codmap-0.0.21}/LICENSE +0 -0
  14. {codmap-0.0.20 → codmap-0.0.21}/codemap/__init__.py +0 -0
  15. {codmap-0.0.20 → codmap-0.0.21}/codemap/apidiff.py +0 -0
  16. {codmap-0.0.20 → codmap-0.0.21}/codemap/diagnostics.py +0 -0
  17. {codmap-0.0.20 → codmap-0.0.21}/codemap/extract/__init__.py +0 -0
  18. {codmap-0.0.20 → codmap-0.0.21}/codemap/extract/attrflow.py +0 -0
  19. {codmap-0.0.20 → codmap-0.0.21}/codemap/extract/behavior.py +0 -0
  20. {codmap-0.0.20 → codmap-0.0.21}/codemap/extract/dataflow.py +0 -0
  21. {codmap-0.0.20 → codmap-0.0.21}/codemap/extract/dispatch.py +0 -0
  22. {codmap-0.0.20 → codmap-0.0.21}/codemap/extract/griffe_extractor.py +0 -0
  23. {codmap-0.0.20 → codmap-0.0.21}/codemap/extract/gsource.py +0 -0
  24. {codmap-0.0.20 → codmap-0.0.21}/codemap/extract/roots.py +0 -0
  25. {codmap-0.0.20 → codmap-0.0.21}/codemap/extract/union.py +0 -0
  26. {codmap-0.0.20 → codmap-0.0.21}/codemap/freshness.py +0 -0
  27. {codmap-0.0.20 → codmap-0.0.21}/codemap/incremental.py +0 -0
  28. {codmap-0.0.20 → codmap-0.0.21}/codemap/integrations/__init__.py +0 -0
  29. {codmap-0.0.20 → codmap-0.0.21}/codemap/integrations/base.py +0 -0
  30. {codmap-0.0.20 → codmap-0.0.21}/codemap/integrations/cocoindex.py +0 -0
  31. {codmap-0.0.20 → codmap-0.0.21}/codemap/integrations/gate.py +0 -0
  32. {codmap-0.0.20 → codmap-0.0.21}/codemap/integrations/gitnexus.py +0 -0
  33. {codmap-0.0.20 → codmap-0.0.21}/codemap/integrations/registry.py +0 -0
  34. {codmap-0.0.20 → codmap-0.0.21}/codemap/integrations/transport.py +0 -0
  35. {codmap-0.0.20 → codmap-0.0.21}/codemap/provenance.py +0 -0
  36. {codmap-0.0.20 → codmap-0.0.21}/codemap/query.py +0 -0
  37. {codmap-0.0.20 → codmap-0.0.21}/codemap/scope.py +0 -0
  38. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/__init__.py +0 -0
  39. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/_scip_pb2.py +0 -0
  40. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/api_surface.py +0 -0
  41. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/apidiff.py +0 -0
  42. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/architecture.py +0 -0
  43. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/audit.py +0 -0
  44. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/ctags.py +0 -0
  45. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/impact.py +0 -0
  46. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/limits.py +0 -0
  47. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/livingdocs.py +0 -0
  48. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/mcp_server.py +0 -0
  49. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/mermaid.py +0 -0
  50. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/pack.py +0 -0
  51. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/rag.py +0 -0
  52. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/review.py +0 -0
  53. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/scip.py +0 -0
  54. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/semantic.py +0 -0
  55. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/server.py +0 -0
  56. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/subsystems.py +0 -0
  57. {codmap-0.0.20 → codmap-0.0.21}/codemap/serve/vault.py +0 -0
  58. {codmap-0.0.20 → codmap-0.0.21}/codemap/store.py +0 -0
  59. {codmap-0.0.20 → codmap-0.0.21}/codemap/tomlio.py +0 -0
  60. {codmap-0.0.20 → codmap-0.0.21}/codemap/watch.py +0 -0
  61. {codmap-0.0.20 → codmap-0.0.21}/codmap.egg-info/dependency_links.txt +0 -0
  62. {codmap-0.0.20 → codmap-0.0.21}/codmap.egg-info/entry_points.txt +0 -0
  63. {codmap-0.0.20 → codmap-0.0.21}/codmap.egg-info/requires.txt +0 -0
  64. {codmap-0.0.20 → codmap-0.0.21}/codmap.egg-info/top_level.txt +0 -0
  65. {codmap-0.0.20 → codmap-0.0.21}/setup.cfg +0 -0
  66. {codmap-0.0.20 → codmap-0.0.21}/tests/test_epistemic.py +0 -0
  67. {codmap-0.0.20 → codmap-0.0.21}/tests/test_gitnexus_router.py +0 -0
  68. {codmap-0.0.20 → codmap-0.0.21}/tests/test_impact_depth.py +0 -0
  69. {codmap-0.0.20 → codmap-0.0.21}/tests/test_integrations.py +0 -0
  70. {codmap-0.0.20 → codmap-0.0.21}/tests/test_issue3_serve_freshness.py +0 -0
  71. {codmap-0.0.20 → codmap-0.0.21}/tests/test_livingdocs.py +0 -0
  72. {codmap-0.0.20 → codmap-0.0.21}/tests/test_m0_api_surface.py +0 -0
  73. {codmap-0.0.20 → codmap-0.0.21}/tests/test_m11_argcontract.py +0 -0
  74. {codmap-0.0.20 → codmap-0.0.21}/tests/test_m12_dataflow.py +0 -0
  75. {codmap-0.0.20 → codmap-0.0.21}/tests/test_m15_review.py +0 -0
  76. {codmap-0.0.20 → codmap-0.0.21}/tests/test_m16_architecture.py +0 -0
  77. {codmap-0.0.20 → codmap-0.0.21}/tests/test_m17_mcp.py +0 -0
  78. {codmap-0.0.20 → codmap-0.0.21}/tests/test_m18_freshness.py +0 -0
  79. {codmap-0.0.20 → codmap-0.0.21}/tests/test_m1_5_semantics.py +0 -0
  80. {codmap-0.0.20 → codmap-0.0.21}/tests/test_m1_query.py +0 -0
  81. {codmap-0.0.20 → codmap-0.0.21}/tests/test_m2_views.py +0 -0
  82. {codmap-0.0.20 → codmap-0.0.21}/tests/test_m32_watch.py +0 -0
  83. {codmap-0.0.20 → codmap-0.0.21}/tests/test_m3_serve.py +0 -0
  84. {codmap-0.0.20 → codmap-0.0.21}/tests/test_m4_behavior.py +0 -0
  85. {codmap-0.0.20 → codmap-0.0.21}/tests/test_m5_deep.py +0 -0
  86. {codmap-0.0.20 → codmap-0.0.21}/tests/test_m6_repo_scope.py +0 -0
  87. {codmap-0.0.20 → codmap-0.0.21}/tests/test_m7_dispatch.py +0 -0
  88. {codmap-0.0.20 → codmap-0.0.21}/tests/test_m9_family.py +0 -0
  89. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  90. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c13_grep_vs_graph.py +0 -0
  91. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c16_semantic.py +0 -0
  92. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c20_attribute_edges.py +0 -0
  93. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c21_flat_layout.py +0 -0
  94. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c22_source_visible_refs.py +0 -0
  95. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c23_hard_python.py +0 -0
  96. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c24_test_mapping.py +0 -0
  97. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c25_provenance.py +0 -0
  98. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c26_deep_union.py +0 -0
  99. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c27_config_honesty.py +0 -0
  100. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c28_limit_envelope.py +0 -0
  101. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c29_lazy_imports.py +0 -0
  102. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c2_ctags.py +0 -0
  103. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c30_local_import_calls.py +0 -0
  104. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c30f1_reexport_calls.py +0 -0
  105. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c30f2_check_scope.py +0 -0
  106. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c31_path_origin.py +0 -0
  107. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c32_report_json.py +0 -0
  108. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c33_query_signature.py +0 -0
  109. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c34_signature_kinds.py +0 -0
  110. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c35_contract_location.py +0 -0
  111. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c36_target_resolution.py +0 -0
  112. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c37_rule_mutation.py +0 -0
  113. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c38_tool_drift.py +0 -0
  114. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c39_edge_resolution.py +0 -0
  115. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c3_arch_contract.py +0 -0
  116. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c3_dogfood.py +0 -0
  117. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c40_flow_reach.py +0 -0
  118. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c41_scope_membership.py +0 -0
  119. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c42_deep_tier_stability.py +0 -0
  120. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c43_incremental_splice.py +0 -0
  121. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c44_absent_answers.py +0 -0
  122. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c45_repeat_union.py +0 -0
  123. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c46_shadowed_definitions.py +0 -0
  124. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c47_incremental_resample.py +0 -0
  125. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c48_type_checking_imports.py +0 -0
  126. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c49_type_only_cycles.py +0 -0
  127. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c4_complexity.py +0 -0
  128. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c50_flow_entry_points.py +0 -0
  129. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c53_narrowing_declared.py +0 -0
  130. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c54_deterministic_rendering.py +0 -0
  131. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c55_override_grade.py +0 -0
  132. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c56_stub_files.py +0 -0
  133. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c57_facade_package.py +0 -0
  134. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c58_cycle_tangles.py +0 -0
  135. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c5_apidiff.py +0 -0
  136. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c6_pack.py +0 -0
  137. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c7_edge_vocab.py +0 -0
  138. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c8_deadcode.py +0 -0
  139. {codmap-0.0.20 → codmap-0.0.21}/tests/test_r1c9_incremental.py +0 -0
  140. {codmap-0.0.20 → codmap-0.0.21}/tests/test_scip_export.py +0 -0
  141. {codmap-0.0.20 → codmap-0.0.21}/tests/test_scope.py +0 -0
  142. {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.20
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
  [![CI](https://github.com/kogriv/codemap/actions/workflows/ci.yml/badge.svg)](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
46
46
 
47
- **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **972 tests with no failures on
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
  [![CI](https://github.com/kogriv/codemap/actions/workflows/ci.yml/badge.svg)](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
10
10
 
11
- **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **972 tests with no failures on
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
- print(render_impact(Query(graph), args.symbol, depth=args.depth,
511
- flow_depth=args.flow_depth), end="")
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
- print(out, end="")
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
- print(render_dead_code(Query(graph),
541
+ _emit_answer(render_dead_code(Query(graph),
525
542
  whitelist=whitelist,
526
543
  min_confidence=args.min_confidence,
527
- whitelist_error=wl_error), end="")
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
- print(payload, end="")
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
- print(render_check(q, contract, []), end="")
775
+ _emit_answer(render_check(q, contract, []))
759
776
  return 2
760
777
  if contract.is_empty() and args.require_contract:
761
- print(render_check(q, contract, []), end="")
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
- print(render_check(q, contract, violations), end="")
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(f"layered ({len(contract.layers)})")
158
+ rules.append(_rule_count("layered", appl))
115
159
  if contract.independent:
116
- rules.append(f"independent ({len(contract.independent)})")
160
+ rules.append(_rule_count("independent", appl))
117
161
  if contract.forbidden:
118
- rules.append(f"forbidden ({len(contract.forbidden)})")
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" + _render_scope(query, contract)
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 SCHEMA_VERSION, SPLICED_EDGE_TYPES as _SPLICED_EDGE_TYPES, Graph
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.20
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
  [![CI](https://github.com/kogriv/codemap/actions/workflows/ci.yml/badge.svg)](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
46
46
 
47
- **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **972 tests with no failures on
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.20"
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