Skip to content

Feat: show what exists, and what nothing reaches - #131

Merged
mrmarchone merged 9 commits into
laramint:mainfrom
webard:feat/reachability-inventory
Sep 4, 2026
Merged

Feat: show what exists, and what nothing reaches#131
mrmarchone merged 9 commits into
laramint:mainfrom
webard:feat/reachability-inventory

Conversation

@webard

@webard webard commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

The graph could show what a route reaches. It could not answer the question underneath that: what exists that nothing reaches at all — the job nobody dispatches, the listener wired to an event never fired, the service left behind by a refactor.

The inventory as it opens: groups, counts, and how many each is holding back

What it reads

Every entry point the framework can start work from — routes, console commands, queued listeners, scheduled entries, broadcast channels, Filament pages — and every class declared in the scanned source. What the tracer reaches from an entry point is reachable; what is left is reported with why it might still be referenced: a container binding, a config value, a class string, inheritance. A class reached only that way is a different finding from one nothing mentions at all, and saying which is the difference between a list worth reading and a list worth ignoring.

On a 60-module application: 843 entry points, 3111 classes nothing reaches.

Three defects found by rendering it, not by testing it

The analysis was right from the start. Everything below is about the screen.

One node per unreached class — 3982 of them, drawn at 0% zoom. No layout fixes that; a list of three thousand names was never a picture. Groups now ask the viewer to open folded, each showing how many it holds, and the reader opens the one they came for.

That alone changed nothing, and the reason is worth recording. This tab introduces four node types, and they were registered in 2 of the 8 frontend type registries. ALL_TYPES in App.tsx was one of the six that missed them — and edgeVisible reads it, so those nodes had no visible edges, the fold walked out to no children, and the type filters could not toggle them either. Registered now in ALL_TYPES, the filter panel labels and order, the legend, and the sidebar palette.

Fitting the view measured every node, including the folded-away ones, framing a picture nobody is looking at. It now measures what is drawn — which fixes the same thing for every collapsed graph, not just this one.

Measured, at each step

zoom nodes drawn
as the branch stood 0% 3982
+ layer wrapping from #118, alone 0% 3982
+ folding (this PR) 19% 28
+ folding and #118's wrapping 65% 28

Worth being plain about, since the question was whether to wait for #118: its layer wrapping does not fix this on its own — 3982 nodes stay unreadable however they are packed. The two are complementary rather than alternatives. This PR stands on its own at 19%; when #118 merges, the same screen becomes comfortable at 65%, because 28 groups in one row is exactly the case wrapping was built for.

Verification

pint clean, phpstan no errors, 430 tests passing.

The fold is pinned by a test asserting both group kinds carry the flag and neither the members nor the roots do — folding a member would hide the thing it names, folding a root would close the screen. Removing the flag from one group kind turns it red.

The frontend fixes are verified by measurement and screenshot rather than by test: there is no frontend test harness in this repository, and I did not want to introduce one inside this PR without asking.

webard pushed a commit to webard/laravel-brain that referenced this pull request Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Benchmark

base c0e0907 → head 3d27b5f · PHP 8.4.25 · 10 repetitions per arm, interleaved in one job

What the scan detects

Deterministic: both arms scan the same generated application on the same PHP version, and every repetition is checked to produce the same figures. A difference here is a change in what Brain detects, not machine noise — which is worth looking at rather than automatically worth fixing, since detecting more legitimately moves these.

Scenario Nodes Edges Tabs Routes Security issues parse() calls
Full scan — 398 files 721 1,961 196 160 80 219
Full scan — 1,188 files 2,133 5,785 584 480 240 639
Method tracing — every entry-point method 6,661 325

No count changed on either corpus, including the per-node-type breakdown.

Timing

Median wall clock on a shared CI runner. Noise is how far the base arm's own repetitions sat from its median, discounting the single worst on each side, with their full spread beside it; a delta inside the noise is not a measurable effect.

Scenario Base Head Δ Noise Base spread
Full scan — 398 files 358 ms 357 ms -0.2% (within noise) ±1.2% 349–364 ms
Full scan — 1,188 files 1083 ms 1076 ms -0.6% (within noise) ±1.1% 1068–1161 ms
Method tracing — every entry-point method 376 ms 375 ms -0.2% (within noise) ±0.8% 373–382 ms
Phase split — Full scan — 1,188 files

A phase carries at least as much runner noise as the scan it is part of, so each one gets its own noise floor from the base arm's repetitions of that phase. Useful for locating a large move; a phase that is a few percent of the scan cannot be read at a few percent of accuracy.

Phase Base Head Δ Noise
lifecycle 290.5 ms 290.6 ms +0.0% (within noise) ±0.3%
graph 240.0 ms 237.6 ms -1.0% (within noise) ±5.9%
queries 122.4 ms 120.5 ms -1.6% (within noise) ±1.8%
controllers 116.2 ms 116.2 ms -0.1% (within noise) ±0.9%
security 83.7 ms 83.0 ms -0.8% (within noise) ±20.7%
commands 40.1 ms 40.1 ms +0.0% (within noise) ±0.4%
models 31.1 ms 31.1 ms -0.0% (within noise) ±0.5%
routes 27.2 ms 27.2 ms -0.1% (within noise) ±1.4%
cmd_chains 21.4 ms 20.9 ms -2.2% (within noise) ±3.3%
split 18.8 ms 19.5 ms +4.0% (within noise) ±7.9%
facades 18.6 ms 18.5 ms -0.4% (within noise) ±14.6%
ai 15.7 ms 15.8 ms +0.1% (within noise) ±1.4%
http_builders 15.1 ms 15.1 ms +0.6% (within noise) ±11.3%
observers 3.3 ms 3.3 ms -1.2% (within noise) ±1.8%
policies 2.4 ms 2.4 ms +0.0% (within noise) ±0.8%

Scans a synthetic Laravel application generated by benchmark/generate-corpus.php, not a real one — the shapes are chosen to stress the scan, so treat the absolute times as a workload, not as a user's scan. Reproduce locally with composer benchmark.

@SanderMuller SanderMuller 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.

Reviewed at 50ba08b. composer test in a fresh worktree: 430 passed (1196 assertions), PHPStan No errors, Pint clean, 15/15 CI. Your numbers.

A tool that tells people which classes nothing reaches is one wrong entry away from someone deleting live code, so the review went almost entirely at the false-positive question.

The gate: purely additive, which is the best result this could have had

Null arm first, base against base on three applications — identical. Then the branch:

full-graph nodes edges tabs changed
A 3,568 → 3,568 8,686 → 8,686 537 → 538 nothing
B 2,889 → 2,889 5,218 → 5,218 370 → 371 nothing
C 2,573 → 2,573 5,286 → 5,286 424 → 425 nothing

Not one existing node, edge, subgraph or manifest entry moves on any of the three. The only difference is one new reachability--inventory tab. For a GraphSplitter diff of +238 lines that is the answer you want and rarely get.

The claim I went after: is the walk right, or does it just look right

I did not take the traversal on faith. For every class the tab reports as unreached, I checked whether the full graph holds a node carrying that FQCN, and then whether that node is reachable from any route node by a plain breadth-first walk over every edge — my own walk, not yours:

unreached classes that ARE nodes in the full graph of those, reachable from a route by plain BFS
A 84 0
B 95 0
C 49 0

Zero on all three. Every class the tab calls unreached really is outside the forward closure of the entry points, including the 77 models on A and the 18 policies on B that made me suspect a bug in the first place. The seeding — signature-built node ids for routes and commands, FQCN matching for queued listeners, and the entry point's own class counted as reached without a node — holds up on real applications. That suspicion is where most of this review went, and it dissolved; saying so is more useful to you than not mentioning it.

Four mutations, whole suite each, anchors asserted, restores verified:

mutation result
fold flag off for entry-point groups 1 failed
fold flag off for unreached groups 1 failed
entry point's own class no longer seeded as reached 1 failed
a class's own file counts as a class-string reference 1 failed

Your description says removing the flag from one group kind turns a test red. Both kinds do, separately — I checked them one at a time rather than together, because a single mutation covering both would have proved neither.

Three things worth knowing, none blocking

1. The pass costs more than the code comment suggests, and it is on by default. ProjectAnalyzer says "A second traversal of the same files, not a second parse: PhpFileParser shares its results process-wide, so both passes read one AST per file." The sharing part is true and works — on B the cache serves 4,518 hits with the pass off and 7,478 with it on, so ClassStringIndex really does ride on what ClassInventory already parsed. What the sentence hides is that the inventory opens files the rest of the build never had a reason to open, which is exactly its job. Counting real parses with the switch on and off, three interleaved reps on B and deterministic counts throughout:

real parses off → on scan wall off → on
A 733 → 1,195 +63% 1.9 s → 2.5 s +32%
B 1,098 → 2,110 +92% 1.35 s → 2.1 s +56%
C 746 → 4,416 ×5.9 1.5 s → 4.5 s ×3.0

C is a modular monolith: the build parses 746 files, the inventory parses 4,416. Tripling a scan is a real price for a feature that ships on. The config block is the honest one — it does say "one extra parse pass ... Turn it off if you do not want it" — so this is a numbers-in-the-description request rather than a design objection. I would put the C row in the PR body and soften the code comment, which currently reads as though the pass were free.

2. Seventeen classes on B are listed with no reason at all while Brain's own graph holds an edge pointing straight at them. All seventeen are policies. unfollowableReferences covers container bindings, facades, config strings, class strings and inheritance, but not "the graph already attaches this to a model by a dedicated edge". A policy wired to a model is precisely the "here is why it might still be referenced" case the design exists to serve, and it currently lands in the worst bucket. B's observers and listeners are in the list too and do carry a reason, so this is one gap rather than a category of them.

3. The scale on a modular monolith is worth stating plainly. On C: 4,303 classes declared, 449 reached, 3,854 unreached — 90% of everything the application declares, of which 1,629 carry no reason at all. The tab is 4,318 nodes. Your own figure (3,111 on a 60-module application) is the same shape. That is not wrong — the note inside the tab says exactly the right thing, and the config comment says it twice — but at 90% the list stops being a finding about the application and becomes a measurement of the tracer's reach. A line in the README or the tab note along the lines of "on a modular application expect most classes here; read the reasons column, not the count" would set the expectation before someone opens it and concludes their codebase is dead.

Checked and fine

The frontend registries. ALL_TYPES in App.tsx carries all four new types with a comment explaining why membership there is what makes them filterable and foldable at all — that is the fix, and it is in the right place. Sweeping every type list in frontend/src, the three that still omit them are ComplexityPanel (an unreached class is not a complexity subject), sequenceUtils (an inventory has no sequence) and exportUtils, whose TYPE_ORDER is a preferred order with everything else appended by [...new Set([...TYPE_ORDER, ...byType.keys()])] — it omits six existing types on main too, so it is neither exhaustive nor meant to be.

The fold, as it actually lands. Three section roots open — entry points, unreached, and outside-what-the-tracer-follows — and every per-kind group under them is folded: 21 of 24 on A, 23 of 26 on B, 22 of 25 on C. That matches what you describe rather than approximately matching it.

The bundle reproduces (npm ci && npm run build leaves git status empty) with a clean one-in, one-out swap of both the JS and the CSS chunk. No orphan left behind, which is worth saying since one of the other open branches did leave one.

The switch is a real switch: with reachability.enabled off the pass does not run at all, which is visible in the parse counts above rather than inferred from the code.

One housekeeping note: this is now the fourth open branch rewriting line 11 of index.blade.php and deleting index-B5C0iocj.js, alongside #128, #129 and #130. Whichever merges last resolves with npm run build and commits what it writes, never by picking a hash out of the conflict.

On the frontend-harness point — same answer I left on #130, so I will not repeat it here beyond the pointer: the components can be run in this repository with the project's own esbuild plus react-dom/server or jsdom, without adding a test runner to package.json. Worth a look for the fold behaviour whenever you next touch it.

Verdict

Approving. Purely additive on three applications, the walk survives an independent traversal, the mutations bite on both branches of the one claim you made about them, and the tab tells the reader what it is a statement about. The cost table belongs in the description before this ships on by default.

@SanderMuller

Copy link
Copy Markdown
Contributor

Following up on my approval rather than reversing it. The tab is right, the walk survived an independent traversal, and the switch works in both directions. This is about the default, and it is one word.

The two instruments agree, and the real one is worse

The Benchmark comment on this branch, at the current head:

base head Δ noise
Full scan — 395 files 317 ms 513 ms +62.0% ±3.0%
Full scan — 1,185 files 980 ms 1521 ms +55.2% ±1.8%

with parse() calls 216 → 397 and 636 → 1,187 — near enough a doubling on both sizes. The phase split puts reachability at 541.6 ms of a 1,521 ms scan.

I measured the same thing a different way before I had read that comment, by toggling reachability.enabled in one process on three real applications so nothing else moved:

real parses off → on scan wall off → on
A 733 → 1,195 +63% 1.9 s → 2.5 s +32%
B 1,098 → 2,110 +92% 1.35 s → 2.1 s +56%
C 746 → 4,416 ×5.9 1.5 s → 4.5 s ×3.0

The two agree on the shape, and the parse doubling on B (+92%) is almost exactly what CI reports on the large corpus. The part worth your attention is C: a modular monolith where the build parses 746 files and the inventory parses 4,416, and the scan takes three times as long. The synthetic corpus has no application with 4,300 declared classes, so CI is measuring the mild case.

Why I think the default is the question, and not the cost

The cost is honest work — the inventory has to open every declared class, and that is the feature, not an inefficiency. I am not asking you to make it faster.

What makes it different from the other branches is the ratio of cost to what a reader gets on the applications that pay the most:

  • On C the tab reports 3,854 of 4,303 declared classes as unreached — 90% — with 1,629 of them carrying no reason at all.
  • The same application is the one whose scan triples.

So the application that pays three times the scan time is the one whose list is least readable. The other expensive features here ship on by default and cost a few percent; this one is a different order of magnitude, and it is the only one where the worst case and the least useful case are the same application.

It also stacks

Every open branch measures against the same merge base c2bf14b, so these deltas compound at merge and no comment shows the total. Adding the ones that clear their noise floors and not double-counting the stacked pair (#130 contains #117):

  • everything except this branch: +23.5%
  • with this branch: +89.9% — a 980 ms large scan becoming roughly 1,861 ms

More than half of the whole train's cost is this one pass. That is an estimate assuming independence; the shared parse cache means they overlap, so the real number needs composer benchmark on the merged tree once several have landed.

The ask

'enabled' => env('LARAVEL_BRAIN_REACHABILITY_ENABLED', false) — one word, and the config block already explains what the reader gains by turning it on. Everything else stays as it is.

If you and the maintainer would rather keep it on, that is a fine answer and I will not argue it twice. In that case what I would ask for instead is the C row in the description: the +55%/+62% from CI is the mild case, and someone deciding whether to accept the default should see the ×3.0 next to it. The config comment is already the most honest thing in this PR — it says outright that this is not a dead-code report — and the cost deserves the same treatment.

@webard

webard commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Done in a1cc541 — the default is now false. But I could not reproduce your ×3.0, and the reason turns out to be worth more than the flag.

A second application, measured the same way

Toggling reachability.enabled on a modular monolith with 7,546 source files, two runs each so the noise is visible:

run 1 run 2
off 21.67 s 20.62 s
on 22.41 s 20.87 s

About +2%, inside the run-to-run spread. The phase self-reports 1.03–1.19 s of a ~21 s scan, while finding 3,111 unreached classes and 843 entry points — the same order of output as your C, at a fraction of the cost.

The difference is the parse cache, and your own numbers point at it: on C the build parses 746 files and the inventory 4,416, so almost everything the inventory opens is new. Here lifecycle alone takes 6.6 s across those 7,546 files, so by the time the inventory runs nearly everything it needs is already parsed and it pays for the walk, not the parsing.

So the cost is not a property of the pass. It is the ratio between what the rest of the scan already parsed and what the inventory has to open — which is why one modular monolith sees ×3.0 and another sees nothing. Both config comment and commit message now carry both rows, because either one alone is misleading.

Why the default still flips

Your cost argument does not survive my measurement, but the other one does, and it is the stronger of the two: on an application this size 79–90% of declared classes come back unreached, and a list that long is read once and then ignored. That is true here as well — 3,111 of roughly 3,950.

A feature whose worst case is ×3.0, whose typical case may be nothing, and whose output is mostly noise at the sizes where it costs most, is one to turn on deliberately. Reversing a default is cheap; paying ×3.0 unasked is not.

The test that reports what the pass finds now turns it on explicitly rather than leaning on the default — which is the small honest side effect of the change, and arguably how it should have been written anyway.

Gate: pint clean, phpstan no errors, 430 passing.

One thing you may want to know about, unrelated to the ask

Setting LARAVEL_BRAIN_REACHABILITY_ENABLED=false did nothing on my first attempt, and the flag was not at fault: the application publishes its own laravel-brain.php, which replaces the package config rather than merging with it, so a key the published file does not name simply does not exist and config(…, true) falls through to the default. Every feature flag added since that file was published is unreachable on that application until someone edits it. Not this PR's problem, but it does mean a switch is only as good as the consumer's config file — worth a line in the docs somewhere.

@SanderMuller SanderMuller 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.

Re-reviewed at a1cc541, against 50ba08b. One commit: the default is now off.

The output gate is byte-identical on all three applications I measure with, null arm run first. That is the strongest statement available about a default flip — the pass does not run, so nothing it would have produced can leak into a scan that did not ask for it.

And it still works when asked. Switching the flag on against the same application: the tab returns, and the report reads 1,951 classes declared, 1,178 unreached — the same figures I measured before the default moved. Nothing was traded away to get the cheap default.

Suite green on this head: 430 passed (1196 assertions), PHPStan no errors, Pint clean.

On the benchmark comment at this head, which reads +1.5% (±0.7%) large and +2.7% (±1.2%) small, down from +55.2% and +62.0%: those residuals are not a cost. The gate says the output is byte-identical to the merge base, so nothing extra is computed — and the base arm itself moved from 980 ms to 896 ms between the two CI runs, which is several times the delta being reported. Worth saying out loud so nobody reads 1.5% as the price of the switch.

On the config comment

Turning the honest case into two rows with the mechanism next to them is the right way to write this down, and the sentence about what varies and what does not is the part that will save someone a decision. One caveat for a reader: the "+2% (within noise)" row is your application, not one I can reproduce — on the three I gate with, the cheapest case was +32% and the worst was ×3.0, because none of them had already parsed most of its own source. So the spread in that comment is real, and the good end of it depends on a property of the codebase rather than on the pass. The comment does say that, which is why this is a caveat rather than a correction.

The 79-90% figure matches what I measured independently: 90% of everything declared on one application, 1,629 of those with no reason attached at all.

Approving, and re-pointing the approval at this head. Thank you for taking the number seriously rather than arguing it.

@mrmarchone

Copy link
Copy Markdown
Member

Conflicts @webard

@webard
webard force-pushed the feat/reachability-inventory branch 3 times, most recently from 1541f28 to fda1048 Compare September 4, 2026 19:56
webard and others added 9 commits September 4, 2026 22:21
Every tab Brain builds is grown forward from one entry point, so what the
graph misses is invisible from inside it. Measured three times on a real
application: it knew 45 of 211 event classes, 27 of 113 job classes, and of
102 route tabs carrying an event node, not one carried a listener. Each of
those gaps took someone going looking.

This adds the inverse view as its own tab, built the way the ERD tab is —
independent of routes, assembled after the split, registered through the same
manifest entry.

Two halves. An inventory of entry points, grouped by kind: routes, console
commands, scheduled entries, broadcast channels, queued listeners, Filament
panels/resources/pages. Nothing is reachable except through one of these, so
their inventory is the denominator for the other half — the classes declared
under source_paths that no entry point's traced chain arrives at, grouped by
kind and largest kind first, so "17 jobs nothing dispatches" is one glance.

The hard part is the wording. A class the container resolves, a facade fronts,
config names by string, or a reached class inherits from is running in
production and still falls out of that subtraction, because there is no call to
follow. So the tab never says "dead code": it says nothing reaches this from a
traced entry point, and it shows the reader why that is a different sentence.
Every reference Brain did find and could not follow travels with the class —
container binding, facade, config/, inherited by a reached class, named as a
class-string elsewhere — onto the node itself rather than a heading three levels
up, because a reader clicks the class, not the group. Service providers and
exceptions, kinds with no call edge at all, are filed in a section of their own;
mixed in, they would be a hundred non-findings burying the real ones.

A queued listener counts as a root and a synchronous one does not: the second
runs inside its dispatcher's chain, the first is picked off the queue with no
caller, and seeding from it is the only way the asynchronous half of an
application counts as reached.

Off with laravel-brain.reachability.enabled, on by default. It costs one extra
traversal of source_paths and config/ — not an extra parse, since PhpFileParser
shares its results process-wide.
Two defects, both found by rendering the tab against a real application
rather than a fixture.

The tab drew one node per class nothing reaches -- 3982 of them at 0% zoom,
which no layout makes readable because a list of three thousand names was
never a picture. Groups now ask the viewer to open folded, each saying how
many it holds.

That alone did nothing, because the four node types this tab introduces were
registered in 2 of the 8 frontend type registries. ALL_TYPES was one of the
six that missed them, and edgeVisible reads it -- so their edges were never
visible, the fold found no children, and the type filters could not toggle
them either. Registered in ALL_TYPES, the filter panel, the legend and the
sidebar palette.

Fitting the view also measured every node including the folded-away ones,
framing a picture nobody is looking at.

Measured on a 60-module application: 3982 nodes at 0% -> 28 at 19%.
The pass opens every declared class, and what that costs depends entirely on
how much of the codebase the rest of the scan already parsed. Two modular
monoliths, measured the same way:

  A   build parses 746 files, inventory 4,416      scan x3.0
  B   7,546 source files, nearly all already cached  +2% (within noise)

So the worst case is real and the typical case may be nothing. What does not
vary is the shape of the answer: 79-90% of declared classes come back
unreached on an application this size, and a list that long is read once and
then ignored. Reversing a default is cheap; paying x3.0 unasked is not.

The test that reports what the pass finds now turns it on explicitly rather
than relying on the default.
@webard
webard force-pushed the feat/reachability-inventory branch from fda1048 to 3d27b5f Compare September 4, 2026 20:21
@mrmarchone
mrmarchone merged commit 2f02569 into laramint:main Sep 4, 2026
15 checks passed
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.

3 participants