codmap 0.0.8__tar.gz → 0.0.10__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 (124) hide show
  1. {codmap-0.0.8/codmap.egg-info → codmap-0.0.10}/PKG-INFO +21 -4
  2. {codmap-0.0.8 → codmap-0.0.10}/README.md +20 -3
  3. {codmap-0.0.8 → codmap-0.0.10}/codemap/cli.py +7 -1
  4. {codmap-0.0.8 → codmap-0.0.10}/codemap/diagnostics.py +73 -1
  5. {codmap-0.0.8 → codmap-0.0.10}/codemap/incremental.py +36 -5
  6. {codmap-0.0.8 → codmap-0.0.10}/codemap/provenance.py +22 -3
  7. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/apidiff.py +4 -0
  8. {codmap-0.0.8 → codmap-0.0.10/codmap.egg-info}/PKG-INFO +21 -4
  9. {codmap-0.0.8 → codmap-0.0.10}/codmap.egg-info/SOURCES.txt +2 -0
  10. {codmap-0.0.8 → codmap-0.0.10}/pyproject.toml +1 -1
  11. codmap-0.0.10/tests/test_r1c42_deep_tier_stability.py +92 -0
  12. codmap-0.0.10/tests/test_r1c43_incremental_splice.py +220 -0
  13. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c9_incremental.py +32 -5
  14. {codmap-0.0.8 → codmap-0.0.10}/LICENSE +0 -0
  15. {codmap-0.0.8 → codmap-0.0.10}/codemap/__init__.py +0 -0
  16. {codmap-0.0.8 → codmap-0.0.10}/codemap/apidiff.py +0 -0
  17. {codmap-0.0.8 → codmap-0.0.10}/codemap/arch.py +0 -0
  18. {codmap-0.0.8 → codmap-0.0.10}/codemap/extract/__init__.py +0 -0
  19. {codmap-0.0.8 → codmap-0.0.10}/codemap/extract/attrflow.py +0 -0
  20. {codmap-0.0.8 → codmap-0.0.10}/codemap/extract/behavior.py +0 -0
  21. {codmap-0.0.8 → codmap-0.0.10}/codemap/extract/dataflow.py +0 -0
  22. {codmap-0.0.8 → codmap-0.0.10}/codemap/extract/dispatch.py +0 -0
  23. {codmap-0.0.8 → codmap-0.0.10}/codemap/extract/griffe_extractor.py +0 -0
  24. {codmap-0.0.8 → codmap-0.0.10}/codemap/extract/gsource.py +0 -0
  25. {codmap-0.0.8 → codmap-0.0.10}/codemap/extract/roots.py +0 -0
  26. {codmap-0.0.8 → codmap-0.0.10}/codemap/freshness.py +0 -0
  27. {codmap-0.0.8 → codmap-0.0.10}/codemap/integrations/__init__.py +0 -0
  28. {codmap-0.0.8 → codmap-0.0.10}/codemap/integrations/base.py +0 -0
  29. {codmap-0.0.8 → codmap-0.0.10}/codemap/integrations/cocoindex.py +0 -0
  30. {codmap-0.0.8 → codmap-0.0.10}/codemap/integrations/gate.py +0 -0
  31. {codmap-0.0.8 → codmap-0.0.10}/codemap/integrations/gitnexus.py +0 -0
  32. {codmap-0.0.8 → codmap-0.0.10}/codemap/integrations/registry.py +0 -0
  33. {codmap-0.0.8 → codmap-0.0.10}/codemap/integrations/transport.py +0 -0
  34. {codmap-0.0.8 → codmap-0.0.10}/codemap/model.py +0 -0
  35. {codmap-0.0.8 → codmap-0.0.10}/codemap/query.py +0 -0
  36. {codmap-0.0.8 → codmap-0.0.10}/codemap/scope.py +0 -0
  37. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/__init__.py +0 -0
  38. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/_scip_pb2.py +0 -0
  39. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/api_surface.py +0 -0
  40. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/architecture.py +0 -0
  41. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/audit.py +0 -0
  42. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/check.py +0 -0
  43. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/ctags.py +0 -0
  44. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/impact.py +0 -0
  45. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/limits.py +0 -0
  46. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/livingdocs.py +0 -0
  47. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/mcp_server.py +0 -0
  48. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/mermaid.py +0 -0
  49. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/pack.py +0 -0
  50. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/rag.py +0 -0
  51. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/review.py +0 -0
  52. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/scip.py +0 -0
  53. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/semantic.py +0 -0
  54. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/server.py +0 -0
  55. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/session.py +0 -0
  56. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/subsystems.py +0 -0
  57. {codmap-0.0.8 → codmap-0.0.10}/codemap/serve/vault.py +0 -0
  58. {codmap-0.0.8 → codmap-0.0.10}/codemap/store.py +0 -0
  59. {codmap-0.0.8 → codmap-0.0.10}/codemap/tomlio.py +0 -0
  60. {codmap-0.0.8 → codmap-0.0.10}/codemap/watch.py +0 -0
  61. {codmap-0.0.8 → codmap-0.0.10}/codmap.egg-info/dependency_links.txt +0 -0
  62. {codmap-0.0.8 → codmap-0.0.10}/codmap.egg-info/entry_points.txt +0 -0
  63. {codmap-0.0.8 → codmap-0.0.10}/codmap.egg-info/requires.txt +0 -0
  64. {codmap-0.0.8 → codmap-0.0.10}/codmap.egg-info/top_level.txt +0 -0
  65. {codmap-0.0.8 → codmap-0.0.10}/setup.cfg +0 -0
  66. {codmap-0.0.8 → codmap-0.0.10}/tests/test_epistemic.py +0 -0
  67. {codmap-0.0.8 → codmap-0.0.10}/tests/test_gitnexus_router.py +0 -0
  68. {codmap-0.0.8 → codmap-0.0.10}/tests/test_impact_depth.py +0 -0
  69. {codmap-0.0.8 → codmap-0.0.10}/tests/test_integrations.py +0 -0
  70. {codmap-0.0.8 → codmap-0.0.10}/tests/test_issue3_serve_freshness.py +0 -0
  71. {codmap-0.0.8 → codmap-0.0.10}/tests/test_livingdocs.py +0 -0
  72. {codmap-0.0.8 → codmap-0.0.10}/tests/test_m0_api_surface.py +0 -0
  73. {codmap-0.0.8 → codmap-0.0.10}/tests/test_m11_argcontract.py +0 -0
  74. {codmap-0.0.8 → codmap-0.0.10}/tests/test_m12_dataflow.py +0 -0
  75. {codmap-0.0.8 → codmap-0.0.10}/tests/test_m15_review.py +0 -0
  76. {codmap-0.0.8 → codmap-0.0.10}/tests/test_m16_architecture.py +0 -0
  77. {codmap-0.0.8 → codmap-0.0.10}/tests/test_m17_mcp.py +0 -0
  78. {codmap-0.0.8 → codmap-0.0.10}/tests/test_m18_freshness.py +0 -0
  79. {codmap-0.0.8 → codmap-0.0.10}/tests/test_m1_5_semantics.py +0 -0
  80. {codmap-0.0.8 → codmap-0.0.10}/tests/test_m1_query.py +0 -0
  81. {codmap-0.0.8 → codmap-0.0.10}/tests/test_m2_views.py +0 -0
  82. {codmap-0.0.8 → codmap-0.0.10}/tests/test_m32_watch.py +0 -0
  83. {codmap-0.0.8 → codmap-0.0.10}/tests/test_m3_serve.py +0 -0
  84. {codmap-0.0.8 → codmap-0.0.10}/tests/test_m4_behavior.py +0 -0
  85. {codmap-0.0.8 → codmap-0.0.10}/tests/test_m5_deep.py +0 -0
  86. {codmap-0.0.8 → codmap-0.0.10}/tests/test_m6_repo_scope.py +0 -0
  87. {codmap-0.0.8 → codmap-0.0.10}/tests/test_m7_dispatch.py +0 -0
  88. {codmap-0.0.8 → codmap-0.0.10}/tests/test_m9_family.py +0 -0
  89. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  90. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c13_grep_vs_graph.py +0 -0
  91. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c16_semantic.py +0 -0
  92. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c20_attribute_edges.py +0 -0
  93. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c21_flat_layout.py +0 -0
  94. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c22_source_visible_refs.py +0 -0
  95. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c23_hard_python.py +0 -0
  96. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c24_test_mapping.py +0 -0
  97. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c25_provenance.py +0 -0
  98. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c26_deep_union.py +0 -0
  99. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c27_config_honesty.py +0 -0
  100. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c28_limit_envelope.py +0 -0
  101. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c29_lazy_imports.py +0 -0
  102. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c2_ctags.py +0 -0
  103. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c30_local_import_calls.py +0 -0
  104. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c30f1_reexport_calls.py +0 -0
  105. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c30f2_check_scope.py +0 -0
  106. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c31_path_origin.py +0 -0
  107. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c32_report_json.py +0 -0
  108. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c33_query_signature.py +0 -0
  109. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c34_signature_kinds.py +0 -0
  110. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c35_contract_location.py +0 -0
  111. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c36_target_resolution.py +0 -0
  112. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c37_rule_mutation.py +0 -0
  113. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c38_tool_drift.py +0 -0
  114. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c3_arch_contract.py +0 -0
  115. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c3_dogfood.py +0 -0
  116. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c41_scope_membership.py +0 -0
  117. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c4_complexity.py +0 -0
  118. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c5_apidiff.py +0 -0
  119. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c6_pack.py +0 -0
  120. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c7_edge_vocab.py +0 -0
  121. {codmap-0.0.8 → codmap-0.0.10}/tests/test_r1c8_deadcode.py +0 -0
  122. {codmap-0.0.8 → codmap-0.0.10}/tests/test_scip_export.py +0 -0
  123. {codmap-0.0.8 → codmap-0.0.10}/tests/test_scope.py +0 -0
  124. {codmap-0.0.8 → codmap-0.0.10}/tests/test_subsystems.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.8
3
+ Version: 0.0.10
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, **743 tests with no failures on
47
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **765 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, **743 tests with no failures on
11
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **765 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,11 @@ 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=_inputs_with_membership(graph, scope, args))
107
+ inputs=_inputs_with_membership(graph, scope, args),
108
+ # R1-C43: this call *overwrites* whatever update_graph stamped, so the flag has
109
+ # to be re-derived here or it is lost. `unchanged` counts as carried over too —
110
+ # nothing in that graph was recomputed by this build at all.
111
+ incremental=(incr_info or {}).get("mode") in ("incremental", "unchanged"))
108
112
  # R1-C21: a well-formed but vacuous graph must announce itself — silence is what
109
113
  # lets an unparsed layout read as a clean bill of health downstream.
110
114
  for d in diagnostics(graph):
@@ -747,6 +751,8 @@ def _cmd_diff(args) -> int:
747
751
  print(f"[warning] {line}", file=sys.stderr)
748
752
  print(f"[warning] differences below may be tool changes, not code changes "
749
753
  f"(old: {cmp['old']} | new: {cmp['new']})", file=sys.stderr)
754
+ for caveat in cmp.get("caveats") or ():
755
+ print(f"[note] {caveat}", file=sys.stderr)
750
756
  print(render_apidiff(old, new), end="")
751
757
  if args.exit_code and not build_apidiff(old, new)["ok"]:
752
758
  return 1
@@ -22,6 +22,8 @@ SCHEMA_MISMATCH = "schema_mismatch"
22
22
  UNREAD_INPUTS = "unread_inputs"
23
23
  MODULE_COUNT_MISMATCH = "module_count_mismatch"
24
24
  SCOPE_MEMBERSHIP = "scope_membership"
25
+ DEEP_TIER_UNSTABLE = "deep_tier_unstable"
26
+ INCREMENTAL_DEEP_SPLICE = "incremental_deep_splice"
25
27
 
26
28
  #: A ``warning`` invalidates the conclusions a surface draws from the graph — read them as
27
29
  #: unknown. A ``note`` states a fact about how the graph was built and invalidates nothing.
@@ -190,6 +192,75 @@ def unread_inputs_diagnostic(graph) -> dict | None:
190
192
  }
191
193
 
192
194
 
195
+ def deep_tier_diagnostic(graph) -> dict | None:
196
+ """State the deep tier's noise floor on the artifact itself (R1-C42).
197
+
198
+ A **note**, not a warning: nothing here is wrong, and no finding below is invalid.
199
+ What was missing is that the tier's instability was known — measured at R1-C9, and
200
+ the reason the CI determinism job runs the fast tier only — while living exclusively
201
+ in a comment in a workflow file. A consumer read "deterministic" and built a
202
+ two-release comparison on it; the difference they saw was the tool, not the code.
203
+
204
+ Measured on two trees: ten deep builds of an unchanged tree produced **two** distinct
205
+ artifacts (7/3), differing in two per-symbol call counters and no edges; on a larger
206
+ tree one build in seven lost one real call edge of 9524. The cause is jedi's
207
+ per-script execution budget — an inference that runs out of it returns nothing, and
208
+ that reads as `unresolved` rather than as an error.
209
+ """
210
+ if (graph.provenance or {}).get("tier") != "deep":
211
+ return None
212
+ return {
213
+ "code": DEEP_TIER_UNSTABLE,
214
+ "severity": NOTE,
215
+ "tier": "deep",
216
+ "consequence": ("Everything here is a lower bound as usual; treat a difference "
217
+ "of a few jedi-resolved edges between two deep graphs as tool "
218
+ "noise rather than a change in the code."),
219
+ "message": (
220
+ "built on the deep (jedi) tier, which is not byte-stable: two builds of an "
221
+ "unchanged tree can differ by a few jedi-resolved edges — measured at roughly "
222
+ "one run in three on two trees: two per-symbol call counters on one, one "
223
+ "`accesses` edge of 12190 on the other."
224
+ ),
225
+ }
226
+
227
+
228
+ def incremental_splice_diagnostic(graph) -> dict | None:
229
+ """Say when a deep graph carries regions this build did not recompute (R1-C43).
230
+
231
+ Sibling of :func:`deep_tier_diagnostic`, and the reason it is a separate code: that
232
+ one says *this graph is one sample*, which a reader can act on by building again.
233
+ This one says the sample is **frozen** — building again incrementally returns it
234
+ unchanged, so the standard remedy silently does nothing.
235
+
236
+ Measured on an 88-module package: from a graph missing one real `accesses` edge,
237
+ five consecutive incremental builds recovered it **0 times** while full builds of
238
+ the very same tree recovered it **5 of 5**. And the miss defends itself — the
239
+ invalidation rule that would have recomputed the writer reads the old graph, where
240
+ the edge is exactly what is absent (gaps/incremental_noise_persistence_2026-09-02.md).
241
+
242
+ Fast tier is excluded on purpose: there the splice is exact and byte-identity to a
243
+ full build is pinned by the suite, so there is no sample to freeze.
244
+ """
245
+ prov = graph.provenance or {}
246
+ if prov.get("tier") != "deep" or not prov.get("incremental"):
247
+ return None
248
+ return {
249
+ "code": INCREMENTAL_DEEP_SPLICE,
250
+ "severity": NOTE,
251
+ "tier": "deep",
252
+ "consequence": ("Do not read a missing call or attribute edge here as evidence "
253
+ "that nothing depends on a symbol, and do not test that by "
254
+ "rebuilding incrementally — only a full rebuild resamples."),
255
+ "message": (
256
+ "parts of this deep graph were spliced from an earlier build rather than "
257
+ "recomputed: on this tier that carries the earlier build's jedi sample "
258
+ "forward, including anything it missed — measured at 0 recoveries in 5 "
259
+ "incremental builds against 5 of 5 full builds of the same tree."
260
+ ),
261
+ }
262
+
263
+
193
264
  def scope_membership_diagnostic(graph) -> dict | None:
194
265
  """Flag files the graph was built from that the input manifest never listed (R1-C41).
195
266
 
@@ -274,7 +345,8 @@ def diagnostics(graph) -> list[dict]:
274
345
  checks = (import_graph_diagnostic(graph), namespace_target_diagnostic(graph),
275
346
  cross_root_diagnostic(graph), schema_diagnostic(graph),
276
347
  unread_inputs_diagnostic(graph), module_count_diagnostic(graph),
277
- scope_membership_diagnostic(graph))
348
+ scope_membership_diagnostic(graph), deep_tier_diagnostic(graph),
349
+ incremental_splice_diagnostic(graph))
278
350
  return [d for d in checks if d is not None]
279
351
 
280
352
 
@@ -25,6 +25,27 @@ so we fall back to it.
25
25
 
26
26
  The acceptance bar (BACKLOG R1-C9) is **byte-identical to a full rebuild**; the test
27
27
  suite pins exactly that across edit / add / remove scenarios on both tiers.
28
+
29
+ **That bar is reachable on the fast tier only** — and saying it without a tier was
30
+ wrong twice over (R1-C43). Two full *deep* builds of an unchanged tree are not
31
+ byte-identical to each other (R1-C42), so there is no fixed artifact to be identical
32
+ *to*. Worse, measurement found a divergence this path introduces on its own:
33
+
34
+ - The splice **freezes a sample.** An edge jedi missed in the old build is copied
35
+ forward verbatim; measured 0 recoveries in 5 incremental builds against 5 in 5 full
36
+ builds of the same tree. "Build it again and see" — the standard answer to tier
37
+ noise — does not work here.
38
+ - The splice **blinds the invalidation that would undo it.** ``_affected_modules``
39
+ rule (b) below reads the *old* graph, so a missing edge is a missing reason to
40
+ recompute: editing the module that owns the target left the writer unaffected when
41
+ the edge was absent, and affected when it was present. Same edit, same tree.
42
+
43
+ The `unresolved` set cannot be indexed by the changed module — not knowing where an
44
+ edge went is what `unresolved` *means* — so this is structural for a cache keyed on
45
+ its own incomplete answer, not an oversight. What we do about it today is declare it:
46
+ ``provenance.incremental`` marks such a graph and the diagnostic says what follows.
47
+
48
+ Measurement: ``gaps/incremental_noise_persistence_2026-09-02.md``.
28
49
  """
29
50
 
30
51
  from __future__ import annotations
@@ -99,6 +120,10 @@ def _affected_modules(old_graph, new_graph, base_mods, changed_removed, module_o
99
120
  if e.type == "imports" and e.target in base_mods:
100
121
  affected.add(e.source)
101
122
  # rule (b): a module whose old behavioral edge targeted a changed/removed module.
123
+ # R1-C43, the limit stated where it lives: this reads the OLD graph, so on the deep
124
+ # tier it is only as complete as that build's jedi sample. An edge the old build
125
+ # missed is a dependency this rule cannot see — measured: with the edge present the
126
+ # writer was invalidated, with the same edge missing it was not, on the same edit.
102
127
  for e in old_graph.edges:
103
128
  if e.type in _DEP_EDGE_TYPES:
104
129
  tgt_mod = module_of(e.target)
@@ -147,10 +172,15 @@ def update_graph(old_graph: Graph, package_path, old_scope: dict, new_scope: dic
147
172
  """Incrementally rebuild ``old_graph`` for the current source tree.
148
173
 
149
174
  Returns ``(graph, info)`` where ``info`` records the decision (``mode``:
150
- ``unchanged`` | ``incremental`` | ``full`` and the affected module list). The
151
- result is byte-identical to ``extract(package_path, deep=deep)`` — the cheap
152
- layers are rebuilt whole and the expensive jedi passes are recomputed for the
153
- affected modules and spliced from the old graph for the rest.
175
+ ``unchanged`` | ``incremental`` | ``full`` and the affected module list). The cheap
176
+ layers are rebuilt whole; the expensive jedi passes are recomputed for the affected
177
+ modules and spliced from the old graph for the rest.
178
+
179
+ On the **fast** tier the result is byte-identical to ``extract(package_path,
180
+ deep=deep)``. On the **deep** tier it is not, and not only because the target moves
181
+ (R1-C42): the spliced regions carry the *previous* build's sample, and the splice
182
+ is self-perpetuating — see the module docstring (R1-C43). Such a graph is stamped
183
+ ``provenance.incremental: true``.
154
184
  """
155
185
  target_pkg = old_graph.target
156
186
  tier = "deep" if deep else "fast"
@@ -191,5 +221,6 @@ def update_graph(old_graph: Graph, package_path, old_scope: dict, new_scope: dic
191
221
  behavior_only=affected, attr_only=affected)
192
222
  unaffected = module_ids - affected
193
223
  _splice_unaffected(graph, old_graph, unaffected, module_of)
194
- graph.provenance = build_provenance(tier=tier, inputs=graph.provenance.get("inputs"))
224
+ graph.provenance = build_provenance(tier=tier, inputs=graph.provenance.get("inputs"),
225
+ incremental=True)
195
226
  return graph, {"mode": "incremental", "affected": sorted(affected)}
@@ -155,9 +155,16 @@ def relative_root(root: str | Path | None, path: str | Path) -> str:
155
155
 
156
156
 
157
157
  def build_provenance(*, tier: str, scope: dict | None = None,
158
- roots: dict | None = None, inputs: dict | None = None) -> dict:
159
- """Assemble the ``provenance`` block. Deterministic; no clock, no absolute path."""
160
- prov: dict = {"tool": tool_identity(), "tier": tier}
158
+ roots: dict | None = None, inputs: dict | None = None,
159
+ incremental: bool = False) -> dict:
160
+ """Assemble the ``provenance`` block. Deterministic; no clock, no absolute path.
161
+
162
+ ``incremental`` records whether parts of this graph were **carried over rather than
163
+ recomputed in this build** (R1-C43). Always written, including ``false`` — absence
164
+ means the graph predates the field, which is *unknown*, not *full* (R1-C28).
165
+ """
166
+ prov: dict = {"tool": tool_identity(), "tier": tier,
167
+ "incremental": bool(incremental)}
161
168
  if inputs:
162
169
  # R1-C23/D2: what the extractor read, and what it could not. Belongs with the
163
170
  # identity rather than in the sidecar — a consumer holding only the graph is
@@ -253,6 +260,7 @@ def comparability(old: dict | None, new: dict | None) -> dict:
253
260
  were dead.
254
261
  """
255
262
  differences: list[str] = []
263
+ caveats: list[str] = []
256
264
  if not old or not new:
257
265
  differences.append("one of the graphs records no provenance "
258
266
  "(built before schema 0.12) — the pair cannot be verified")
@@ -264,9 +272,20 @@ def comparability(old: dict | None, new: dict | None) -> dict:
264
272
  old_roots, new_roots = old.get("roots"), new.get("roots")
265
273
  if old_roots != new_roots:
266
274
  differences.append(f"different scope roots: {old_roots} → {new_roots}")
275
+ if old.get("tier") == new.get("tier") == "deep":
276
+ # R1-C42: matching tiers make the pair comparable, and on the deep tier that
277
+ # is not the same as "every difference here is a code change". Measured on
278
+ # two trees: builds of an *unchanged* tree differ in roughly one run of
279
+ # three, by a couple of call classifications — and on the larger tree by one
280
+ # real call edge in ~9500. A caveat, not a difference: it is the right pair,
281
+ # the reader just must not read a two-edge delta as a fact about the code.
282
+ caveats.append("both sides are deep-tier, which is not byte-stable: two "
283
+ "builds of an unchanged tree can differ by a few jedi-resolved "
284
+ "edges, so read a small delta as possible tool noise")
267
285
  return {
268
286
  "comparable": not differences,
269
287
  "differences": differences,
288
+ "caveats": caveats,
270
289
  "old": describe(old),
271
290
  "new": describe(new),
272
291
  }
@@ -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.8
3
+ Version: 0.0.10
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, **743 tests with no failures on
47
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **765 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
@@ -109,6 +109,8 @@ tests/test_r1c38_tool_drift.py
109
109
  tests/test_r1c3_arch_contract.py
110
110
  tests/test_r1c3_dogfood.py
111
111
  tests/test_r1c41_scope_membership.py
112
+ tests/test_r1c42_deep_tier_stability.py
113
+ tests/test_r1c43_incremental_splice.py
112
114
  tests/test_r1c4_complexity.py
113
115
  tests/test_r1c5_apidiff.py
114
116
  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.8"
12
+ version = "0.0.10"
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,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"