Skip to content

fix(analytics): stop /report and /duplicates hanging, and scope the ownership panels (#13602) - #14014

Merged
mrveiss merged 8 commits into
Dev_new_guifrom
issue-13602
Aug 11, 2026
Merged

mrveiss merged 8 commits into
Dev_new_guifrom
issue-13602

Conversation

@mrveiss

@mrveiss mrveiss commented Aug 11, 2026 •

Copy link
Copy Markdown
Owner

Thinking Path

#13602 reports two endpoints that never respond — 180s, 0 bytes, and nothing in the log. No exception, no timeout, no trace. That last part is what made it worth chasing carefully: a hang with no output means the handler never ran, not that it ran and failed.

It turned out to be four defects that compound, plus a fifth in a sibling panel.

The hang itself. _build_analysis_task_list fans out five analyses. Four wrap themselves in asyncio.wait_for; api_endpoint does not. The shared analytics executor has 8 workers, so once the slower analyses occupy them all, that unbounded submission queues and never starts — gather never returns, the socket stays open, and no handler ever runs to log anything. Not a slow endpoint: a hang, and a silent one by construction.

Why the workers were all busy. duplicate_detector._get_files_to_scan has documented .worktrees/ as pruned since #12779. It never was — the name was not in SKIP_DIRS. Measured on a working checkout: 160,105 of 167,072 code files (96%) lived under .worktrees and .claude/worktrees, so every analytics walk scanned the repo ~26 times over.

Why abandoned scans stayed. #12779 added a cancel token but consulted it in exactly one place, inside the os.walk loop — 0.14s of a run whose remaining phases measured over six minutes on a single tree (105,441 blocks, still building MinHash signatures at 6m15s, RSS 2.0 GB, against a 120s timeout). Every timed-out scan held a worker until it finished naturally.

Why they stacked. /report submitted its own scan straight to the same executor with neither the single-flight lock nor the token, and duplicates.py released that lock in a finally that ran on timeout while the orphan was still going. Both re-created the accumulation #12779 set out to stop.

And the separate 400. /ownership/analysis resolved its source's clone path and then validated against _get_project_root() — the root it had just replaced, on the next line but one.

What Changed

Bounded execution. The deadline now rides in the task tuple and _run_parallel_analyses applies it, so a task without one is a type error rather than an oversight — adding a fifth wait_for is the pattern that let one be forgotten. An analysis abandoned at its deadline now says so; previously the section simply vanished from the report with no explanation.

Cancellation that works. The token is polled in block extraction and in signature building — the phase that dominates the runtime — plus at the boundary between them.

One guarded path. /report delegates to _run_standard_analysis, passing the detector default it was already relying on implicitly. The lock is released by a done-callback on the future, so it outlasts the orphan. That is only bounded because the token now works, which is why the test asserts the lock reopens as well as that it stays shut.

The walk. .worktrees/worktrees added to SKIP_DIRS — 167,072 files / 5.46s becomes 6,967 / 0.06s.

Adding the name is not enough on its own, and this is the part worth reading. Five call sites tested SKIP_DIRS & set(path.parts) against an absolute path, which asks whether a skip name appears anywhere in it — including inside the scan root. CI and development both run from inside .worktrees/, so adding the name would have made a scan rooted there classify every file below it as skippable: no error, no log, zero results, indistinguishable from a clean repo. That is precisely the failure mode this umbrella exists to remove, so shipping it as the fix was not an option. is_skipped_path(path, root) relativises first, applying the pattern #7128b already established after hitting this exact trap. All five consumers migrated — including duplicate_detector._should_skip_path, which had no callers, so its absolute comparison was a trap armed for whoever wired it in next. Wired in rather than deleted.

Ownership scoping. All three endpoints now use resolve_scan_root (#12330). /expertise and /knowledge-gaps were worse than the 400: they accepted source_id and ignored it, silently analysing AutoBot's own tree for every source — plausible data for the wrong project, which is why nobody noticed. The false "Path traversal attempt blocked" warning is gone, and it no longer echoes the internal clone path.

Fixing the 400 makes that panel reachable for the first time, which exposed what it would then do: git blame as a bare synchronous subprocess inside async def, one per file, up to 30s each, blocking the event loop. Moved off the loop, worktrees skipped, capped at 2000 files with the truncation logged — a silent cap reads as "we analysed everything".

Verification

312 passed, 11 skipped   api/codebase_analytics/ + code_intelligence/shared/
black · isort · flake8 · 5 repo checkers   clean on all 14 changed files

Every guard mutation-tested — the fix reverted, the test confirmed to fail, the fix restored. 14 mutations, 14 caught:

Reverted Fails
.worktrees dropped from SKIP_DIRS 5
is_skipped_path back to the absolute check 1
_should_skip_path unwired 1
per-task deadline not applied 2 (and without the bound the test hangs — the defect itself)
abandonment log removed 1
api_endpoint deadline set to 0 2
/report submitting directly again 2
lock released on timeout 1
orphan not signalled 1
validation vs project root 1
siblings ignoring source_id 2
every rejection logged as an attack 2
blame back on the event loop 1
analyzer skip-list reverted 1

Two of those deserve a note. The deadline mutation originally hung rather than failed, because one test had no outer bound — a test that hangs burns the CI job timeout instead of reporting, so it is now bounded and fails cleanly in 21s. And the traversal-warning guard asserts both directions: a real ../../etc/passwd must still be caught, since a test proving only that the false alert is gone passes equally against a detector that says nothing.

Not in this PR

  • AC 2 asks for a server-side deadline on every analytics endpoint. There is no such mechanism anywhere in the codebase — no timeout middleware, no decorator, no timeout_keep_alive; every handler hand-rolls its own wait_for with three different constants. This PR makes the deadline structural for /report's fan-out, which is where the reported hang lives. A universal one is an architectural change with a much wider blast radius, filed separately with options.
  • Two pre-existing flake8 findings in ownership_analyzer.py, which sits in a lint-excluded directory: an unused recency_cutoff left over from refactor(code_analysis): fix pre-existing function length violations in analysis scripts #1183's extraction (the helper computes its own and uses it, so it is redundant rather than unwired) and a long line. Unrelated to this issue.

Issue stays open pending host evidence — the endpoints are fixed in code, but #13602's measurements were taken against a running instance and closure needs the same.

Refs #13602 — deliberately not Closes. The endpoints are fixed in code, but #13602's
measurements were taken against a running instance, and closure needs the same numbers
from a host. Part of umbrella #13852.

Model Used

Opus 5 (1M context)

`duplicate_detector._get_files_to_scan` documented `.worktrees/` as pruned
since #12779. It never was — the name was not in SKIP_DIRS. Measured on a
working checkout: 160,105 of 167,072 code files (96%) lived under `.worktrees`
and `.claude/worktrees`, so every analytics walk scanned the repo ~26 times
over. 167,072 files / 5.46s becomes 6,967 / 0.06s.

Adding the name is not enough on its own, and the reason is the interesting
part. Five call sites tested `SKIP_DIRS & set(path.parts)` against an ABSOLUTE
path, which asks whether a skip name appears anywhere in it — including inside
the scan root. CI and development both run from inside `.worktrees/`, so adding
the name would have made a scan rooted there classify every file below it as
skippable: no error, no log, zero results, indistinguishable from a clean repo.
That is the failure mode umbrella #13852 exists to remove, so shipping it as
the fix was not an option.

`is_skipped_path(path, root)` relativises first, applying the pattern #7128b
already established in pipeline-scripts after hitting this exact trap. All five
consumers migrated: file_counter, file_pipeline, file_analyzer, file_cache, and
duplicate_detector's own `_should_skip_path` — which had NO callers, so its
absolute comparison was a trap armed for whoever wired it in next. Wired in
rather than deleted.

Guards: _tree() now builds both worktree layouts (it never did, which is why
the existing pruning tests passed throughout). Mutations verified — dropping
the names fails 5 tests, restoring the absolute check fails 1, unwiring
_should_skip_path fails 1.
…n stop (#13602)

`/report` never responded and logged nothing while not responding, which is the
hardest failure to diagnose from outside. Mechanism:

`_build_analysis_task_list` fans out five analyses. Four wrapped themselves in
`asyncio.wait_for`; `api_endpoint` did not. The shared analytics executor has 8
workers, so once slower analyses occupied them all, that unbounded submission
queued and never started — `gather` never returned, the socket stayed open, and
no handler ever ran to log anything. Not a slow endpoint: a hang.

Adding a fifth `wait_for` is the pattern that let one be forgotten, so the
deadline now rides in the task tuple and `_run_parallel_analyses` applies it.
A task without a deadline is a type error rather than an oversight. The margin
lets an analysis that bounds itself report its own, better-named timeout first;
the outer bound only fires where there is no inner one. An analysis abandoned at
its deadline now says so — previously the section simply vanished from the
report with no explanation.

Second half: #12779 added a cancel token but consulted it in exactly one place,
inside the os.walk loop — 0.14s of a run whose remaining phases measured over
six minutes on a single tree (105,441 blocks, still building MinHash signatures
at 6m15s, RSS 2.0 GB, against a 120s timeout). So every timed-out scan kept a
worker thread until it finished naturally, and that is the starvation above.
The token is now polled in block extraction and in signature building — the
phase that dominates the runtime — plus at the phase boundary between them.

Mutations verified: removing the per-task deadline fails 2 tests (and without
the bound the test hangs, which is the defect itself); removing the abandonment
log fails 1; setting api_endpoint's deadline to 0 fails 2. The existing
scoping test caught the tuple shape change on its own.
…ht guard (#13602)

#12779 added a lock and a cancel token so abandoned duplicate scans could not
accumulate. Two holes let the accumulation straight back in.

`/report` submitted its own scan directly to the shared analytics executor with
neither the lock nor the token — same executor, same 8 workers. So a report
queued a full walk regardless of one already running, and its orphan could not
be told to stop. It now delegates to `_run_standard_analysis`, passing
LOW_SIMILARITY_THRESHOLD, which is the detector default this call site was
already relying on implicitly; behaviour is unchanged.

The lock was released in a `finally` that ran on timeout while the orphan thread
was still going, so the next poll acquired it immediately and queued a second
scan on top of the first. It is now released by a done-callback on the future.
That is only bounded because the previous commit made the token effective in
the long phases — without it this would be a permanent block, which is why the
guard test asserts the lock reopens as well as that it stays shut.

Both holes are invisible from outside: the endpoint returns, the log says the
scan timed out, and nothing reports that the work is still running or that
scans are stacking.

Mutations verified: restoring the direct submission fails 2, releasing the lock
on timeout fails 1, dropping the cancel signal fails 1.
…blocking the loop (#13602)

`/ownership/analysis` returned 400 "Invalid path: must be within project root"
for its own source. It resolved the source's clone path correctly and then
validated against `_get_project_root()` — the root it had just replaced, on the
next line but one. A clone path lives under data/code-sources/<id> while the
project root resolves to the checkout: sibling subtrees that never nest, so the
containment check rejected EVERY source-scoped request. Deterministic, not
layout-dependent, and not #13149 — the resolver works; the wrong one was called.

Now uses `resolve_scan_root` (#12330), as environment.py, call_graph.py and
dependencies.py already do, rather than hand-rolling the lookup.

`/ownership/expertise` and `/ownership/knowledge-gaps` had it worse. They
accepted `source_id` "for API consistency" and ignored it, so every source's
panel silently analysed AutoBot's own tree — plausible data for the wrong
project, which is why nobody noticed. Both now resolve the same way.

The rejection was logged as "Path traversal attempt blocked", so ordinary use
filled the security log with false attack alerts and echoed the internal clone
path while doing it. A path that stays inside its root after normalisation is a
scope mismatch; only one that climbs out earns an attack-shaped warning. The
test asserts both directions — a real `../../etc/passwd` must still be caught,
since a check that only proves the false alert is gone passes equally against a
detector that says nothing.

Fixing the 400 makes this panel reachable for the first time, which exposed
what it would then do: `git blame` ran as a bare synchronous subprocess inside
`async def`, one per file, up to 30s each, blocking the event loop — the reason
unrelated endpoints returned 000 while an analytics panel loaded. It also
globbed a tree with no `.worktrees` in its skip list and no ceiling. Moved off
the loop, worktrees skipped, capped at 2000 files with the truncation logged;
a silent cap would read as "we analysed everything".

Mutations verified: restoring the wrong validation root fails 1, siblings
ignoring source_id fails 2, treating every rejection as an attack fails 2,
blame back on the event loop fails 1, dropping the analyzer skip fails 1.

Not fixed here: ownership_analyzer.py has two pre-existing flake8 findings in a
lint-excluded directory (an unused `recency_cutoff` left over from #1183's
extraction — the helper computes its own and uses it, so it is redundant rather
than unwired, and a long line). Out of scope for this issue.
@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No hardcoded values detected that have SSOT config equivalents!

Blocker 1 is one I manufactured while fixing the walk. Adding `.worktrees` to
SKIP_DIRS was safe because `is_skipped_path` relativises — but I also added it
to `_SKIP_DIRECTORIES`, a SECOND skip list in ownership_analyzer.py that still
matched the ABSOLUTE path, with exactly the trap my own docstring documents.
`resolve_project_root()` lands inside a worktree on any dev checkout, so
`/ownership/analysis` — the endpoint this issue is about — returned 200 with
zero contributors and no error. Measured: 4,725 .py files globbed, 0 surviving.
I replaced a loud 400 with a silent empty 200, which is the defect class this
umbrella exists to remove. `_should_skip_file` now takes the root.

Blocker 2: `CancelledError` is not `TimeoutError`, so it skipped the timeout
handler, fell through to `finally`, and released the lock with the token never
set — while the shield guaranteed the orphan kept burning an executor thread.
Reachable via uvicorn graceful shutdown and via any outer deadline tighter than
the inner one, which /report's now is, separated only by the grace margin.
Same treatment as a timeout, then re-raised.

Blocker 3: seven lines shipped with no test that could fail — every cancel
check in the detector, the file cap, and the new fallback. The detector half
could be deleted wholesale with the suite green, and the cap test asserted
`isinstance(int) and > 0`, which cannot fail for any positive integer. Replaced
with behavioural guards; all seven now fail under mutation.

Also from the review:

The `.resolve()` fast path I added for perf accepted a symlink pointing OUT of
the scan root as scannable. The 167k-file measurement that justified it is the
number this PR removes — at 6,967 files it saves ~0.5s on a scan that was
taking six minutes. Reverted; correctness is the better trade here.

The fallback returned the absolute check, i.e. the bug, on precisely the inputs
hardest to reason about. A path that cannot be placed under the scan root is
not in the scan: return True.

A 2000-file cap at the measured 0.45s per `git blame` is a 15-minute request —
a hang traded for a wait nobody sits through. Added a wall-clock budget, made
it cover all patterns rather than giving each a fresh one, and surfaced
`truncated`/`truncated_reason` in the response: server-side logging is not
enough when the caller is the one drawing conclusions from the numbers.

The traversal warning logged the internal root and dropped the attacker-supplied
path — the wrong one of the two. Inverted, and the non-attack branch stays at
WARNING so an alert keyed on a rejected path keeps firing; only the misleading
"attack" wording is scoped to an actual climb out of the root.

/report mapped "scan in flight" and "scan timed out" to the same None, so the
duplicate section vanished without comment whenever the GUI's /duplicates poll
held the lock — now the common case, since they share one. Logged.
#13602)

Round 2 review returned "land it" with one regression I had introduced and one
comment that overstated a guarantee.

The `resolve()`-on-both-sides check cost 98us per call against 4.5us, and the
cost is paid per GLOBBED path, not per surviving file. My comment used the
wrong denominator and understated it by ~30x: 214k globbed paths on this repo,
not 6,967 files. Measured 2.6-5x slower walks — 9.1s to 24.0s on the shared
FileListCache — in a PR whose first commit is labelled `perf`.

Three tiers now. Lexically inside a skip dir returns immediately with no stat
at all, and that is 96% of paths: a symlink cannot change the answer for a file
already under a skipped directory. Lexically clean and not a symlink returns
kept, paying one lstat — glob/rglob do not recurse symlinked directories, so
only a symlinked LEAF can escape, and that is what the earlier lexical
shortcut got wrong. Everything else resolves properly. Tuple slicing rather
than relative_to(), which measured ~28us of the ~29us total. 98us -> 12.6us,
against a 1.8us floor for the unsafe original.

The wall-clock budget did not cover the walk: the deadline check sat behind the
skip `continue`, so globbing and skipping were outside it entirely — 18s of
walk on this repo with blame costing nothing. Moved ahead of the continue, so
the budget is now what the comment claims it is.

Also from the review: the `test_one_budget_covers_all_patterns` docstring named
the outer `break` as what prevents a fresh budget per pattern. It is not — the
counters are hoisted above the pattern loop, so removing the break leaves an
equivalent mutant, and the test could not fail for the reason it gave. It
guards the hoisting; reworded to say so. Dropped the `startswith` clause in
_should_skip_file, subsumed by the prefixed form. The traversal warning now
logs %r truncated to 200 chars — the attacker controls that string and an
unescaped newline in it forges log lines.

Mutations re-verified after the rework: removing the lstat guard fails 1,
disabling the tier-1 short circuit fails 3, slicing against the absolute path
fails 3, disabling the time budget fails 1.
…13602)

CI's hardcoded-value checker (#694) flagged `timeout=30` on the git blame
subprocess. The literal was pre-existing, but wrapping it in `asyncio.to_thread`
re-added the line, so it counts as mine — correctly.

The inconsistency it exposed is the more interesting half: a single blame was
allowed 30s inside a phase budget of 20s, so one slow file could consume the
entire budget and overrun it. The per-blame timeout is now 10s, deliberately
below the budget that contains it, with a test asserting that relationship
rather than the values.

All three bounds now come from env via the repo's `env_float`/`env_int` pattern
(AUTOBOT_OWNERSHIP_MAX_FILES, AUTOBOT_OWNERSHIP_BUDGET_SECONDS,
AUTOBOT_OWNERSHIP_BLAME_TIMEOUT_SECONDS) instead of module literals.

Also this round: the PR body needed a literal `Refs #13602` for the issue-
linkage check. `Refs`, not `Closes` — the endpoints are fixed in code but the
issue's measurements came from a running instance and closure needs the same.
@mrveiss
mrveiss merged commit d018373 into Dev_new_gui Aug 11, 2026
64 checks passed
@mrveiss
mrveiss deleted the issue-13602 branch August 11, 2026 13:26
mrveiss added a commit that referenced this pull request Aug 14, 2026
Review blocker, and it inverted the change on four routes: a blanket 60s outer
bound sat BELOW pre-existing internal timeouts, so requests that previously
succeeded slowly would have become guaranteed 504s.

  /analysis      60s outer over ANALYSIS_TIMEOUT     = 180s
  /env-analysis  60s outer over LLM_ANALYSIS_TIMEOUT = 240s  (use_llm_filter is
                 a caller-facing Query param, so that path is reachable on
                 request, not theoretical)
  /report       180s outer under its own fan-out ceiling of 195s

The /report case indicts the design rather than a number. That file's docstring
— written in #14014 — says the fan-out deadlines are OUTER bounds with a grace
margin so an analysis that times out reports its own, better-named message and
wins the race. A route bound of 180s under a 195s ceiling means the generic
"report did not complete within 180s" wins instead, which is exactly what that
comment says must not happen.

Deadlines are now derived, not guessed: the three constants are hoisted to
module level and each route is `@bounded(THAT_TIMEOUT + ROUTE_DEADLINE_GRACE)`.
A change to the internal budget carries the route bound with it, which a magic
number would not.

The checker now enforces the relationship, and that immediately found FOUR more
routes the review had not flagged — siblings in the same two files. It is a
file-level heuristic: proving which budget a route can reach needs call-graph
analysis, but noticing that a file declares 240s and bounds one of its own
routes at 60s is cheap and catches the real shape. Where it cannot prove
reachability I raised the bound rather than exempting the route, because being
wrong in the tight direction breaks requests while being generous only costs a
longer worst case.

Also from the review: mutating `_is_bounded` to `return True` — so the checker
would accept ANY route — left the suite green. Every test asserted the checker
is happy with a repo that happens to be fully bounded, which is not the same as
asserting it catches an omission. Five tests now plant a synthetic unbounded
route in a tmpdir and assert exit 1, plus the direction that must stay true and
the declared-with-a-reason path.
mrveiss added a commit that referenced this pull request Aug 15, 2026
* chore: claim worktree for #14015

* feat(api): a request deadline that cannot be forgotten (#14015)

#13602 was an endpoint that held the socket open past 180s and logged nothing —
no exception, no timeout, no trace. Not a slow handler; one that never ran,
because it queued behind eight busy executor workers on the single analysis in
its fan-out with no asyncio.wait_for.

The defect is the pattern, not that one omission. When bounding is opt-in and
per-call-site, an unbounded path is invisible: it looks exactly like every other
handler until it hangs. report.py alone carried three different timeout
constants and one analysis with none, and nothing about the file made that
visible. Measured on this surface: 85 routes, 0 bounded.

Two halves, both load-bearing.

`bounded(seconds)` stacks with with_error_handling and turns the hang into a 504
naming the handler and the limit, plus a WARNING naming it in the log — because
a 504 alone tells the caller nothing about which of many analyses ran long. A
non-positive deadline raises at decoration time rather than on the first
request: wait_for(0) expires immediately, and an always-on failure reads as a
broken endpoint rather than a misconfiguration. It sits OUTSIDE
with_error_handling so error-response construction is inside the deadline too;
that decorator already re-raises HTTPException, so the 504 survives either way.

`tools/lint/check_route_deadlines.py` is the half that makes omission
impossible. A route in a covered package must carry @bounded or appear in
UNBOUNDED_BY_DESIGN **with a reason** — so an unbounded route is a declaration a
reviewer can see and argue with, never a default nobody chose. Widening coverage
is adding a path, at which point every unbounded route under it fails until it
is bounded or declared: the gap gets enumerated, never stays silent. It also
fails when it matches zero routes, because an empty result otherwise reads as a
clean one.

Deadlines come from #13602's own measurements: 180s for report and duplicates
(the two that hung), 120s for call-graph (36.4s cold, over a 45s client cap
under contention), 60s default for the rest, which were sub-2s in that sweep.

Mutations verified with the restore in a trap rather than a following command:
dropping wait_for fails 4, accepting a non-positive deadline fails 3, the log no
longer naming the handler fails 4, the payload losing the name fails 2, one
route losing @bounded fails the checker, a stale UNBOUNDED_BY_DESIGN entry fails
it, and a broken matcher fails it — where the same broken matcher with the
empty-result guard removed exits 0 and reports a clean tree.

* fix(api): a route deadline must clear the budget it can reach (#14015)

Review blocker, and it inverted the change on four routes: a blanket 60s outer
bound sat BELOW pre-existing internal timeouts, so requests that previously
succeeded slowly would have become guaranteed 504s.

  /analysis      60s outer over ANALYSIS_TIMEOUT     = 180s
  /env-analysis  60s outer over LLM_ANALYSIS_TIMEOUT = 240s  (use_llm_filter is
                 a caller-facing Query param, so that path is reachable on
                 request, not theoretical)
  /report       180s outer under its own fan-out ceiling of 195s

The /report case indicts the design rather than a number. That file's docstring
— written in #14014 — says the fan-out deadlines are OUTER bounds with a grace
margin so an analysis that times out reports its own, better-named message and
wins the race. A route bound of 180s under a 195s ceiling means the generic
"report did not complete within 180s" wins instead, which is exactly what that
comment says must not happen.

Deadlines are now derived, not guessed: the three constants are hoisted to
module level and each route is `@bounded(THAT_TIMEOUT + ROUTE_DEADLINE_GRACE)`.
A change to the internal budget carries the route bound with it, which a magic
number would not.

The checker now enforces the relationship, and that immediately found FOUR more
routes the review had not flagged — siblings in the same two files. It is a
file-level heuristic: proving which budget a route can reach needs call-graph
analysis, but noticing that a file declares 240s and bounds one of its own
routes at 60s is cheap and catches the real shape. Where it cannot prove
reachability I raised the bound rather than exempting the route, because being
wrong in the tight direction breaks requests while being generous only costs a
longer worst case.

Also from the review: mutating `_is_bounded` to `return True` — so the checker
would accept ANY route — left the suite green. Every test asserted the checker
is happy with a repo that happens to be fully bounded, which is not the same as
asserting it catches an omission. Five tests now plant a synthetic unbounded
route in a tmpdir and assert exit 1, plus the direction that must stay true and
the declared-with-a-reason path.

* fix(lint): emit through the streams, not builtin print (#14015)

code-quality flagged 10 newly-added print() calls in the new checker. The
sibling checkers use print too, but they predate the rule and only NEW lines are
flagged — so the grandfathering, not the file type, is what makes them clean.

Two _err/_out helpers writing to sys.stderr/sys.stdout instead. The documented
escape is a per-line noqa, and ten of those would be noise where one helper is
not. Verified against the repo's own hook rather than by reading the rule: 22
files scanned, no violations.

* fix(lint): resolve derived deadlines instead of exempting them (#14015)

Round-2 review returned "land it" with two findings that are the same hole this
PR's own commit message criticised, reappearing on the newer half of the checker.

`_literal_deadline` exempted every computed expression, on the reasoning that
deriving from a budget beats guessing past it. That reasoning is right about the
pattern and wrong as a check: it assumes the arithmetic is correct.
`@bounded(PATTERN_ANALYSIS_TIMEOUT - 170.0)` resolves to 10 seconds under a 195s
ceiling and passed unchallenged — demonstrated by the reviewer, reproduced here.
"Derived" and "correct" are different claims, and conflating them is the shape
this whole issue is about.

Expressions are now evaluated: literals, module constants, and additive
arithmetic over them. An unresolvable name contributes ZERO rather than making
the whole expression unresolvable — ROUTE_DEADLINE_GRACE is imported, not a
module constant, so returning None there would silently skip every route this
check exists for. Zero under-estimates the deadline, so the error runs toward
flagging: a false flag is argued down in review, a missed one ships a 504.

Second finding: disabling the too-tight branch entirely left all 17 tests green.
Coverage for "the checker enforces the ceiling" came only from the real repo
happening to have no too-tight routes today. Five synthetic-route tests now
exercise it directly — a literal under the budget, a derived expression that
computes under it, one that clears it, an unresolvable name, and a file with no
budget at all.

Mutations verified: disabling the ceiling branch fails 3, making unknown names
unresolvable fails 1, dropping arithmetic resolution fails 11.
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