Skip to content

feat(observability-map): static observability scorer for webapp route entry points - #4455

Open
1stvamp wants to merge 106 commits into
mainfrom
feat/observability-map
Open

feat(observability-map): static observability scorer for webapp route entry points#4455
1stvamp wants to merge 106 commits into
mainfrom
feat/observability-map

Conversation

@1stvamp

@1stvamp 1stvamp commented Aug 1, 2026

Copy link
Copy Markdown
Member

A static observability scorer for the webapp's route entry points, Lighthouse-style. The idea comes from evlog's map command, but that tool has no Remix adapter and checks for its own logging API, so the idea is ported rather than the tool.

It scans all 427 loader/action entry points in apps/webapp/app/routes with the TypeScript compiler API and scores each against five checks: error-classification, auth-boundary, auth-scope, request-context and audit-trail. Current output on the real tree is 19/100 over 412 measured entry points.

cd internal-packages/observability-map
pnpm exec tsx src/cli.ts               # terminal report
pnpm exec tsx src/cli.ts --json        # machine output
pnpm exec tsx src/cli.ts api/v1/token  # one entry, per-check detail

The two findings at the top of the fix list are real: /auth/sso and /api/v1/authorization-code mint or exchange credentials unauthenticated, and /_app/orgs/:organizationSlug/settings/team resolves its org from a URL slug and gates each mutating branch on an RBAC check alone, which per apps/webapp/CLAUDE.md is not the tenant floor on self-hosted.

Decisions worth knowing, all with the reasoning in the README:

  • The score started at 83 during development and fell to 19. Every drop was a perverse incentive being removed, not a regression: routes were being paid for having no error handling, two checks were reading the same fact, suppressing a failure raised the score, and a no-op catch (e) { throw e } was worth 50 points a route.
  • A mutation corpus is the tool's main defence. 44 entries apply semantics-preserving edits to a copy of the real route tree and assert the score cannot rise, per route as well as globally, because a mean can hide one route going up by taking another down. One entry runs as a live expected failure: try { String(0); } with a deciding catch is a known open hole worth 19 to 44, and it is disclosed rather than quietly excluded.
  • audit-trail and request-context are reported as headline figures rather than one finding repeated hundreds of times. Both still count in full where they should.
  • A cohort change moves the number without anything in the codebase getting better. Widening the sensitive cohort from 26 to 67 took the global from 15 to 19 with no webapp change at all, so the report prints per-check applicability and what the global would be without each one.

CI: a report-only job posts a sticky comment when a PR moves the report, and says nothing when it does not. The package's own tests gate through pr_checks.yml. The diff-scoped merge gate is still deferred until the report has been used in anger.

524 tests plus the corpus. No runtime or dependency changes to anything that ships.

@changeset-bot

changeset-bot Bot commented Aug 1, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 4b0b243

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@023bc03

trigger.dev

npm i https://pkg.pr.new/trigger.dev@023bc03

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@023bc03

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@023bc03

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@023bc03

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@023bc03

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@023bc03

@trigger.dev/schema-to-json

npm i https://pkg.pr.new/@trigger.dev/schema-to-json@023bc03

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@023bc03

commit: 023bc03

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds the @internal/observability-map package. It scans Remix routes, classifies sensitivity, evaluates five observability checks, applies suppressions, computes scores, and renders terminal, JSON, and pull-request reports. Adds map and comment CLIs, mutation testing, extensive tests, package documentation, and GitHub Actions workflows for reporting and validation.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 65.91% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the added static observability scorer for webapp route entry points.
Description check ✅ Passed The description is detailed, on-topic, and includes implementation scope, findings, testing commands, CI behavior, and known limitations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/observability-map

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

coderabbitai[bot]

This comment was marked as resolved.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Observability map

Nothing in this pull request moves the report any more. The findings an earlier push reported are gone.

Report only, nothing here gates the merge. The rules and their reasons: internal-packages/observability-map/README.md.

coderabbitai[bot]

This comment was marked as resolved.

github-advanced-security[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

1stvamp added a commit that referenced this pull request Aug 2, 2026
CodeRabbit round on #4455, four findings in the mutation corpus.

The baseline scan ran in the describe callback body, which Vitest executes
during collection, where the suite's timeout option does not apply and a throw
has no test name to attach to. It now runs in beforeAll with its own timeout.
Collection of the enabled file drops from 9.7s to 3.7s, and the full corpus
passes in 282s with no --testTimeout flag.

readTree filtered with an inline copy of the scanner's file predicate, so the
corpus could materialize files scanDirectory never reads and still count them
towards the anti-vacuity thresholds. It uses the exported isScannableFile now.
No behaviour change today: the two predicates were identical.

The additive-coverage assertion reads no route tree and cost nothing, so
gating it behind OBS_MAP_MUTATION_CORPUS only hid a stale list from the run
people actually do. Moved next to the registry assertion.

merge-comma-expressions is labelled preserving but would have merged a
directive prologue into 'use client', foo(), which is no longer a directive.
No route has that shape today; the guard and its test are there so the label
stays true.
coderabbitai[bot]

This comment was marked as resolved.

1stvamp added 20 commits August 2, 2026 17:14
…nalysis

The scanner missed 30% of server entry points in apps/webapp/app/routes.
It found 299 of 427; it now finds all 427 with 0 parse failures.

- detect named export clauses (export { loader }, export { h as loader }),
  resolving a local binding back to its declaration for the builder callee
- recurse one level into flat-route directories and key entry points by a
  path relative to the scan root, so route.tsx files stay distinct
- count statements for the loader/action bodies only, recursing through
  try, if, loop and switch blocks so a try-wrapped body reports its real size
- scope hasTryCatch and calleeNames to the entry-point bodies, leaving
  importedNames file-wide
- resolve the initializer callee to the root of a call chain
- throw on parse diagnostics so parseFailures can actually fire
- scan .test.ts route files and exclude .d.ts instead
…r heuristics

Follow-up to the adversarial review of the scanner fixes.

- follow a call from a loader/action body to a same-file helper, one hop
  with a cycle guard, so a body that delegates reports the helper's
  statements, try/catch and callees rather than just the delegation.
  ph.$.ts goes from 2 statements and hasTryCatch false to 30 and true;
  66 entry points gain statements, 6 gain hasTryCatch
- only treat a ParseFailureError as a parse failure in scanDirectory and
  rethrow everything else, so an unreadable file is no longer reported as
  malformed source, and keep the diagnostic alongside the file name
- match a builder handler only at the top of the config object or under
  methods.<HTTP method>.handler, not by name at any depth
- read handler arguments from the root call of a builder chain only, so a
  callback given to a later decorator is not the route body
- pin the loosened assertions and add negatives for the new resolution,
  the handler shapes and the chained builder
Adds error-classification, auth-boundary, request-context and audit-trail,
plus the CHECKS registry. Every check is a pure function of an EntryPoint and
reads body-scoped evidence only.

Two rules differ from the design. error-classification uses hasTryCatch as its
gate rather than a regex over ep.source, which is the whole file including the
React component; EntryPoint carries no evidence about what a catch does with
the error, so the check reports the hand-rolled catch and says it has not been
read. request-context looks for an identity resolved in the body rather than
grepping the file for identifier names, for the same reason. Both deviations,
and the calibration run over the 427 webapp entry points, are written up in the
task 5 report.
…ntry points

The error-classification and request-context checks could not tell a
rethrow from a swallow, or a database call from any method with a common
name. Four additive fields, all body scoped through the existing one-hop
helper resolution.

- catchRethrows and catchBranches: whether a catch clause in the bodies
  contains a throw, or branches with if, switch or instanceof. Of the 190
  routes that catch, 140 do one of those and 50 take one path out
- calleeTexts: the full callee path (prisma.organization.findFirst), index
  aligned with calleeNames, which is unchanged
- logCalls: logger.* and log.* calls with their object argument field names
  and whether the call sits in a catch, so a check can ask whether the
  failure path logs an identifier

No existing field changes value on any of the 427 route entry points.
error-classification now reads catchRethrows and catchBranches instead of the
mere presence of a try. It fails only where every catch in the bodies takes one
way out regardless of what was thrown, which drops the finding count from 130
to 50. The swallow is read before the builder is credited: a swallow inside a
builder-wrapped handler never reaches the builder, and 18 of the 50 are that
shape.

request-context now asks whether a failure-path log names a tenant, using
logCalls with inCatch and the field names. The builder pass is gone, because
the builders log { error, url } at their boundary and the logger only attaches
http context ambiently, so a wrapped route is not attributed either. The check
no longer echoes auth-boundary: 17 entry points of 427 are scored by both, and
they disagree on 10 of those.

auth-boundary and audit-trail are unchanged.
…eration

error-classification cannot tell the deliberate narrow guard, e.g.
try { body = await request.json() } catch { 400 }, from a catch that
swallows the whole handler. Both take one path out.

catchesNarrowly is true when an entry point has at least one catch clause
and no try block with a catch holds more than two statements, counted in
the loader/action bodies and the same one-hop helpers as the other fields.
Every catch has to qualify: one broad catch anywhere makes it false, so a
route that guards a JSON.parse and also wraps its handler is still
reported. Two statements lets the guarded operation bind its result and
stops short of the three-statement try that covers a handler.

55 of the 427 route entry points, and 11 of the 32 error-classification
failures, all eleven hand-read as the deliberate idiom. No existing field
changes value.
… nothing

Applicability keyed off the presence of a failure-path log, so a route that
kept its errors and recorded nothing was not-applicable rather than reported,
and deleting a log line took a route out of the report. Every non-trivial entry
point is now judged: no catch at all passes, since the error reaches the central
handler, and a catch has to name whose failure it was. 87 of the 169 failures
are routes that record nothing, which is what the old gate was hiding.

Verified over the real tree that removing logging cannot help: re-running all
four checks against every entry point with log calls deleted, failure-path logs
deleted, and log fields stripped moves 63 verdicts, none of them for the better.

error-classification now uses catchesNarrowly to excuse the guard that wraps a
single parse. Applied on its own the field also excuses a one-statement try
around a service call, which passes the design's own swallow fixture and four
findings that were hand-read as real, so the exemption also asks that the body
parse something. That clears the nine verbatim request.json guards and keeps the
rest: 50 failures become 35.
…see into

The rendered fix list opened with three auth-boundary findings and all three
were wrong. Two delegate to clearImpersonation, which authenticates and writes
an audit row in a file the scanner never opens, and the third is a redirect stub
flagged only because its path contains billing.

A fail here says the route does privileged work with no guard, which is only
supportable when the body is where a guard would have to be. A trivial body
cannot hold a visible privileged operation, by the triviality rule's own
definition, so either nothing privileged happens or the work sits behind an
import along with any guard. Those now report not-applicable with a detail
saying the guard could not be verified, rather than failing. Signature checks
also count as guards now, which clears the HMAC-authenticated waitpoint
callback. Three findings remain and all three are genuinely unauthenticated.

request-context stops treating a parse guard as the route taking over its
failure path, through the same shared reading of catchesNarrowly that
error-classification uses. Re-ran the incentive sweep after the change: 57
verdicts move when logging is removed, none for the better.
Whole-entry catch booleans collapse when a route has a narrow parse guard
and a broad handler catch, so a check cannot reason about either. 17 route
entry points are in that state.

- catches: one CatchEvidence per catch clause in the bodies and the one-hop
  helpers, carrying narrow, rethrows, branches, guardsParse and the try
  block statement count
- guardsParse reads constructors as well as parse calls, so new URL(referer)
  is visible without touching calleeTexts, which other checks read
- a try/finally now yields an empty catches list. hasTryCatch keeps its
  meaning, a try appears, so ask catches.length whether anything is caught
- catchRethrows, catchBranches and catchesNarrowly are now derived from the
  list and keep their values on all 427 route entry points

242 catch clauses over 189 entry points, 9 of them swallowing outright.
Clears all three false positives at the top of the report.
Both checks now read EntryPoint.catches instead of the aggregate booleans, so
an entry point is only as good as its worst catch. 39 routes have more than one
catch and 17 mix a narrow guard with a broad handler, and a single well-behaved
catch used to speak for the swallow beside it.

Neither check reads hasTryCatch any more. A try/finally leaves it true with no
catch clause at all, which is what put runs-replication.status at the top of the
first rendered fix list; the question is now catches.length. A parse guard is
recognised when it covers less than half the body, which keeps otel.v1.logs
reported, where the catch covers 15 of 18 statements and merely contains a
request.json.

The narrow limb of the proposed rule is left out. A one-statement try around an
awaited service call is as narrow as one around a parse. Taking it clears eleven
more routes and reading all eleven says six are real, including a silent run
cancellation and two credential paths that report a database failure to the
browser as a 400 with the internal message in it.

FIX FIRST now reads account.tokens, api.v1.authorization-code and api.v1.token,
all three genuine. Global score 83.
… smoke

Adds pnpm run map (repo root and package script), single-entry inspection
mode, and index.ts exports. The routes directory now resolves against the
repo root found by walking up to pnpm-workspace.yaml, not process.cwd(),
so the CLI works from both the repo root and the package directory.
Single-entry mode notes when an entry has no applicable scored checks
rather than printing a bare 100/100. Gitignores the generated
observability-map.json artifact.
…likes

Two catch-evidence fields matched shapes that resemble the thing they
detect, which excused catches the checks exist to find.

- guardsParse took any new X(), so new BranchesPresenter() or new Set()
  excused a catch over ordinary work. It now needs a parsing constructor,
  URL, URLSearchParams or RegExp, chosen from what the route tree actually
  constructs inside try blocks. 60 of 242 clauses change, 141 true to 81
- branches took an instanceof anywhere in the clause, including the
  error instanceof Error ? error.message : String(error) idiom, which words
  a message rather than picking a path. It now needs an if, a switch, or a
  conditional that is the whole return or throw. 29 of 242 clauses change,
  134 true to 105. All 33 bare instanceof uses in the tree are the
  formatting idiom

Clauses with no evidence at all go from 9 to 37. error-classification will
need recalibrating: on this evidence it reports 64 routes rather than 28,
and nothing it reported before stops being reported.
The build config had no include and no rootDir, so tsc inferred the package root
because vitest.config.ts happened to be inside the compilation. That is the only
reason dist/src/index.js landed where the package's main points, and excluding the
config would have silently moved the entry point. Scope the build to src and pin
rootDir so the layout is intentional.

vitest was resolving from the root workspace by hoisting despite being the test
runner and supplying the global types. Declare it at the version the other internal
packages use.
Emptying every catch clause in the tree scored it 100. Both scored checks
passed on the single fact that a route has no catch: error-classification
credited it as propagating to the global handler, request-context treated it as
having handed its failures over. So the gradient rewarded deleting error
handling, and 222 of 412 entries scored 100 on that one shared fact.

error-classification now reports not-applicable for a route with no catch,
since there is no classification decision to judge, and no longer credits a
builder wrapper for error handling the route does not do. request-context fails
it instead: the global handler carries requestId, path, host and method and no
tenant, so such a route genuinely cannot name whose request broke. Excusing it
would reinstate the perverse incentive.

Score falls from 76 to 22, which is the honest reading. Deleting all error
handling now takes it to 7 rather than 100. The two checks decorrelate: kappa
on error-classification against request-context moves from +0.231 to -0.032,
and the other two pairs stay near zero.
@1stvamp
1stvamp force-pushed the feat/observability-map branch from 52a0a4c to 023bc03 Compare August 2, 2026 16:17
@1stvamp
1stvamp marked this pull request as ready for review August 2, 2026 17:13
devin-ai-integration[bot]

This comment was marked as resolved.

1stvamp added 2 commits August 2, 2026 21:02
…after it

definitelyExits read a bare break or continue as leaving the statement list
wherever it found one, and both target the nearest enclosing construct of their
kind instead. A switch whose clauses all break falls through to the statement
written after it, so cutting that statement made
catch (e) { switch (e.code) { ...break } throw e; } read as a swallow, failing a
route that rethrows with a detail line saying it takes one way out regardless of
what was thrown. Same for a do body that breaks or continues.

definitelyExits now carries which bare jumps escape at that point in the
recursion: a switch clause drops break and inherits continue (continue targets
an enclosing loop, which the switch cannot be, so dropping it would stop a
genuinely dead throw being cut), a do body drops both, and a labelled jump always
counts. reachableStatements wraps its findIndex callback, which was passing an
index where the jumps record now goes.

The real route tree is byte-identical, report and clause evidence both:
nothing in apps/webapp writes this shape today.

dead-throw-after-switch-break is the corpus guard for the other direction, a
clause that returns and also breaks, which still has to be cut.
…wrapped exports

A route whose action is builder-wrapped and whose loader is a plain
export async function loader is judged on the action alone, and the pass detail
reads as a claim about the whole route. Ten routes in the tree mix the two
shapes and one of them is sensitive, so the check runs on exactly one of them.
Hand-read: its plain loader filters on members: { some: { userId } } and is
scoped, which nothing in the check saw.

Known limits only; the check is unchanged.
devin-ai-integration[bot]

This comment was marked as resolved.

1stvamp added 6 commits August 2, 2026 22:36
catchClauseEvidence raised its exited flag off containsExit, which is true
of a provably dead statement itself, so prepending if (false) { throw e; }
to a deciding clause blinded the walk to the real classification below it:
78 real routes turned pass into a swallow verdict, the same set for all
eleven dead-* corpus spellings. The returns veto had the same defect via
containsReturn, regressing 11 rethrow-only routes from n/a to fail.

The walk's four evidence reads now go through a liveness fold
(literalTruth folding literal guards only) so a dead branch contributes
nothing, while an undecidable guard keeps the containment answer and
refusal stays intact. Real-tree report is byte-identical (global 19,
measured 412); the twelve dead-prepend corpus entries measure falls 0
rises 0 in the mirror direction. New corpus entry dead-if-false-return
covers the returns half at tree scale. containsReturn is deleted: its
only read site is now the live fold.
…ranteed

The catch-evidence walk entered only a bare block and a do body, so
relocating a clause's own statements inside if (true), a single-default
switch, an if/else or a try/finally hid the branch evidence while the
returns veto still saw the return: a deciding clause read as a swallow on
83 real routes per corpus entry, the generalisation of the switch-break
bug fixed in 87e0822.

The walk now enters the positions guaranteed to execute whenever the
clause runs: a catchless try's tryBlock, the sole clause of a
single-default switch, the then-arm of a keyword-exact if (true), and
both arms of an if/else with isolated per-arm states merged by
intersection (evidence in one arm only earns nothing). definitelyExits
folds the literal true keyword so a trailing dead statement after
if (true) { exit } is cut. The entry folds are keyword-exact while the
liveness folds stay wide, deliberately: entry grants credit, liveness
only withholds blindness.

Refactors the walk's flags into a threaded state record (verified
byte-identical on the real tree before the entries landed); the finished
step is also byte-identical including per-clause evidence over all 427
entry points. The six mechanism-B corpus entries measure falls 0 rises 0.
New corpus entry dead-classifier-one-arm pins the intersection: widening
it to a union raises 80 routes and turns the entry red.
callbackCatches was a bare count and error-classification failed any
route whose only catches were refused by the iteration boundary, on
placement alone: wrapping a body in a non-array .map or .filter turned a
passing route into a fail, a false accusation on 85 real routes per
entry. The owner asked for the trade to be revisited.

Refused catches now carry full CatchEvidence, built by the same
catchClauseEvidence machinery as an own catch, and the check reads two
arms off it: a refused swallow fails whenever nothing the route owns
decides (deliberately not conditioned on the route owning no catches, so
an own inert rethrow cannot lift a refused swallow out of the verdict,
which closes a latent rise in the old code), and a route whose only
catches are refused and none swallows sits out at not-applicable, never
a pass. The ceiling is pinned at tree scale by the new dead-deciding-map
corpus entry: any future crediting of refused catches raises ~261
catchless routes and turns it red.

Real tree: global 19 -> 19, zero score or verdict changes, exactly two
detail-only changes on the tree's two callback-catch routes, both
genuine per-item swallows that keep failing. Mirror measurement:
wrap-body-in-non-array-map and -filter at falls 77 rises 0 dropouts 0,
every fall error-classification pass -> not-applicable; dead-deciding-map
falls 0 rises 0.
…operty

The corpus only failed on a score RISE, so it structurally could not
catch a false accusation: 19 of 43 preserving entries were lowering 104
real routes' scores and nothing noticed. Every preserving entry now also
asserts fallsIn, the mirror of risesIn: no route measured in both runs
may score lower, over a comparison population pinned to the whole
measured baseline so a shrunken population cannot pass vacuously.

Exactly two entries carry a permanent lowers exemption, the reason on
the entry itself (wrap-body-in-non-array-map and -filter, mechanism C:
relocated deciding catches cap at not-applicable, 77 routes each). An
exempted entry must still fall, and every fall must be exactly
error-classification pass -> not-applicable with nothing moving to fail;
anything else is a new defect hiding under the exemption. Red-capable in
both directions: dropping the lowers field fails the entry on its 77
falls, and reverting the mechanism-A fix fails dead-if-false and
dead-if-false-return on 78 falls each.
…ncels

A finally that leaves itself by break or continue cancels the try's
completion, so a throw or classifier in that tryBlock never escapes the
clause. The catchless-try walk entry credited it anyway: prepending
do { try { if (e instanceof Error) { throw e; } } finally { break; } }
while (false); to every catch raised 80 routes and took the global from
19 to 27. Entry now requires the finally to contain no escaping jump
(containment, since entry grants credit), and containsLiveWhere folds a
try dead when its finally provably completes abruptly, so the refused
statement cannot blind the classification after it either.
dead-throw-in-cancelled-try in the mutation corpus is the tree-scale
guard; the unit pins cover the break, continue, switch-hosted and
may-break spellings and the no-blinding identity.
…wn deciding catch that may raise

Arm c was ordered off reachable, own catches filtered by guardCanRaise,
so a route owning a real classifying catch that canRaise cannot see (a
destructuring guard) beside a per-item .map swallow was told nothing it
owns decides. canRaise is a whitelist and cannot carry that decision;
the new guardMayRaise is its containment twin, false only for the
provably inert try { 0; }, so the dead classifier dead-classifying-try
prepends still blocks nothing while the real catch does. The pin that
was meant to hold this asserted the absence of a detail string no arm
ever emits; it now asserts the verdict.
devin-ai-integration[bot]

This comment was marked as resolved.

Round E, the six Devin threads on PR #4455.

auth-scope credited any call handed an object with a caller-id property,
so `logger.error("create failed", { userId: user.id })` in a
builder-wrapped handler cleared the check for that export. Measured at
tree scale: the new corpus entry log-caller-scope-userid raises the two
routes auth-scope has ever found, settings.sso and settings.team, both
confirmed cross-org exposures. The callee now also has to be something
that could plausibly narrow a read. A denylist of sinks (loggers,
console, response serializers) rather than an allowlist of query
callees: 72 distinct callees earn credit on the real tree, and the
allowlist was measured and rejected because it accuses
regenerate-api-key, whose helper does members: { some: { userId } } and
throws. Real tree unmoved, global stays 19, no route newly accused.

Also in this round:

- cap the delegated route list in the PR comment at 15, the section the
  file's own docstring wrongly claimed was bounded by construction.
- drop the false "No audit helper exists in the webapp" sentence, and
  the branch that carried it, since the helper exists and AUDIT_SYMBOLS
  names it.
- reword the README's "merge base" to the tip of the base branch. The
  checkout is a pull_request default, so the scanned tree is the test
  merge commit and base.sha is its parent, which makes base.sha the
  right base and the README the thing that was wrong.
- gate the mutation corpus to the package's own paths plus a nightly,
  instead of four and a half minutes on every route PR.
- narrow the obsmap paths filter to routes only, so the package's tests
  stop running twice on every PR touching it.
devin-ai-integration[bot]

This comment was marked as resolved.

1stvamp added 2 commits August 3, 2026 02:22
… filter

`needs: changes` carries an implicit success() that outranks the event test in
the `if`, so a failed or skipped filter job silently skipped the nightly
mutation corpus and the tree-drift scan stopped without saying so. Adding a
status-check function to the `if` drops the implicit success() and lets the
event test decide alone.

The filter job now runs only for pull requests, the sole path that reads its
output. Pull request gating is unchanged.
The tests call String.prototype.matchAll, an ES2020 library feature, while
`lib` said ES2019. Typecheck passed anyway because @types/node v24 declares
`/// <reference lib="es2020" />`, so the program already contained es2020.
No behaviour changes; the declaration now states the requirement instead of
resting on a transitive reference.
devin-ai-integration[bot]

This comment was marked as resolved.

1stvamp added 2 commits August 3, 2026 02:56
The suite scans apps/webapp/app, packages/plugins/src, internal-packages/rbac/src
and four files under .github/workflows, none of which turbo hashes for this
package, so turbo run test replayed a pass recorded before those trees changed.
Measured rather than argued: a route file with a syntax error fails the suite
under vitest, and the same tree came back FULL TURBO in 301ms with the failure
cached away as a success.

inputs was tried and rejected rather than assumed unworkable. Turbo 1.x does
accept .. in an input glob, and ../../apps/webapp/app/** did bust the cache on a
route change, but it replaces the default file set instead of adding to it, so
the same config silently dropped this package's own vitest.config.ts from the
hash. The $TURBO_DEFAULT$ token that would add rather than replace is turbo 2.x
only and matches nothing on 1.10.3.

Costs about 23s per run and no CI job pays it: the dedicated workflow calls
vitest without turbo, and unit-tests-internal.yml runs cold.

Reported by Devin on #4455.
…ring stdout

Both scan steps redirected pnpm --filter ... exec stdout into files the renderer
JSON.parses. pnpm takes its recursive path under --filter and some versions
announce 'Scope: N of M workspace projects' on it; one such line in head.json
fails the parse and degrades every run to the stale-report comment, which is a
permanent quiet failure rather than a loud one.

It does not reproduce on the 10.33.2 the workflow pins, which was checked, so
this closes the class rather than a reproduction: the scanner writes its own
file and stdout is left to be log output. The -s guard keeps the partial dance
honest now the redirect no longer creates the file, so a scanner that exits 0
without writing takes the stale-report branch instead of failing the mv and
turning the job red.

The render step still captures stdout, since prCommentCli has no --out and a
banner there puts a stray line in a markdown comment rather than breaking a
parse.

Reported by Devin on #4455.
devin-ai-integration[bot]

This comment was marked as resolved.

1stvamp added 2 commits August 3, 2026 03:31
The filter watched apps/webapp/app/routes only, which was narrower than the
suite's actual coupling. webappSymbols.test.ts walks all of apps/webapp/app and
fails when a guard, sensitive or audit symbol stops resolving, so renaming e.g.
requireUserId in app/services/session.server.ts matched the webapp filter and
nothing else: no job ran this suite and the break landed on main, or on the next
unrelated internal-packages PR. integration.test.ts also asserts on the text of
observability-map.yml, which no filter watched at all, so editing the report
workflow alone ran nothing.

Derived the coupling set from the code rather than from the comment. Outside its
own directory the suite reads apps/webapp/app (whole tree for symbols, the route
subtree for the scan), packages/plugins/src, internal-packages/rbac/src, and
four workflow files. packages/plugins/src and internal-packages/rbac/src stay
out: internal already matches packages/** and internal-packages/**, and
unit-tests-internal.yml runs the same suite, so listing them here would run it
twice. A new test pins that reasoning.

Cost, over the last 400 commits on main: 31% touch routes, 52% touch
apps/webapp/app, so the job fires on roughly half of PRs instead of roughly a
third. It is the cheap one, a single 4x runner with no containers and no
database.

Reported by Devin on #4455.
…tion

The 30s and 60s per-test timeouts on the two real-tree tests were chosen on an
idle machine, and the suite also runs inside unit-tests-internal.yml, which
executes turbo run test --filter "@internal/*" as twelve concurrent shard
processes on one runner. The 30s one does flake under that.

Measured on an 8-core box. This file alone at load average 0.9: 6.3-6.4s for the
scan, 10.8-11.2s for the sweep, both well above the 1.6-2.6s the old comment
claimed. Two batches of twelve concurrent copies on those same 8 cores: 24.2-34.0s
for the scan and 27.6-39.7s for the sweep, with one of the first twelve dying on
"Test timed out in 30000ms". Twelve processes over 8 cores is 1.5 per core where
the 32-vCPU runner is 0.375, so the reproduction is harsher than CI, which is why
it is the thing to size against.

Both now use one 120s constant, which is 3x the worst contended run measured.
60s was the other candidate and is not enough: the sweep already reached 39.7s.
Neither test asserts anything about elapsed time, so the number is a hang
detector rather than a performance budget, and the docstring says so.

Reported by Devin on #4455.
devin-ai-integration[bot]

This comment was marked as resolved.

1stvamp added 3 commits August 3, 2026 04:45
Every input auth-boundary read was entry-point-wide, so one guarded export
spoke for the whole file: calleeNames is the union of both bodies,
checkedCallees was too, and usesBuilder was an OR over both initializer
callees. A file whose loader called requireUser and whose action called
nothing read as guarded in the body, and a createLoaderApiRoute loader
authenticated a hand-written action beside it. This is the same defect
auth-scope was fixed for a round earlier, in its sibling check.

scanFile now splits calleeNames, calleeTexts, checkedCallees, statementCount
and hasTryCatch per export, filled from one push site each so the union and
the split cannot drift apart. usesBuilder had no other caller and is gone.
routeExports is the single enumeration of a file's exports, shared with
auth-scope, which had grown its own [loader, action] literal.

Triviality had to follow, or the fix trades a false pass for a false
accusation: naive per-export attribution moved auth.github.ts and
auth.google.ts to fail, both being a one-line redirect-stub loader beside a
guarded action that the entry-point-wide rule called non-trivial. isTrivial
is now one rule over two views. The per-export view matches the side-effect
hints against that export's own callee paths: the whole file is defeatable
(the corpus's log-caller-scope-userid puts the word logger in the file and
un-excuses the untouched loader) and nothing at all guts the check (five
fixtures go from fail to not-applicable, because calleeNames keeps only a
call's last segment and prisma.x.findMany reads as findMany).

login.mfa's action verifies a TOTP or recovery code, which is a login-surface
proof of possession like the verify* guards already listed, so it joins them
rather than being accused once its loader stops speaking for it.

Real tree unmoved: global 19, 62 auth-boundary applicable, 59 passing, no
route changing any check.

scan.ts also picks up routeModuleFiles here, shared with the corpus harness,
because it sits in the same hunk as the per-export return shape.
mutations.ts entryBodies collected exported function declarations and
exported const identifiers only, so it missed the object binding pattern
(export const { action, loader } = createActionApiRoute(...)), the export
clause (const { action } = builder(...); export { action }), and
export const action = route.action. That is 36 of the tree's 427 entry
points, all of them API routes: every whole-body corpus entry skipped them
while the file count suggested otherwise. The scanner has read all four forms
since early on, so this was the harness lagging it.

No assertion could have noticed. A mutation that reaches fewer routes lowers
the score rather than raising it, which is exactly how the suppress-every-check
omission hid, so the answer is the same: assert the population. admin.tsx is
the one exclusion, named rather than counted, because its handler is a concise
arrow with no block for a block wrapper to wrap.

wrap-body-in-rethrow goes from 391 files with 36 entry points missed to 426
files, 1020 sites, 1 missed. Widening changes no entry's verdict: the full
corpus is 55 passed and 1 expected fail either way, and with the narrow
population the only failure is the new population assertion itself.

readTree now calls routeModuleFiles rather than keeping its own copy of the
directory walk. isScannableFile had already replaced the file half of that
copy; the directory half survived.
…rules

Sweeping the package for the defect behind the two review threads: one
question answered by two pieces of code, where only one copy gets fixed.

Shared:
- the scannable-file predicate, copied into integration.test.ts and
  webappSymbols.test.ts after it was exported to stop mutationCorpus.test.ts
  copying it
- the FIX FIRST filter and sort, byte-identical in terminal.ts and
  prComment.ts, which already imports five helpers from it; failingIds is now
  scoredFailures plus a map
- normalizeSegment, in the test that validates SENSITIVE_SEGMENTS against the
  real tree. It was splitting segments with /_+$/, the regex that function's
  own comment says not to use
- the five bare-literal node kinds, written out in literalTruth three lines
  above the literalValue that already had them

Pinned:
- contextGap and auditGap, which redo by hand what checkContributions
  computes generically, on two headline figures with nothing saying they had
  to agree. Reverting either to a different denominator or numerator now goes
  red

Left alone, with reasons recorded in the sweep report: canRaise vs
tryBlockMayThrow, the two exact true-keyword folds, the two comment
extractors, the three means, ratio vs globalWithout, and the eight AST
helpers mutations.ts keeps its own copies of so the corpus can disagree with
the scanner.
devin-ai-integration[bot]

This comment was marked as resolved.

… fold

selectsADistinctPath decided whether an if or a switch in a catch clause
made a real classification decision by asking containsExit, a plain
containment walk. Containment is true of an exit that can never run, so
catch (e) { if (e instanceof Error) { if (false) { return null; } }
return json(x, { status: 500 }); } read as a decision while the same
clause without the if read as a swallow: 50 points a route for a
behaviour-preserving mechanical edit. Measured over apps/webapp/app/routes,
that shape took the tree from 19 to 27 and raised 80 of 412 routes.

An earlier wave had already moved catchClauseEvidence's exited flag onto
containsLiveExit for the same eleven dead spellings. The branch predicate
120 lines below it kept the containment read, so this is one rule fixed in
one place and left in its sibling.

The three exit reads now go through containsLiveExit and containsExit is
deleted, so there is one exit read in the file. The property that makes one
helper safe for two callers reading it for opposite purposes is now written
down on containsLiveWhere: it is strictly subtractive against containment,
so it only ever un-blinds the exited flag and only ever withholds a branch
grant. Conservatism is a property of the helper plus what the caller does
with a true, and auditing it at the definition is how this was missed.

Adds dead-armed-instanceof-if to the mutation corpus, additive class, and
dead-conjunction-instanceof-if under KNOWN_GAPS. The second is the sibling
this fix does not close: folding the arm does not fold a dead condition,
and if (e instanceof Error && false) reaches the same grant for the same 80
routes. literalTruth treats && and || as always null on purpose, so closing
it means widening that fold, a different rule with its own measurement.
Recorded and running rather than left to be rediscovered.

Requiring the arm to definitelyExits was measured and rejected: it accuses
admin.api.v1.orgs.$organizationId.environments.staging.ts, which classifies
Prisma's P2002 and rethrows everything else, of taking one way out
regardless of what was thrown. Pinned by 'still credits an arm guarded by a
condition that does not fold'.

The real tree does not move: every route's score and every check's status
are byte-identical before and after, global 19 either way.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment thread .github/workflows/observability-map.yml
Comment thread .github/workflows/pr_checks.yml
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.

2 participants