codmap 0.0.9__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.9/codmap.egg-info → codmap-0.0.10}/PKG-INFO +2 -2
  2. {codmap-0.0.9 → codmap-0.0.10}/README.md +1 -1
  3. {codmap-0.0.9 → codmap-0.0.10}/codemap/cli.py +5 -1
  4. {codmap-0.0.9 → codmap-0.0.10}/codemap/diagnostics.py +43 -4
  5. {codmap-0.0.9 → codmap-0.0.10}/codemap/incremental.py +36 -5
  6. {codmap-0.0.9 → codmap-0.0.10}/codemap/provenance.py +12 -5
  7. {codmap-0.0.9 → codmap-0.0.10/codmap.egg-info}/PKG-INFO +2 -2
  8. {codmap-0.0.9 → codmap-0.0.10}/codmap.egg-info/SOURCES.txt +1 -0
  9. {codmap-0.0.9 → codmap-0.0.10}/pyproject.toml +1 -1
  10. codmap-0.0.10/tests/test_r1c43_incremental_splice.py +220 -0
  11. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c9_incremental.py +32 -5
  12. {codmap-0.0.9 → codmap-0.0.10}/LICENSE +0 -0
  13. {codmap-0.0.9 → codmap-0.0.10}/codemap/__init__.py +0 -0
  14. {codmap-0.0.9 → codmap-0.0.10}/codemap/apidiff.py +0 -0
  15. {codmap-0.0.9 → codmap-0.0.10}/codemap/arch.py +0 -0
  16. {codmap-0.0.9 → codmap-0.0.10}/codemap/extract/__init__.py +0 -0
  17. {codmap-0.0.9 → codmap-0.0.10}/codemap/extract/attrflow.py +0 -0
  18. {codmap-0.0.9 → codmap-0.0.10}/codemap/extract/behavior.py +0 -0
  19. {codmap-0.0.9 → codmap-0.0.10}/codemap/extract/dataflow.py +0 -0
  20. {codmap-0.0.9 → codmap-0.0.10}/codemap/extract/dispatch.py +0 -0
  21. {codmap-0.0.9 → codmap-0.0.10}/codemap/extract/griffe_extractor.py +0 -0
  22. {codmap-0.0.9 → codmap-0.0.10}/codemap/extract/gsource.py +0 -0
  23. {codmap-0.0.9 → codmap-0.0.10}/codemap/extract/roots.py +0 -0
  24. {codmap-0.0.9 → codmap-0.0.10}/codemap/freshness.py +0 -0
  25. {codmap-0.0.9 → codmap-0.0.10}/codemap/integrations/__init__.py +0 -0
  26. {codmap-0.0.9 → codmap-0.0.10}/codemap/integrations/base.py +0 -0
  27. {codmap-0.0.9 → codmap-0.0.10}/codemap/integrations/cocoindex.py +0 -0
  28. {codmap-0.0.9 → codmap-0.0.10}/codemap/integrations/gate.py +0 -0
  29. {codmap-0.0.9 → codmap-0.0.10}/codemap/integrations/gitnexus.py +0 -0
  30. {codmap-0.0.9 → codmap-0.0.10}/codemap/integrations/registry.py +0 -0
  31. {codmap-0.0.9 → codmap-0.0.10}/codemap/integrations/transport.py +0 -0
  32. {codmap-0.0.9 → codmap-0.0.10}/codemap/model.py +0 -0
  33. {codmap-0.0.9 → codmap-0.0.10}/codemap/query.py +0 -0
  34. {codmap-0.0.9 → codmap-0.0.10}/codemap/scope.py +0 -0
  35. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/__init__.py +0 -0
  36. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/_scip_pb2.py +0 -0
  37. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/api_surface.py +0 -0
  38. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/apidiff.py +0 -0
  39. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/architecture.py +0 -0
  40. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/audit.py +0 -0
  41. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/check.py +0 -0
  42. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/ctags.py +0 -0
  43. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/impact.py +0 -0
  44. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/limits.py +0 -0
  45. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/livingdocs.py +0 -0
  46. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/mcp_server.py +0 -0
  47. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/mermaid.py +0 -0
  48. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/pack.py +0 -0
  49. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/rag.py +0 -0
  50. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/review.py +0 -0
  51. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/scip.py +0 -0
  52. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/semantic.py +0 -0
  53. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/server.py +0 -0
  54. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/session.py +0 -0
  55. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/subsystems.py +0 -0
  56. {codmap-0.0.9 → codmap-0.0.10}/codemap/serve/vault.py +0 -0
  57. {codmap-0.0.9 → codmap-0.0.10}/codemap/store.py +0 -0
  58. {codmap-0.0.9 → codmap-0.0.10}/codemap/tomlio.py +0 -0
  59. {codmap-0.0.9 → codmap-0.0.10}/codemap/watch.py +0 -0
  60. {codmap-0.0.9 → codmap-0.0.10}/codmap.egg-info/dependency_links.txt +0 -0
  61. {codmap-0.0.9 → codmap-0.0.10}/codmap.egg-info/entry_points.txt +0 -0
  62. {codmap-0.0.9 → codmap-0.0.10}/codmap.egg-info/requires.txt +0 -0
  63. {codmap-0.0.9 → codmap-0.0.10}/codmap.egg-info/top_level.txt +0 -0
  64. {codmap-0.0.9 → codmap-0.0.10}/setup.cfg +0 -0
  65. {codmap-0.0.9 → codmap-0.0.10}/tests/test_epistemic.py +0 -0
  66. {codmap-0.0.9 → codmap-0.0.10}/tests/test_gitnexus_router.py +0 -0
  67. {codmap-0.0.9 → codmap-0.0.10}/tests/test_impact_depth.py +0 -0
  68. {codmap-0.0.9 → codmap-0.0.10}/tests/test_integrations.py +0 -0
  69. {codmap-0.0.9 → codmap-0.0.10}/tests/test_issue3_serve_freshness.py +0 -0
  70. {codmap-0.0.9 → codmap-0.0.10}/tests/test_livingdocs.py +0 -0
  71. {codmap-0.0.9 → codmap-0.0.10}/tests/test_m0_api_surface.py +0 -0
  72. {codmap-0.0.9 → codmap-0.0.10}/tests/test_m11_argcontract.py +0 -0
  73. {codmap-0.0.9 → codmap-0.0.10}/tests/test_m12_dataflow.py +0 -0
  74. {codmap-0.0.9 → codmap-0.0.10}/tests/test_m15_review.py +0 -0
  75. {codmap-0.0.9 → codmap-0.0.10}/tests/test_m16_architecture.py +0 -0
  76. {codmap-0.0.9 → codmap-0.0.10}/tests/test_m17_mcp.py +0 -0
  77. {codmap-0.0.9 → codmap-0.0.10}/tests/test_m18_freshness.py +0 -0
  78. {codmap-0.0.9 → codmap-0.0.10}/tests/test_m1_5_semantics.py +0 -0
  79. {codmap-0.0.9 → codmap-0.0.10}/tests/test_m1_query.py +0 -0
  80. {codmap-0.0.9 → codmap-0.0.10}/tests/test_m2_views.py +0 -0
  81. {codmap-0.0.9 → codmap-0.0.10}/tests/test_m32_watch.py +0 -0
  82. {codmap-0.0.9 → codmap-0.0.10}/tests/test_m3_serve.py +0 -0
  83. {codmap-0.0.9 → codmap-0.0.10}/tests/test_m4_behavior.py +0 -0
  84. {codmap-0.0.9 → codmap-0.0.10}/tests/test_m5_deep.py +0 -0
  85. {codmap-0.0.9 → codmap-0.0.10}/tests/test_m6_repo_scope.py +0 -0
  86. {codmap-0.0.9 → codmap-0.0.10}/tests/test_m7_dispatch.py +0 -0
  87. {codmap-0.0.9 → codmap-0.0.10}/tests/test_m9_family.py +0 -0
  88. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  89. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c13_grep_vs_graph.py +0 -0
  90. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c16_semantic.py +0 -0
  91. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c20_attribute_edges.py +0 -0
  92. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c21_flat_layout.py +0 -0
  93. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c22_source_visible_refs.py +0 -0
  94. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c23_hard_python.py +0 -0
  95. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c24_test_mapping.py +0 -0
  96. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c25_provenance.py +0 -0
  97. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c26_deep_union.py +0 -0
  98. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c27_config_honesty.py +0 -0
  99. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c28_limit_envelope.py +0 -0
  100. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c29_lazy_imports.py +0 -0
  101. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c2_ctags.py +0 -0
  102. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c30_local_import_calls.py +0 -0
  103. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c30f1_reexport_calls.py +0 -0
  104. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c30f2_check_scope.py +0 -0
  105. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c31_path_origin.py +0 -0
  106. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c32_report_json.py +0 -0
  107. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c33_query_signature.py +0 -0
  108. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c34_signature_kinds.py +0 -0
  109. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c35_contract_location.py +0 -0
  110. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c36_target_resolution.py +0 -0
  111. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c37_rule_mutation.py +0 -0
  112. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c38_tool_drift.py +0 -0
  113. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c3_arch_contract.py +0 -0
  114. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c3_dogfood.py +0 -0
  115. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c41_scope_membership.py +0 -0
  116. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c42_deep_tier_stability.py +0 -0
  117. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c4_complexity.py +0 -0
  118. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c5_apidiff.py +0 -0
  119. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c6_pack.py +0 -0
  120. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c7_edge_vocab.py +0 -0
  121. {codmap-0.0.9 → codmap-0.0.10}/tests/test_r1c8_deadcode.py +0 -0
  122. {codmap-0.0.9 → codmap-0.0.10}/tests/test_scip_export.py +0 -0
  123. {codmap-0.0.9 → codmap-0.0.10}/tests/test_scope.py +0 -0
  124. {codmap-0.0.9 → 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.9
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, **752 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 &
@@ -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, **752 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 &
@@ -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):
@@ -23,6 +23,7 @@ UNREAD_INPUTS = "unread_inputs"
23
23
  MODULE_COUNT_MISMATCH = "module_count_mismatch"
24
24
  SCOPE_MEMBERSHIP = "scope_membership"
25
25
  DEEP_TIER_UNSTABLE = "deep_tier_unstable"
26
+ INCREMENTAL_DEEP_SPLICE = "incremental_deep_splice"
26
27
 
27
28
  #: A ``warning`` invalidates the conclusions a surface draws from the graph — read them as
28
29
  #: unknown. A ``note`` states a fact about how the graph was built and invalidates nothing.
@@ -213,12 +214,49 @@ def deep_tier_diagnostic(graph) -> dict | None:
213
214
  "severity": NOTE,
214
215
  "tier": "deep",
215
216
  "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
+ "of a few jedi-resolved edges between two deep graphs as tool "
217
218
  "noise rather than a change in the code."),
218
219
  "message": (
219
220
  "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."
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."
222
260
  ),
223
261
  }
224
262
 
@@ -307,7 +345,8 @@ def diagnostics(graph) -> list[dict]:
307
345
  checks = (import_graph_diagnostic(graph), namespace_target_diagnostic(graph),
308
346
  cross_root_diagnostic(graph), schema_diagnostic(graph),
309
347
  unread_inputs_diagnostic(graph), module_count_diagnostic(graph),
310
- scope_membership_diagnostic(graph), deep_tier_diagnostic(graph))
348
+ scope_membership_diagnostic(graph), deep_tier_diagnostic(graph),
349
+ incremental_splice_diagnostic(graph))
311
350
  return [d for d in checks if d is not None]
312
351
 
313
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
@@ -273,8 +280,8 @@ def comparability(old: dict | None, new: dict | None) -> dict:
273
280
  # real call edge in ~9500. A caveat, not a difference: it is the right pair,
274
281
  # the reader just must not read a two-edge delta as a fact about the code.
275
282
  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")
283
+ "builds of an unchanged tree can differ by a few jedi-resolved "
284
+ "edges, so read a small delta as possible tool noise")
278
285
  return {
279
286
  "comparable": not differences,
280
287
  "differences": differences,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.9
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, **752 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 &
@@ -110,6 +110,7 @@ tests/test_r1c3_arch_contract.py
110
110
  tests/test_r1c3_dogfood.py
111
111
  tests/test_r1c41_scope_membership.py
112
112
  tests/test_r1c42_deep_tier_stability.py
113
+ tests/test_r1c43_incremental_splice.py
113
114
  tests/test_r1c4_complexity.py
114
115
  tests/test_r1c5_apidiff.py
115
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.9"
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,220 @@
1
+ """R1-C43 — an incremental deep graph says that parts of it were not recomputed.
2
+
3
+ Two separate things are pinned here, and only the second one is a *fix*:
4
+
5
+ 1. ``provenance.incremental`` is written by every build path, ``false`` included — a
6
+ consumer must be able to tell "recomputed in full" from "predates the field", and an
7
+ absent key is the second of those (R1-C28).
8
+ 2. The diagnostic that reads it, which fires on the deep tier only.
9
+
10
+ The third block pins a **known limit, not a guarantee**: ``_affected_modules`` rule (b)
11
+ reads the *old* graph, so an edge that build missed is a dependency it cannot see. On
12
+ the deep tier the old graph is one jedi sample, so a noise-dropped edge switches off the
13
+ recompute that would have restored it. Measured on an 88-module package — same edit,
14
+ same tree, the writer module was invalidated when the edge was present and not when it
15
+ was absent (gaps/incremental_noise_persistence_2026-09-02.md §6). These tests state that
16
+ behaviour deliberately: when it is fixed, they must be rewritten, not deleted quietly.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from pathlib import Path
23
+
24
+ from codemap import cli, store
25
+ from codemap.diagnostics import (
26
+ DEEP_TIER_UNSTABLE, INCREMENTAL_DEEP_SPLICE, NOTE, WARNING,
27
+ diagnostics, incremental_splice_diagnostic, render_lines,
28
+ )
29
+ from codemap.extract import extract
30
+ from codemap.incremental import _affected_modules, _module_indexer, update_graph
31
+ from codemap.model import Edge, Graph, Node
32
+ from codemap.provenance import build_provenance
33
+ from codemap.scope import resolve_scope
34
+
35
+ PKG_INIT = '"""Tiny package."""\n'
36
+ BASE = '''\
37
+ """Base."""
38
+
39
+
40
+ class Config:
41
+ """A config."""
42
+
43
+ width = 10
44
+ '''
45
+ LEAF = '''\
46
+ """Leaf."""
47
+
48
+ from .base import Config
49
+
50
+
51
+ def widen(c: Config) -> int:
52
+ """Read a field off a typed parameter."""
53
+ return c.width + 1
54
+ '''
55
+ OTHER = '''\
56
+ """Unrelated to the pair above."""
57
+
58
+
59
+ def helper() -> int:
60
+ return 1
61
+ '''
62
+
63
+
64
+ def _pkg(tmp_path: Path) -> Path:
65
+ pkg = tmp_path / "tiny"
66
+ pkg.mkdir()
67
+ (pkg / "__init__.py").write_text(PKG_INIT, encoding="utf-8")
68
+ (pkg / "base.py").write_text(BASE, encoding="utf-8")
69
+ (pkg / "leaf.py").write_text(LEAF, encoding="utf-8")
70
+ (pkg / "other.py").write_text(OTHER, encoding="utf-8")
71
+ return pkg
72
+
73
+
74
+ def _scope(pkg: Path) -> dict:
75
+ return resolve_scope(pkg, use_git=False)
76
+
77
+
78
+ # -- 1. the field ------------------------------------------------------------
79
+
80
+ def test_a_full_build_declares_that_it_recomputed_everything(tmp_path):
81
+ """`false` is a statement; the reader must not have to infer it from absence."""
82
+ g = extract(_pkg(tmp_path), deep=False)
83
+ assert g.provenance["incremental"] is False
84
+
85
+
86
+ def test_an_incremental_rebuild_declares_that_it_did_not(tmp_path):
87
+ pkg = _pkg(tmp_path)
88
+ g0 = extract(pkg, deep=False)
89
+ s0 = _scope(pkg)
90
+ (pkg / "other.py").write_text(OTHER + "\n# edited\n", encoding="utf-8")
91
+ g1, info = update_graph(g0, pkg, s0, _scope(pkg), deep=False)
92
+ if info["mode"] == "incremental":
93
+ assert g1.provenance["incremental"] is True
94
+ else:
95
+ # a four-module fixture trips the >=50% full-rebuild fallback easily; a full
96
+ # rebuild recomputes everything and must say so.
97
+ assert g1.provenance["incremental"] is False
98
+
99
+
100
+ def test_the_cli_does_not_drop_the_flag_when_it_restamps_provenance(tmp_path):
101
+ """The subtle one: `_cmd_build` **overwrites** the block `update_graph` produced.
102
+
103
+ Nothing in the library-level tests can catch that — they read the graph object
104
+ before the CLI touches it — so the field would have been written, overwritten with
105
+ the default, and shipped as `false` on every incremental build.
106
+ """
107
+ pkg = _pkg(tmp_path)
108
+ out = tmp_path / "g.json"
109
+ assert cli.main(["build", str(pkg), "-o", str(out)]) == 0
110
+ assert json.loads(out.read_text(encoding="utf-8"))["provenance"]["incremental"] is False
111
+
112
+ (pkg / "other.py").write_text(OTHER + "\n# edited\n", encoding="utf-8")
113
+ assert cli.main(["build", str(pkg), "-o", str(out), "--incremental"]) == 0
114
+ written = json.loads(out.read_text(encoding="utf-8"))["provenance"]
115
+ assert written["incremental"] is True
116
+
117
+
118
+ def test_an_untouched_tree_is_carried_over_too(tmp_path):
119
+ """`mode: unchanged` returns the previous graph whole — nothing was recomputed."""
120
+ pkg = _pkg(tmp_path)
121
+ out = tmp_path / "g.json"
122
+ assert cli.main(["build", str(pkg), "-o", str(out)]) == 0
123
+ assert cli.main(["build", str(pkg), "-o", str(out), "--incremental"]) == 0
124
+ assert json.loads(out.read_text(encoding="utf-8"))["provenance"]["incremental"] is True
125
+
126
+
127
+ def test_the_flag_survives_a_round_trip_through_the_artifact(tmp_path):
128
+ g = extract(_pkg(tmp_path), deep=False)
129
+ g.provenance = build_provenance(tier="deep", incremental=True)
130
+ out = tmp_path / "g.json"
131
+ store.save(g, out)
132
+ assert store.load(out).provenance["incremental"] is True
133
+
134
+
135
+ # -- 2. the diagnostic -------------------------------------------------------
136
+
137
+ def _graph(tier: str, **kw) -> Graph:
138
+ g = Graph(target="tiny")
139
+ g.provenance = build_provenance(tier=tier, **kw)
140
+ return g
141
+
142
+
143
+ def test_a_spliced_deep_graph_says_so():
144
+ d = incremental_splice_diagnostic(_graph("deep", incremental=True))
145
+ assert d is not None
146
+ assert d["code"] == INCREMENTAL_DEEP_SPLICE
147
+ assert d["severity"] == NOTE and d["severity"] != WARNING
148
+
149
+
150
+ def test_a_full_deep_graph_is_not_accused_of_splicing():
151
+ assert incremental_splice_diagnostic(_graph("deep", incremental=False)) is None
152
+
153
+
154
+ def test_the_fast_tier_is_excluded_because_its_splice_is_exact():
155
+ """Byte-identity to a full build is pinned there; there is no sample to freeze."""
156
+ assert incremental_splice_diagnostic(_graph("fast", incremental=True)) is None
157
+
158
+
159
+ def test_a_graph_predating_the_field_is_not_guessed_about():
160
+ g = Graph(target="tiny")
161
+ g.provenance = {"tier": "deep", "tool": {"name": "codemap"}} # no `incremental` key
162
+ assert incremental_splice_diagnostic(g) is None
163
+
164
+
165
+ def test_it_reaches_the_aggregator_alongside_the_tier_note():
166
+ """Two different facts about one graph: it is a sample, and the sample is frozen."""
167
+ codes = {d["code"] for d in diagnostics(_graph("deep", incremental=True))}
168
+ assert {INCREMENTAL_DEEP_SPLICE, DEEP_TIER_UNSTABLE} <= codes
169
+
170
+
171
+ def test_the_rendered_note_tells_the_reader_not_to_retry_incrementally():
172
+ text = " ".join(render_lines(_graph("deep", incremental=True)))
173
+ assert "full rebuild" in text or "full rebuild resamples" in text
174
+ assert "ℹ️" in text
175
+
176
+
177
+ # -- 3. the limit this does NOT fix ------------------------------------------
178
+
179
+ def _old_graph(with_edge: bool) -> Graph:
180
+ """Two old graphs differing by exactly one behavioral edge.
181
+
182
+ `leaf.widen` writes/reads `base.Config.width`. The pair is *not* import-linked in
183
+ the way rule (a) needs (that rule keys on module-level `imports` edges, added
184
+ below only for the unrelated module), so rule (b) — which reads this graph — is the
185
+ only route by which editing `base` can invalidate `leaf`.
186
+ """
187
+ g = Graph(target="tiny")
188
+ for mid in ("tiny", "tiny.base", "tiny.leaf", "tiny.other"):
189
+ g.add_node(Node(id=mid, kind="module"))
190
+ g.add_node(Node(id="tiny.base.Config.width", kind="attribute"))
191
+ g.add_node(Node(id="tiny.leaf.widen", kind="function"))
192
+ if with_edge:
193
+ g.add_edge(Edge(type="accesses", source="tiny.leaf.widen",
194
+ target="tiny.base.Config.width",
195
+ extras={"access": "read", "resolution": "deep"}))
196
+ return g
197
+
198
+
199
+ def _affected_for(with_edge: bool) -> set[str]:
200
+ old = _old_graph(with_edge)
201
+ new = Graph(target="tiny") # no fresh `imports` edge into base → rule (a) idle
202
+ for mid in ("tiny", "tiny.base", "tiny.leaf", "tiny.other"):
203
+ new.add_node(Node(id=mid, kind="module"))
204
+ module_of = _module_indexer({n.id for n in old.nodes.values() if n.kind == "module"})
205
+ changed = {"tiny.base"}
206
+ return _affected_modules(old, new, changed, changed, module_of)
207
+
208
+
209
+ def test_the_edge_is_what_makes_the_writer_get_recomputed():
210
+ assert "tiny.leaf" in _affected_for(with_edge=True)
211
+
212
+
213
+ def test_and_without_it_the_writer_is_silently_left_alone():
214
+ """The measured failure, stated: a dropped edge is a dropped reason to recompute.
215
+
216
+ Not an assertion that this is right — it is the limit R1-C43 names and does not
217
+ close. Door (2) (a periodic full rebuild) or door (3) (restricting what an
218
+ incremental deep graph may be used to conclude) would change this expectation.
219
+ """
220
+ assert "tiny.leaf" not in _affected_for(with_edge=False)
@@ -7,14 +7,24 @@ fast and deep tiers, over a small multi-module package with cross-module calls,
7
7
  inheritance, and attribute access (the relationships the splice must get right).
8
8
 
9
9
  Deep byte-identity holds here because this fixture's jedi inference is single-hop and
10
- stable. On large packages jedi's *bounded* deep inference is cache-warmth-dependent —
11
- two full ``--deep`` builds already differ by a few deep-only edges — so there the
12
- guarantee is "identical up to jedi's own run-to-run variance" (see docs/incremental.md).
10
+ stable. On large packages it does not, and the two reasons are worth keeping apart:
11
+
12
+ - Two full ``--deep`` builds of an unchanged tree already differ from each other
13
+ (R1-C42), so on that tier there is no fixed artifact to be identical *to*. This was
14
+ attributed to jedi's **cache warmth** until 2026-09-02, when a cold ``XDG_CACHE_HOME``
15
+ per build flipped just as often (5 of 10) and refuted it; the cause is jedi's
16
+ per-script execution budget. That correction reached ``docs/incremental.md`` the same
17
+ day and not this docstring — the copy nobody grepped for.
18
+ - The splice adds a divergence of its own (R1-C43): it carries the previous build's
19
+ sample forward, and the rule that would invalidate it reads that same incomplete
20
+ graph. Pinned in ``tests/test_r1c43_incremental_splice.py``.
21
+
13
22
  The splice logic itself is exact, which is what this fixture proves.
14
23
  """
15
24
 
16
25
  from __future__ import annotations
17
26
 
27
+ import json
18
28
  from pathlib import Path
19
29
 
20
30
  import pytest
@@ -95,13 +105,30 @@ def _scope(pkg: Path) -> dict:
95
105
 
96
106
 
97
107
  def _assert_incremental_matches_full(old_graph, pkg, scope0, *, deep, expect_mode):
108
+ """The splice reproduces a full build's *content* — and says it was not one.
109
+
110
+ R1-C43 split what used to be a single `dumps == dumps`. The graphs must agree on
111
+ every node and edge, which is the acceptance bar; they must **disagree** on
112
+ `provenance.incremental`, because an artifact that was partly carried over may not
113
+ present itself as one that was recomputed. Comparing the whole file conflated the
114
+ two, and the field that exists to distinguish them would have read as a regression.
115
+ """
98
116
  scope1 = _scope(pkg)
99
117
  inc, info = update_graph(old_graph, pkg, scope0, scope1, deep=deep)
100
118
  full = extract(pkg, deep=deep)
101
- assert store.dumps(inc) == store.dumps(full), f"incremental != full ({info})"
119
+
120
+ def content(g):
121
+ d = json.loads(store.dumps(g))
122
+ d.pop("provenance", None)
123
+ return json.dumps(d, sort_keys=True)
124
+
125
+ assert content(inc) == content(full), f"incremental != full ({info})"
126
+ assert full.provenance["incremental"] is False
102
127
  # a small toy package trips the full-rebuild fallback easily; the correctness
103
- # invariant (dumps-equal) holds either way, so accept a set of allowed modes.
128
+ # invariant holds either way, so accept a set of allowed modes — but the flag must
129
+ # follow the mode that actually ran, not the one we hoped for.
104
130
  assert info["mode"] in expect_mode
131
+ assert inc.provenance["incremental"] is (info["mode"] != "full")
105
132
  return inc, info
106
133
 
107
134
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes