Skip to content

Commit 4f2deb0

Browse files
author
jgstern-agent
committed
fix(io-boundaries): preserve leaf-caller rollups in CLI filter pass (WI-rubir)
cmd_io_boundaries reconstructs BoundaryMapEntry whenever `primitive_filter or exclude_tests` is true. Since exclude_tests=True is the default (WI-sifif), every normal CLI invocation hit this path and silently dropped the WI-darad rollups (leaf_callers / entry_points_per_leaf) by omitting them from the dataclass construction. The bakeoff cohort-001/iter-010 artifact for alertmanager / kafka / prometheus showed chain_count > 0 with leaf_callers=[] for every boundary. Extract compute_leaf_rollups() as a public io_boundary helper; have the CLI filter path lazily build the reverse graph and recompute rollups for the surviving chain subset. Three regression tests cover the default exclude_tests=True path, the entry_points_per_leaf serialization, and the --primitive filter path. Signed-off-by: jgstern-agent <josh-agent@iterabloom.com>
1 parent 26d1236 commit 4f2deb0

5 files changed

Lines changed: 266 additions & 44 deletions

File tree

.ci/affected-tests.txt

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,72 @@
11
# Test selection manifest
2-
# Generated by smart-test at 2026-04-25T17:31:03-04:00
2+
# Generated by smart-test at 2026-04-25T18:29:01-04:00
33
# Mode: targeted
4-
# Baseline: 6e8145cf3768c4673ab6c98fdcdb334b05fe3415
5-
# Changed files: 6
4+
# Baseline: 26d1236f23e7200fa351a1c875f9356c4bb3241e
5+
# Changed files: 7
66
# Changed source files: 2
7-
# Selected tests: 10
7+
# Selected tests: 60
88
#
99
# === CHANGED_SOURCE_FILES ===
10-
packages/hypergumbo-tracker/src/hypergumbo_tracker/cli.py
11-
packages/hypergumbo-tracker/src/hypergumbo_tracker/validation.py
10+
packages/hypergumbo-core/src/hypergumbo_core/cli.py
11+
packages/hypergumbo-core/src/hypergumbo_core/io_boundary.py
1212
# === SELECTED_TESTS ===
13-
packages/hypergumbo-tracker/tests/test_cli.py
14-
packages/hypergumbo-tracker/tests/test_configure.py
15-
packages/hypergumbo-tracker/tests/test_fork_workflow.py
16-
packages/hypergumbo-tracker/tests/test_migration.py
17-
packages/hypergumbo-tracker/tests/test_serve.py
18-
packages/hypergumbo-tracker/tests/test_setup.py
19-
packages/hypergumbo-tracker/tests/test_store.py
20-
packages/hypergumbo-tracker/tests/test_sync.py
21-
packages/hypergumbo-tracker/tests/test_tui.py
22-
packages/hypergumbo-tracker/tests/test_validation.py
13+
packages/hypergumbo-core/tests/test_backend_cli_flag.py
14+
packages/hypergumbo-core/tests/test_build_grammars.py
15+
packages/hypergumbo-core/tests/test_cli_basic.py
16+
packages/hypergumbo-core/tests/test_cli_cache.py
17+
packages/hypergumbo-core/tests/test_cli_commands.py
18+
packages/hypergumbo-core/tests/test_cli_config.py
19+
packages/hypergumbo-core/tests/test_cli_dead_code.py
20+
packages/hypergumbo-core/tests/test_cli_explain.py
21+
packages/hypergumbo-core/tests/test_cli_io_boundaries.py
22+
packages/hypergumbo-core/tests/test_cli_relativize_paths.py
23+
packages/hypergumbo-core/tests/test_cli_routes.py
24+
packages/hypergumbo-core/tests/test_cli_run_behavior_map.py
25+
packages/hypergumbo-core/tests/test_cli_search.py
26+
packages/hypergumbo-core/tests/test_cli_symbols.py
27+
packages/hypergumbo-core/tests/test_cli_test_coverage.py
28+
packages/hypergumbo-core/tests/test_cli_verify_claims.py
29+
packages/hypergumbo-core/tests/test_file_excludes.py
30+
packages/hypergumbo-core/tests/test_frameworks_flag.py
31+
packages/hypergumbo-core/tests/test_gitleaks.py
32+
packages/hypergumbo-core/tests/test_handler_slices.py
33+
packages/hypergumbo-core/tests/test_install_extras.py
34+
packages/hypergumbo-core/tests/test_io_boundary.py
35+
packages/hypergumbo-core/tests/test_locale.py
36+
packages/hypergumbo-core/tests/test_max_tier.py
37+
packages/hypergumbo-core/tests/test_no_first_party_priority.py
38+
packages/hypergumbo-core/tests/test_profile.py
39+
packages/hypergumbo-core/tests/test_pyffi_linker.py
40+
packages/hypergumbo-core/tests/test_rubyffi_linker.py
41+
packages/hypergumbo-core/tests/test_run_behavior_map.py
42+
packages/hypergumbo-core/tests/test_rust_analyzer_install.py
43+
packages/hypergumbo-core/tests/test_schema_compliance.py
44+
packages/hypergumbo-core/tests/test_sketch.py
45+
packages/hypergumbo-core/tests/test_sketch_sanity.py
46+
packages/hypergumbo-core/tests/test_slice_tier_filter.py
47+
packages/hypergumbo-core/tests/test_stable_shape_ids.py
48+
packages/hypergumbo-core/tests/test_supply_chain.py
49+
packages/hypergumbo-core/tests/test_taint.py
50+
packages/hypergumbo-core/tests/test_verify_claims.py
51+
packages/hypergumbo-lang-common/tests/BRANCHES_test_dart.py
52+
packages/hypergumbo-lang-common/tests/BRANCHES_test_elixir.py
53+
packages/hypergumbo-lang-common/tests/test_haskell.py
54+
packages/hypergumbo-lang-mainstream/tests/BRANCHES_test_cpp.py
55+
packages/hypergumbo-lang-mainstream/tests/BRANCHES_test_c.py
56+
packages/hypergumbo-lang-mainstream/tests/BRANCHES_test_csharp.py
57+
packages/hypergumbo-lang-mainstream/tests/BRANCHES_test_go.py
58+
packages/hypergumbo-lang-mainstream/tests/BRANCHES_test_java.py
59+
packages/hypergumbo-lang-mainstream/tests/BRANCHES_test_js_ts.py
60+
packages/hypergumbo-lang-mainstream/tests/BRANCHES_test_kotlin.py
61+
packages/hypergumbo-lang-mainstream/tests/BRANCHES_test_php.py
62+
packages/hypergumbo-lang-mainstream/tests/BRANCHES_test_python_ast_analysis.py
63+
packages/hypergumbo-lang-mainstream/tests/BRANCHES_test_ruby.py
64+
packages/hypergumbo-lang-mainstream/tests/BRANCHES_test_rust.py
65+
packages/hypergumbo-lang-mainstream/tests/BRANCHES_test_scala.py
66+
packages/hypergumbo-lang-mainstream/tests/BRANCHES_test_swift.py
67+
packages/hypergumbo-lang-mainstream/tests/test_c.py
68+
packages/hypergumbo-lang-mainstream/tests/test_html_analysis.py
69+
packages/hypergumbo-lang-mainstream/tests/test_polyglot_call_site_coverage.py
70+
packages/hypergumbo-lang-mainstream/tests/test_py_deps.py
71+
packages/hypergumbo-lang-mainstream/tests/test_python_ast_analysis.py
72+
packages/hypergumbo-lang-mainstream/tests/test_rust.py

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ This changelog tracks the **tool version** (package releases). The **schema vers
4444

4545
### Fixed
4646

47+
- **`io-boundaries` CLI dropped WI-darad leaf-caller roll-ups in the filter pass** (WI-rubir): `cmd_io_boundaries` reconstructs `BoundaryMapEntry` whenever `primitive_filter or exclude_tests` is true, and since `exclude_tests=True` is the default (WI-sifif) every normal CLI invocation hit this path. The reconstruction set `chains` / `entry_points` / `primitives_used` but omitted `leaf_callers` and `entry_points_per_leaf` (they default to empty), so the bakeoff io-boundaries.txt artifact for cohort-001/iter-010 (alertmanager, kafka, prometheus) showed `chain_count > 0` with `leaf_callers=[]` for every boundary — the WI-darad fix had landed in `compute_boundary_map` but was silently stripped before serialization. The leaf-rollup loop is now a public module helper `compute_leaf_rollups(chains, reverse_graph, entrypoint_ids)` exported from `io_boundary.py`; the CLI filter path lazily builds the reverse graph (`_build_reverse_graph`) and recomputes the rollups for the surviving chain subset. Tagged `awaits_bakeoff_validation`; the next DEEP cohort that re-runs alertmanager/kafka/prometheus should observe non-empty `leaf_callers` per net_send/fs_read/fs_write boundary, validating WI-darad's original metric movement claim.
4748
- **TOML `[project.scripts]` `defines_target` dst is a well-formed Python id; path moved to `meta.target_path`** (INV-nodij): the previous emitter at `toml_config.py:_extract_pyproject_scripts` set `dst="hypergumbo_core/cli.py"` (bare path, no language prefix or colons). `_parse_dangling_id`'s `len(parts)<5` fallback (`ir.py:701-706`) then stuffed the whole path into the `language` slot of the synthesized boundary node, producing 2 orphan `external_symbol` nodes per pyproject script entry on hypergumbo self-analysis with `language='hypergumbo_core/cli.py'` and `language='hypergumbo_tracker/cli.py'`. Same shape as the WI-diruj Markdown link bug, different producer. The dst is now `f"python:{module_dotted}:0-0:{target_func}:unresolved"` — a properly-formed 5-part Python id that synthesizes a clean boundary `Symbol` (`language="python"`, `kind="external_symbol"`, with `canonical_name="python:hypergumbo_core.cli:main:unresolved"`); the WI-zujip manifest awareness then auto-promotes hypergumbo_core entries to tier-2. The build-target linker (`linkers/build_target.py`) reads `target_path` from `edge.meta` first, falling back to `edge.dst` for the 8 other `defines_target` producers (xml_config, json_config, manifest_targets) which keep their existing path-as-dst contract. Self-analysis: 2 → 0 path-shaped-language orphan nodes. New regression test `test_pyproject_scripts_dst_is_well_formed_id` asserts the 5-part id shape on every pyproject script `defines_target` edge.
4849
- **`parse_python_dependencies` walks monorepo `packages/<pkg>/pyproject.toml`** (WI-zujip, follow-up to WI-nunuj): the shipped parser only inspected `repo_root/pyproject.toml`, which silently missed monorepo layouts where the root file is shared-tool-configuration only and actual `[project].dependencies` live in per-package files. Self-analysis dogfood on hypergumbo (which IS such a monorepo) confirmed the symptom: `parse_python_dependencies(Path("."))` returned a manifest with `entries=[]`, the wire-up at `py.py:3307` set `dependency_manifest=None`, and all 1,443 python `external_symbol` boundary nodes stayed tier-3 (including `tree_sitter`, `protobuf`, `rich`, `pyyaml`, `pygments`, `sentence_transformers` — every one of which IS declared in some `packages/*/pyproject.toml`). Replaced with `_find_pyproject_files(repo_root)` that walks the tree (skipping `DEFAULT_EXCLUDES` directories and dot-prefixed dirs so a `.venv/site-packages/<some-pkg>/pyproject.toml` cannot smuggle a fake dep), and a `parse_python_dependencies` that unions dist names across every discovered file. Single-pyproject layouts collapse to the previous behaviour. After the fix, hypergumbo self-analysis reports 90 declared imports and `tree_sitter` / `rich` / `pygments` / `yaml` / `sentence_transformers` / `pytest` / `jsonschema` / `requests` all classify tier-2. Direct parallel of WI-davan E1, which fixed the same monorepo gap on the file-discovery side via `_detect_source_roots`. Tagged `awaits_bakeoff_validation`.
4950
- **Orchestrator synthesises real `kind="file"` Symbols for every `make_file_id`-shape edge endpoint; ranking suppression keeps them out of top-N** (WI-ramuv, Plan B follow-up to WI-fozoh / PR #3355): a new `synthesize_file_symbols_for_dangling_edges` helper runs once at the analyzer-result chokepoint in `analyze.all_analyzers.run_all_analyzers` (after edge dedup, before `ir.create_boundary_nodes`). Any edge whose `src` or `dst` matches `{lang}:{path}:1-1:file:file` shape and has no producer-side Symbol gets one synthesised on the spot — `kind="file"`, `name=path`, `language=lang`, `origin="orchestrator_file_symbol_synthesis"`. The 20+ analyzers that previously emitted `make_file_id`-style import-edge srcs without a matching Symbol (Python, C, C++, Perl, Bash, Proto, Thrift, plus ~13 in lang-common / lang-extended1) all benefit from one orchestrator change, with no per-analyzer churn. Dart-style colon-bearing path slots (`dart:dart:io:1-1:file:file` → path `dart:io`) are preserved by stripping the fixed suffix before the language split. Co-required deliverable to keep ranking stable: a new `apply_file_kind_weights` dampener in `ranking.py` (wired into `rank_symbols` after `apply_generated_code_weights`) zeros centrality for every `kind="file"` Symbol whose language is NOT in the (currently empty) `_FILE_KIND_RANKING_ALLOWED_LANGUAGES` allow-list. Inverse of WI-gafog's CSS-variable precedent: default is "exclude from ranking", a future language whose file Symbols ARE meaningful as ranking entries (e.g. HTML pages on a static-site analyser) opts back in by joining the set. The Symbols stay in the symbol list (slice traversal, containment, per-file metrics still see them) — only their ranking weight is zeroed. Schema 0.2.3 → 0.2.4 (additive): `"file"` added to `Symbol.kind` enum. Self-analysis dogfood: canonical `python:<external>:0-0:file:file` boundary Symbol drops 1 → 0; imports edge count rises 2,136 → 9,252 with full per-file src attribution; top-30 sketch ranking contains zero `kind="file"` Symbols and is preserved within ±3 positions (TreeSitterAnalyzer, find_files, Symbol, Span, register_analyzer, …); 739 first-party file Symbols enter the graph. Tagged `awaits_bakeoff_validation`; the cohort that confirms it should also revisit WI-fozoh's "37% external-symbol drop" tag, since the denominator changes when these 739 Symbols stop counting as boundary externals.

packages/hypergumbo-core/src/hypergumbo_core/cli.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3205,7 +3205,25 @@ class _Edge:
32053205
# CLI users do the same via --include-tests.
32063206
exclude_tests = getattr(args, "exclude_tests", True)
32073207

3208-
from .io_boundary import BoundaryMapEntry
3208+
from .io_boundary import (
3209+
BoundaryMapEntry,
3210+
_build_reverse_graph,
3211+
compute_leaf_rollups,
3212+
)
3213+
3214+
# WI-rubir: when the filter path rebuilds BoundaryMapEntry it must
3215+
# also recompute the WI-darad leaf-caller roll-ups for the surviving
3216+
# chain subset; otherwise leaf_callers / entry_points_per_leaf are
3217+
# silently dropped (they default to empty on the dataclass), and
3218+
# because exclude_tests=True is the default, every normal CLI
3219+
# invocation hits this path.
3220+
reverse_graph_for_filter: Optional[Dict[str, set[str]]] = None
3221+
3222+
def _ensure_reverse_graph() -> Dict[str, set[str]]:
3223+
nonlocal reverse_graph_for_filter
3224+
if reverse_graph_for_filter is None:
3225+
reverse_graph_for_filter = _build_reverse_graph(edges)
3226+
return reverse_graph_for_filter
32093227

32103228
filtered_entries: Dict[str, BoundaryMapEntry] = {}
32113229
for btype, entry in bmap.entries.items():
@@ -3231,11 +3249,18 @@ def _is_test_chain(chain: Any) -> bool:
32313249
continue
32323250

32333251
if primitive_filter or exclude_tests:
3252+
leaf_callers, entry_points_per_leaf = compute_leaf_rollups(
3253+
chains,
3254+
_ensure_reverse_graph(),
3255+
entrypoint_ids or None,
3256+
)
32343257
filtered_entries[btype] = BoundaryMapEntry(
32353258
boundary=entry.boundary,
32363259
chains=chains,
32373260
entry_points=sorted({ep for c in chains for ep in c.entry_points}),
32383261
primitives_used=sorted({c.primitive for c in chains}),
3262+
leaf_callers=leaf_callers,
3263+
entry_points_per_leaf=entry_points_per_leaf,
32393264
)
32403265
else:
32413266
filtered_entries[btype] = entry

packages/hypergumbo-core/src/hypergumbo_core/io_boundary.py

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -931,44 +931,67 @@ def compute_boundary_map(
931931
by_boundary["external_potential"] = ext_chains
932932

933933
# Build boundary map entries, including WI-darad leaf-caller roll-ups.
934-
# A "leaf caller" of an io_edge_src is an immediate caller of that src
935-
# in the reverse graph; when src has no callers, src itself is its own
936-
# leaf (the primitive is invoked directly from that function).
937934
bmap = BoundaryMap(total_io_edges=tagged_count)
938935
leaf_ep_cache: dict[str, set[str]] = {}
939936
for boundary, chains in by_boundary.items():
940-
entry_points_set: set[str] = set()
941-
primitives_set: set[str] = set()
942-
leaf_set: set[str] = set()
943-
per_leaf: dict[str, set[str]] = {}
944-
for chain in chains:
945-
primitives_set.add(chain.primitive)
946-
for ep in chain.entry_points:
947-
entry_points_set.add(ep)
948-
callers = reverse_graph.get(chain.io_edge_src, set())
949-
leaves = callers if callers else {chain.io_edge_src}
950-
for leaf in leaves:
951-
leaf_set.add(leaf)
952-
if entrypoint_ids:
953-
if leaf not in leaf_ep_cache:
954-
leaf_ep_cache[leaf] = _reachable_entry_points(
955-
leaf, reverse_graph, entrypoint_ids
956-
)
957-
per_leaf.setdefault(leaf, set()).update(leaf_ep_cache[leaf])
937+
leaf_callers, entry_points_per_leaf = compute_leaf_rollups(
938+
chains, reverse_graph, entrypoint_ids, leaf_ep_cache,
939+
)
958940
bmap.entries[boundary] = BoundaryMapEntry(
959941
boundary=boundary,
960942
chains=chains,
961-
entry_points=sorted(entry_points_set),
962-
primitives_used=sorted(primitives_set),
963-
leaf_callers=sorted(leaf_set),
964-
entry_points_per_leaf={
965-
leaf: sorted(eps) for leaf, eps in per_leaf.items()
966-
},
943+
entry_points=sorted({ep for c in chains for ep in c.entry_points}),
944+
primitives_used=sorted({c.primitive for c in chains}),
945+
leaf_callers=leaf_callers,
946+
entry_points_per_leaf=entry_points_per_leaf,
967947
)
968948

969949
return bmap
970950

971951

952+
def compute_leaf_rollups(
953+
chains: list[IoChain],
954+
reverse_graph: dict[str, set[str]],
955+
entrypoint_ids: Optional[set[str]] = None,
956+
leaf_ep_cache: Optional[dict[str, set[str]]] = None,
957+
) -> tuple[list[str], dict[str, list[str]]]:
958+
"""Compute the WI-darad leaf-caller roll-ups for a chain set.
959+
960+
A "leaf caller" of an io_edge_src is an immediate caller of that src
961+
in the reverse graph; when src has no callers, src itself is its own
962+
leaf (the primitive is invoked directly from that function).
963+
964+
Exposed at module level so the CLI's filter pass (cmd_io_boundaries)
965+
can recompute rollups for a subset of chains after dropping test-file
966+
chains or applying --primitive — otherwise the BoundaryMapEntry it
967+
rebuilds loses the rollups, and the bakeoff io-boundaries.txt shows
968+
chain_count>0 with leaf_callers=[] (WI-rubir regression).
969+
970+
``leaf_ep_cache`` lets callers reuse entry-point reachability sets
971+
across multiple boundary types in the same map; pass ``None`` to use
972+
a per-call scratch cache.
973+
"""
974+
if leaf_ep_cache is None:
975+
leaf_ep_cache = {}
976+
leaf_set: set[str] = set()
977+
per_leaf: dict[str, set[str]] = {}
978+
for chain in chains:
979+
callers = reverse_graph.get(chain.io_edge_src, set())
980+
leaves = callers if callers else {chain.io_edge_src}
981+
for leaf in leaves:
982+
leaf_set.add(leaf)
983+
if entrypoint_ids:
984+
if leaf not in leaf_ep_cache:
985+
leaf_ep_cache[leaf] = _reachable_entry_points(
986+
leaf, reverse_graph, entrypoint_ids
987+
)
988+
per_leaf.setdefault(leaf, set()).update(leaf_ep_cache[leaf])
989+
return (
990+
sorted(leaf_set),
991+
{leaf: sorted(eps) for leaf, eps in per_leaf.items()},
992+
)
993+
994+
972995
# ---------------------------------------------------------------------------
973996
# Boundary-tagging pass (ADR-0016 Phase 1b)
974997
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)