codmap 0.0.7__tar.gz → 0.0.9__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 (123) hide show
  1. {codmap-0.0.7/codmap.egg-info → codmap-0.0.9}/PKG-INFO +21 -4
  2. {codmap-0.0.7 → codmap-0.0.9}/README.md +20 -3
  3. {codmap-0.0.7 → codmap-0.0.9}/codemap/cli.py +24 -1
  4. {codmap-0.0.7 → codmap-0.0.9}/codemap/diagnostics.py +72 -1
  5. {codmap-0.0.7 → codmap-0.0.9}/codemap/provenance.py +12 -0
  6. {codmap-0.0.7 → codmap-0.0.9}/codemap/scope.py +66 -5
  7. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/apidiff.py +4 -0
  8. {codmap-0.0.7 → codmap-0.0.9/codmap.egg-info}/PKG-INFO +21 -4
  9. {codmap-0.0.7 → codmap-0.0.9}/codmap.egg-info/SOURCES.txt +2 -0
  10. {codmap-0.0.7 → codmap-0.0.9}/pyproject.toml +1 -1
  11. codmap-0.0.9/tests/test_r1c41_scope_membership.py +213 -0
  12. codmap-0.0.9/tests/test_r1c42_deep_tier_stability.py +92 -0
  13. {codmap-0.0.7 → codmap-0.0.9}/LICENSE +0 -0
  14. {codmap-0.0.7 → codmap-0.0.9}/codemap/__init__.py +0 -0
  15. {codmap-0.0.7 → codmap-0.0.9}/codemap/apidiff.py +0 -0
  16. {codmap-0.0.7 → codmap-0.0.9}/codemap/arch.py +0 -0
  17. {codmap-0.0.7 → codmap-0.0.9}/codemap/extract/__init__.py +0 -0
  18. {codmap-0.0.7 → codmap-0.0.9}/codemap/extract/attrflow.py +0 -0
  19. {codmap-0.0.7 → codmap-0.0.9}/codemap/extract/behavior.py +0 -0
  20. {codmap-0.0.7 → codmap-0.0.9}/codemap/extract/dataflow.py +0 -0
  21. {codmap-0.0.7 → codmap-0.0.9}/codemap/extract/dispatch.py +0 -0
  22. {codmap-0.0.7 → codmap-0.0.9}/codemap/extract/griffe_extractor.py +0 -0
  23. {codmap-0.0.7 → codmap-0.0.9}/codemap/extract/gsource.py +0 -0
  24. {codmap-0.0.7 → codmap-0.0.9}/codemap/extract/roots.py +0 -0
  25. {codmap-0.0.7 → codmap-0.0.9}/codemap/freshness.py +0 -0
  26. {codmap-0.0.7 → codmap-0.0.9}/codemap/incremental.py +0 -0
  27. {codmap-0.0.7 → codmap-0.0.9}/codemap/integrations/__init__.py +0 -0
  28. {codmap-0.0.7 → codmap-0.0.9}/codemap/integrations/base.py +0 -0
  29. {codmap-0.0.7 → codmap-0.0.9}/codemap/integrations/cocoindex.py +0 -0
  30. {codmap-0.0.7 → codmap-0.0.9}/codemap/integrations/gate.py +0 -0
  31. {codmap-0.0.7 → codmap-0.0.9}/codemap/integrations/gitnexus.py +0 -0
  32. {codmap-0.0.7 → codmap-0.0.9}/codemap/integrations/registry.py +0 -0
  33. {codmap-0.0.7 → codmap-0.0.9}/codemap/integrations/transport.py +0 -0
  34. {codmap-0.0.7 → codmap-0.0.9}/codemap/model.py +0 -0
  35. {codmap-0.0.7 → codmap-0.0.9}/codemap/query.py +0 -0
  36. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/__init__.py +0 -0
  37. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/_scip_pb2.py +0 -0
  38. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/api_surface.py +0 -0
  39. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/architecture.py +0 -0
  40. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/audit.py +0 -0
  41. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/check.py +0 -0
  42. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/ctags.py +0 -0
  43. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/impact.py +0 -0
  44. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/limits.py +0 -0
  45. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/livingdocs.py +0 -0
  46. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/mcp_server.py +0 -0
  47. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/mermaid.py +0 -0
  48. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/pack.py +0 -0
  49. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/rag.py +0 -0
  50. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/review.py +0 -0
  51. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/scip.py +0 -0
  52. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/semantic.py +0 -0
  53. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/server.py +0 -0
  54. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/session.py +0 -0
  55. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/subsystems.py +0 -0
  56. {codmap-0.0.7 → codmap-0.0.9}/codemap/serve/vault.py +0 -0
  57. {codmap-0.0.7 → codmap-0.0.9}/codemap/store.py +0 -0
  58. {codmap-0.0.7 → codmap-0.0.9}/codemap/tomlio.py +0 -0
  59. {codmap-0.0.7 → codmap-0.0.9}/codemap/watch.py +0 -0
  60. {codmap-0.0.7 → codmap-0.0.9}/codmap.egg-info/dependency_links.txt +0 -0
  61. {codmap-0.0.7 → codmap-0.0.9}/codmap.egg-info/entry_points.txt +0 -0
  62. {codmap-0.0.7 → codmap-0.0.9}/codmap.egg-info/requires.txt +0 -0
  63. {codmap-0.0.7 → codmap-0.0.9}/codmap.egg-info/top_level.txt +0 -0
  64. {codmap-0.0.7 → codmap-0.0.9}/setup.cfg +0 -0
  65. {codmap-0.0.7 → codmap-0.0.9}/tests/test_epistemic.py +0 -0
  66. {codmap-0.0.7 → codmap-0.0.9}/tests/test_gitnexus_router.py +0 -0
  67. {codmap-0.0.7 → codmap-0.0.9}/tests/test_impact_depth.py +0 -0
  68. {codmap-0.0.7 → codmap-0.0.9}/tests/test_integrations.py +0 -0
  69. {codmap-0.0.7 → codmap-0.0.9}/tests/test_issue3_serve_freshness.py +0 -0
  70. {codmap-0.0.7 → codmap-0.0.9}/tests/test_livingdocs.py +0 -0
  71. {codmap-0.0.7 → codmap-0.0.9}/tests/test_m0_api_surface.py +0 -0
  72. {codmap-0.0.7 → codmap-0.0.9}/tests/test_m11_argcontract.py +0 -0
  73. {codmap-0.0.7 → codmap-0.0.9}/tests/test_m12_dataflow.py +0 -0
  74. {codmap-0.0.7 → codmap-0.0.9}/tests/test_m15_review.py +0 -0
  75. {codmap-0.0.7 → codmap-0.0.9}/tests/test_m16_architecture.py +0 -0
  76. {codmap-0.0.7 → codmap-0.0.9}/tests/test_m17_mcp.py +0 -0
  77. {codmap-0.0.7 → codmap-0.0.9}/tests/test_m18_freshness.py +0 -0
  78. {codmap-0.0.7 → codmap-0.0.9}/tests/test_m1_5_semantics.py +0 -0
  79. {codmap-0.0.7 → codmap-0.0.9}/tests/test_m1_query.py +0 -0
  80. {codmap-0.0.7 → codmap-0.0.9}/tests/test_m2_views.py +0 -0
  81. {codmap-0.0.7 → codmap-0.0.9}/tests/test_m32_watch.py +0 -0
  82. {codmap-0.0.7 → codmap-0.0.9}/tests/test_m3_serve.py +0 -0
  83. {codmap-0.0.7 → codmap-0.0.9}/tests/test_m4_behavior.py +0 -0
  84. {codmap-0.0.7 → codmap-0.0.9}/tests/test_m5_deep.py +0 -0
  85. {codmap-0.0.7 → codmap-0.0.9}/tests/test_m6_repo_scope.py +0 -0
  86. {codmap-0.0.7 → codmap-0.0.9}/tests/test_m7_dispatch.py +0 -0
  87. {codmap-0.0.7 → codmap-0.0.9}/tests/test_m9_family.py +0 -0
  88. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  89. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c13_grep_vs_graph.py +0 -0
  90. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c16_semantic.py +0 -0
  91. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c20_attribute_edges.py +0 -0
  92. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c21_flat_layout.py +0 -0
  93. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c22_source_visible_refs.py +0 -0
  94. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c23_hard_python.py +0 -0
  95. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c24_test_mapping.py +0 -0
  96. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c25_provenance.py +0 -0
  97. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c26_deep_union.py +0 -0
  98. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c27_config_honesty.py +0 -0
  99. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c28_limit_envelope.py +0 -0
  100. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c29_lazy_imports.py +0 -0
  101. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c2_ctags.py +0 -0
  102. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c30_local_import_calls.py +0 -0
  103. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c30f1_reexport_calls.py +0 -0
  104. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c30f2_check_scope.py +0 -0
  105. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c31_path_origin.py +0 -0
  106. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c32_report_json.py +0 -0
  107. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c33_query_signature.py +0 -0
  108. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c34_signature_kinds.py +0 -0
  109. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c35_contract_location.py +0 -0
  110. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c36_target_resolution.py +0 -0
  111. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c37_rule_mutation.py +0 -0
  112. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c38_tool_drift.py +0 -0
  113. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c3_arch_contract.py +0 -0
  114. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c3_dogfood.py +0 -0
  115. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c4_complexity.py +0 -0
  116. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c5_apidiff.py +0 -0
  117. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c6_pack.py +0 -0
  118. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c7_edge_vocab.py +0 -0
  119. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c8_deadcode.py +0 -0
  120. {codmap-0.0.7 → codmap-0.0.9}/tests/test_r1c9_incremental.py +0 -0
  121. {codmap-0.0.7 → codmap-0.0.9}/tests/test_scip_export.py +0 -0
  122. {codmap-0.0.7 → codmap-0.0.9}/tests/test_scope.py +0 -0
  123. {codmap-0.0.7 → codmap-0.0.9}/tests/test_subsystems.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.7
3
+ Version: 0.0.9
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, **734 tests with no failures on
47
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **752 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 &
@@ -58,8 +58,9 @@ export edges, best-effort call edges, registry-family `implements` links, string
58
58
  and per-call argument contracts — then answers questions over it.
59
59
 
60
60
  Design principles: **source-only** (static `ast`/`griffe`, never imports the target), **deterministic**
61
- (canonical sorted JSON, no timestamps — diffable), **CLI-AI-first** (JSON by default, stable exit
62
- codes), **honest** (approximations are labeled, not hidden).
61
+ (canonical sorted JSON, no timestamps — diffable; byte-stable on the fast tier, see the caveat
62
+ [below](#one-caveat-on-deterministic)), **CLI-AI-first** (JSON by default, stable exit codes),
63
+ **honest** (approximations are labeled, not hidden).
63
64
 
64
65
  ## Point questions and whole-graph questions
65
66
 
@@ -247,6 +248,22 @@ against *all* true edges (the price of Python's dynamism), and a grep-vs-graph p
247
248
  cheaper than grep for impact on unique names, tens of × on polymorphic ones, and no cheaper for locating a
248
249
  symbol.
249
250
 
251
+ ## One caveat on "deterministic"
252
+
253
+ The **fast tier is byte-stable**: two builds of an unchanged tree produce identical files, and CI
254
+ compares the bytes on every push. The **deep tier (`--deep`, jedi) is not**. Ten deep builds of an
255
+ unchanged tree here produced two distinct artifacts — differing in two per-symbol call counters out
256
+ of 2133 nodes, no edges — and on a larger external tree one build in seven lost one real call edge of
257
+ 9524. The cause is jedi's per-script execution budget: an inference that runs out of it returns
258
+ nothing, and nothing is recorded as `unresolved`.
259
+
260
+ So a deep graph is one sample of a slightly fuzzy function, not a function of its input. Every deep
261
+ build says so in its own diagnostics, and `codemap diff` says so when both sides are deep — read a
262
+ delta of a few call edges as possible tool noise before reading it as a change in the code. Anything
263
+ that must be reproducible byte-for-byte — a gate, a two-release comparison — belongs on the fast tier.
264
+
265
+ **Measurement:** [gaps/deep_tier_nondeterminism_2026-09-02.md](gaps/deep_tier_nondeterminism_2026-09-02.md).
266
+
250
267
  ## Dogfooding
251
268
 
252
269
  codemap is validated end-to-end against a real external package. Place a target repo as a sibling and
@@ -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, **734 tests with no failures on
11
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **752 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 &
@@ -22,8 +22,9 @@ export edges, best-effort call edges, registry-family `implements` links, string
22
22
  and per-call argument contracts — then answers questions over it.
23
23
 
24
24
  Design principles: **source-only** (static `ast`/`griffe`, never imports the target), **deterministic**
25
- (canonical sorted JSON, no timestamps — diffable), **CLI-AI-first** (JSON by default, stable exit
26
- codes), **honest** (approximations are labeled, not hidden).
25
+ (canonical sorted JSON, no timestamps — diffable; byte-stable on the fast tier, see the caveat
26
+ [below](#one-caveat-on-deterministic)), **CLI-AI-first** (JSON by default, stable exit codes),
27
+ **honest** (approximations are labeled, not hidden).
27
28
 
28
29
  ## Point questions and whole-graph questions
29
30
 
@@ -211,6 +212,22 @@ against *all* true edges (the price of Python's dynamism), and a grep-vs-graph p
211
212
  cheaper than grep for impact on unique names, tens of × on polymorphic ones, and no cheaper for locating a
212
213
  symbol.
213
214
 
215
+ ## One caveat on "deterministic"
216
+
217
+ The **fast tier is byte-stable**: two builds of an unchanged tree produce identical files, and CI
218
+ compares the bytes on every push. The **deep tier (`--deep`, jedi) is not**. Ten deep builds of an
219
+ unchanged tree here produced two distinct artifacts — differing in two per-symbol call counters out
220
+ of 2133 nodes, no edges — and on a larger external tree one build in seven lost one real call edge of
221
+ 9524. The cause is jedi's per-script execution budget: an inference that runs out of it returns
222
+ nothing, and nothing is recorded as `unresolved`.
223
+
224
+ So a deep graph is one sample of a slightly fuzzy function, not a function of its input. Every deep
225
+ build says so in its own diagnostics, and `codemap diff` says so when both sides are deep — read a
226
+ delta of a few call edges as possible tool noise before reading it as a change in the code. Anything
227
+ that must be reproducible byte-for-byte — a gate, a two-release comparison — belongs on the fast tier.
228
+
229
+ **Measurement:** [gaps/deep_tier_nondeterminism_2026-09-02.md](gaps/deep_tier_nondeterminism_2026-09-02.md).
230
+
214
231
  ## Dogfooding
215
232
 
216
233
  codemap is validated end-to-end against a real external package. Place a target repo as a sibling and
@@ -104,7 +104,7 @@ def _cmd_build(args) -> int:
104
104
  # roots come from extract_repo in this same call; never inherited from a graph
105
105
  # loaded off disk, which is how a stale scope would sneak into a fresh build.
106
106
  roots=graph.provenance.get("roots") if (args.consumer or args.docs) else None,
107
- inputs=graph.provenance.get("inputs"))
107
+ inputs=_inputs_with_membership(graph, scope, args))
108
108
  # R1-C21: a well-formed but vacuous graph must announce itself — silence is what
109
109
  # lets an unparsed layout read as a clean bill of health downstream.
110
110
  for d in diagnostics(graph):
@@ -132,6 +132,27 @@ def _cmd_build(args) -> int:
132
132
  return 0
133
133
 
134
134
 
135
+ def _inputs_with_membership(graph, scope, args):
136
+ """The extractor's ``inputs`` block plus the manifest membership result (R1-C41).
137
+
138
+ Computed here because it is the one place that holds both sides: the graph and the
139
+ scope manifest that claims to describe its input. The *result* travels in the graph
140
+ (beside ``skipped``, which carries the same kind of fact) because build-time stderr
141
+ never reaches whoever reads the artifact an hour later, possibly without the sidecar.
142
+
143
+ Omitted entirely when no manifest resolved — that is "could not compare", and writing
144
+ a zero there would turn an unknown into a clean bill of health.
145
+ """
146
+ inputs = dict(graph.provenance.get("inputs") or {})
147
+ if not scope:
148
+ return inputs or None
149
+ from codemap.scope import unlisted_files
150
+ base = roots_base(args.path, tuple(args.consumer or ()), tuple(args.docs or ()))
151
+ inputs["unlisted"] = unlisted_files(
152
+ (n.file for n in graph.nodes.values() if n.file), scope, base=base)
153
+ return inputs
154
+
155
+
135
156
  def _resolve_scope_quietly(args):
136
157
  """The input scope manifest, or None. Never fatal — a build must not fail on it."""
137
158
  from codemap.scope import resolve_scope
@@ -726,6 +747,8 @@ def _cmd_diff(args) -> int:
726
747
  print(f"[warning] {line}", file=sys.stderr)
727
748
  print(f"[warning] differences below may be tool changes, not code changes "
728
749
  f"(old: {cmp['old']} | new: {cmp['new']})", file=sys.stderr)
750
+ for caveat in cmp.get("caveats") or ():
751
+ print(f"[note] {caveat}", file=sys.stderr)
729
752
  print(render_apidiff(old, new), end="")
730
753
  if args.exit_code and not build_apidiff(old, new)["ok"]:
731
754
  return 1
@@ -21,6 +21,8 @@ NO_CROSS_ROOT_EDGES = "no_cross_root_edges"
21
21
  SCHEMA_MISMATCH = "schema_mismatch"
22
22
  UNREAD_INPUTS = "unread_inputs"
23
23
  MODULE_COUNT_MISMATCH = "module_count_mismatch"
24
+ SCOPE_MEMBERSHIP = "scope_membership"
25
+ DEEP_TIER_UNSTABLE = "deep_tier_unstable"
24
26
 
25
27
  #: A ``warning`` invalidates the conclusions a surface draws from the graph — read them as
26
28
  #: unknown. A ``note`` states a fact about how the graph was built and invalidates nothing.
@@ -189,6 +191,74 @@ def unread_inputs_diagnostic(graph) -> dict | None:
189
191
  }
190
192
 
191
193
 
194
+ def deep_tier_diagnostic(graph) -> dict | None:
195
+ """State the deep tier's noise floor on the artifact itself (R1-C42).
196
+
197
+ A **note**, not a warning: nothing here is wrong, and no finding below is invalid.
198
+ What was missing is that the tier's instability was known — measured at R1-C9, and
199
+ the reason the CI determinism job runs the fast tier only — while living exclusively
200
+ in a comment in a workflow file. A consumer read "deterministic" and built a
201
+ two-release comparison on it; the difference they saw was the tool, not the code.
202
+
203
+ Measured on two trees: ten deep builds of an unchanged tree produced **two** distinct
204
+ artifacts (7/3), differing in two per-symbol call counters and no edges; on a larger
205
+ tree one build in seven lost one real call edge of 9524. The cause is jedi's
206
+ per-script execution budget — an inference that runs out of it returns nothing, and
207
+ that reads as `unresolved` rather than as an error.
208
+ """
209
+ if (graph.provenance or {}).get("tier") != "deep":
210
+ return None
211
+ return {
212
+ "code": DEEP_TIER_UNSTABLE,
213
+ "severity": NOTE,
214
+ "tier": "deep",
215
+ "consequence": ("Everything here is a lower bound as usual; treat a difference "
216
+ "of a few call edges between two deep graphs as possible tool "
217
+ "noise rather than a change in the code."),
218
+ "message": (
219
+ "built on the deep (jedi) tier, which is not byte-stable: two builds of an "
220
+ "unchanged tree can differ by a few call edges — measured at roughly one run "
221
+ "in three on a 2133-node tree, by two per-symbol counters and no edges."
222
+ ),
223
+ }
224
+
225
+
226
+ def scope_membership_diagnostic(graph) -> dict | None:
227
+ """Flag files the graph was built from that the input manifest never listed (R1-C41).
228
+
229
+ Sibling of the conservation law above, and it catches what that one provably cannot:
230
+ the count compares the *extractor's* walk against the graph, so when both agree and
231
+ only the **manifest** disagrees — an untracked or gitignored module, a file leaked in
232
+ from outside the target — it stays silent, correctly. Reported by the second real
233
+ target (issue #15), who found a sidecar listing 47 files with a hash on each beside a
234
+ graph built from 48.
235
+
236
+ Derived from ``provenance.inputs.unlisted``, which the build records because the
237
+ comparison needs the scope manifest and a consumer may hold only the graph. Absent
238
+ field means the build could not compare (no manifest resolved) — that is *unknown*,
239
+ not zero, so it is not reported as a clean result.
240
+ """
241
+ unlisted = ((graph.provenance or {}).get("inputs") or {}).get("unlisted") or {}
242
+ count = unlisted.get("count") or 0
243
+ if not count:
244
+ return None
245
+ sample = ", ".join(unlisted.get("sample") or [])
246
+ where = (" (at least one lies outside the scope root entirely)"
247
+ if unlisted.get("outside_root") else "")
248
+ return {
249
+ "code": SCOPE_MEMBERSHIP,
250
+ "severity": WARNING,
251
+ "unlisted": count,
252
+ "consequence": ("The manifest and `scope_id` describe a different input than this "
253
+ "graph was built from, so read the input identity as **unknown** — "
254
+ "and with it `--incremental` and `watch`, which key off that value."),
255
+ "message": (
256
+ f"{count} file(s) in this graph are not listed in the input manifest{where}: "
257
+ f"{sample}" + (" …" if count > len(unlisted.get("sample") or []) else "")
258
+ ),
259
+ }
260
+
261
+
192
262
  def module_count_diagnostic(graph) -> dict | None:
193
263
  """Conservation law over the build: modules cannot outnumber the files that define
194
264
  them, nor silently fall short of them (R1-C23 / design D6).
@@ -236,7 +306,8 @@ def diagnostics(graph) -> list[dict]:
236
306
  """Every diagnostic that applies to ``graph`` (empty list when it looks sound)."""
237
307
  checks = (import_graph_diagnostic(graph), namespace_target_diagnostic(graph),
238
308
  cross_root_diagnostic(graph), schema_diagnostic(graph),
239
- unread_inputs_diagnostic(graph), module_count_diagnostic(graph))
309
+ unread_inputs_diagnostic(graph), module_count_diagnostic(graph),
310
+ scope_membership_diagnostic(graph), deep_tier_diagnostic(graph))
240
311
  return [d for d in checks if d is not None]
241
312
 
242
313
 
@@ -253,6 +253,7 @@ def comparability(old: dict | None, new: dict | None) -> dict:
253
253
  were dead.
254
254
  """
255
255
  differences: list[str] = []
256
+ caveats: list[str] = []
256
257
  if not old or not new:
257
258
  differences.append("one of the graphs records no provenance "
258
259
  "(built before schema 0.12) — the pair cannot be verified")
@@ -264,9 +265,20 @@ def comparability(old: dict | None, new: dict | None) -> dict:
264
265
  old_roots, new_roots = old.get("roots"), new.get("roots")
265
266
  if old_roots != new_roots:
266
267
  differences.append(f"different scope roots: {old_roots} → {new_roots}")
268
+ if old.get("tier") == new.get("tier") == "deep":
269
+ # R1-C42: matching tiers make the pair comparable, and on the deep tier that
270
+ # is not the same as "every difference here is a code change". Measured on
271
+ # two trees: builds of an *unchanged* tree differ in roughly one run of
272
+ # three, by a couple of call classifications — and on the larger tree by one
273
+ # real call edge in ~9500. A caveat, not a difference: it is the right pair,
274
+ # the reader just must not read a two-edge delta as a fact about the code.
275
+ caveats.append("both sides are deep-tier, which is not byte-stable: two "
276
+ "builds of an unchanged tree can differ by a few call edges, "
277
+ "so read a small call-graph delta as possible tool noise")
267
278
  return {
268
279
  "comparable": not differences,
269
280
  "differences": differences,
281
+ "caveats": caveats,
270
282
  "old": describe(old),
271
283
  "new": describe(new),
272
284
  }
@@ -90,8 +90,17 @@ def _pick_root(core: Path, consumers, docs) -> tuple[Path, str]:
90
90
  return base, "fs"
91
91
 
92
92
 
93
- def _enumerate_git(root: Path, roots: list[tuple[str, str]]) -> tuple[list[str], dict]:
94
- """git ls-files over the scope pathspecs → (rel paths, {path: git_blob})."""
93
+ def _enumerate_git(root: Path, roots: list[tuple[str, str]]) -> tuple[list[str], dict, set[str]]:
94
+ """git ls-files over the scope pathspecs → (rel paths, {path: git_blob}, untracked).
95
+
96
+ Two calls, because "the input" is not "the commit" (R1-C41). The tracked set is what
97
+ ``git ls-files`` knows; ``--others --exclude-standard`` adds exactly what ``git add .``
98
+ would stage — a module that exists and has not been added yet is read by the extractor
99
+ like any other, so leaving it out made the manifest describe a different input than the
100
+ graph was built from, without moving ``scope_id``. Ignored files stay out on purpose:
101
+ if ``.gitignore`` says a file is not part of the tree, the manifest does not get to
102
+ decide otherwise — the membership check names them instead (design §1.7 D2).
103
+ """
95
104
  specs = [rp for rp, _ in roots if rp]
96
105
  out = _git(root, "ls-files", "-s", "--", *specs) or ""
97
106
  paths, blobs = [], {}
@@ -104,7 +113,9 @@ def _enumerate_git(root: Path, roots: list[tuple[str, str]]) -> tuple[list[str],
104
113
  if len(cols) >= 2:
105
114
  blobs[path] = cols[1]
106
115
  paths.append(path)
107
- return paths, blobs
116
+ others = _git(root, "ls-files", "--others", "--exclude-standard", "--", *specs) or ""
117
+ untracked = {p for p in others.splitlines() if p}
118
+ return paths + sorted(untracked), blobs, untracked
108
119
 
109
120
 
110
121
  def _enumerate_fs(root: Path, roots, include, exclude_dirs) -> list[str]:
@@ -138,10 +149,10 @@ def resolve_scope(
138
149
  roots = _roots_spec(root, core, consumers, docs)
139
150
 
140
151
  if mode == "git":
141
- rels, blobs = _enumerate_git(root, roots)
152
+ rels, blobs, untracked = _enumerate_git(root, roots)
142
153
  rels = [r for r in rels if _match_include(Path(r).name, include)]
143
154
  else:
144
- rels, blobs = _enumerate_fs(root, roots, include, exclude_dirs), {}
155
+ rels, blobs, untracked = _enumerate_fs(root, roots, include, exclude_dirs), {}, set()
145
156
 
146
157
  files = []
147
158
  for rel in sorted(set(rels)):
@@ -155,6 +166,11 @@ def resolve_scope(
155
166
  "loc": data.count(b"\n") + (1 if data and not data.endswith(b"\n") else 0)}
156
167
  if rel in blobs:
157
168
  rec["git_blob"] = blobs[rel]
169
+ if mode == "git":
170
+ # Always stated, never inferred from a missing key — absence is ambiguous
171
+ # (R1-C28's rule, applied to the manifest). fs mode omits it: there is no
172
+ # index to be tracked in, so the question does not arise.
173
+ rec["tracked"] = rel not in untracked
158
174
  files.append(rec)
159
175
 
160
176
  scope_id = "sha256:" + hashlib.sha256(
@@ -201,6 +217,51 @@ def _git_block(root: Path, roots: list[tuple[str, str]], mode: str) -> dict:
201
217
  "dirty": bool(dirty_files), "dirty_files": dirty_files}
202
218
 
203
219
 
220
+ def unlisted_files(node_files, scope: dict, *, base: str | Path | None = None,
221
+ sample: int = 5) -> dict:
222
+ """Files the graph was built from that the manifest does not list (R1-C41).
223
+
224
+ The manifest is the artifact a consumer reads to answer *"what exactly was
225
+ analyzed"*, and until this check existed it could answer wrong in silence: the
226
+ graph carried a module whose file the sidecar never mentioned, and ``scope_id`` —
227
+ the same value ``--incremental`` and ``watch`` key off — did not move.
228
+
229
+ **The two sides do not share an origin**, which is the whole difficulty. Node paths
230
+ are relative to the graph's own base (R1-C31: the parent of the package, or the
231
+ common ancestor of the roots), manifest paths are relative to the scope root (the
232
+ git top-level). On a ``src/`` layout those differ by a segment, and a naive string
233
+ comparison called both files of a healthy build unlisted — measured, 2 of 2. So the
234
+ node path is joined onto ``base`` and re-expressed against the scope root before it
235
+ is looked up; lexically, never through ``resolve()``, so a symlinked checkout does
236
+ not silently rewrite the answer.
237
+
238
+ Returns ``{count, sample, outside_root}`` — always, including ``count: 0``, because
239
+ a missing field is not the same statement as "nothing was unlisted". Paths outside
240
+ the scope root are reduced to a bare name: they are absolute (the D5 symptom of a
241
+ file the build should never have read), and the provenance block this ends up in
242
+ refuses absolute paths by contract.
243
+ """
244
+ root = Path(scope.get("root") or ".")
245
+ listed = {f["path"] for f in scope.get("files") or ()}
246
+ unlisted: set[str] = set()
247
+ outside = False
248
+ for raw in node_files:
249
+ if not raw:
250
+ continue
251
+ p = Path(raw)
252
+ absolute = p if p.is_absolute() else Path(os.path.normpath(Path(base or root) / p))
253
+ try:
254
+ rel = absolute.relative_to(root).as_posix()
255
+ except ValueError:
256
+ outside = True
257
+ unlisted.add(p.name)
258
+ continue
259
+ if rel not in listed:
260
+ unlisted.add(rel)
261
+ return {"count": len(unlisted), "sample": sorted(unlisted)[:sample],
262
+ "outside_root": outside}
263
+
264
+
204
265
  def diff_scopes(a: dict, b: dict) -> dict:
205
266
  """Added / removed / changed files between two scope manifests (by path+sha256)."""
206
267
  am = {f["path"]: f["sha256"] for f in a.get("files", [])}
@@ -53,6 +53,10 @@ def render_apidiff(old: Graph, new: Graph) -> str:
53
53
  + f". Old: {prov['old']} | new: {prov['new']}. "
54
54
  "Differences below may be tool changes, not code changes.")
55
55
  out.append("")
56
+ # R1-C42: comparable, and still carrying a noise floor the reader has to know about.
57
+ for caveat in prov.get("caveats") or ():
58
+ out.append(f"> ℹ️ {caveat}.")
59
+ out.append("")
56
60
 
57
61
  removed = d["removed"]
58
62
  if removed:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.7
3
+ Version: 0.0.9
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, **734 tests with no failures on
47
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **752 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 &
@@ -58,8 +58,9 @@ export edges, best-effort call edges, registry-family `implements` links, string
58
58
  and per-call argument contracts — then answers questions over it.
59
59
 
60
60
  Design principles: **source-only** (static `ast`/`griffe`, never imports the target), **deterministic**
61
- (canonical sorted JSON, no timestamps — diffable), **CLI-AI-first** (JSON by default, stable exit
62
- codes), **honest** (approximations are labeled, not hidden).
61
+ (canonical sorted JSON, no timestamps — diffable; byte-stable on the fast tier, see the caveat
62
+ [below](#one-caveat-on-deterministic)), **CLI-AI-first** (JSON by default, stable exit codes),
63
+ **honest** (approximations are labeled, not hidden).
63
64
 
64
65
  ## Point questions and whole-graph questions
65
66
 
@@ -247,6 +248,22 @@ against *all* true edges (the price of Python's dynamism), and a grep-vs-graph p
247
248
  cheaper than grep for impact on unique names, tens of × on polymorphic ones, and no cheaper for locating a
248
249
  symbol.
249
250
 
251
+ ## One caveat on "deterministic"
252
+
253
+ The **fast tier is byte-stable**: two builds of an unchanged tree produce identical files, and CI
254
+ compares the bytes on every push. The **deep tier (`--deep`, jedi) is not**. Ten deep builds of an
255
+ unchanged tree here produced two distinct artifacts — differing in two per-symbol call counters out
256
+ of 2133 nodes, no edges — and on a larger external tree one build in seven lost one real call edge of
257
+ 9524. The cause is jedi's per-script execution budget: an inference that runs out of it returns
258
+ nothing, and nothing is recorded as `unresolved`.
259
+
260
+ So a deep graph is one sample of a slightly fuzzy function, not a function of its input. Every deep
261
+ build says so in its own diagnostics, and `codemap diff` says so when both sides are deep — read a
262
+ delta of a few call edges as possible tool noise before reading it as a change in the code. Anything
263
+ that must be reproducible byte-for-byte — a gate, a two-release comparison — belongs on the fast tier.
264
+
265
+ **Measurement:** [gaps/deep_tier_nondeterminism_2026-09-02.md](gaps/deep_tier_nondeterminism_2026-09-02.md).
266
+
250
267
  ## Dogfooding
251
268
 
252
269
  codemap is validated end-to-end against a real external package. Place a target repo as a sibling and
@@ -108,6 +108,8 @@ tests/test_r1c37_rule_mutation.py
108
108
  tests/test_r1c38_tool_drift.py
109
109
  tests/test_r1c3_arch_contract.py
110
110
  tests/test_r1c3_dogfood.py
111
+ tests/test_r1c41_scope_membership.py
112
+ tests/test_r1c42_deep_tier_stability.py
111
113
  tests/test_r1c4_complexity.py
112
114
  tests/test_r1c5_apidiff.py
113
115
  tests/test_r1c6_pack.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.7"
12
+ version = "0.0.9"
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,213 @@
1
+ """R1-C41 — the manifest must describe the input the graph was actually built from.
2
+
3
+ Raised by the second real target (issue #15): their sidecar listed 47 files with a
4
+ `git_blob` and a `sha256` on each while the graph carried 48, and the artifact a
5
+ consumer reads to answer *"what exactly was analyzed"* answered wrong in silence.
6
+
7
+ Measuring it made the defect wider than the report — no decoy directory is needed. In
8
+ git mode the manifest enumerated the *tracked* set while the extractor walked the
9
+ *filesystem*, so an untracked module (one that exists and has not been `git add`ed) or a
10
+ gitignored one was in the graph, absent from `scope.files`, and moved `scope_id` not at
11
+ all. Since `scope_id` is the cache key for `--incremental` and the `watch` probe, that is
12
+ a correctness defect and not a reporting one: `--incremental` printed
13
+ `unchanged: 0 module(s) recomputed` over a file that had just grown a new symbol.
14
+
15
+ Every test here drives the real CLI over a real git repo. The point is not the arithmetic
16
+ of a comparison — it is whether the condition can arise at all, which is the lesson 0.0.6
17
+ paid for.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import shutil
24
+ import subprocess
25
+ from pathlib import Path
26
+
27
+ import pytest
28
+
29
+ from codemap import cli
30
+ from codemap.diagnostics import SCOPE_MEMBERSHIP, scope_membership_diagnostic
31
+ from codemap.provenance import build_provenance
32
+ from codemap.scope import resolve_scope, unlisted_files
33
+
34
+
35
+ def _git(base: Path, *args: str) -> None:
36
+ subprocess.run(("git", "-C", str(base), *args), check=True,
37
+ capture_output=True, text=True)
38
+
39
+
40
+ @pytest.fixture
41
+ def repo(tmp_path) -> Path:
42
+ """A git repo whose .gitignore excludes a *source* file the extractor still reads."""
43
+ if not shutil.which("git"):
44
+ pytest.skip("git not available")
45
+ (tmp_path / "pkg").mkdir()
46
+ (tmp_path / "pkg" / "__init__.py").write_text("")
47
+ (tmp_path / "pkg" / "core.py").write_text("def a():\n return 1\n")
48
+ (tmp_path / ".gitignore").write_text("pkg/generated_*.py\n")
49
+ _git(tmp_path, "init", "-q")
50
+ _git(tmp_path, "config", "user.email", "t@t")
51
+ _git(tmp_path, "config", "user.name", "t")
52
+ _git(tmp_path, "add", "-A")
53
+ _git(tmp_path, "commit", "-qm", "init")
54
+ return tmp_path
55
+
56
+
57
+ def _build(repo: Path, out: Path, *extra: str) -> tuple[dict, dict]:
58
+ assert cli.main(["build", str(repo / "pkg"), "-o", str(out), *extra]) == 0
59
+ graph = json.loads(out.read_text(encoding="utf-8"))
60
+ meta = json.loads(Path(str(out) + ".meta.json").read_text(encoding="utf-8"))
61
+ return graph, meta
62
+
63
+
64
+ def _files(meta: dict) -> dict[str, dict]:
65
+ return {f["path"]: f for f in meta["scope"]["files"]}
66
+
67
+
68
+ # -- D1: enumerate what will be read, not what the commit remembers ------------
69
+
70
+ def test_an_untracked_module_enters_the_manifest_and_moves_the_identity(repo, tmp_path):
71
+ before, meta_before = _build(repo, tmp_path / "a.json")
72
+ (repo / "pkg" / "untracked.py").write_text("def brand_new():\n return 42\n")
73
+ after, meta_after = _build(repo, tmp_path / "b.json")
74
+
75
+ assert "pkg/untracked.py" not in _files(meta_before)
76
+ assert "pkg/untracked.py" in _files(meta_after), (
77
+ "a module that exists and has not been `git add`ed is read by the extractor; "
78
+ "leaving it out of the manifest describes a different input than the graph")
79
+ # …and the identity has to move, or every cache keyed on it is wrong (below).
80
+ assert before["provenance"]["scope_id"] != after["provenance"]["scope_id"]
81
+
82
+
83
+ def test_incremental_recomputes_an_untracked_edit(repo, tmp_path):
84
+ out = tmp_path / "inc.json"
85
+ (repo / "pkg" / "untracked.py").write_text("def brand_new():\n return 42\n")
86
+ _build(repo, out)
87
+ (repo / "pkg" / "untracked.py").write_text(
88
+ "def brand_new():\n return 42\n\n\ndef added_later():\n return 7\n")
89
+ graph, _ = _build(repo, out, "--incremental")
90
+ assert any(n["id"].endswith("added_later") for n in graph["nodes"]), (
91
+ "scope_id is the incremental cache key: if an untracked edit does not move it, "
92
+ "the rebuild reports `unchanged` and serves a stale graph as current")
93
+
94
+
95
+ def test_git_mode_states_tracked_on_every_record_and_fs_mode_omits_it(repo):
96
+ scope = resolve_scope(repo / "pkg")
97
+ (repo / "pkg" / "untracked.py").write_text("x = 1\n")
98
+ scope2 = resolve_scope(repo / "pkg")
99
+ recs = {f["path"]: f for f in scope2["files"]}
100
+
101
+ # stated on every record, never inferred from a missing key (R1-C28's rule)
102
+ assert all("tracked" in f for f in scope["files"])
103
+ assert recs["pkg/core.py"]["tracked"] is True
104
+ assert recs["pkg/untracked.py"]["tracked"] is False
105
+ assert "git_blob" not in recs["pkg/untracked.py"] # there is none to record
106
+ assert "git_blob" in recs["pkg/core.py"]
107
+
108
+ # fs mode has no index, so the question does not arise and nothing is claimed
109
+ fs = resolve_scope(repo / "pkg", use_git=False)
110
+ assert fs["files"] and all("tracked" not in f for f in fs["files"])
111
+
112
+
113
+ # -- D2: membership named, not adopted ----------------------------------------
114
+
115
+ def test_a_gitignored_module_is_named_and_not_quietly_adopted(repo, tmp_path, capsys):
116
+ (repo / "pkg" / "generated_version.py").write_text('def v():\n return "1.2.3"\n')
117
+ graph, meta = _build(repo, tmp_path / "g.json")
118
+
119
+ # the repo says this file is not part of the tree; the manifest does not overrule it
120
+ assert "pkg/generated_version.py" not in _files(meta)
121
+ # …and the graph says so out loud instead of leaving a hole
122
+ unlisted = graph["provenance"]["inputs"]["unlisted"]
123
+ assert unlisted["count"] == 1
124
+ assert unlisted["sample"] == ["pkg/generated_version.py"]
125
+ assert "not listed in the input manifest" in capsys.readouterr().err
126
+
127
+
128
+ def test_the_count_check_stays_silent_on_exactly_this_state(repo, tmp_path):
129
+ """The conservation law cannot catch it, which is why this check exists."""
130
+ (repo / "pkg" / "generated_version.py").write_text('def v():\n return "1.2.3"\n')
131
+ graph, _ = _build(repo, tmp_path / "g.json")
132
+ from codemap.diagnostics import module_count_diagnostic
133
+ from codemap.store import load
134
+
135
+ g = load(str(tmp_path / "g.json"))
136
+ modules = sum(1 for n in g.nodes.values() if n.kind == "module")
137
+ assert modules == graph["provenance"]["inputs"]["python_files"] == 3
138
+ assert module_count_diagnostic(g) is None # right to be silent: it agrees
139
+ assert scope_membership_diagnostic(g)["code"] == SCOPE_MEMBERSHIP
140
+
141
+
142
+ # -- the trap: the two sides do not share an origin ----------------------------
143
+
144
+ def test_a_src_layout_is_not_a_violation(tmp_path):
145
+ """Node paths are root-relative to the graph's base, manifest paths to the repo.
146
+
147
+ On `src/pkg` those differ by a segment, and comparing the strings as they stand
148
+ called both files of a healthy build unlisted — 2 of 2, measured before the fix.
149
+ """
150
+ if not shutil.which("git"):
151
+ pytest.skip("git not available")
152
+ (tmp_path / "src" / "pkg").mkdir(parents=True)
153
+ (tmp_path / "src" / "pkg" / "__init__.py").write_text("")
154
+ (tmp_path / "src" / "pkg" / "core.py").write_text("def a():\n return 1\n")
155
+ (tmp_path / "tests").mkdir()
156
+ (tmp_path / "tests" / "test_core.py").write_text("from pkg.core import a\n")
157
+ _git(tmp_path, "init", "-q")
158
+ _git(tmp_path, "config", "user.email", "t@t")
159
+ _git(tmp_path, "config", "user.name", "t")
160
+ _git(tmp_path, "add", "-A")
161
+ _git(tmp_path, "commit", "-qm", "init")
162
+
163
+ out = tmp_path / "s.json"
164
+ assert cli.main(["build", str(tmp_path / "src" / "pkg"), "-o", str(out)]) == 0
165
+ core_only = json.loads(out.read_text(encoding="utf-8"))
166
+ assert cli.main(["build", str(tmp_path / "src" / "pkg"), "--consumer",
167
+ str(tmp_path / "tests"), "-o", str(out)]) == 0
168
+ repo_scoped = json.loads(out.read_text(encoding="utf-8"))
169
+
170
+ assert core_only["provenance"]["inputs"]["unlisted"]["count"] == 0
171
+ assert repo_scoped["provenance"]["inputs"]["unlisted"]["count"] == 0
172
+
173
+
174
+ def test_a_file_outside_the_root_is_reduced_to_a_bare_name(tmp_path):
175
+ """The files this check catches are the ones most likely to be absolute — and the
176
+ provenance block refuses an absolute path by contract, so a naive record would turn
177
+ the diagnostic into a build crash."""
178
+ scope = {"root": str(tmp_path), "files": [{"path": "pkg/core.py"}]}
179
+ result = unlisted_files(["pkg/core.py", "/elsewhere/decoy/impostor.py"], scope)
180
+
181
+ assert result["count"] == 1
182
+ assert result["sample"] == ["impostor.py"] # not the absolute path
183
+ assert result["outside_root"] is True
184
+ # the contract that would have been violated (build_provenance raises on absolutes)
185
+ block = build_provenance(tier="fast", inputs={"python_files": 1, "unlisted": result})
186
+ assert block["inputs"]["unlisted"]["outside_root"] is True
187
+
188
+
189
+ # -- zero is a statement; unknown is an omission -------------------------------
190
+
191
+ def test_zero_is_stated_and_no_manifest_is_left_unstated(repo, tmp_path, monkeypatch):
192
+ graph, _ = _build(repo, tmp_path / "clean.json")
193
+ assert graph["provenance"]["inputs"]["unlisted"] == {
194
+ "count": 0, "sample": [], "outside_root": False}
195
+
196
+ from codemap import cli as cli_mod
197
+ monkeypatch.setattr(cli_mod, "_resolve_scope_quietly", lambda args: None)
198
+ out = tmp_path / "noscope.json"
199
+ assert cli.main(["build", str(repo / "pkg"), "-o", str(out)]) == 0
200
+ unresolved = json.loads(out.read_text(encoding="utf-8"))
201
+ assert "unlisted" not in unresolved["provenance"]["inputs"], (
202
+ "no manifest means the build could not compare — writing a zero there would "
203
+ "turn an unknown into a clean bill of health")
204
+
205
+
206
+ def test_the_diagnostic_reports_nothing_when_there_is_nothing_to_report():
207
+ class _G:
208
+ provenance = {"inputs": {"unlisted": {"count": 0, "sample": [],
209
+ "outside_root": False}}}
210
+
211
+ assert scope_membership_diagnostic(_G()) is None
212
+ _G.provenance = {"inputs": {}} # pre-R1-C41 graph
213
+ assert scope_membership_diagnostic(_G()) is None
@@ -0,0 +1,92 @@
1
+ """R1-C42 — the deep tier is not byte-stable, and the artifact must say so.
2
+
3
+ Raised by the second real target: seven builds of an unchanged clean tree, and in one
4
+ of them a real `calls` edge (resolved through `getattr`) was gone — 9524 edges against
5
+ 9523. They had briefly concluded a regression between two releases from it, which is
6
+ the failure this suite exists to prevent: a consumer reading "deterministic" and
7
+ comparing two deep graphs as if every difference were a change in the code.
8
+
9
+ The instability itself was **known** — measured at R1-C9, and the reason the CI
10
+ determinism job runs the fast tier only. It lived in a comment in a workflow file,
11
+ while README said "deterministic" unqualified and `provenance.md` said two builds of an
12
+ unchanged tree are byte-identical. So the fix is disclosure in the places a consumer
13
+ actually reads: the graph's own diagnostics, and any comparison of two deep graphs.
14
+
15
+ Reproduced here on our own tree before the fix: ten deep builds → two distinct
16
+ artifacts (7/3), differing in two per-symbol call counters and no edges. The cause is
17
+ jedi's per-script execution budget: an inference that runs out of it returns nothing,
18
+ and nothing reads as `unresolved`. That is not testable in a unit suite — a build takes
19
+ ~40 s and flips about one run in three — so what is pinned here is the disclosure.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from codemap.diagnostics import DEEP_TIER_UNSTABLE, NOTE, WARNING, deep_tier_diagnostic, diagnostics
25
+ from codemap.model import Graph
26
+ from codemap.provenance import comparability
27
+ from codemap.serve.apidiff import render_apidiff
28
+
29
+
30
+ def _graph(tier: str) -> Graph:
31
+ g = Graph(target="pkg")
32
+ g.provenance = {"tier": tier, "tool": {"name": "codemap", "version": "0.0.9"}}
33
+ return g
34
+
35
+
36
+ # -- the note on the artifact --------------------------------------------------
37
+
38
+ def test_a_fast_graph_says_nothing():
39
+ assert deep_tier_diagnostic(_graph("fast")) is None
40
+
41
+
42
+ def test_a_deep_graph_declares_its_noise_floor():
43
+ d = deep_tier_diagnostic(_graph("deep"))
44
+ assert d is not None and d["code"] == DEEP_TIER_UNSTABLE
45
+ assert "not byte-stable" in d["message"]
46
+ assert d["consequence"]
47
+
48
+
49
+ def test_it_is_a_note_and_not_a_warning():
50
+ """A warning says the findings below are invalid. Nothing here is invalid — the
51
+ graph is sound, it is one sample of a slightly fuzzy function (issue #8's rule)."""
52
+ d = deep_tier_diagnostic(_graph("deep"))
53
+ assert d["severity"] == NOTE != WARNING
54
+
55
+
56
+ def test_it_reaches_the_surface_a_consumer_reads():
57
+ codes = [d["code"] for d in diagnostics(_graph("deep"))]
58
+ assert DEEP_TIER_UNSTABLE in codes
59
+ assert DEEP_TIER_UNSTABLE not in [d["code"] for d in diagnostics(_graph("fast"))]
60
+
61
+
62
+ def test_a_pre_provenance_graph_is_not_guessed_about():
63
+ g = Graph(target="pkg")
64
+ g.provenance = {}
65
+ assert deep_tier_diagnostic(g) is None
66
+
67
+
68
+ # -- the caveat on a comparison ------------------------------------------------
69
+
70
+ def test_two_deep_graphs_stay_comparable_and_carry_the_caveat():
71
+ c = comparability(_graph("deep").provenance, _graph("deep").provenance)
72
+ assert c["comparable"] is True, "matching tiers are the right pair — never a refusal"
73
+ assert c["caveats"] and "not byte-stable" in c["caveats"][0]
74
+
75
+
76
+ def test_two_fast_graphs_carry_no_caveat():
77
+ c = comparability(_graph("fast").provenance, _graph("fast").provenance)
78
+ assert c["caveats"] == []
79
+
80
+
81
+ def test_mixed_tiers_are_still_an_incomparability_not_a_caveat():
82
+ c = comparability(_graph("fast").provenance, _graph("deep").provenance)
83
+ assert c["comparable"] is False
84
+ assert any("different tier" in d for d in c["differences"])
85
+ assert c["caveats"] == [], "a caveat must not soften a genuine incomparability"
86
+
87
+
88
+ def test_the_rendered_diff_shows_it_above_the_verdict():
89
+ out = render_apidiff(_graph("deep"), _graph("deep"))
90
+ assert "not byte-stable" in out
91
+ head = out.split("## ")[0]
92
+ assert "not byte-stable" in head, "a footnote is not a disclosure"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes