codmap 0.0.11__tar.gz → 0.0.13__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.
- {codmap-0.0.11/codmap.egg-info → codmap-0.0.13}/PKG-INFO +7 -5
- {codmap-0.0.11 → codmap-0.0.13}/README.md +6 -4
- {codmap-0.0.11 → codmap-0.0.13}/codemap/arch.py +2 -1
- {codmap-0.0.11 → codmap-0.0.13}/codemap/cli.py +32 -16
- {codmap-0.0.11 → codmap-0.0.13}/codemap/extract/griffe_extractor.py +87 -20
- {codmap-0.0.11 → codmap-0.0.13}/codemap/extract/union.py +22 -9
- {codmap-0.0.11 → codmap-0.0.13}/codemap/incremental.py +63 -18
- {codmap-0.0.11 → codmap-0.0.13}/codemap/model.py +7 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/query.py +18 -5
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/architecture.py +9 -7
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/audit.py +7 -5
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/check.py +12 -7
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/impact.py +3 -1
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/livingdocs.py +3 -2
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/session.py +94 -10
- {codmap-0.0.11 → codmap-0.0.13/codmap.egg-info}/PKG-INFO +7 -5
- {codmap-0.0.11 → codmap-0.0.13}/codmap.egg-info/SOURCES.txt +3 -0
- {codmap-0.0.11 → codmap-0.0.13}/pyproject.toml +1 -1
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_m1_query.py +9 -4
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c29_lazy_imports.py +5 -5
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c30f2_check_scope.py +1 -1
- codmap-0.0.13/tests/test_r1c44_absent_answers.py +197 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c45_repeat_union.py +55 -6
- codmap-0.0.13/tests/test_r1c47_incremental_resample.py +155 -0
- codmap-0.0.13/tests/test_r1c48_type_checking_imports.py +187 -0
- {codmap-0.0.11 → codmap-0.0.13}/LICENSE +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/__init__.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/apidiff.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/diagnostics.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/extract/behavior.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/extract/roots.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/freshness.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/integrations/base.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/provenance.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/scope.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/limits.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/mcp_server.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/pack.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/rag.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/review.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/scip.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/server.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/serve/vault.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/store.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/tomlio.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codemap/watch.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/setup.cfg +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_epistemic.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_integrations.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_m15_review.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_m16_architecture.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_m2_views.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_m9_family.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c38_tool_drift.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c41_scope_membership.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c42_deep_tier_stability.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c43_incremental_splice.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c46_shadowed_definitions.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_scip_export.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_scope.py +0 -0
- {codmap-0.0.11 → codmap-0.0.13}/tests/test_subsystems.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codmap
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.13
|
|
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
|
|
@@ -85,7 +85,7 @@ layers core 9 · data 12 · indicators 16 · analysis 42 · visualizatio
|
|
|
85
85
|
analysis → core 38 edges · indicators → core 22 · data → core 13
|
|
86
86
|
violation ⚠ analysis ↔ core — one backward edge, written inside a function
|
|
87
87
|
cycle pipeline → cache → pipeline — the classic Python import-order landmine
|
|
88
|
-
lazy cycles 40 more, closed only by a function-local
|
|
88
|
+
lazy cycles 40 more, closed only by a non-eager import (function-local or `TYPE_CHECKING`) — not import-time failures,
|
|
89
89
|
still mutual coupling: neither module can be extracted without the other
|
|
90
90
|
coupling core.logging_config Ca 96 — a breaking change here reaches 96 modules
|
|
91
91
|
concentration ZoneVisualizer 35 methods, worst function CC 66 / MI 12.5
|
|
@@ -161,7 +161,7 @@ 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
|
|
@@ -271,8 +271,10 @@ unstable among 13 675). So one build misses such an edge about 1 time in 4, two
|
|
|
271
271
|
1 in 64. `codemap build --deep --repeat N` builds N samples — each in a fresh interpreter, the
|
|
272
272
|
regime the share was measured in; in-process repeats come in correlated streaks — and unions them: an edge seen in fewer
|
|
273
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)
|
|
275
|
-
|
|
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.
|
|
276
278
|
|
|
277
279
|
**Measurement:** [gaps/deep_tier_nondeterminism_2026-09-02.md](gaps/deep_tier_nondeterminism_2026-09-02.md),
|
|
278
280
|
[gaps/deep_tier_union_by_repeat_2026-09-04.md](gaps/deep_tier_union_by_repeat_2026-09-04.md).
|
|
@@ -49,7 +49,7 @@ layers core 9 · data 12 · indicators 16 · analysis 42 · visualizatio
|
|
|
49
49
|
analysis → core 38 edges · indicators → core 22 · data → core 13
|
|
50
50
|
violation ⚠ analysis ↔ core — one backward edge, written inside a function
|
|
51
51
|
cycle pipeline → cache → pipeline — the classic Python import-order landmine
|
|
52
|
-
lazy cycles 40 more, closed only by a function-local
|
|
52
|
+
lazy cycles 40 more, closed only by a non-eager import (function-local or `TYPE_CHECKING`) — not import-time failures,
|
|
53
53
|
still mutual coupling: neither module can be extracted without the other
|
|
54
54
|
coupling core.logging_config Ca 96 — a breaking change here reaches 96 modules
|
|
55
55
|
concentration ZoneVisualizer 35 methods, worst function CC 66 / MI 12.5
|
|
@@ -125,7 +125,7 @@ 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
|
|
@@ -235,8 +235,10 @@ unstable among 13 675). So one build misses such an edge about 1 time in 4, two
|
|
|
235
235
|
1 in 64. `codemap build --deep --repeat N` builds N samples — each in a fresh interpreter, the
|
|
236
236
|
regime the share was measured in; in-process repeats come in correlated streaks — and unions them: an edge seen in fewer
|
|
237
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)
|
|
239
|
-
|
|
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.
|
|
240
242
|
|
|
241
243
|
**Measurement:** [gaps/deep_tier_nondeterminism_2026-09-02.md](gaps/deep_tier_nondeterminism_2026-09-02.md),
|
|
242
244
|
[gaps/deep_tier_union_by_repeat_2026-09-04.md](gaps/deep_tier_union_by_repeat_2026-09-04.md).
|
|
@@ -207,7 +207,8 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
|
|
|
207
207
|
worst = sorted(lazy, key=lambda c: (len(c), c))
|
|
208
208
|
violations.append(Violation(
|
|
209
209
|
"no_lazy_cycles",
|
|
210
|
-
f"{len(lazy)} dependency cycle(s) closed only by a
|
|
210
|
+
f"{len(lazy)} dependency cycle(s) closed only by a non-eager import "
|
|
211
|
+
f"(function-local, or under `if TYPE_CHECKING:`)",
|
|
211
212
|
modules=tuple(" → ".join(c) + " → " + c[0] for c in worst),
|
|
212
213
|
))
|
|
213
214
|
|
|
@@ -85,10 +85,9 @@ def _cmd_build(args) -> int:
|
|
|
85
85
|
incr_info = None
|
|
86
86
|
repeat = getattr(args, "repeat", None)
|
|
87
87
|
repeat = 1 if repeat is None else repeat
|
|
88
|
-
# R1-C45 / D7
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
# the affected modules would leave `seen` relative to different N on neighbouring edges.
|
|
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.
|
|
92
91
|
if repeat < 1:
|
|
93
92
|
print("error: --repeat must be >= 1", file=sys.stderr)
|
|
94
93
|
return 2
|
|
@@ -96,12 +95,13 @@ def _cmd_build(args) -> int:
|
|
|
96
95
|
print(f"error: --repeat {repeat} needs --deep — the fast tier is byte-stable, so "
|
|
97
96
|
"repeating it unions identical samples", file=sys.stderr)
|
|
98
97
|
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
|
|
104
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)
|
|
105
105
|
graph = extract_repo(
|
|
106
106
|
args.path,
|
|
107
107
|
consumers=tuple(args.consumer or ()),
|
|
@@ -201,12 +201,15 @@ def _incremental_build(args):
|
|
|
201
201
|
old_meta = read_meta(args.out)
|
|
202
202
|
old_scope = (old_meta or {}).get("scope")
|
|
203
203
|
if not (Path(args.out).exists() and old_scope):
|
|
204
|
-
return extract(args.path, deep=args.deep
|
|
204
|
+
return extract(args.path, deep=args.deep, repeat=getattr(args, "repeat", None) or 1), \
|
|
205
|
+
{"mode": "full", "affected": []}
|
|
205
206
|
old_graph = store.load(args.out)
|
|
206
207
|
new_scope = resolve_scope(args.path)
|
|
208
|
+
repeat = getattr(args, "repeat", None) or 1
|
|
207
209
|
if old_graph.target != Path(args.path).resolve().name:
|
|
208
|
-
return extract(args.path, deep=args.deep), {"mode": "full", "affected": []}
|
|
209
|
-
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)
|
|
210
213
|
|
|
211
214
|
|
|
212
215
|
def _watch_build_argv(args) -> list[str]:
|
|
@@ -219,6 +222,8 @@ def _watch_build_argv(args) -> list[str]:
|
|
|
219
222
|
argv = ["build", args.path, "-o", args.out, "--incremental"]
|
|
220
223
|
if args.deep:
|
|
221
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
|
|
222
227
|
for c in args.consumer or ():
|
|
223
228
|
argv += ["--consumer", c]
|
|
224
229
|
for d in args.docs or ():
|
|
@@ -261,6 +266,7 @@ def _cmd_watch(args) -> int:
|
|
|
261
266
|
build_args = _argparse.Namespace(
|
|
262
267
|
path=args.path, out=args.out, deep=args.deep, consumer=args.consumer,
|
|
263
268
|
docs=args.docs, mode=args.mode, incremental=True,
|
|
269
|
+
repeat=getattr(args, "repeat", None) or 1,
|
|
264
270
|
_argv=_watch_build_argv(args))
|
|
265
271
|
|
|
266
272
|
reported: set[str] = set()
|
|
@@ -891,7 +897,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
891
897
|
sub = p.add_subparsers(dest="command", required=True)
|
|
892
898
|
|
|
893
899
|
b = sub.add_parser("build", help="Build the canonical graph from a package path.")
|
|
894
|
-
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.")
|
|
895
903
|
b.add_argument("-o", "--out", help="Write graph.json here (default: stdout JSON).")
|
|
896
904
|
b.add_argument("--deep", action="store_true",
|
|
897
905
|
help="Deep call resolution via jedi (richer, ~1 min; default fast).")
|
|
@@ -904,14 +912,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
904
912
|
help="Consumer granularity: thin=per-file (default), full=per-function.")
|
|
905
913
|
b.add_argument("--incremental", action="store_true",
|
|
906
914
|
help="Reuse an existing --out graph + its scope sidecar: recompute "
|
|
907
|
-
"only changed modules (R1-C9).
|
|
908
|
-
"
|
|
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).")
|
|
909
919
|
b.add_argument("--repeat", type=int, default=1, metavar="N",
|
|
910
920
|
help="--deep only: run the jedi layer N times and union the samples. "
|
|
911
921
|
"One deep build is one sample (a real edge was present in 75%% "
|
|
912
922
|
"of single builds, measured); an edge seen in fewer than N runs "
|
|
913
923
|
"carries extras.seen, provenance.samples records N. Costs N× the "
|
|
914
|
-
"jedi time
|
|
924
|
+
"jedi time, in parallel. With --incremental N becomes a property "
|
|
925
|
+
"of the chain: a different N restarts it from a full build.")
|
|
915
926
|
b.set_defaults(func=_cmd_build)
|
|
916
927
|
|
|
917
928
|
sc = sub.add_parser("scope", help="Resolve the input scope manifest (scope_id + profile), or --diff two.")
|
|
@@ -1015,6 +1026,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1015
1026
|
w.add_argument("--docs", action="append", metavar="PATH", help="Docs root. Repeatable.")
|
|
1016
1027
|
w.add_argument("--mode", choices=["thin", "full"], default="thin",
|
|
1017
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.")
|
|
1018
1034
|
w.add_argument("--cycles", type=int, default=None,
|
|
1019
1035
|
help="Stop after N polls instead of running forever (CI / testing).")
|
|
1020
1036
|
_add_poll_options(
|
|
@@ -140,15 +140,22 @@ def add_behavioral_layer(graph, root, module_name, search_path, *, deep: bool,
|
|
|
140
140
|
only=attr_only)
|
|
141
141
|
|
|
142
142
|
|
|
143
|
-
def _sample_worker(package_path: str, deep: bool) -> dict:
|
|
144
|
-
"""One
|
|
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
|
+
"""
|
|
145
151
|
graph, root, module_name, search_path = build_structural(package_path)
|
|
146
|
-
add_behavioral_layer(graph, root, module_name, search_path, deep=deep
|
|
152
|
+
add_behavioral_layer(graph, root, module_name, search_path, deep=deep,
|
|
153
|
+
behavior_only=only, attr_only=only)
|
|
147
154
|
return graph.to_dict()
|
|
148
155
|
|
|
149
156
|
|
|
150
157
|
def collect_samples(package_path, *, deep: bool, runs: int,
|
|
151
|
-
workers: int | None = None) -> list[Graph]:
|
|
158
|
+
workers: int | None = None, only=None) -> list[Graph]:
|
|
152
159
|
"""``runs`` independent samples of ``package_path``, each in a fresh interpreter.
|
|
153
160
|
|
|
154
161
|
Lives here, beside the two functions the worker calls, so ``extract/union.py`` stays
|
|
@@ -161,7 +168,8 @@ def collect_samples(package_path, *, deep: bool, runs: int,
|
|
|
161
168
|
ctx = multiprocessing.get_context("spawn")
|
|
162
169
|
procs = max(1, min(runs, workers or os.cpu_count() or 1))
|
|
163
170
|
with ctx.Pool(processes=procs) as pool:
|
|
164
|
-
dicts = pool.starmap(_sample_worker,
|
|
171
|
+
dicts = pool.starmap(_sample_worker,
|
|
172
|
+
[(str(package_path), deep, set(only) if only else None)] * runs)
|
|
165
173
|
samples = []
|
|
166
174
|
for d in dicts:
|
|
167
175
|
g = Graph.from_dict(d)
|
|
@@ -257,8 +265,9 @@ def _input_report(graph, pkg_dir: Path, root: Path, walk) -> dict:
|
|
|
257
265
|
return report
|
|
258
266
|
|
|
259
267
|
|
|
260
|
-
def _source_import_targets(module) -> list[tuple[str, str]]:
|
|
261
|
-
"""``(target, scope)``
|
|
268
|
+
def _source_import_targets(module) -> tuple[list[tuple[str, str]], set[str]]:
|
|
269
|
+
"""``([(target, scope)], eager targets)`` — the imports griffe's module-level map does
|
|
270
|
+
not carry, and the targets it carries correctly.
|
|
262
271
|
|
|
263
272
|
Two families, one traversal, one parse — griffe records neither, and both used to be
|
|
264
273
|
invisible to the import graph:
|
|
@@ -274,6 +283,11 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
|
|
|
274
283
|
- **imports written in a class body** → scope ``"module"``. They run at
|
|
275
284
|
class-definition time, i.e. at import time, so they are ordinary eager dependencies
|
|
276
285
|
and *can* close a real import cycle. griffe does not record them either (measured).
|
|
286
|
+
- **imports under `if TYPE_CHECKING:`** (R1-C48 / issue #18) → scope
|
|
287
|
+
``"type_checking"``. griffe *does* record these, as module-level — it has no notion
|
|
288
|
+
of a condition that is always false at run time — so the second value returned is
|
|
289
|
+
the set of targets imported at plain module level: the caller demotes griffe's entry
|
|
290
|
+
for a target that appears only under ``TYPE_CHECKING``.
|
|
277
291
|
|
|
278
292
|
Cost discipline, and it is not theoretical: the first version of this walked every
|
|
279
293
|
function's subtree separately, which is quadratic in nesting and cost the dogfood
|
|
@@ -285,13 +299,13 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
|
|
|
285
299
|
try:
|
|
286
300
|
src = module.source
|
|
287
301
|
except Exception: # no source (namespace dir, synthetic)
|
|
288
|
-
return []
|
|
302
|
+
return [], set()
|
|
289
303
|
if not NESTED_IMPORT_HINT.search(src):
|
|
290
|
-
return []
|
|
304
|
+
return [], set()
|
|
291
305
|
try:
|
|
292
306
|
tree = ast.parse(src)
|
|
293
307
|
except (SyntaxError, ValueError):
|
|
294
|
-
return []
|
|
308
|
+
return [], set() # unreadable: D2's report owns this file
|
|
295
309
|
modpath = module.canonical_path
|
|
296
310
|
f = module_file(module)
|
|
297
311
|
is_pkg = f is not None and f.name == "__init__.py"
|
|
@@ -304,9 +318,11 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
|
|
|
304
318
|
return node.module or ""
|
|
305
319
|
|
|
306
320
|
out: list[tuple[str, str]] = []
|
|
321
|
+
eager: set[str] = set() # targets imported at plain module level (griffe has them)
|
|
307
322
|
|
|
308
323
|
def visit(node, scope: str | None) -> None:
|
|
309
|
-
"""``scope`` is None at module level (griffe has those), else
|
|
324
|
+
"""``scope`` is None at module level (griffe has those), else
|
|
325
|
+
module|function|type_checking."""
|
|
310
326
|
for child in ast.iter_child_nodes(node):
|
|
311
327
|
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
312
328
|
visit(child, "function")
|
|
@@ -314,9 +330,21 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
|
|
|
314
330
|
# A class body runs at import time — unless we are already inside a
|
|
315
331
|
# function, in which case the whole thing does not.
|
|
316
332
|
visit(child, "function" if scope == "function" else "module")
|
|
333
|
+
elif isinstance(child, ast.If) and scope in (None, "type_checking") \
|
|
334
|
+
and (tc := _type_checking_branches(child)) is not None:
|
|
335
|
+
# R1-C48 (issue #18): `if TYPE_CHECKING:` never runs. Its body is a third
|
|
336
|
+
# scope; its `else` is ordinary module level. Inside a function the whole
|
|
337
|
+
# `if` is function-local already and is not looked at.
|
|
338
|
+
body_scope, else_scope = tc
|
|
339
|
+
for stmt in child.body:
|
|
340
|
+
visit_stmt(stmt, body_scope if body_scope else scope)
|
|
341
|
+
for stmt in child.orelse:
|
|
342
|
+
visit_stmt(stmt, else_scope if else_scope else scope)
|
|
317
343
|
elif isinstance(child, ast.Import):
|
|
318
344
|
if scope is not None:
|
|
319
345
|
out.extend((a.name, scope) for a in child.names)
|
|
346
|
+
else:
|
|
347
|
+
eager.update(a.name for a in child.names)
|
|
320
348
|
elif isinstance(child, ast.ImportFrom):
|
|
321
349
|
target = resolve(child)
|
|
322
350
|
if not target:
|
|
@@ -325,6 +353,8 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
|
|
|
325
353
|
if scope is None:
|
|
326
354
|
if star: # the D3 case: griffe records nothing
|
|
327
355
|
out.append((target, "module"))
|
|
356
|
+
eager.add(target)
|
|
357
|
+
eager.update(f"{target}.{a.name}" for a in child.names if a.name != "*")
|
|
328
358
|
continue
|
|
329
359
|
# `from pkg.mod import name` → keep the member paths so the resolver walks
|
|
330
360
|
# down to the containing module exactly as it does for the module-level
|
|
@@ -335,19 +365,49 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
|
|
|
335
365
|
else:
|
|
336
366
|
visit(child, scope)
|
|
337
367
|
|
|
368
|
+
def visit_stmt(stmt, scope: str | None) -> None:
|
|
369
|
+
"""Route one statement through ``visit`` as if it were the only child."""
|
|
370
|
+
holder = ast.Module(body=[stmt], type_ignores=[])
|
|
371
|
+
visit(holder, scope)
|
|
372
|
+
|
|
338
373
|
visit(tree, None)
|
|
339
|
-
return out
|
|
374
|
+
return out, eager
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _type_checking_branches(node: ast.If) -> tuple[str | None, str | None] | None:
|
|
378
|
+
"""``(scope of body, scope of else)`` for a recognised ``TYPE_CHECKING`` test, else None.
|
|
379
|
+
|
|
380
|
+
Recognised, narrowly (design D3): the bare name ``TYPE_CHECKING``, any attribute named
|
|
381
|
+
``TYPE_CHECKING`` (``typing.TYPE_CHECKING``, ``t.TYPE_CHECKING``), and ``not`` of either,
|
|
382
|
+
which swaps the branches. A compound test (``TYPE_CHECKING or X``) is *not* recognised
|
|
383
|
+
and stays eager: a condition the tool cannot read is judged strictly, never leniently.
|
|
384
|
+
``None`` in a slot means "the enclosing scope" (module level).
|
|
385
|
+
"""
|
|
386
|
+
test, negated = node.test, False
|
|
387
|
+
if isinstance(test, ast.UnaryOp) and isinstance(test.op, ast.Not):
|
|
388
|
+
test, negated = test.operand, True
|
|
389
|
+
is_tc = ((isinstance(test, ast.Name) and test.id == "TYPE_CHECKING")
|
|
390
|
+
or (isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING"))
|
|
391
|
+
if not is_tc:
|
|
392
|
+
return None
|
|
393
|
+
return (None, "type_checking") if negated else ("type_checking", None)
|
|
340
394
|
|
|
341
395
|
|
|
342
396
|
def _collect(graph, obj, root, target_pkg, walk) -> None:
|
|
343
397
|
if obj.kind.value == "module":
|
|
344
398
|
_claim(obj, walk) # the root claims its own path before any member is walked
|
|
345
399
|
_add_node(graph, obj, root)
|
|
400
|
+
# R1-C23/D3 (star imports) + R1-C29 (function-local and class-body imports) +
|
|
401
|
+
# R1-C48 (`if TYPE_CHECKING:`): everything griffe's module-level map does not
|
|
402
|
+
# carry, or carries under the wrong scope, in one parse.
|
|
403
|
+
nested, eager = _source_import_targets(obj)
|
|
404
|
+
type_checking = {tgt for tgt, scope in nested if scope == "type_checking"}
|
|
346
405
|
for name, tgt in (obj.imports or {}).items():
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
406
|
+
# griffe files an import under `if TYPE_CHECKING:` as module-level; it never
|
|
407
|
+
# runs. Demote it unless the same target is also imported eagerly (D2).
|
|
408
|
+
scope = "type_checking" if tgt in type_checking and tgt not in eager else "module"
|
|
409
|
+
walk.imports.append((obj.canonical_path, tgt, scope))
|
|
410
|
+
for tgt, scope in nested:
|
|
351
411
|
walk.imports.append((obj.canonical_path, tgt, scope))
|
|
352
412
|
for name, member in obj.members.items():
|
|
353
413
|
if member.is_alias:
|
|
@@ -421,6 +481,13 @@ def _emit_decorated_by(graph, obj) -> None:
|
|
|
421
481
|
|
|
422
482
|
# -- pass 2: resolve export + import edges against known nodes ----------------
|
|
423
483
|
|
|
484
|
+
#: How early an import scope reaches its target: an edge carries the earliest (R1-C29 D2,
|
|
485
|
+
#: R1-C48 D2). ``module`` runs at import time, ``type_checking`` never, ``function`` when
|
|
486
|
+
#: the function runs — the middle one is ordered before ``function`` only so the label
|
|
487
|
+
#: names the construct actually written at module level.
|
|
488
|
+
_SCOPE_RANK = {"module": 0, "type_checking": 1, "function": 2}
|
|
489
|
+
|
|
490
|
+
|
|
424
491
|
def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
425
492
|
module_ids = sorted(
|
|
426
493
|
(n.id for n in graph.nodes.values() if n.kind == "module"), key=len, reverse=True
|
|
@@ -449,7 +516,7 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
|
449
516
|
# the eager import it is. `scope` only ever *weakens* to "function" for a pair that
|
|
450
517
|
# has no module-level import at all — the edge says how the dependency is reached at
|
|
451
518
|
# its earliest, never how it happens to appear last in the walk.
|
|
452
|
-
ordered = sorted(imports, key=lambda t: t[2]
|
|
519
|
+
ordered = sorted(imports, key=lambda t: _SCOPE_RANK.get(t[2], 0))
|
|
453
520
|
|
|
454
521
|
# pass A — package-qualified targets. Exact, and run first so that a pair reachable
|
|
455
522
|
# both ways is recorded as exact rather than inferred.
|
|
@@ -464,7 +531,7 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
|
464
531
|
if key in seen:
|
|
465
532
|
continue
|
|
466
533
|
seen.add(key)
|
|
467
|
-
extras = {"scope":
|
|
534
|
+
extras = {"scope": scope} if scope != "module" else {}
|
|
468
535
|
graph.add_edge(Edge("imports", src_module, tgt_module, extras=extras))
|
|
469
536
|
|
|
470
537
|
# pass B — flat layout (R1-C21): sibling modules importing each other by bare name
|
|
@@ -481,8 +548,8 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
|
481
548
|
continue
|
|
482
549
|
seen.add(key)
|
|
483
550
|
extras = {"resolution": "flat"}
|
|
484
|
-
if scope
|
|
485
|
-
extras["scope"] =
|
|
551
|
+
if scope != "module":
|
|
552
|
+
extras["scope"] = scope
|
|
486
553
|
graph.add_edge(Edge("imports", src_module, tgt_module, extras=extras))
|
|
487
554
|
|
|
488
555
|
|
|
@@ -36,7 +36,10 @@ An edge seen in fewer than N runs carries ``extras.seen: k``; nothing is written
|
|
|
36
36
|
``k == N``, so a single-sample build's edges are byte-identical to what they were. Node
|
|
37
37
|
counters (``calls`` / ``attr_access``) count *sites*, which dedupe into edges — the
|
|
38
38
|
measured node had three counter variants behind one flapping edge — so per node the
|
|
39
|
-
variant with the fewest ``unresolved`` is kept (tie: most ``resolved``, tie:
|
|
39
|
+
variant with the fewest ``unresolved`` is kept (tie: most ``resolved``, tie: the serialized
|
|
40
|
+
counter). Every choice between variants of one key is a **total order** over the candidates
|
|
41
|
+
(:func:`_rank`, :func:`_counter_rank`): the merged graph is a function of the set of
|
|
42
|
+
samples, and the same samples in any order produce the same bytes.
|
|
40
43
|
|
|
41
44
|
**Every sample is a fresh process.** The share above was measured across processes —
|
|
42
45
|
the consumer's 175 builds and the eight here. Repeating the behavioural layer *inside*
|
|
@@ -74,20 +77,30 @@ def _identity(e: Edge) -> tuple:
|
|
|
74
77
|
json.dumps(e.extras, sort_keys=True, ensure_ascii=False))
|
|
75
78
|
|
|
76
79
|
|
|
77
|
-
def
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
def _rank(e: Edge) -> tuple:
|
|
81
|
+
"""Total order among the variants of one identity key: ``deep`` beats any other
|
|
82
|
+
resolution, more call sites beat fewer, and the serialized ``extras`` break what is
|
|
83
|
+
left — so the winner is a function of the *set* of samples, never of the order they
|
|
84
|
+
arrived in. The consumer's hint on codemap#17: where two candidates are equally
|
|
85
|
+
legitimate, 'first one wins' is a dependence on arrival order, and one that a probe
|
|
86
|
+
permuting the *output* can never see."""
|
|
87
|
+
ex = e.extras
|
|
88
|
+
return (ex.get("resolution") == "deep", ex.get("callsites", 0),
|
|
89
|
+
json.dumps(ex, sort_keys=True, ensure_ascii=False))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _counter_rank(c: dict) -> tuple:
|
|
93
|
+
return (-c.get("unresolved", 0), c.get("resolved", 0), json.dumps(c, sort_keys=True))
|
|
80
94
|
|
|
81
95
|
|
|
82
96
|
def _better_counter(a: dict | None, b: dict | None) -> dict | None:
|
|
83
|
-
"""The counter variant that resolved more sites (``None`` never beats a value)
|
|
97
|
+
"""The counter variant that resolved more sites (``None`` never beats a value);
|
|
98
|
+
a tie is broken by the serialized counter, not by which sample came first."""
|
|
84
99
|
if a is None:
|
|
85
100
|
return b
|
|
86
101
|
if b is None:
|
|
87
102
|
return a
|
|
88
|
-
|
|
89
|
-
kb = (-b.get("unresolved", 0), b.get("resolved", 0))
|
|
90
|
-
return b if kb > ka else a
|
|
103
|
+
return b if _counter_rank(b) > _counter_rank(a) else a
|
|
91
104
|
|
|
92
105
|
|
|
93
106
|
def merge_samples(samples: list[Graph]) -> tuple[Graph, dict]:
|
|
@@ -116,7 +129,7 @@ def merge_samples(samples: list[Graph]) -> tuple[Graph, dict]:
|
|
|
116
129
|
else:
|
|
117
130
|
if key not in seen_this_run:
|
|
118
131
|
entry[0] += 1
|
|
119
|
-
if
|
|
132
|
+
if _rank(e) > _rank(entry[1]):
|
|
120
133
|
entry[1] = e
|
|
121
134
|
seen_this_run.add(key)
|
|
122
135
|
|
|
@@ -54,10 +54,10 @@ import copy
|
|
|
54
54
|
from pathlib import Path
|
|
55
55
|
|
|
56
56
|
from codemap.extract.griffe_extractor import (
|
|
57
|
-
add_behavioral_layer, build_structural, extract,
|
|
57
|
+
add_behavioral_layer, build_structural, collect_samples, extract,
|
|
58
58
|
)
|
|
59
|
-
from codemap.extract.union import BEHAVIOR_CALL_RES
|
|
60
|
-
from codemap.model import Graph
|
|
59
|
+
from codemap.extract.union import BEHAVIOR_CALL_RES, merge_samples
|
|
60
|
+
from codemap.model import SPLICED_EDGE_TYPES, Graph
|
|
61
61
|
from codemap.provenance import build_provenance, tool_identity
|
|
62
62
|
from codemap.scope import diff_scopes
|
|
63
63
|
|
|
@@ -75,8 +75,9 @@ _BEHAVIOR_REF_RES = frozenset({"name", "annotation"})
|
|
|
75
75
|
# it — or the fast tier's byte-identity to a full build (R1-C9) breaks on it.
|
|
76
76
|
_BEHAVIORAL_EXTRAS = ("calls", "control", "complexity", "attr_access", "shadows")
|
|
77
77
|
# Old edge types whose target landing in a changed/removed module makes the source
|
|
78
|
-
# module stale (it must re-resolve). Only the spliced passes matter here
|
|
79
|
-
|
|
78
|
+
# module stale (it must re-resolve). Only the spliced passes matter here — and the serve
|
|
79
|
+
# layer reads the same set to know which ops answer from a spliced class (R1-C44).
|
|
80
|
+
_DEP_EDGE_TYPES = SPLICED_EDGE_TYPES
|
|
80
81
|
|
|
81
82
|
# Above this fraction of modules affected, a full rebuild is cheaper (and trivially
|
|
82
83
|
# correct) — no point splicing most of the graph.
|
|
@@ -159,21 +160,38 @@ def _splice_unaffected(new_graph, old_graph, unaffected, module_of) -> None:
|
|
|
159
160
|
node.extras[k] = copy.deepcopy(old_extras[k])
|
|
160
161
|
|
|
161
162
|
|
|
162
|
-
def _same_builder(provenance: dict, tier: str) ->
|
|
163
|
-
"""
|
|
163
|
+
def _same_builder(provenance: dict, tier: str, repeat: int = 1) -> str | None:
|
|
164
|
+
"""Why the old graph is *not* from this builder — ``None`` when it is (R1-C25).
|
|
164
165
|
|
|
165
166
|
A graph with no provenance (pre-0.12) cannot answer, so it is treated as a
|
|
166
167
|
different builder — the conservative direction: a needless full rebuild costs a
|
|
167
168
|
minute, a silently stale graph costs a wrong answer.
|
|
169
|
+
|
|
170
|
+
R1-C47: the sample count is part of the builder. ``extras.seen`` on a spliced edge
|
|
171
|
+
means "k of N", and it can only mean that if N is the same N the chain has used all
|
|
172
|
+
along — a request with a different N starts a new chain from a full build.
|
|
168
173
|
"""
|
|
169
174
|
if not provenance:
|
|
170
|
-
return
|
|
171
|
-
|
|
172
|
-
|
|
175
|
+
return "builder-changed"
|
|
176
|
+
if (provenance.get("tool") != tool_identity()
|
|
177
|
+
or provenance.get("tier") != tier):
|
|
178
|
+
return "builder-changed"
|
|
179
|
+
if ((provenance.get("samples") or {}).get("runs", 1)) != repeat:
|
|
180
|
+
return "samples-changed"
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _full(package_path, *, deep: bool, repeat: int) -> Graph:
|
|
185
|
+
"""A full build with the chain's sample count (R1-C47 / D2)."""
|
|
186
|
+
return extract(package_path, deep=deep, repeat=repeat)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _count_seen(graph: Graph) -> int:
|
|
190
|
+
return sum(1 for e in graph.edges if "seen" in e.extras)
|
|
173
191
|
|
|
174
192
|
|
|
175
193
|
def update_graph(old_graph: Graph, package_path, old_scope: dict, new_scope: dict,
|
|
176
|
-
*, deep: bool = False) -> tuple[Graph, dict]:
|
|
194
|
+
*, deep: bool = False, repeat: int = 1) -> tuple[Graph, dict]:
|
|
177
195
|
"""Incrementally rebuild ``old_graph`` for the current source tree.
|
|
178
196
|
|
|
179
197
|
Returns ``(graph, info)`` where ``info`` records the decision (``mode``:
|
|
@@ -186,16 +204,25 @@ def update_graph(old_graph: Graph, package_path, old_scope: dict, new_scope: dic
|
|
|
186
204
|
(R1-C42): the spliced regions carry the *previous* build's sample, and the splice
|
|
187
205
|
is self-perpetuating — see the module docstring (R1-C43). Such a graph is stamped
|
|
188
206
|
``provenance.incremental: true``.
|
|
207
|
+
|
|
208
|
+
``repeat=N`` (R1-C47) makes every place this path samples — the fallback full build
|
|
209
|
+
and the recompute of the affected modules — take N samples in fresh interpreters,
|
|
210
|
+
merged as a full ``--repeat`` build would be. Measured on twenty real commits: the
|
|
211
|
+
chain's misses come from single samples, not from age, so this is the remedy and a
|
|
212
|
+
periodic full rebuild is not. N must equal the old graph's ``samples.runs`` or the
|
|
213
|
+
chain restarts from a full build (``reason: samples-changed``).
|
|
189
214
|
"""
|
|
190
215
|
target_pkg = old_graph.target
|
|
191
216
|
tier = "deep" if deep else "fast"
|
|
192
217
|
# R1-C25: the input is not the only thing that can change. `unchanged` below decides
|
|
193
218
|
# from the source tree alone, so an upgraded codemap over an untouched tree used to
|
|
194
219
|
# return yesterday's graph built by yesterday's extractor — the exact confusion the
|
|
195
|
-
# provenance block exists to name. A different tool or
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
220
|
+
# provenance block exists to name. A different tool, tier or sample count is a full
|
|
221
|
+
# rebuild.
|
|
222
|
+
reason = _same_builder(old_graph.provenance, tier, repeat)
|
|
223
|
+
if reason is not None:
|
|
224
|
+
return _full(package_path, deep=deep, repeat=repeat), \
|
|
225
|
+
{"mode": "full", "affected": [], "reason": reason}
|
|
199
226
|
|
|
200
227
|
d = diff_scopes(old_scope, new_scope)
|
|
201
228
|
changed = {m for p in d["changed"] if (m := _path_to_module(p, target_pkg))}
|
|
@@ -218,14 +245,32 @@ def update_graph(old_graph: Graph, package_path, old_scope: dict, new_scope: dic
|
|
|
218
245
|
module_of) & module_ids
|
|
219
246
|
|
|
220
247
|
if not module_ids or len(affected) >= _FULL_FALLBACK_FRACTION * len(module_ids):
|
|
248
|
+
if repeat > 1:
|
|
249
|
+
# R1-C47: the fallback used to be the chain's weakest point — a 63-module
|
|
250
|
+
# commit reset a --repeat 3 chain to one sample on the replay's third tick.
|
|
251
|
+
return _full(package_path, deep=deep, repeat=repeat), \
|
|
252
|
+
{"mode": "full", "affected": sorted(affected)}
|
|
221
253
|
add_behavioral_layer(graph, root, module_name, search_path, deep=deep)
|
|
222
254
|
graph.provenance = build_provenance(tier=tier, inputs=graph.provenance.get("inputs"))
|
|
223
255
|
return graph, {"mode": "full", "affected": sorted(affected)}
|
|
224
256
|
|
|
225
|
-
|
|
226
|
-
|
|
257
|
+
if repeat > 1:
|
|
258
|
+
# R1-C47 / D2: N fresh interpreters, each restricted to the affected modules;
|
|
259
|
+
# the structural base is rebuilt in each (deterministic, ~4 s) so the merge
|
|
260
|
+
# sees identical nodes. The spliced part keeps its own `seen` — same N (D1).
|
|
261
|
+
graph, stats = merge_samples(collect_samples(package_path, deep=deep,
|
|
262
|
+
runs=repeat, only=affected))
|
|
263
|
+
graph.loaded_schema = None
|
|
264
|
+
else:
|
|
265
|
+
add_behavioral_layer(graph, root, module_name, search_path, deep=deep,
|
|
266
|
+
behavior_only=affected, attr_only=affected)
|
|
267
|
+
stats = {"runs": 1}
|
|
227
268
|
unaffected = module_ids - affected
|
|
228
269
|
_splice_unaffected(graph, old_graph, unaffected, module_of)
|
|
270
|
+
if repeat > 1:
|
|
271
|
+
# `unstable` describes the artifact a reader holds, not the part this tick
|
|
272
|
+
# touched: recount over recomputed and spliced edges alike.
|
|
273
|
+
stats = {"runs": repeat, "unstable": _count_seen(graph)}
|
|
229
274
|
graph.provenance = build_provenance(tier=tier, inputs=graph.provenance.get("inputs"),
|
|
230
|
-
incremental=True)
|
|
275
|
+
incremental=True, samples=stats)
|
|
231
276
|
return graph, {"mode": "incremental", "affected": sorted(affected)}
|
|
@@ -97,6 +97,13 @@ EDGE_TYPES = frozenset({
|
|
|
97
97
|
"accesses", # function → attribute node it reads/writes (extras.access, R1-C20)
|
|
98
98
|
})
|
|
99
99
|
|
|
100
|
+
# The subset of EDGE_TYPES an incremental build splices from the old graph for modules
|
|
101
|
+
# it did not recompute (``incremental.py``) — and therefore the classes a deep+incremental
|
|
102
|
+
# graph answers from an *earlier* build's jedi sample (R1-C43). Lives with the vocabulary
|
|
103
|
+
# because two layers that may not import each other need it: the extractor's union
|
|
104
|
+
# (R1-C45) and the serve layer, which derives from it which ops must say so (R1-C44 / D4).
|
|
105
|
+
SPLICED_EDGE_TYPES = frozenset({"calls", "accesses", "references"})
|
|
106
|
+
|
|
100
107
|
|
|
101
108
|
@dataclass
|
|
102
109
|
class Node:
|