Skip to content

Bound tool calls and optimize change discovery process - #1047

Merged
tirth8205 merged 9 commits into
testingfrom
staging
Sep 18, 2026
Merged

tirth8205 merged 9 commits into
testingfrom
staging

Conversation

@tirth8205

Copy link
Copy Markdown
Owner

Pull Request

Linked issue

Closes #

What & why

How it was tested

uv run pytest tests/ --tb=short -q
uv run ruff check code_review_graph/
uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional

Checklist

  • Tests added for new functionality
  • All tests pass: uv run pytest tests/ --tb=short -q
  • Linting passes: uv run ruff check code_review_graph/
  • Type checking passes: uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
  • Lines are at most 100 characters
  • Docs updated where behavior changed (README, docs/, docstrings)

tirth8205 and others added 9 commits September 18, 2026 13:53
Three of the four causes in the #262 diagnosis, measured rather than
assumed. Cause 4 (a timed-out `git diff` returning an empty list, #913)
is deliberately untouched: PR #922 is open for it.

A. One offload helper, not seven copies.

`_offload` in main.py runs a tool body on a worker thread and, when
`CRG_TOOL_TIMEOUT` is set above 0, bounds it. 28 of the 30 registered
tools now route through it; only `get_docs_section_tool` and
`list_repos_tool` stay inline, and each reads one small file.
`detect_changes_tool` loses its private copy of the timeout logic and
keeps its exact observable behaviour, including the advice naming
`CRG_MAX_CHANGED_FUNCS` / `CRG_MAX_TRANSITIVE_FRONTIER`.

Measured against origin/staging, driving a real `code-review-graph serve`
over stdio: the loop is NOT blocked on staging today, because fastmcp 3
runs sync tool bodies in a worker thread itself (`run_in_thread=True`).
A negative-control server with `run_in_thread=False` shows the probe can
detect blocking, so that result is the instrument working, not failing.
What staging does not do is bound the call: with `CRG_TOOL_TIMEOUT=5`,
`get_impact_radius_tool` ran 25.2s and answered only when it was ready;
it now answers at 6.0s with `status: error` naming the tool and the
budget. An unbounded request is what a client reports as -32001.

B. Change discovery gets its own budget.

`CRG_DISCOVERY_TIMEOUT` (default 5s, never above `CRG_GIT_TIMEOUT`,
read at call time rather than frozen at import) applies only to the
read-only discovery chain. `CRG_GIT_TIMEOUT` keeps its 30-second
default for build, update and watch, which legitimately run long.
The two byte-identical `_GIT_TIMEOUT` definitions, at changes.py:35 and
incremental.py:731, are now one constant in constants.py that both
modules alias.

`discover_review_changes` is that chain: resolve the base, diff, fall
back to the working tree. review.py (3 sites), query.py and cli.py (2
sites) call it instead of repeating the three steps, so the budget and
the scope below cannot be applied to five of six call sites.

C. The working-tree fallback stops walking every untracked file.

`get_staged_and_unstaged` takes `untracked=`, defaulting to `"all"`, so
no existing caller changes. Discovery passes `"normal"`: a new module in
a tracked package is still named, a wholly-untracked directory is not
descended into, and the unusable `dir/` placeholder is dropped rather
than handed on as a file path.

Verified first that no caller needs `"all"`: all six are the discovery
fallback, and `incremental_update` never calls it at all -- on
origin/staging as on this branch, `update` does not pick up a
never-committed module, so scoping this walk cannot regress it. A test
pins that.

On a repository with an empty `git diff HEAD~1` and 250,000 untracked
files, `get_impact_radius_tool` goes from 16.8s -- reporting all 250,000
as "changed" -- to 0.02s and "No changed files detected". The discovery
chain alone: 0.397s to 0.016s, the walk 0.377s to 0.006s. That is macOS
with a warm page cache; the report is from Windows, where each of those
stats costs far more.

D. Tests.

test_main.py: the heavy-tool guard covers all 28 offloaded tools and
checks delegation plus one threading implementation; a new guard fails
when a tool is added as a plain `def`; the loop-responsiveness test
drives a real event loop rather than asserting `iscoroutinefunction`,
and fails when `_offload` is mutated to run inline.
test_incremental.py: the budget's precedence, call-time reads, invalid
values, the chain's wiring, and that build-side callers keep the git
budget. test_integration_git.py: the three untracked modes against real
git.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`staging` reaching `testing` was a manual click every day for a change
that is, by then, already green: ci.yml runs on push, so the eight
required contexts are satisfied on the `staging` tip before anyone opens
the promotion PR. This automates that one step and nothing else.

`.github/workflows/auto-promote.yml` runs on a daily schedule and on
workflow_dispatch. When `staging` has commits `testing` lacks and every
required check is green on its tip, it opens or updates the promotion PR
and merges it with a merge commit, so contributor authorship on every
promoted commit survives. A hand-started run defaults to a dry run.

Every rule lives in `scripts/auto_promote.py`, which is importable and
tested with no network; the YAML fetches facts and obeys, matching the
shape of scripts/promotion_gate.py. Two decisions worth naming:

* The required contexts are read from the `testing` ruleset at run time,
  through the branch-rules endpoint that needs only repository read
  access. Hardcoding eight strings would let a renamed CI job drop out
  of the gate silently.
* A required context that was skipped or neutral is not green here, even
  though GitHub's own evaluation accepts both. The gate may refuse a
  promotion GitHub would allow; it can never allow one GitHub refuses.

Every guard exits cleanly with a notice: nothing to promote, CI still
running, a failed or skipped required check, a conflicting or blocked
promotion PR. Those states are reported elsewhere already and tomorrow's
run looks again. Only two outcomes turn the run red, both of them things
nothing else reports:

* GitHub refused the merge. The PR is left open and the refusal quoted
  in the job summary. The likeliest cause is the ruleset's
  require_extra_approval_for_unattributed_changes, which GITHUB_TOKEN
  cannot satisfy because a bot may not approve.
* The follow-up checks could not be started. A push made with
  GITHUB_TOKEN starts no `push` workflow run, so this merge does not run
  ci.yml or the promotion gate on `testing` the way a hand merge does.
  Left alone the `testing` tip would carry no required contexts at all:
  the next manual `testing` -> `main` PR would be unmergeable for ever
  and the release gate would quietly stop running on every landing. The
  last step starts both through workflow_dispatch, the documented
  exception to that rule. Neither of those files is modified.

A summary is written on every run, including the runs that do nothing,
so "nothing to promote" is never indistinguishable from "did not run".

Promotion to `main` stays entirely manual. The workflow has no input,
variable or code path that can target it, and tests/test_auto_promote.py
asserts that against the script, its command line and the YAML.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three reviews of the previous commit found the automation trusted three
things it had no right to trust, and asserted a fourth that is not true.

**It merged whatever `gh pr list --head staging` returned.** That filter
matches a branch called `staging` in any repository -- `gh pr list --help`
says outright that `<owner>:<branch>` syntax is not supported -- and this
repository is public with 2,871 forks. An outsider opening
`theirfork:staging -> testing` became the PR the daily job resumed, had its
body rewritten, and was merged: `testing`'s ruleset requires zero approvals
and their own `pull_request` run satisfies the eight contexts. The same
defect had a no-effort denial-of-service form, a merely conflicting fork PR
making every run decide BLOCKED and exit green for ever.

**It merged a PR number, and a PR's base branch is mutable by its author.**
Repointing the base at `main` starts no `pull_request` run, so the green
checks stay put, and `main`'s ruleset is identical to `testing`'s. Every
"this can never target main" control -- the env vars, `assert_safe_targets`,
the YAML test -- constrained what the job asks for, none constrained what
the PR it merges is.

Selection now requires the PR to be same-repository, aimed exactly
`staging -> testing`, and to carry a new `auto-promotion` label, and
`verify` re-reads all of it from the API seconds before the merge. The
label also fixes a third case: a promotion PR the maintainer opened by hand
and deliberately did not merge used to be indistinguishable, so it was
silently merged with its body overwritten first. It is now refused by
number, with the reason.

**The commit whose checks were read was not the commit that got merged.**
The PR head tracks a branch, so a push mid-run moved it. `headRefOid` was
fetched and never read. It is compared now, and `gh pr merge` passes
`--match-head-commit`, which GitHub enforces.

**The premise that a GITHUB_TOKEN PR triggers no `pull_request` run is
false.** PR #991, opened by app/github-actions, produced a ci.yml run three
seconds later; PR #1040 re-queued all eight already-green contexts on the
same SHA and sat BLOCKED for fourteen minutes. The merge step waited sixty
seconds and polled `mergeable`, which is only the conflict computation and
says MERGEABLE throughout. It now polls `mergeStateStatus` for up to thirty
minutes. Every refusal also used to blame the ruleset's
require_extra_approval_for_unattributed_changes; that rule has never fired
here -- #991 merged under it with zero reviews -- so the refusal quotes
GitHub and lists causes in the order they actually occur.

Also: the PR number is read from the URL `gh pr create` prints, because
re-listing raced GitHub's index and an empty answer skipped the merge, the
refusal report and everything else while the run stayed green and the
summary said "Promoting"; a failed create names the repository setting that
causes it, *Allow GitHub Actions to create and approve pull requests*, which
is off today and has already failed the manual Promote workflow; a run stuck
on its own PR is red rather than a green warning every morning; a `testing`
tip the release gate never ran on is repaired by the next run; a failed gate
on `testing` blocks tomorrow's promotion, which is what replaces the
maintainer's per-landing decision; and ci.yml is no longer dispatched --
those contexts are satisfied by the manual `testing -> main` PR's own run,
and dispatching it switched on a manual-only 45-minute job that is not a
required context.

uv run pytest tests/ -q: 4171 passed, 842 skipped. ruff and mypy clean.
actionlint clean on the workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merges origin/staging (PR #1029, which this branch predated and no longer
applied over), then fixes what three reviewers found. Reproduced each one
first; each fix is verified with the reporter's own reproducer.

C is withdrawn, not repaired.

`get_staged_and_unstaged(untracked=)` and the `--untracked-files=normal`
discovery walk are gone; the walk is back to `all`. Under `normal`, git
collapses a wholly-untracked directory to one `dir/` record, which is not a
path any caller can open, and the code dropped it -- so the first commit of
any new package vanished from all four review tools and both CLI review
commands, with `status: ok` and no warning at any log level. Reproduced:
a repo whose only change is `feature/newmod/{a.py,b.py}` reported
"No changes detected"; it now reports both files. The measured prize was
0.377s -> 0.006s of git time on 250,000 untracked files. Being slow is a
bug; confidently reviewing nothing is a worse one, and the task's own
instruction was to say so rather than trade one for the other.

The 5s budget stays, because it no longer lies.

staging landed `ChangeDiscoveryError` and `require_vcs` while this branch
was out. `discover_review_changes` now passes `require_vcs=True` to all
three steps, so exhausting the short budget raises instead of returning
`[]`. That is what licenses shortening it at all: the reviewers were right
that cutting 30s -> 5s on a path whose timeout returns an empty list makes
#913's false all-clear several times easier to hit, and extends it to
`resolve_review_base`, where a timed-out merge base silently degrades a
three-dot diff to two-dot. #913 itself is still untouched --
`get_changed_files`'s default is unchanged and `require_vcs` is staging's
own flag, so PR #922 keeps its ground.

Verified with a `git` shim that sleeps 7s: before, `detect_changes_tool`
answered `status: ok` with `changed_files: ["scratch_note.txt"]` -- an
unrelated file. Now all four tools answer `status: error` naming the budget
and `CRG_DISCOVERY_TIMEOUT`, and `detect-changes` exits 1 rather than 0.

`discovery_timeout()` no longer clamps to `min(5, GIT_TIMEOUT)`: an
explicitly set `CRG_GIT_TIMEOUT` is honoured verbatim. It predates this
variable and is what #262's reporters were already told to raise; a
documented knob that silently stops working is worse than one that never
existed. `CRG_GIT_TIMEOUT=120` with the same 7s git returns the correct
file list again.

`CRG_TOOL_TIMEOUT` keeps its scope and its guard.

`_offload` gained `bounded=`, and the five writing tools pass False:
build, postprocess, embed, wiki and apply_refactor. `asyncio.wait_for`
cancels the await, never the worker, so bounding these reported failure to
the client while the build kept writing graph.db (verified: the "failed"
run left 52,000 nodes behind) and while apply_refactor's rename landed on
disk, whose retry then failed again with "not found or expired". They were
also unbounded before the shared helper existed, and `CRG_TOOL_TIMEOUT` is
what #262 tells a user to set -- the documented remedy must not break
builds.

Parsing goes through staging's `env_int`, so `CRG_TOOL_TIMEOUT=2.5` (or
"", or "abc") no longer raises `ValueError` out of every tool. This also
satisfies staging's `test_no_new_unguarded_numeric_env_parse` gate, which
the pre-merge branch failed in both directions.

The offload runs on anyio's limiter, not asyncio's executor.

`anyio.to_thread.run_sync(abandon_on_cancel=True)` replaces
`asyncio.to_thread`. Making these tools `async def` had moved them off the
40-slot limiter FastMCP dispatches sync bodies through and onto asyncio's
default executor, capped at `min(32, cpu+4)` -- 18 here, 8 on a 4-core
Windows box. At the reviewer's N=19 burst a cheap tool waited 10.15s and
got a false "timed out"; it now answers in 0.01s, and still does at N=30.
anyio is declared in pyproject rather than borrowed from fastmcp.

`get_minimal_context_tool` is on the budget too.

The entry point CLAUDE.md tells agents to call first was the one tool the
previous commit skipped: `resolve_review_base` (30s) + two hardcoded 10s
probes + `get_changed_files` (30s). One `discover_review_changes` call
replaces all five subprocesses. Measured with a 6s git: 31.2s -> 6.0s. A
discovery failure appends a "Degraded:" note, following the churn note's
existing contract, rather than reporting risk it could not compute.

Also: the timeout hint no longer names `changed_files` / `max_depth` /
`max_results` as though every bounded tool accepted them; `_vcs_unavailable`
names the budget that actually expired and the knob that governs it; and the
CHANGELOG and all five README env-var tables say which tools
`CRG_TOOL_TIMEOUT` bounds and that a discovery timeout is an error rather
than an all-clear.

Tests: the two that pinned the collapsed-directory drop are replaced by ones
that pin the opposite, including an end-to-end check that a new package
reaches all four review tools against real git (verified to fail when the
drop is reintroduced). New coverage for per-step require_vcs, the budget
precedence, the unbounded writing tools, malformed CRG_TOOL_TIMEOUT, and the
anyio limiter.

Gates: 4163 passed, 848 skipped, 2 xfailed, 2 xpassed (both xpassed are
pre-existing R-notebook tests). ruff and mypy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records the merge whose resolution the previous commit already carried: a
`git stash` run mid-merge had cleared MERGE_HEAD, so that commit landed with
one parent and git would have re-conflicted on the next merge. Every
conflicted file keeps the resolution verified there; origin/staging's only
new content since is tests/determinism_dump.py and tests/test_determinism.py,
which merged cleanly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(mcp): bound every tool call and stop handing the whole working tree to review
Both changelog entries kept: the discovery budget from #262 and the daily
promotion. No other file collided.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ci: promote staging to testing automatically, once a day
@github-actions

Copy link
Copy Markdown

code-review-graph review

Overall risk: 0.80 (HIGH) — 248 changed function(s)/class(es), 38 affected flow(s), 98 test gap(s)

Risk-scored changes

Risk Level Symbol Location Tested
0.80 high scripts/auto_promote.py::ForeignPullRequestError scripts/auto_promote.py:132 no
0.80 high scripts/auto_promote.py::cmd_verify scripts/auto_promote.py:1137 no
0.75 high code_review_graph/main.py::query_graph_tool code_review_graph/main.py:399 no
0.75 high code_review_graph/main.py::get_surprising_connections_tool code_review_graph/main.py:1173 no
0.65 medium code_review_graph/incremental.py::_vcs_unavailable code_review_graph/incremental.py:1009 no
0.65 medium code_review_graph/incremental.py::_get_svn_changed_files code_review_graph/incremental.py:1170 no
0.65 medium code_review_graph/main.py::_run_off_loop code_review_graph/main.py:111 no
0.65 medium code_review_graph/main.py::_offload code_review_graph/main.py:130 no
0.65 medium scripts/auto_promote.py::UnsafeTargetError scripts/auto_promote.py:128 no
0.65 medium scripts/auto_promote.py::clean scripts/auto_promote.py:158 no

Affected execution flows

  • run — criticality 0.64, 28 node(s) across 3 file(s)
  • main — criticality 0.53, 53 node(s) across 3 file(s)
  • query_graph_tool — criticality 0.43, 4 node(s) across 1 file(s)
  • get_surprising_connections_tool — criticality 0.43, 4 node(s) across 1 file(s)
  • main — criticality 0.40, 35 node(s) across 1 file(s)
  • ...and 33 more affected flow(s)

Test gaps

  • code_review_graph/cli.py::_dispatch (code_review_graph/cli.py:1663)
  • code_review_graph/incremental.py::_vcs_unavailable (code_review_graph/incremental.py:1009)
  • code_review_graph/incremental.py::_get_svn_changed_files (code_review_graph/incremental.py:1170)
  • code_review_graph/main.py::_run_off_loop (code_review_graph/main.py:111)
  • code_review_graph/main.py::_offload (code_review_graph/main.py:130)
  • ...and 93 more without direct tests

Token savings: this graph-backed report used ~546,337 fewer tokens (~89%) than reading every changed file in full (estimated, chars/4 approximation).


Powered by code-review-graph — local-first analysis; no code leaves the CI runner.

@tirth8205
tirth8205 merged commit d47a951 into testing Sep 18, 2026
51 checks passed
pull Bot pushed a commit to bhardwajRahul/code-review-graph that referenced this pull request Sep 18, 2026
The Action's gate on promotion PR tirth8205#1047 named _offload, _run_off_loop and
71 other symbols as test gaps while the suite executed every one of them.
TESTED_BY is a direct edge from a test to a production symbol, so a private
helper that tests reach only by calling the public function above it carries
no such edge and was reported as untested.

changes.py now classifies each changed symbol in one batched pass before
scoring: direct TESTED_BY, else a two-hop upward walk over incoming CALLS
edges looking for a tested caller, else nothing. The walk is a new
GraphStore.get_caller_test_routes -- the opposite direction from
get_transitive_tests, which asks whether a symbol's callees have tests.

Indirect coverage is a weaker class, never a suppression. Every gap stays in
the report; the indirect ones carry coverage/covered_via/covered_depth/
covered_by, sort after the unreached ones so truncation keeps the real gaps,
and get their own heading in the PR comment with a "indirect" Tested column.
That distinction is load-bearing on this very delta: incremental.py::
_get_svn_changed_files executes 0 of its 29 statements yet sits one hop under
two well-tested callers, so a rule that dropped reachable symbols would have
hidden the delta's only genuine gap while fixing six false alarms.

Measurements on the 0af6b8b..ca23a73 delta the Action scored (248 changed
symbols, 74 gaps on a clean full build):

  - 74 gaps before, 74 after -- identical set, nothing silenced.
  - 5 reclassified as reached-through-a-caller: cli.py::_dispatch,
    incremental.py::_vcs_unavailable, incremental.py::_get_svn_changed_files,
    main.py::_offload (1 hop), main.py::_run_off_loop (2 hops).
  - Hop limit 2 is measured, not guessed. Over the 74 gaps the rescue counts
    are {1: 4, 2: 1}; depths 3, 4 and 5 add exactly zero. Over all 1539
    package symbols each further hop re-labels ~8% more of the codebase as
    covered (27.0% direct, 49.4% at 1, 59.5% at 2, 67.6%, 76.3%, 81.1%).
  - Risk: an indirectly reached symbol with no direct tests gets a flat 0.03
    off the 0.30 untested term -- strictly less than the 0.05 one direct test
    earns, and never stacked on a symbol that already scored coverage. Two
    scores moved on the delta (0.65 -> 0.62); overall risk stays 0.80.
  - Cost: the classification got cheaper. Batching the per-symbol direct
    check into one query drops it from 5.6 ms to 0.2 ms over 248 symbols, and
    the two-hop walk over the 227 uncovered ones costs 0.6 ms, so the step
    goes 5.6 ms -> 0.8 ms. End-to-end analyze_changes is unchanged at 0.25 s.
    The report payload grows 0.23%.

Refs tirth8205#1047, crg-2g8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pull Bot pushed a commit to bhardwajRahul/code-review-graph that referenced this pull request Sep 18, 2026
The first cut of this fix classified a test gap as "indirect" when a tested
caller reached it, then treated that class as weaker evidence of coverage: a
0.03 risk discount, sorted last in the gap list, and copy saying the caller's
test "is execution, not an assertion". Three reviewers showed each of those
three is wrong, and the worst case is the same symbol in all three.

incremental.py::_get_svn_changed_files runs 0 of its 29 statements under the
full suite. It sits one hop under get_changed_files -- the VCS dispatcher's
SVN branch, which no git-repo test takes. Classifying it "indirect" moved it
from position 3 of 74 to 72 of 74 (past detect_changes_tool's default
max_results=25, so gone from the headline MCP tool), dropped its risk 0.65 ->
0.62, and that 0.03 evicted it from the top-10 review_priorities the Action
renders as its whole risk table. The delta's one genuine gap was demoted out
of every surface a maintainer reads, which is the "fix that silences real
gaps" the work was told not to ship.

The premise was the error. Measured against line coverage over this repo's own
4,286-test suite, 32 of the 504 symbols the walk reaches are never executed --
branch-dispatch bodies like parser.py::_parse_svelte, certified via
parse_bytes while "svelte" appears nowhere in tests/. Four rules were tried to
separate them: depth 1, caller fan-out limits from 5 to 50, a same-file
restriction, and combinations. None moves the never-executed share below ~5%
(from 6.5%) and each halves what the walk reaches -- fan-out<=15 drops 494
rescues to 268. The discriminating fact is which branch a test takes, and the
call graph does not record it. So reachability is not scorable, at any tuning.

It is still useful, as a pointer to where a test already runs. That is what it
now reports, and nothing more:

  - No risk discount. An indirectly reached symbol scores exactly as untested.
    0.03 was also off the 0.05 grid every other term uses, and scores quantize
    hard (15 distinct values over 49 symbols, 25 tied at 0.05), so it could
    never leave a symbol tied -- it dropped it below its whole tie class.
  - Class-blind gap order. Sorting reached ones last made them truncation's
    first casualty: at max_results=25 a delta reported 9 indirect gaps and
    shipped 0, and a consumer deriving its own Tested column from the
    truncated list then labelled those symbols "yes".
  - Honest copy. "no test in reach" -> "no tested caller found"; "That is
    execution" -> "a path in the call graph, not a record of execution ...
    these count as gaps and are scored as untested". On this very delta
    auto_promote.py::cmd_verify and ::ForeignPullRequestError are exercised by
    tests that load the script through importlib, so no edge records it; the
    weaker claim is true of them, the stronger one was not.

Also fixed in the same surfaces:

  - render_pr_comment took its headline total from len(test_gaps) and its
    split from the untruncated counts, printing "25 test gap(s) (73 ..., 9
    ...)". Both now come from test_gaps_total.
  - A truncated table no longer renders "yes" for a symbol whose gap row was
    cut; it renders "?".
  - A truncated body that loses every indirect row now says so instead of
    letting the section vanish under a headline promising it.
  - get_review_context and its "lack test coverage" line used direct edges
    only, contradicting detect_changes about the same symbols in the same
    session (CLAUDE.md sends reviewers through both). It now splits the same
    way, with the same wording.
  - The walk credited coverage through callers inside test files, whose
    TESTED_BY edges point at tests in their own file -- 20% of this graph's
    TESTED_BY edges start at a test-file symbol. Filtered; costs 10 of 504.
  - The 2000-caller cap was a budget shared across the change set and applied
    after materialising every caller, so one hub in a PR erased the routes of
    every other changed symbol (206 of them on a real graph) while making the
    walk 3x slower. Replaced by a per-node limit of 500 applied before the
    edges are read: hub cost 365ms -> 2ms, cross-contamination 206 -> 0.

Results on the 0af6b8b..ca23a73 delta, scored from a clean tree so all three
runs see the identical diff (248 changed symbols, 74 gaps):

                        gaps  _get_svn_changed_files  in 25-window  risk  top-10
  staging                 74  pos 3                   yes           0.65  yes
  first cut               74  pos 72                  no            0.62  no
  this commit             74  pos 3                   yes           0.65  yes

The gap set is byte-identical to staging's in all three; nothing is silenced.
_offload (1 hop) and _run_off_loop (2 hops) are classified indirect as the
issue asked. cmd_verify and ForeignPullRequestError stay unreached -- correct,
since importlib leaves no edge -- and are now described in terms that are true
of them.

Of the 74 gaps, 35 are in code_review_graph/ and measurable against line
coverage: 34 are executed by the suite, 1 (_get_svn_changed_files) never is.
The walk recovers 5 of those 34; the other 29 are executed through fixtures,
CLI entry points and subprocesses that leave no edge, which no hop limit
reaches. The remaining 39 gaps are in scripts/, outside --cov scope.

Cost: analyze_changes 286ms on staging, 283ms here. get_caller_test_routes
1.0ms over 74 seeds. Report payload +0.15% vs staging (was +0.23%); indirect
entries shrank 32% (1492 -> 1012 chars) by naming one crediting test, and all
13 now survive the default 25-row cut instead of none.

Gates: 4286 passed, 844 skipped, 2 xfailed, 2 xpassed. ruff clean. mypy clean
but for one pre-existing google-genai stub error in embeddings.py, present on
staging and untouched here.

Refs tirth8205#1047, crg-2g8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant