codmap 0.0.9__tar.gz → 0.0.11__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 (127) hide show
  1. {codmap-0.0.9/codmap.egg-info → codmap-0.0.11}/PKG-INFO +16 -3
  2. {codmap-0.0.9 → codmap-0.0.11}/README.md +15 -2
  3. {codmap-0.0.9 → codmap-0.0.11}/codemap/cli.py +34 -2
  4. {codmap-0.0.9 → codmap-0.0.11}/codemap/diagnostics.py +76 -7
  5. {codmap-0.0.9 → codmap-0.0.11}/codemap/extract/behavior.py +95 -1
  6. {codmap-0.0.9 → codmap-0.0.11}/codemap/extract/griffe_extractor.py +47 -4
  7. {codmap-0.0.9 → codmap-0.0.11}/codemap/extract/roots.py +30 -5
  8. codmap-0.0.11/codemap/extract/union.py +144 -0
  9. {codmap-0.0.9 → codmap-0.0.11}/codemap/incremental.py +43 -7
  10. {codmap-0.0.9 → codmap-0.0.11}/codemap/provenance.py +34 -5
  11. {codmap-0.0.9 → codmap-0.0.11}/codemap/query.py +18 -0
  12. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/audit.py +16 -0
  13. {codmap-0.0.9 → codmap-0.0.11/codmap.egg-info}/PKG-INFO +16 -3
  14. {codmap-0.0.9 → codmap-0.0.11}/codmap.egg-info/SOURCES.txt +4 -0
  15. {codmap-0.0.9 → codmap-0.0.11}/pyproject.toml +1 -1
  16. codmap-0.0.11/tests/test_r1c43_incremental_splice.py +220 -0
  17. codmap-0.0.11/tests/test_r1c45_repeat_union.py +236 -0
  18. codmap-0.0.11/tests/test_r1c46_shadowed_definitions.py +142 -0
  19. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c9_incremental.py +32 -5
  20. {codmap-0.0.9 → codmap-0.0.11}/LICENSE +0 -0
  21. {codmap-0.0.9 → codmap-0.0.11}/codemap/__init__.py +0 -0
  22. {codmap-0.0.9 → codmap-0.0.11}/codemap/apidiff.py +0 -0
  23. {codmap-0.0.9 → codmap-0.0.11}/codemap/arch.py +0 -0
  24. {codmap-0.0.9 → codmap-0.0.11}/codemap/extract/__init__.py +0 -0
  25. {codmap-0.0.9 → codmap-0.0.11}/codemap/extract/attrflow.py +0 -0
  26. {codmap-0.0.9 → codmap-0.0.11}/codemap/extract/dataflow.py +0 -0
  27. {codmap-0.0.9 → codmap-0.0.11}/codemap/extract/dispatch.py +0 -0
  28. {codmap-0.0.9 → codmap-0.0.11}/codemap/extract/gsource.py +0 -0
  29. {codmap-0.0.9 → codmap-0.0.11}/codemap/freshness.py +0 -0
  30. {codmap-0.0.9 → codmap-0.0.11}/codemap/integrations/__init__.py +0 -0
  31. {codmap-0.0.9 → codmap-0.0.11}/codemap/integrations/base.py +0 -0
  32. {codmap-0.0.9 → codmap-0.0.11}/codemap/integrations/cocoindex.py +0 -0
  33. {codmap-0.0.9 → codmap-0.0.11}/codemap/integrations/gate.py +0 -0
  34. {codmap-0.0.9 → codmap-0.0.11}/codemap/integrations/gitnexus.py +0 -0
  35. {codmap-0.0.9 → codmap-0.0.11}/codemap/integrations/registry.py +0 -0
  36. {codmap-0.0.9 → codmap-0.0.11}/codemap/integrations/transport.py +0 -0
  37. {codmap-0.0.9 → codmap-0.0.11}/codemap/model.py +0 -0
  38. {codmap-0.0.9 → codmap-0.0.11}/codemap/scope.py +0 -0
  39. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/__init__.py +0 -0
  40. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/_scip_pb2.py +0 -0
  41. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/api_surface.py +0 -0
  42. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/apidiff.py +0 -0
  43. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/architecture.py +0 -0
  44. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/check.py +0 -0
  45. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/ctags.py +0 -0
  46. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/impact.py +0 -0
  47. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/limits.py +0 -0
  48. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/livingdocs.py +0 -0
  49. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/mcp_server.py +0 -0
  50. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/mermaid.py +0 -0
  51. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/pack.py +0 -0
  52. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/rag.py +0 -0
  53. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/review.py +0 -0
  54. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/scip.py +0 -0
  55. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/semantic.py +0 -0
  56. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/server.py +0 -0
  57. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/session.py +0 -0
  58. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/subsystems.py +0 -0
  59. {codmap-0.0.9 → codmap-0.0.11}/codemap/serve/vault.py +0 -0
  60. {codmap-0.0.9 → codmap-0.0.11}/codemap/store.py +0 -0
  61. {codmap-0.0.9 → codmap-0.0.11}/codemap/tomlio.py +0 -0
  62. {codmap-0.0.9 → codmap-0.0.11}/codemap/watch.py +0 -0
  63. {codmap-0.0.9 → codmap-0.0.11}/codmap.egg-info/dependency_links.txt +0 -0
  64. {codmap-0.0.9 → codmap-0.0.11}/codmap.egg-info/entry_points.txt +0 -0
  65. {codmap-0.0.9 → codmap-0.0.11}/codmap.egg-info/requires.txt +0 -0
  66. {codmap-0.0.9 → codmap-0.0.11}/codmap.egg-info/top_level.txt +0 -0
  67. {codmap-0.0.9 → codmap-0.0.11}/setup.cfg +0 -0
  68. {codmap-0.0.9 → codmap-0.0.11}/tests/test_epistemic.py +0 -0
  69. {codmap-0.0.9 → codmap-0.0.11}/tests/test_gitnexus_router.py +0 -0
  70. {codmap-0.0.9 → codmap-0.0.11}/tests/test_impact_depth.py +0 -0
  71. {codmap-0.0.9 → codmap-0.0.11}/tests/test_integrations.py +0 -0
  72. {codmap-0.0.9 → codmap-0.0.11}/tests/test_issue3_serve_freshness.py +0 -0
  73. {codmap-0.0.9 → codmap-0.0.11}/tests/test_livingdocs.py +0 -0
  74. {codmap-0.0.9 → codmap-0.0.11}/tests/test_m0_api_surface.py +0 -0
  75. {codmap-0.0.9 → codmap-0.0.11}/tests/test_m11_argcontract.py +0 -0
  76. {codmap-0.0.9 → codmap-0.0.11}/tests/test_m12_dataflow.py +0 -0
  77. {codmap-0.0.9 → codmap-0.0.11}/tests/test_m15_review.py +0 -0
  78. {codmap-0.0.9 → codmap-0.0.11}/tests/test_m16_architecture.py +0 -0
  79. {codmap-0.0.9 → codmap-0.0.11}/tests/test_m17_mcp.py +0 -0
  80. {codmap-0.0.9 → codmap-0.0.11}/tests/test_m18_freshness.py +0 -0
  81. {codmap-0.0.9 → codmap-0.0.11}/tests/test_m1_5_semantics.py +0 -0
  82. {codmap-0.0.9 → codmap-0.0.11}/tests/test_m1_query.py +0 -0
  83. {codmap-0.0.9 → codmap-0.0.11}/tests/test_m2_views.py +0 -0
  84. {codmap-0.0.9 → codmap-0.0.11}/tests/test_m32_watch.py +0 -0
  85. {codmap-0.0.9 → codmap-0.0.11}/tests/test_m3_serve.py +0 -0
  86. {codmap-0.0.9 → codmap-0.0.11}/tests/test_m4_behavior.py +0 -0
  87. {codmap-0.0.9 → codmap-0.0.11}/tests/test_m5_deep.py +0 -0
  88. {codmap-0.0.9 → codmap-0.0.11}/tests/test_m6_repo_scope.py +0 -0
  89. {codmap-0.0.9 → codmap-0.0.11}/tests/test_m7_dispatch.py +0 -0
  90. {codmap-0.0.9 → codmap-0.0.11}/tests/test_m9_family.py +0 -0
  91. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  92. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c13_grep_vs_graph.py +0 -0
  93. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c16_semantic.py +0 -0
  94. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c20_attribute_edges.py +0 -0
  95. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c21_flat_layout.py +0 -0
  96. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c22_source_visible_refs.py +0 -0
  97. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c23_hard_python.py +0 -0
  98. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c24_test_mapping.py +0 -0
  99. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c25_provenance.py +0 -0
  100. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c26_deep_union.py +0 -0
  101. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c27_config_honesty.py +0 -0
  102. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c28_limit_envelope.py +0 -0
  103. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c29_lazy_imports.py +0 -0
  104. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c2_ctags.py +0 -0
  105. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c30_local_import_calls.py +0 -0
  106. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c30f1_reexport_calls.py +0 -0
  107. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c30f2_check_scope.py +0 -0
  108. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c31_path_origin.py +0 -0
  109. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c32_report_json.py +0 -0
  110. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c33_query_signature.py +0 -0
  111. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c34_signature_kinds.py +0 -0
  112. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c35_contract_location.py +0 -0
  113. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c36_target_resolution.py +0 -0
  114. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c37_rule_mutation.py +0 -0
  115. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c38_tool_drift.py +0 -0
  116. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c3_arch_contract.py +0 -0
  117. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c3_dogfood.py +0 -0
  118. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c41_scope_membership.py +0 -0
  119. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c42_deep_tier_stability.py +0 -0
  120. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c4_complexity.py +0 -0
  121. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c5_apidiff.py +0 -0
  122. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c6_pack.py +0 -0
  123. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c7_edge_vocab.py +0 -0
  124. {codmap-0.0.9 → codmap-0.0.11}/tests/test_r1c8_deadcode.py +0 -0
  125. {codmap-0.0.9 → codmap-0.0.11}/tests/test_scip_export.py +0 -0
  126. {codmap-0.0.9 → codmap-0.0.11}/tests/test_scope.py +0 -0
  127. {codmap-0.0.9 → codmap-0.0.11}/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.11
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 &
@@ -167,6 +167,9 @@ codemap build ./yourpkg -o graph.json
167
167
  # repo-scoped: add consumers (tests/examples) + docs for blast-radius/impact
168
168
  codemap build ./yourpkg --deep --mode full --consumer ./tests --docs ./docs -o graph.json
169
169
 
170
+ # deep tier: union N independent samples (one build misses a real edge ~1 time in 4)
171
+ codemap build ./yourpkg --deep --repeat 3 -o graph.json
172
+
170
173
  # ask about a symbol (JSON by default; --format text for humans)
171
174
  codemap query analyze_zones --graph graph.json
172
175
 
@@ -262,7 +265,17 @@ build says so in its own diagnostics, and `codemap diff` says so when both sides
262
265
  delta of a few call edges as possible tool noise before reading it as a change in the code. Anything
263
266
  that must be reproducible byte-for-byte — a gate, a two-release comparison — belongs on the fast tier.
264
267
 
265
- **Measurement:** [gaps/deep_tier_nondeterminism_2026-09-02.md](gaps/deep_tier_nondeterminism_2026-09-02.md).
268
+ **How many times to build.** A consumer measured 175 full deep builds of one tree and found a real
269
+ edge present in **75 %** of them; eight builds here agreed (5 of 8, the same edge, and nothing else
270
+ unstable among 13 675). So one build misses such an edge about 1 time in 4, two builds 1 in 16, three
271
+ 1 in 64. `codemap build --deep --repeat N` builds N samples — each in a fresh interpreter, the
272
+ regime the share was measured in; in-process repeats come in correlated streaks — and unions them: an edge seen in fewer
273
+ than N runs carries `extras.seen`, `provenance.samples` records N and how many edges varied, and the
274
+ note on the graph says both. Refused out loud on the fast tier (byte-stable already) and together with
275
+ `--incremental` (a spliced graph cannot be resampled).
276
+
277
+ **Measurement:** [gaps/deep_tier_nondeterminism_2026-09-02.md](gaps/deep_tier_nondeterminism_2026-09-02.md),
278
+ [gaps/deep_tier_union_by_repeat_2026-09-04.md](gaps/deep_tier_union_by_repeat_2026-09-04.md).
266
279
 
267
280
  ## Dogfooding
268
281
 
@@ -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 &
@@ -131,6 +131,9 @@ codemap build ./yourpkg -o graph.json
131
131
  # repo-scoped: add consumers (tests/examples) + docs for blast-radius/impact
132
132
  codemap build ./yourpkg --deep --mode full --consumer ./tests --docs ./docs -o graph.json
133
133
 
134
+ # deep tier: union N independent samples (one build misses a real edge ~1 time in 4)
135
+ codemap build ./yourpkg --deep --repeat 3 -o graph.json
136
+
134
137
  # ask about a symbol (JSON by default; --format text for humans)
135
138
  codemap query analyze_zones --graph graph.json
136
139
 
@@ -226,7 +229,17 @@ build says so in its own diagnostics, and `codemap diff` says so when both sides
226
229
  delta of a few call edges as possible tool noise before reading it as a change in the code. Anything
227
230
  that must be reproducible byte-for-byte — a gate, a two-release comparison — belongs on the fast tier.
228
231
 
229
- **Measurement:** [gaps/deep_tier_nondeterminism_2026-09-02.md](gaps/deep_tier_nondeterminism_2026-09-02.md).
232
+ **How many times to build.** A consumer measured 175 full deep builds of one tree and found a real
233
+ edge present in **75 %** of them; eight builds here agreed (5 of 8, the same edge, and nothing else
234
+ unstable among 13 675). So one build misses such an edge about 1 time in 4, two builds 1 in 16, three
235
+ 1 in 64. `codemap build --deep --repeat N` builds N samples — each in a fresh interpreter, the
236
+ regime the share was measured in; in-process repeats come in correlated streaks — and unions them: an edge seen in fewer
237
+ than N runs carries `extras.seen`, `provenance.samples` records N and how many edges varied, and the
238
+ note on the graph says both. Refused out loud on the fast tier (byte-stable already) and together with
239
+ `--incremental` (a spliced graph cannot be resampled).
240
+
241
+ **Measurement:** [gaps/deep_tier_nondeterminism_2026-09-02.md](gaps/deep_tier_nondeterminism_2026-09-02.md),
242
+ [gaps/deep_tier_union_by_repeat_2026-09-04.md](gaps/deep_tier_union_by_repeat_2026-09-04.md).
230
243
 
231
244
  ## Dogfooding
232
245
 
@@ -83,6 +83,24 @@ def _graph_from(args):
83
83
 
84
84
  def _cmd_build(args) -> int:
85
85
  incr_info = None
86
+ repeat = getattr(args, "repeat", None)
87
+ repeat = 1 if repeat is None else repeat
88
+ # R1-C45 / D7–D8: refused out loud, never swallowed. The fast tier is byte-stable and
89
+ # CI pins it, so N runs there buy nothing and cost N minutes; and an incremental graph's
90
+ # spliced part is an earlier build's sample by definition (R1-C43), so resampling only
91
+ # the affected modules would leave `seen` relative to different N on neighbouring edges.
92
+ if repeat < 1:
93
+ print("error: --repeat must be >= 1", file=sys.stderr)
94
+ return 2
95
+ if repeat > 1 and not args.deep:
96
+ print(f"error: --repeat {repeat} needs --deep — the fast tier is byte-stable, so "
97
+ "repeating it unions identical samples", file=sys.stderr)
98
+ return 2
99
+ if repeat > 1 and getattr(args, "incremental", False):
100
+ print(f"error: --repeat {repeat} cannot be combined with --incremental — the spliced "
101
+ "part of an incremental graph is an earlier build's sample and cannot be "
102
+ "resampled; run a full build", file=sys.stderr)
103
+ return 2
86
104
  if args.consumer or args.docs:
87
105
  graph = extract_repo(
88
106
  args.path,
@@ -90,11 +108,12 @@ def _cmd_build(args) -> int:
90
108
  docs=tuple(args.docs or ()),
91
109
  mode=args.mode,
92
110
  deep=args.deep,
111
+ repeat=repeat,
93
112
  )
94
113
  elif getattr(args, "incremental", False) and args.out:
95
114
  graph, incr_info = _incremental_build(args)
96
115
  else:
97
- graph = extract(args.path, deep=args.deep)
116
+ graph = extract(args.path, deep=args.deep, repeat=repeat)
98
117
  # R1-C25: stamp the input's identity into the graph itself. The scope manifest is
99
118
  # resolved here once and reused for the sidecar below — the graph gets the part that
100
119
  # must travel with it (scope_id, source commit), the sidecar keeps the rebuild recipe.
@@ -104,7 +123,14 @@ def _cmd_build(args) -> int:
104
123
  # roots come from extract_repo in this same call; never inherited from a graph
105
124
  # loaded off disk, which is how a stale scope would sneak into a fresh build.
106
125
  roots=graph.provenance.get("roots") if (args.consumer or args.docs) else None,
107
- inputs=_inputs_with_membership(graph, scope, args))
126
+ inputs=_inputs_with_membership(graph, scope, args),
127
+ # R1-C43: this call *overwrites* whatever update_graph stamped, so the flag has
128
+ # to be re-derived here or it is lost. `unchanged` counts as carried over too —
129
+ # nothing in that graph was recomputed by this build at all.
130
+ incremental=(incr_info or {}).get("mode") in ("incremental", "unchanged"),
131
+ # R1-C45: same trap — the extractor stamped the sample count; carry it, or a
132
+ # `--repeat 3` graph leaves here saying `runs: 1`.
133
+ samples=graph.provenance.get("samples"))
108
134
  # R1-C21: a well-formed but vacuous graph must announce itself — silence is what
109
135
  # lets an unparsed layout read as a clean bill of health downstream.
110
136
  for d in diagnostics(graph):
@@ -880,6 +906,12 @@ def build_parser() -> argparse.ArgumentParser:
880
906
  help="Reuse an existing --out graph + its scope sidecar: recompute "
881
907
  "only changed modules (R1-C9). Identical to a full build; much "
882
908
  "faster on --deep. Single-package only (no --consumer/--docs).")
909
+ b.add_argument("--repeat", type=int, default=1, metavar="N",
910
+ help="--deep only: run the jedi layer N times and union the samples. "
911
+ "One deep build is one sample (a real edge was present in 75%% "
912
+ "of single builds, measured); an edge seen in fewer than N runs "
913
+ "carries extras.seen, provenance.samples records N. Costs N× the "
914
+ "jedi time. Not combinable with --incremental.")
883
915
  b.set_defaults(func=_cmd_build)
884
916
 
885
917
  sc = sub.add_parser("scope", help="Resolve the input scope manifest (scope_id + profile), or --diff two.")
@@ -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.
@@ -206,19 +207,86 @@ def deep_tier_diagnostic(graph) -> dict | None:
206
207
  per-script execution budget — an inference that runs out of it returns nothing, and
207
208
  that reads as `unresolved` rather than as an error.
208
209
  """
209
- if (graph.provenance or {}).get("tier") != "deep":
210
+ prov = graph.provenance or {}
211
+ if prov.get("tier") != "deep":
210
212
  return None
213
+ # R1-C45: a reader deciding whether to trust an *absence* needs the per-edge share and
214
+ # what N does to it — "roughly one run in three" named the noise and not its remedy.
215
+ # The 75 % is stated as measured on one tree for one edge (126 of 168 full builds,
216
+ # issue #16; 5 of 8 here), never as a property of the tier: the consumer's own batches
217
+ # ranged from 1 of 7 to 7 of 7, and eight builds cannot see a 95 % edge at all.
218
+ samples = prov.get("samples") or {}
219
+ runs = samples.get("runs") or 1
220
+ if runs >= 2:
221
+ unstable = samples.get("unstable", 0)
222
+ residual = 0.25 ** runs
223
+ return {
224
+ "code": DEEP_TIER_UNSTABLE,
225
+ "severity": NOTE,
226
+ "tier": "deep",
227
+ "samples": runs,
228
+ "unstable": unstable,
229
+ "consequence": ("Edges carrying `extras.seen` are the ones the tier is unsure "
230
+ "about; everything else was present in every run."),
231
+ "message": (
232
+ f"union of {runs} deep (jedi) samples of one tree: {unstable} edge(s) "
233
+ f"were seen in fewer than {runs} runs and carry `extras.seen`. At the "
234
+ f"measured per-run rate (a real edge present in 75 % of single builds) "
235
+ f"an edge missed by all {runs} runs has probability about "
236
+ f"{residual * 100:.2g} %."
237
+ ),
238
+ }
211
239
  return {
212
240
  "code": DEEP_TIER_UNSTABLE,
213
241
  "severity": NOTE,
214
242
  "tier": "deep",
243
+ "samples": 1,
215
244
  "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."),
245
+ "of a few jedi-resolved edges between two deep graphs as tool "
246
+ "noise rather than a change in the code, and do not read a "
247
+ "missing call or attribute edge in one build as absence."),
218
248
  "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."
249
+ "built on the deep (jedi) tier, which is not byte-stable — one build is one "
250
+ "sample. Measured on an 88-module tree, a real `accesses` edge was present in "
251
+ "126 of 168 full builds (75 %): one build misses such an edge about 1 time in "
252
+ "4, two builds 1 in 16, three 1 in 64. `build --repeat N` unions N samples "
253
+ "and marks what varied."
254
+ ),
255
+ }
256
+
257
+
258
+ def incremental_splice_diagnostic(graph) -> dict | None:
259
+ """Say when a deep graph carries regions this build did not recompute (R1-C43).
260
+
261
+ Sibling of :func:`deep_tier_diagnostic`, and the reason it is a separate code: that
262
+ one says *this graph is one sample*, which a reader can act on by building again.
263
+ This one says the sample is **frozen** — building again incrementally returns it
264
+ unchanged, so the standard remedy silently does nothing.
265
+
266
+ Measured on an 88-module package: from a graph missing one real `accesses` edge,
267
+ five consecutive incremental builds recovered it **0 times** while full builds of
268
+ the very same tree recovered it **5 of 5**. And the miss defends itself — the
269
+ invalidation rule that would have recomputed the writer reads the old graph, where
270
+ the edge is exactly what is absent (gaps/incremental_noise_persistence_2026-09-02.md).
271
+
272
+ Fast tier is excluded on purpose: there the splice is exact and byte-identity to a
273
+ full build is pinned by the suite, so there is no sample to freeze.
274
+ """
275
+ prov = graph.provenance or {}
276
+ if prov.get("tier") != "deep" or not prov.get("incremental"):
277
+ return None
278
+ return {
279
+ "code": INCREMENTAL_DEEP_SPLICE,
280
+ "severity": NOTE,
281
+ "tier": "deep",
282
+ "consequence": ("Do not read a missing call or attribute edge here as evidence "
283
+ "that nothing depends on a symbol, and do not test that by "
284
+ "rebuilding incrementally — only a full rebuild resamples."),
285
+ "message": (
286
+ "parts of this deep graph were spliced from an earlier build rather than "
287
+ "recomputed: on this tier that carries the earlier build's jedi sample "
288
+ "forward, including anything it missed — measured at 0 recoveries in 5 "
289
+ "incremental builds against 5 of 5 full builds of the same tree."
222
290
  ),
223
291
  }
224
292
 
@@ -307,7 +375,8 @@ def diagnostics(graph) -> list[dict]:
307
375
  checks = (import_graph_diagnostic(graph), namespace_target_diagnostic(graph),
308
376
  cross_root_diagnostic(graph), schema_diagnostic(graph),
309
377
  unread_inputs_diagnostic(graph), module_count_diagnostic(graph),
310
- scope_membership_diagnostic(graph), deep_tier_diagnostic(graph))
378
+ scope_membership_diagnostic(graph), deep_tier_diagnostic(graph),
379
+ incremental_splice_diagnostic(graph))
311
380
  return [d for d in checks if d is not None]
312
381
 
313
382
 
@@ -78,6 +78,9 @@ def add_behavior(graph, griffe_root, target_pkg: str, *, deep: bool = False,
78
78
  modmembers = set(mod.members.keys())
79
79
  script = _jedi_script(source, fp, project) if deep else None
80
80
  nested: dict[tuple[str, str], dict] = {}
81
+ # R1-C46: the surviving definition says which earlier bodies it replaced. Stamped
82
+ # here because this pass holds the AST; the walkers below skip those bodies.
83
+ _stamp_shadows(graph, tree, modpath)
81
84
  for fnode, class_stack, scope in _named_functions_scoped(tree):
82
85
  node_id = _node_id(modpath, class_stack, fnode.name)
83
86
  local = local_imports.get(id(fnode))
@@ -349,17 +352,105 @@ def _class_members(mod, class_stack, modules) -> dict:
349
352
 
350
353
  # -- ast scope walking -------------------------------------------------------
351
354
 
355
+ _DEF_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
356
+ # Decorators that make a repeated binding of one name legitimate (R1-C46 / D2): an
357
+ # `@overload` *before* the implementation, a property accessor or a singledispatch
358
+ # registration *after* the first binding. Matched on the decorator's last dotted segment
359
+ # with any call stripped, so `typing.overload` and `@f.register(int)` both count.
360
+ _EXEMPT_EARLIER = frozenset({"overload"})
361
+ _EXEMPT_LATER = frozenset({"setter", "getter", "deleter", "register"})
362
+
363
+
364
+ def _decorator_tails(node) -> list[str]:
365
+ out = []
366
+ for d in node.decorator_list:
367
+ text = ast.unparse(d).split("(", 1)[0]
368
+ out.append(text.rsplit(".", 1)[-1])
369
+ return out
370
+
371
+
372
+ def _rebinding_is_legitimate(earlier, later) -> bool:
373
+ if later.name == "_":
374
+ return True # singledispatch convention
375
+ if any(t in _EXEMPT_EARLIER for t in _decorator_tails(earlier)):
376
+ return True
377
+ return any(t in _EXEMPT_LATER for t in _decorator_tails(later))
378
+
379
+
380
+ def _shadow_map(tree) -> tuple[set[int], dict, list[tuple[int, int]]]:
381
+ """Definitions that a later definition of the same name in the same body replaces.
382
+
383
+ R1-C46: griffe keeps the **last** body of a name defined twice and drops the first
384
+ without a word; the AST walkers here used to visit *both* and attribute both bodies'
385
+ calls to the one surviving node — an edge from code that can never run. So every
386
+ walker skips the shadowed subtree, and the surviving node records what it shadowed.
387
+
388
+ Only a *direct* statement of a module, class or function body shadows or is
389
+ shadowed. A definition under ``if`` / ``try`` / ``with`` is conditional on either
390
+ side (``if TYPE_CHECKING:``, an ``except ImportError`` fallback): it neither replaces
391
+ nor is replaced, and both bodies keep being walked — the honest over-approximation.
392
+
393
+ Returns ``(ids of AST nodes to skip, records, dead line ranges)``; ``records`` maps
394
+ ``(scope chain, name) -> {"linenos": [...], "node": bool}`` where ``node`` says the
395
+ chain is classes only, i.e. the survivor is a definition node the graph holds.
396
+ """
397
+ skip: set[int] = set()
398
+ records: dict[tuple[tuple[str, ...], str], dict] = {}
399
+ ranges: list[tuple[int, int]] = []
400
+
401
+ def walk(node, scope: list[str], kinds: list[str]) -> None:
402
+ if isinstance(node, (ast.Module, *_DEF_NODES)):
403
+ last: dict[str, ast.AST] = {}
404
+ for stmt in node.body:
405
+ if not isinstance(stmt, _DEF_NODES):
406
+ continue
407
+ prev = last.get(stmt.name)
408
+ if prev is not None and not _rebinding_is_legitimate(prev, stmt):
409
+ skip.add(id(prev))
410
+ ranges.append((prev.lineno, getattr(prev, "end_lineno", prev.lineno)))
411
+ rec = records.setdefault((tuple(scope), stmt.name),
412
+ {"linenos": [], "node": all(k == "c" for k in kinds)})
413
+ rec["linenos"].append(prev.lineno)
414
+ last[stmt.name] = stmt
415
+ for child in ast.iter_child_nodes(node):
416
+ if id(child) in skip:
417
+ continue
418
+ if isinstance(child, ast.ClassDef):
419
+ walk(child, scope + [child.name], kinds + ["c"])
420
+ elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
421
+ walk(child, scope + [child.name], kinds + ["f"])
422
+ else:
423
+ walk(child, scope, kinds)
424
+
425
+ walk(tree, [], [])
426
+ return skip, records, ranges
427
+
428
+
429
+ def _stamp_shadows(graph, tree, prefix: str) -> None:
430
+ """Write ``extras.shadows`` on every surviving definition node under ``prefix``."""
431
+ for (scope, name), rec in _shadow_map(tree)[1].items():
432
+ if not rec["node"]:
433
+ continue
434
+ nid = ".".join([prefix, *scope, name])
435
+ if nid in graph.nodes:
436
+ graph.nodes[nid].extras["shadows"] = sorted(rec["linenos"])
437
+
438
+
352
439
  def _named_functions(tree):
353
440
  """Yield (FunctionDef, [class names]) for functions reachable as definitions.
354
441
 
355
442
  Tracks the class nesting so we can rebuild canonical ids. Functions nested
356
443
  inside other functions are still yielded but filtered out by the caller
357
- (their id won't match a graph node).
444
+ (their id won't match a graph node). A definition shadowed by a later one of
445
+ the same name in the same body is not yielded (R1-C46, :func:`_shadow_map`).
358
446
  """
359
447
  results = []
448
+ skip = _shadow_map(tree)[0]
360
449
 
361
450
  def visit(node, class_stack):
362
451
  for child in ast.iter_child_nodes(node):
452
+ if id(child) in skip:
453
+ continue
363
454
  if isinstance(child, ast.ClassDef):
364
455
  visit(child, class_stack + [child.name])
365
456
  elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
@@ -845,9 +936,12 @@ def _named_functions_scoped(tree):
845
936
  because `dataflow`/`dispatch`/`attrflow` unpack the two-tuple.
846
937
  """
847
938
  results = []
939
+ skip = _shadow_map(tree)[0] # R1-C46: a shadowed body is not walked
848
940
 
849
941
  def visit(node, class_stack, scope):
850
942
  for child in ast.iter_child_nodes(node):
943
+ if id(child) in skip:
944
+ continue
851
945
  if isinstance(child, ast.ClassDef):
852
946
  visit(child, class_stack + [child.name], scope + [child.name])
853
947
  elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
@@ -18,6 +18,7 @@ Emits (M0 + M1 + M1.5):
18
18
  from __future__ import annotations
19
19
 
20
20
  import ast
21
+ import multiprocessing
21
22
  import os
22
23
  from dataclasses import dataclass, field
23
24
  from pathlib import Path
@@ -29,6 +30,7 @@ from codemap.extract.behavior import add_behavior
29
30
  from codemap.extract.dataflow import add_dataflow
30
31
  from codemap.extract.dispatch import add_dispatch, add_family_links
31
32
  from codemap.extract.gsource import NESTED_IMPORT_HINT, module_file, module_identity
33
+ from codemap.extract.union import merge_samples
32
34
  from codemap.provenance import build_provenance
33
35
  from codemap.model import Edge, Graph, Node
34
36
 
@@ -138,20 +140,61 @@ def add_behavioral_layer(graph, root, module_name, search_path, *, deep: bool,
138
140
  only=attr_only)
139
141
 
140
142
 
141
- def extract(package_path: str | Path, *, deep: bool = False) -> Graph:
143
+ def _sample_worker(package_path: str, deep: bool) -> dict:
144
+ """One full extraction in a child interpreter; returns the graph as a dict (R1-C45)."""
145
+ graph, root, module_name, search_path = build_structural(package_path)
146
+ add_behavioral_layer(graph, root, module_name, search_path, deep=deep)
147
+ return graph.to_dict()
148
+
149
+
150
+ def collect_samples(package_path, *, deep: bool, runs: int,
151
+ workers: int | None = None) -> list[Graph]:
152
+ """``runs`` independent samples of ``package_path``, each in a fresh interpreter.
153
+
154
+ Lives here, beside the two functions the worker calls, so ``extract/union.py`` stays
155
+ a pure merge and the import graph stays acyclic — codemap's own ``no_lazy_cycles``
156
+ contract caught the first draft, which reached back from ``union`` with a
157
+ function-local import. ``workers`` bounds concurrency (default: the machine's core
158
+ count, capped at ``runs``). Spawn, not fork: a forked child would inherit exactly the
159
+ state that makes an in-process repeat correlate with the pass before it.
160
+ """
161
+ ctx = multiprocessing.get_context("spawn")
162
+ procs = max(1, min(runs, workers or os.cpu_count() or 1))
163
+ with ctx.Pool(processes=procs) as pool:
164
+ dicts = pool.starmap(_sample_worker, [(str(package_path), deep)] * runs)
165
+ samples = []
166
+ for d in dicts:
167
+ g = Graph.from_dict(d)
168
+ g.loaded_schema = None # a fresh build, not a file — keep schema_diagnostic quiet
169
+ samples.append(g)
170
+ return samples
171
+
172
+
173
+ def extract(package_path: str | Path, *, deep: bool = False, repeat: int = 1) -> Graph:
142
174
  """Build a code graph from a Python package directory.
143
175
 
144
176
  ``deep=True`` runs the jedi-backed call resolver (M5) — richer call-graph
145
177
  (local-variable type inference) at ~1 min build cost; default is the fast
146
178
  ast tier (sub-second). See ``extract/behavior.py``.
179
+
180
+ ``repeat=N`` (R1-C45) builds N samples — **each in a fresh interpreter**, the
181
+ regime whose recovery share was measured; in-process repeats come in correlated
182
+ streaks (see ``extract/union.py``) — and unions them: a deep build is one sample
183
+ of jedi's bounded inference, and an edge seen in fewer than N runs carries
184
+ ``extras.seen``.
147
185
  """
148
- graph, root, module_name, search_path = build_structural(package_path)
149
- add_behavioral_layer(graph, root, module_name, search_path, deep=deep)
186
+ if repeat > 1:
187
+ graph, stats = merge_samples(collect_samples(package_path, deep=deep, runs=repeat))
188
+ else:
189
+ graph, root, module_name, search_path = build_structural(package_path)
190
+ add_behavioral_layer(graph, root, module_name, search_path, deep=deep)
191
+ stats = {"runs": 1}
150
192
  # R1-C25: even a library-built graph says which tool and which tier made it. The
151
193
  # input identity (scope_id, source commit) is added by whoever resolved the scope —
152
194
  # `extract` deliberately does not hash the tree a second time.
153
195
  graph.provenance = build_provenance(tier="deep" if deep else "fast",
154
- inputs=graph.provenance.get("inputs"))
196
+ inputs=graph.provenance.get("inputs"),
197
+ samples=stats)
155
198
  return graph
156
199
 
157
200
 
@@ -32,7 +32,7 @@ import os
32
32
  import re
33
33
  from pathlib import Path
34
34
 
35
- from codemap.extract.behavior import _arg_contract, _arg_shape
35
+ from codemap.extract.behavior import _arg_contract, _arg_shape, _shadow_map
36
36
  from codemap.extract.griffe_extractor import extract
37
37
  from codemap.provenance import canonicalize
38
38
  from codemap.model import Edge, Graph, Node
@@ -78,18 +78,20 @@ def extract_repo(
78
78
  docs: tuple[str | Path, ...] = (),
79
79
  mode: str = "thin",
80
80
  deep: bool = False,
81
+ repeat: int = 1,
81
82
  ) -> Graph:
82
83
  """Build a repo-scoped graph: core package + consumer roots + doc roots.
83
84
 
84
85
  ``core`` is analysed exactly as the single-package extractor (griffe, plus the
85
- behavioral pass when ``deep``). ``consumers`` and ``docs`` are extra root
86
- directories scanned for references into the core. ``mode`` is ``"thin"`` or
86
+ behavioral pass when ``deep``, unioned over ``repeat`` samples — R1-C45).
87
+ ``consumers`` and ``docs`` are extra root directories scanned for references into
88
+ the core; those scans are deterministic and run once. ``mode`` is ``"thin"`` or
87
89
  ``"full"`` (see module docstring).
88
90
  """
89
91
  if mode not in ("thin", "full"):
90
92
  raise ValueError(f"mode must be 'thin' or 'full', got {mode!r}")
91
93
 
92
- graph = extract(core, deep=deep)
94
+ graph = extract(core, deep=deep, repeat=repeat)
93
95
  core_pkg = graph.target
94
96
  for node in graph.nodes.values():
95
97
  node.extras.setdefault("root", "core")
@@ -266,8 +268,14 @@ def _scan_consumer_module(graph, py, base, label, tree, index, mode, origin=None
266
268
  if isinstance(n, ast.Attribute) and isinstance(n.value, ast.Attribute)
267
269
  }
268
270
  func_ranges = _func_ranges(tree) if mode == "full" else []
271
+ # R1-C46: a use inside a body that a later definition replaced is not a use — it
272
+ # would attribute a reference (or a call) to code that never runs, in either mode.
273
+ dead = _shadow_map(tree)[2]
269
274
 
270
275
  for node in ast.walk(tree):
276
+ line = getattr(node, "lineno", None)
277
+ if line is not None and any(a <= line <= b for a, b in dead):
278
+ continue
271
279
  target = None
272
280
  use_node = node
273
281
  if isinstance(node, ast.Name) and node.id in symbol_map:
@@ -375,14 +383,28 @@ def _materialize_defs(graph, tree, mod_id, label, file: str | None = None) -> No
375
383
  extras={"root": label}))
376
384
  parent = ".".join([mod_id, *class_stack]) if class_stack else mod_id
377
385
  graph.add_edge(Edge("contains", parent, node_id))
386
+ # R1-C46: one node and one `contains` per definition that can run (issue #16 found
387
+ # the record twice); the survivor says which earlier bodies it replaced.
388
+ for (scope, name), rec in _shadow_map(tree)[1].items():
389
+ if rec["node"]:
390
+ nid = ".".join([mod_id, *scope, name])
391
+ if nid in graph.nodes:
392
+ graph.nodes[nid].extras["shadows"] = sorted(rec["linenos"])
378
393
 
379
394
 
380
395
  def _defs(tree):
381
- """Yield (def-node, [enclosing class/def names]) for top-level & nested defs."""
396
+ """Yield (def-node, [enclosing class/def names]) for top-level & nested defs.
397
+
398
+ A body shadowed by a later definition of the same name in the same scope is not
399
+ yielded (R1-C46, :func:`_shadow_map`).
400
+ """
382
401
  results = []
402
+ skip = _shadow_map(tree)[0]
383
403
 
384
404
  def visit(node, stack):
385
405
  for child in ast.iter_child_nodes(node):
406
+ if id(child) in skip:
407
+ continue
386
408
  if isinstance(child, ast.ClassDef):
387
409
  results.append((child, list(stack)))
388
410
  visit(child, stack + [child.name])
@@ -399,9 +421,12 @@ def _defs(tree):
399
421
  def _func_ranges(tree):
400
422
  """(start, end, node_id) for each def, longest-first, to place a use by line."""
401
423
  ranges = []
424
+ skip = _shadow_map(tree)[0] # R1-C46: a shadowed body owns no uses
402
425
 
403
426
  def visit(node, mod_stack):
404
427
  for child in ast.iter_child_nodes(node):
428
+ if id(child) in skip:
429
+ continue
405
430
  if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
406
431
  nid = ".".join([*mod_stack, child.name])
407
432
  end = getattr(child, "end_lineno", child.lineno)