codmap 0.0.10__tar.gz → 0.0.12__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 (129) hide show
  1. {codmap-0.0.10/codmap.egg-info → codmap-0.0.12}/PKG-INFO +18 -3
  2. {codmap-0.0.10 → codmap-0.0.12}/README.md +17 -2
  3. {codmap-0.0.10 → codmap-0.0.12}/codemap/cli.py +52 -8
  4. {codmap-0.0.10 → codmap-0.0.12}/codemap/diagnostics.py +36 -6
  5. {codmap-0.0.10 → codmap-0.0.12}/codemap/extract/behavior.py +95 -1
  6. {codmap-0.0.10 → codmap-0.0.12}/codemap/extract/griffe_extractor.py +55 -4
  7. {codmap-0.0.10 → codmap-0.0.12}/codemap/extract/roots.py +30 -5
  8. codmap-0.0.12/codemap/extract/union.py +144 -0
  9. {codmap-0.0.10 → codmap-0.0.12}/codemap/incremental.py +69 -19
  10. {codmap-0.0.10 → codmap-0.0.12}/codemap/model.py +7 -0
  11. {codmap-0.0.10 → codmap-0.0.12}/codemap/provenance.py +25 -3
  12. {codmap-0.0.10 → codmap-0.0.12}/codemap/query.py +26 -0
  13. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/audit.py +16 -0
  14. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/impact.py +3 -1
  15. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/session.py +94 -10
  16. {codmap-0.0.10 → codmap-0.0.12/codmap.egg-info}/PKG-INFO +18 -3
  17. {codmap-0.0.10 → codmap-0.0.12}/codmap.egg-info/SOURCES.txt +5 -0
  18. {codmap-0.0.10 → codmap-0.0.12}/pyproject.toml +1 -1
  19. codmap-0.0.12/tests/test_r1c44_absent_answers.py +197 -0
  20. codmap-0.0.12/tests/test_r1c45_repeat_union.py +236 -0
  21. codmap-0.0.12/tests/test_r1c46_shadowed_definitions.py +142 -0
  22. codmap-0.0.12/tests/test_r1c47_incremental_resample.py +155 -0
  23. {codmap-0.0.10 → codmap-0.0.12}/LICENSE +0 -0
  24. {codmap-0.0.10 → codmap-0.0.12}/codemap/__init__.py +0 -0
  25. {codmap-0.0.10 → codmap-0.0.12}/codemap/apidiff.py +0 -0
  26. {codmap-0.0.10 → codmap-0.0.12}/codemap/arch.py +0 -0
  27. {codmap-0.0.10 → codmap-0.0.12}/codemap/extract/__init__.py +0 -0
  28. {codmap-0.0.10 → codmap-0.0.12}/codemap/extract/attrflow.py +0 -0
  29. {codmap-0.0.10 → codmap-0.0.12}/codemap/extract/dataflow.py +0 -0
  30. {codmap-0.0.10 → codmap-0.0.12}/codemap/extract/dispatch.py +0 -0
  31. {codmap-0.0.10 → codmap-0.0.12}/codemap/extract/gsource.py +0 -0
  32. {codmap-0.0.10 → codmap-0.0.12}/codemap/freshness.py +0 -0
  33. {codmap-0.0.10 → codmap-0.0.12}/codemap/integrations/__init__.py +0 -0
  34. {codmap-0.0.10 → codmap-0.0.12}/codemap/integrations/base.py +0 -0
  35. {codmap-0.0.10 → codmap-0.0.12}/codemap/integrations/cocoindex.py +0 -0
  36. {codmap-0.0.10 → codmap-0.0.12}/codemap/integrations/gate.py +0 -0
  37. {codmap-0.0.10 → codmap-0.0.12}/codemap/integrations/gitnexus.py +0 -0
  38. {codmap-0.0.10 → codmap-0.0.12}/codemap/integrations/registry.py +0 -0
  39. {codmap-0.0.10 → codmap-0.0.12}/codemap/integrations/transport.py +0 -0
  40. {codmap-0.0.10 → codmap-0.0.12}/codemap/scope.py +0 -0
  41. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/__init__.py +0 -0
  42. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/_scip_pb2.py +0 -0
  43. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/api_surface.py +0 -0
  44. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/apidiff.py +0 -0
  45. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/architecture.py +0 -0
  46. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/check.py +0 -0
  47. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/ctags.py +0 -0
  48. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/limits.py +0 -0
  49. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/livingdocs.py +0 -0
  50. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/mcp_server.py +0 -0
  51. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/mermaid.py +0 -0
  52. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/pack.py +0 -0
  53. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/rag.py +0 -0
  54. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/review.py +0 -0
  55. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/scip.py +0 -0
  56. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/semantic.py +0 -0
  57. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/server.py +0 -0
  58. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/subsystems.py +0 -0
  59. {codmap-0.0.10 → codmap-0.0.12}/codemap/serve/vault.py +0 -0
  60. {codmap-0.0.10 → codmap-0.0.12}/codemap/store.py +0 -0
  61. {codmap-0.0.10 → codmap-0.0.12}/codemap/tomlio.py +0 -0
  62. {codmap-0.0.10 → codmap-0.0.12}/codemap/watch.py +0 -0
  63. {codmap-0.0.10 → codmap-0.0.12}/codmap.egg-info/dependency_links.txt +0 -0
  64. {codmap-0.0.10 → codmap-0.0.12}/codmap.egg-info/entry_points.txt +0 -0
  65. {codmap-0.0.10 → codmap-0.0.12}/codmap.egg-info/requires.txt +0 -0
  66. {codmap-0.0.10 → codmap-0.0.12}/codmap.egg-info/top_level.txt +0 -0
  67. {codmap-0.0.10 → codmap-0.0.12}/setup.cfg +0 -0
  68. {codmap-0.0.10 → codmap-0.0.12}/tests/test_epistemic.py +0 -0
  69. {codmap-0.0.10 → codmap-0.0.12}/tests/test_gitnexus_router.py +0 -0
  70. {codmap-0.0.10 → codmap-0.0.12}/tests/test_impact_depth.py +0 -0
  71. {codmap-0.0.10 → codmap-0.0.12}/tests/test_integrations.py +0 -0
  72. {codmap-0.0.10 → codmap-0.0.12}/tests/test_issue3_serve_freshness.py +0 -0
  73. {codmap-0.0.10 → codmap-0.0.12}/tests/test_livingdocs.py +0 -0
  74. {codmap-0.0.10 → codmap-0.0.12}/tests/test_m0_api_surface.py +0 -0
  75. {codmap-0.0.10 → codmap-0.0.12}/tests/test_m11_argcontract.py +0 -0
  76. {codmap-0.0.10 → codmap-0.0.12}/tests/test_m12_dataflow.py +0 -0
  77. {codmap-0.0.10 → codmap-0.0.12}/tests/test_m15_review.py +0 -0
  78. {codmap-0.0.10 → codmap-0.0.12}/tests/test_m16_architecture.py +0 -0
  79. {codmap-0.0.10 → codmap-0.0.12}/tests/test_m17_mcp.py +0 -0
  80. {codmap-0.0.10 → codmap-0.0.12}/tests/test_m18_freshness.py +0 -0
  81. {codmap-0.0.10 → codmap-0.0.12}/tests/test_m1_5_semantics.py +0 -0
  82. {codmap-0.0.10 → codmap-0.0.12}/tests/test_m1_query.py +0 -0
  83. {codmap-0.0.10 → codmap-0.0.12}/tests/test_m2_views.py +0 -0
  84. {codmap-0.0.10 → codmap-0.0.12}/tests/test_m32_watch.py +0 -0
  85. {codmap-0.0.10 → codmap-0.0.12}/tests/test_m3_serve.py +0 -0
  86. {codmap-0.0.10 → codmap-0.0.12}/tests/test_m4_behavior.py +0 -0
  87. {codmap-0.0.10 → codmap-0.0.12}/tests/test_m5_deep.py +0 -0
  88. {codmap-0.0.10 → codmap-0.0.12}/tests/test_m6_repo_scope.py +0 -0
  89. {codmap-0.0.10 → codmap-0.0.12}/tests/test_m7_dispatch.py +0 -0
  90. {codmap-0.0.10 → codmap-0.0.12}/tests/test_m9_family.py +0 -0
  91. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  92. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c13_grep_vs_graph.py +0 -0
  93. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c16_semantic.py +0 -0
  94. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c20_attribute_edges.py +0 -0
  95. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c21_flat_layout.py +0 -0
  96. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c22_source_visible_refs.py +0 -0
  97. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c23_hard_python.py +0 -0
  98. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c24_test_mapping.py +0 -0
  99. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c25_provenance.py +0 -0
  100. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c26_deep_union.py +0 -0
  101. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c27_config_honesty.py +0 -0
  102. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c28_limit_envelope.py +0 -0
  103. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c29_lazy_imports.py +0 -0
  104. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c2_ctags.py +0 -0
  105. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c30_local_import_calls.py +0 -0
  106. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c30f1_reexport_calls.py +0 -0
  107. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c30f2_check_scope.py +0 -0
  108. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c31_path_origin.py +0 -0
  109. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c32_report_json.py +0 -0
  110. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c33_query_signature.py +0 -0
  111. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c34_signature_kinds.py +0 -0
  112. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c35_contract_location.py +0 -0
  113. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c36_target_resolution.py +0 -0
  114. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c37_rule_mutation.py +0 -0
  115. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c38_tool_drift.py +0 -0
  116. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c3_arch_contract.py +0 -0
  117. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c3_dogfood.py +0 -0
  118. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c41_scope_membership.py +0 -0
  119. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c42_deep_tier_stability.py +0 -0
  120. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c43_incremental_splice.py +0 -0
  121. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c4_complexity.py +0 -0
  122. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c5_apidiff.py +0 -0
  123. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c6_pack.py +0 -0
  124. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c7_edge_vocab.py +0 -0
  125. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c8_deadcode.py +0 -0
  126. {codmap-0.0.10 → codmap-0.0.12}/tests/test_r1c9_incremental.py +0 -0
  127. {codmap-0.0.10 → codmap-0.0.12}/tests/test_scip_export.py +0 -0
  128. {codmap-0.0.10 → codmap-0.0.12}/tests/test_scope.py +0 -0
  129. {codmap-0.0.10 → codmap-0.0.12}/tests/test_subsystems.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.10
3
+ Version: 0.0.12
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
@@ -161,12 +161,15 @@ Optional extras: `mcp` (Model Context Protocol server), `scip` (protobuf, for SC
161
161
  ## Quickstart
162
162
 
163
163
  ```bash
164
- # build the canonical graph of a package
164
+ # build the canonical graph of a package (its directory name is the package name every id starts with)
165
165
  codemap build ./yourpkg -o graph.json
166
166
 
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,19 @@ 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). With `--incremental`
275
+ and `watch`, N becomes a property of the chain and every place the chain samples — base, fallback, each
276
+ tick's recompute — samples N times; measured on twenty real commits, that is what removes the chain's misses,
277
+ and a periodic full rebuild does not.
278
+
279
+ **Measurement:** [gaps/deep_tier_nondeterminism_2026-09-02.md](gaps/deep_tier_nondeterminism_2026-09-02.md),
280
+ [gaps/deep_tier_union_by_repeat_2026-09-04.md](gaps/deep_tier_union_by_repeat_2026-09-04.md).
266
281
 
267
282
  ## Dogfooding
268
283
 
@@ -125,12 +125,15 @@ Optional extras: `mcp` (Model Context Protocol server), `scip` (protobuf, for SC
125
125
  ## Quickstart
126
126
 
127
127
  ```bash
128
- # build the canonical graph of a package
128
+ # build the canonical graph of a package (its directory name is the package name every id starts with)
129
129
  codemap build ./yourpkg -o graph.json
130
130
 
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,19 @@ 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). With `--incremental`
239
+ and `watch`, N becomes a property of the chain and every place the chain samples — base, fallback, each
240
+ tick's recompute — samples N times; measured on twenty real commits, that is what removes the chain's misses,
241
+ and a periodic full rebuild does not.
242
+
243
+ **Measurement:** [gaps/deep_tier_nondeterminism_2026-09-02.md](gaps/deep_tier_nondeterminism_2026-09-02.md),
244
+ [gaps/deep_tier_union_by_repeat_2026-09-04.md](gaps/deep_tier_union_by_repeat_2026-09-04.md).
230
245
 
231
246
  ## Dogfooding
232
247
 
@@ -83,18 +83,37 @@ 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: refused out loud, never swallowed. The fast tier is byte-stable and CI
89
+ # pins it, so N runs there buy nothing and cost N minutes. With --incremental the flag
90
+ # is allowed since R1-C47: N is a property of the chain, and a different N restarts it.
91
+ if repeat < 1:
92
+ print("error: --repeat must be >= 1", file=sys.stderr)
93
+ return 2
94
+ if repeat > 1 and not args.deep:
95
+ print(f"error: --repeat {repeat} needs --deep — the fast tier is byte-stable, so "
96
+ "repeating it unions identical samples", file=sys.stderr)
97
+ return 2
86
98
  if args.consumer or args.docs:
99
+ if getattr(args, "incremental", False):
100
+ # R1-C44 / D6: the flag used to be swallowed here without a word. Not an
101
+ # error — a stated boundary: the incremental path is single-package.
102
+ print("[note] --incremental was not applied: --consumer/--docs make this a "
103
+ "repo-scoped build, which is always full (the incremental path is "
104
+ "single-package only)", file=sys.stderr)
87
105
  graph = extract_repo(
88
106
  args.path,
89
107
  consumers=tuple(args.consumer or ()),
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.
@@ -108,7 +127,10 @@ def _cmd_build(args) -> int:
108
127
  # R1-C43: this call *overwrites* whatever update_graph stamped, so the flag has
109
128
  # to be re-derived here or it is lost. `unchanged` counts as carried over too —
110
129
  # nothing in that graph was recomputed by this build at all.
111
- incremental=(incr_info or {}).get("mode") in ("incremental", "unchanged"))
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"))
112
134
  # R1-C21: a well-formed but vacuous graph must announce itself — silence is what
113
135
  # lets an unparsed layout read as a clean bill of health downstream.
114
136
  for d in diagnostics(graph):
@@ -179,12 +201,15 @@ def _incremental_build(args):
179
201
  old_meta = read_meta(args.out)
180
202
  old_scope = (old_meta or {}).get("scope")
181
203
  if not (Path(args.out).exists() and old_scope):
182
- return extract(args.path, deep=args.deep), {"mode": "full", "affected": []}
204
+ return extract(args.path, deep=args.deep, repeat=getattr(args, "repeat", None) or 1), \
205
+ {"mode": "full", "affected": []}
183
206
  old_graph = store.load(args.out)
184
207
  new_scope = resolve_scope(args.path)
208
+ repeat = getattr(args, "repeat", None) or 1
185
209
  if old_graph.target != Path(args.path).resolve().name:
186
- return extract(args.path, deep=args.deep), {"mode": "full", "affected": []}
187
- return update_graph(old_graph, args.path, old_scope, new_scope, deep=args.deep)
210
+ return extract(args.path, deep=args.deep, repeat=repeat), {"mode": "full", "affected": []}
211
+ return update_graph(old_graph, args.path, old_scope, new_scope, deep=args.deep,
212
+ repeat=repeat)
188
213
 
189
214
 
190
215
  def _watch_build_argv(args) -> list[str]:
@@ -197,6 +222,8 @@ def _watch_build_argv(args) -> list[str]:
197
222
  argv = ["build", args.path, "-o", args.out, "--incremental"]
198
223
  if args.deep:
199
224
  argv.append("--deep")
225
+ if (getattr(args, "repeat", None) or 1) > 1:
226
+ argv += ["--repeat", str(args.repeat)] # R1-C47: N is a property of the chain
200
227
  for c in args.consumer or ():
201
228
  argv += ["--consumer", c]
202
229
  for d in args.docs or ():
@@ -239,6 +266,7 @@ def _cmd_watch(args) -> int:
239
266
  build_args = _argparse.Namespace(
240
267
  path=args.path, out=args.out, deep=args.deep, consumer=args.consumer,
241
268
  docs=args.docs, mode=args.mode, incremental=True,
269
+ repeat=getattr(args, "repeat", None) or 1,
242
270
  _argv=_watch_build_argv(args))
243
271
 
244
272
  reported: set[str] = set()
@@ -869,7 +897,9 @@ def build_parser() -> argparse.ArgumentParser:
869
897
  sub = p.add_subparsers(dest="command", required=True)
870
898
 
871
899
  b = sub.add_parser("build", help="Build the canonical graph from a package path.")
872
- b.add_argument("path", help="Path to the package directory (holds __init__.py).")
900
+ b.add_argument("path", help="Path to the package directory (holds __init__.py). Its "
901
+ "directory name is the package name every node id starts "
902
+ "with — a copy under another name answers to that name.")
873
903
  b.add_argument("-o", "--out", help="Write graph.json here (default: stdout JSON).")
874
904
  b.add_argument("--deep", action="store_true",
875
905
  help="Deep call resolution via jedi (richer, ~1 min; default fast).")
@@ -882,8 +912,17 @@ def build_parser() -> argparse.ArgumentParser:
882
912
  help="Consumer granularity: thin=per-file (default), full=per-function.")
883
913
  b.add_argument("--incremental", action="store_true",
884
914
  help="Reuse an existing --out graph + its scope sidecar: recompute "
885
- "only changed modules (R1-C9). Identical to a full build; much "
886
- "faster on --deep. Single-package only (no --consumer/--docs).")
915
+ "only changed modules (R1-C9). Byte-identical to a full build on "
916
+ "the fast tier; on --deep it carries the earlier build's jedi "
917
+ "sample forward (docs/incremental.md). Much faster on --deep. "
918
+ "Single-package only (no --consumer/--docs).")
919
+ b.add_argument("--repeat", type=int, default=1, metavar="N",
920
+ help="--deep only: run the jedi layer N times and union the samples. "
921
+ "One deep build is one sample (a real edge was present in 75%% "
922
+ "of single builds, measured); an edge seen in fewer than N runs "
923
+ "carries extras.seen, provenance.samples records N. Costs N× the "
924
+ "jedi time, in parallel. With --incremental N becomes a property "
925
+ "of the chain: a different N restarts it from a full build.")
887
926
  b.set_defaults(func=_cmd_build)
888
927
 
889
928
  sc = sub.add_parser("scope", help="Resolve the input scope manifest (scope_id + profile), or --diff two.")
@@ -987,6 +1026,11 @@ def build_parser() -> argparse.ArgumentParser:
987
1026
  w.add_argument("--docs", action="append", metavar="PATH", help="Docs root. Repeatable.")
988
1027
  w.add_argument("--mode", choices=["thin", "full"], default="thin",
989
1028
  help="Consumer granularity (as for `build`).")
1029
+ w.add_argument("--repeat", type=int, default=1, metavar="N",
1030
+ help="--deep only: sample the jedi layer N times on every build this "
1031
+ "loop makes — base, fallback and each tick's recompute (R1-C47). "
1032
+ "Measured: an incremental chain's misses come from single samples, "
1033
+ "not from age.")
990
1034
  w.add_argument("--cycles", type=int, default=None,
991
1035
  help="Stop after N polls instead of running forever (CI / testing).")
992
1036
  _add_poll_options(
@@ -207,20 +207,50 @@ def deep_tier_diagnostic(graph) -> dict | None:
207
207
  per-script execution budget — an inference that runs out of it returns nothing, and
208
208
  that reads as `unresolved` rather than as an error.
209
209
  """
210
- if (graph.provenance or {}).get("tier") != "deep":
210
+ prov = graph.provenance or {}
211
+ if prov.get("tier") != "deep":
211
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
+ }
212
239
  return {
213
240
  "code": DEEP_TIER_UNSTABLE,
214
241
  "severity": NOTE,
215
242
  "tier": "deep",
243
+ "samples": 1,
216
244
  "consequence": ("Everything here is a lower bound as usual; treat a difference "
217
245
  "of a few jedi-resolved edges between two deep graphs as tool "
218
- "noise rather than a change in the code."),
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."),
219
248
  "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."
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."
224
254
  ),
225
255
  }
226
256
 
@@ -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,69 @@ 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, only=None) -> dict:
144
+ """One extraction in a child interpreter; returns the graph as a dict (R1-C45).
145
+
146
+ ``only`` (a set of module paths) restricts the two jedi passes to those modules —
147
+ the incremental hook (R1-C9), so a chain can resample just what a tick touched
148
+ (R1-C47). The structural base is rebuilt in every worker regardless: cheap,
149
+ deterministic, and cheaper than shipping it across processes.
150
+ """
151
+ graph, root, module_name, search_path = build_structural(package_path)
152
+ add_behavioral_layer(graph, root, module_name, search_path, deep=deep,
153
+ behavior_only=only, attr_only=only)
154
+ return graph.to_dict()
155
+
156
+
157
+ def collect_samples(package_path, *, deep: bool, runs: int,
158
+ workers: int | None = None, only=None) -> list[Graph]:
159
+ """``runs`` independent samples of ``package_path``, each in a fresh interpreter.
160
+
161
+ Lives here, beside the two functions the worker calls, so ``extract/union.py`` stays
162
+ a pure merge and the import graph stays acyclic — codemap's own ``no_lazy_cycles``
163
+ contract caught the first draft, which reached back from ``union`` with a
164
+ function-local import. ``workers`` bounds concurrency (default: the machine's core
165
+ count, capped at ``runs``). Spawn, not fork: a forked child would inherit exactly the
166
+ state that makes an in-process repeat correlate with the pass before it.
167
+ """
168
+ ctx = multiprocessing.get_context("spawn")
169
+ procs = max(1, min(runs, workers or os.cpu_count() or 1))
170
+ with ctx.Pool(processes=procs) as pool:
171
+ dicts = pool.starmap(_sample_worker,
172
+ [(str(package_path), deep, set(only) if only else None)] * runs)
173
+ samples = []
174
+ for d in dicts:
175
+ g = Graph.from_dict(d)
176
+ g.loaded_schema = None # a fresh build, not a file — keep schema_diagnostic quiet
177
+ samples.append(g)
178
+ return samples
179
+
180
+
181
+ def extract(package_path: str | Path, *, deep: bool = False, repeat: int = 1) -> Graph:
142
182
  """Build a code graph from a Python package directory.
143
183
 
144
184
  ``deep=True`` runs the jedi-backed call resolver (M5) — richer call-graph
145
185
  (local-variable type inference) at ~1 min build cost; default is the fast
146
186
  ast tier (sub-second). See ``extract/behavior.py``.
187
+
188
+ ``repeat=N`` (R1-C45) builds N samples — **each in a fresh interpreter**, the
189
+ regime whose recovery share was measured; in-process repeats come in correlated
190
+ streaks (see ``extract/union.py``) — and unions them: a deep build is one sample
191
+ of jedi's bounded inference, and an edge seen in fewer than N runs carries
192
+ ``extras.seen``.
147
193
  """
148
- graph, root, module_name, search_path = build_structural(package_path)
149
- add_behavioral_layer(graph, root, module_name, search_path, deep=deep)
194
+ if repeat > 1:
195
+ graph, stats = merge_samples(collect_samples(package_path, deep=deep, runs=repeat))
196
+ else:
197
+ graph, root, module_name, search_path = build_structural(package_path)
198
+ add_behavioral_layer(graph, root, module_name, search_path, deep=deep)
199
+ stats = {"runs": 1}
150
200
  # R1-C25: even a library-built graph says which tool and which tier made it. The
151
201
  # input identity (scope_id, source commit) is added by whoever resolved the scope —
152
202
  # `extract` deliberately does not hash the tree a second time.
153
203
  graph.provenance = build_provenance(tier="deep" if deep else "fast",
154
- inputs=graph.provenance.get("inputs"))
204
+ inputs=graph.provenance.get("inputs"),
205
+ samples=stats)
155
206
  return graph
156
207
 
157
208
 
@@ -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)