Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,17 @@ name for a *controller action*. The two are unrelated and neither affects the ot
"an Action" unqualified means this pattern in ordinary Laravel usage, the interface gives the
unqualified label to `action_class` and calls `action` a **Controller action**.

### Reachability
Every other tab is grown forward from one entry point, so a gap in the graph is invisible from inside it — measured on one application, the graph knew 45 of its 211 event classes and 27 of its 113 job classes, and no screen said so.
The **Reachability** tab is the inverse view: an inventory of every entry point (routes, console commands, scheduled entries, broadcast channels, queued listeners, Filament panels/resources/pages), and the classes under `source_paths` that no entry point's traced call chain arrives at, grouped by kind so "17 jobs nothing dispatches" is answerable at a glance.
**It is not a dead-code report.** "Nothing reaches this from a traced entry point" is a statement about the tracer, not about whether the code runs: a class resolved out of the container, fronted by a facade, named as a string in config, or built by reflection is alive and still lands on the list. Every reference Brain *did* find — container binding, facade, `config/`, inherited by a reached class, named as a class-string elsewhere — is shown next to the class, so the two cases can be told apart. Service providers and exceptions, which Brain has no call edge for at all, are filed in a section of their own rather than mixed in.
```php
// config/laravel-brain.php — on by default
'reachability' => [
'enabled' => false, // or LARAVEL_BRAIN_REACHABILITY_ENABLED=false
],
```

## Graph Node Types

| Node | Accent Color | Represents |
Expand All @@ -530,6 +541,8 @@ unqualified label to `action_class` and calls `action` a **Controller action**.
| Filament Page Method | Pink `#E879F9` | Method on a Filament page |
| Filament Widget | Cyan `#06B6D4` | Filament widget class |
| Filament Relation Manager | Teal `#0891B2` | Filament relation manager |
| Entry Point | Cyan `#22D3EE` | A root on the Reachability tab |
| Not Reached | Grey `#94A3B8` | A class no entry point's chain arrives at. Grey on purpose — it is a question, not a verdict |

> **Note:** Command, Schedule, Channel, and Repository nodes are discovered and added to the graph but use the closest matching accent color from their parent type.

Expand Down
38 changes: 38 additions & 0 deletions config/laravel-brain.php
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,44 @@
'src',
],

// -------------------------------------------------------------------------
// Reachability
// -------------------------------------------------------------------------
// The "Reachability" tab: an inventory of every entry point the application can be
// entered from (routes, console commands, scheduled entries, broadcast channels, queued
// listeners, Filament panels/resources/pages), and the classes under `source_paths` that
// no entry point's traced call chain arrives at.
//
// Every other tab is grown forward from one entry point, so a gap in the graph is
// invisible from inside it. This is the inverse view — what exists, and what nothing
// reaches.
//
// Read what it reports carefully: "nothing reaches this from a traced entry point" is a
// statement about the tracer, not about whether the code runs. A class resolved out of
// the container, fronted by a facade, named as a string in config, or built by reflection
// is alive and still lands on the list, which is why every reference Brain *did* find is
// shown next to the class. It is not a dead-code report and must not be read as one.
//
// Cost: one extra parse pass over `source_paths` and over `config/`, on top of the pass a
// scan already makes. Turn it off if you do not want it.
//
// Override via the LARAVEL_BRAIN_REACHABILITY_ENABLED env variable.
//
'reachability' => [
// Off by default, which is the one setting here that is a judgement rather than a
// fact. 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:
//
// application A build parses 746 files, inventory 4,416 scan ×3.0
// application B 7,546 source files, nearly all already parsed +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: on a large modular application 79-90% of declared classes
// come back unreached, and a list that long is read once and then ignored. Turn it on
// when you are hunting for what nothing reaches; leave it off for a scan you run often.
'enabled' => env('LARAVEL_BRAIN_REACHABILITY_ENABLED', false),
],

// -------------------------------------------------------------------------
// Watch Paths
// -------------------------------------------------------------------------
Expand Down
38 changes: 38 additions & 0 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,42 @@ Agents are ordinary application classes, so the scan follows `source_paths` by d

`enabled` is a second, independent switch: it answers "this application uses the SDK and I still do not want it on the graph", which the string prefilter above cannot. Turning it off skips the pass before the directory scan, so nothing is read and nothing is parsed.


## Reachability

Every other tab is grown forward from one entry point, which means a gap in the graph is invisible from inside it. Measured on one application, the graph knew 45 of its 211 event classes and 27 of its 113 job classes, and no screen said so.

The **Reachability** tab is the inverse view. It has three sections:

1. **Entry points**, grouped by kind — routes, console commands, scheduled entries, broadcast channels, queued listeners, Filament panels/resources/pages. Nothing in the application is reachable except through one of these, so their inventory is the denominator for everything below.
2. **Nothing reaches these from an entry point**, grouped by kind, largest group first — so "17 jobs nothing dispatches" is answerable at a glance.
3. **Outside what the tracer follows** — service providers and exceptions, kinds Brain has no call edge for at all. The framework boots a provider and an exception is thrown rather than called, so their absence from the graph is the expected outcome and says nothing either way. They are kept apart so they do not bury the section above.

::: warning This is not a dead-code report
"Nothing reaches this from a traced entry point" is a statement about the tracer, not about whether the code runs. A class resolved out of the container, fronted by a facade, named as a string in config, or built by reflection is alive and will still land on the list.

Every reference Brain *did* find is shown next to the class, so you can tell the two apart:

| Shown as | Means |
|----------|-------|
| bound in the container | named as the abstract or the concrete of a `bind()` / `singleton()` in a provider |
| reached through a facade | is a facade, or the class one resolves to |
| named in config/ | appears as `Foo::class` or a quoted FQCN under `config/` |
| inherited by a class that is reached | a class the tracer did reach extends it, implements it, or uses it as a trait |
| named as a class-string elsewhere | appears as `Foo::class` or a quoted FQCN in another source file |

A class with none of these is the one worth opening first — and still worth opening rather than deleting.
:::

The tab reads the classes declared under [`source_paths`](#source-paths-and-watch-mode) and costs one extra parse pass over them plus `config/`. Turn it off with:

```php
// config/laravel-brain.php
'reachability' => [
'enabled' => false, // or LARAVEL_BRAIN_REACHABILITY_ENABLED=false
],
```

## Graph Node Types

| Node | Accent Color | Represents |
Expand All @@ -335,6 +371,8 @@ Agents are ordinary application classes, so the scan follows `source_paths` by d
| Filament Page Method | <span class="color-dot" style="background:#E879F9"></span> Pink `#E879F9` | Method on a Filament page |
| Filament Widget | <span class="color-dot" style="background:#06B6D4"></span> Cyan `#06B6D4` | Filament widget class |
| Filament Relation Manager | <span class="color-dot" style="background:#0891B2"></span> Teal `#0891B2` | Filament relation manager |
| Entry Point | <span class="color-dot" style="background:#22D3EE"></span> Cyan `#22D3EE` | A root on the Reachability tab |
| Not Reached | <span class="color-dot" style="background:#94A3B8"></span> Grey `#94A3B8` | A class no entry point's chain arrives at. Grey on purpose — it is a question, not a verdict |

::: tip Note
Command, Schedule, Channel, and Repository nodes are discovered and added to the graph but use the closest matching accent color from their parent type.
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,16 @@ body::before {
.sidebar-section { padding: 12px 16px; border-top: 1px solid var(--border); }
.sidebar-section h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--dim); margin-bottom: 8px; }

/* Reachability tab — the caveat that has to travel with every unreached class. */
.reachability-note { font-size: 12px; line-height: 1.5; color: var(--dim); margin-bottom: 8px; }
.reachability-note:last-child { margin-bottom: 0; }
.reachability-references { list-style: none; margin: 0; padding: 0; font-size: 12px; }
.reachability-references li {
padding: 4px 8px; margin-bottom: 4px; border-radius: 4px;
border: 1px solid var(--border); background: var(--panel);
}
.reachability-references li:last-child { margin-bottom: 0; }

.sidebar-structure-list { list-style: none; margin: 0; padding: 0; font-size: 12px; }
.sidebar-structure-item {
display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline;
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const ALL_TYPES: GraphNode['type'][] = [
'view', 'mail', 'notification', 'enum', 'interface', 'trait', 'abstract_class', 'service_provider',
'ai_agent', 'ai_tool',
'filament_panel', 'filament_resource', 'filament_page', 'filament_page_method', 'filament_widget', 'filament_relation_manager',
// The inventory's own kinds. Membership here is what makes a type filterable at all, and
// `edgeVisible` reads it too — a type left out has no visible edges, so folding a group
// finds no children to fold.
'entry_point', 'entry_point_group', 'unreached_class', 'unreached_group',
]

/**
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/FilterPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ const TYPE_LABELS: Partial<Record<GraphNode['type'], string>> = {
filament_page_method: 'F. Methods',
filament_widget: 'F. Widgets',
filament_relation_manager: 'F. Relations',
entry_point: 'Entry points',
entry_point_group: 'Entry groups',
unreached_class: 'Not reached',
unreached_group: 'Unreached groups',
}

// Stable order matching App.tsx ALL_TYPES
Expand All @@ -48,6 +52,7 @@ const ORDER: GraphNode['type'][] = [
'service_provider', 'facade', 'ai_agent', 'ai_tool',
'filament_panel', 'filament_resource', 'filament_page',
'filament_page_method', 'filament_widget', 'filament_relation_manager',
'entry_point', 'entry_point_group', 'unreached_class', 'unreached_group',
]

/**
Expand Down
35 changes: 31 additions & 4 deletions frontend/src/components/GraphView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,17 @@ interface Props {
* misread, this is the one function to look at, and logging
* `{deltaX, deltaY, deltaMode, wheelDeltaY}` from the real hardware is what decides it.
*/
/**
* The nodes a tab asks to open folded.
*
* The flag is set by the splitter, not guessed from a node's size or its child count here: only
* the pass that built the tab knows whether its groups are the point of the screen or an
* incidental grouping the reader still wants to see through.
*/
function defaultCollapsed(ns: LayoutNode[]): Set<string> {
return new Set(ns.filter((n) => n.data?.collapsedByDefault === true).map((n) => n.id))
}

function isTrackpadPan(ev: WheelEvent): boolean {
if (ev.ctrlKey) return false
if (ev.deltaX !== 0) return true
Expand Down Expand Up @@ -438,14 +449,23 @@ export function GraphView({
const isDraggingRef = useRef(false)

// ── Collapse state ─────────────────────────────────────────────────────────
const [collapsedNodes, setCollapsedNodes] = useState<Set<string>>(new Set())
//
// A tab may open with parts of itself already folded. The inventory needs it: it holds one
// node per class nothing reaches, which on a real application is a few thousand of them, and
// a canvas that draws them all is unreadable at any zoom — not because the layout packs them
// badly, but because a list of three thousand names was never a picture. Folded to its
// groups it opens as a couple of dozen nodes, each saying how many it holds, and the reader
// opens the one they came for.
const [collapsedNodes, setCollapsedNodes] = useState<Set<string>>(
() => defaultCollapsed(nodes),
)

// Reset drag and collapse state when nodes change (during render, not in an effect).
const [prevNodes, setPrevNodes] = useState(nodes)
if (prevNodes !== nodes) {
setPrevNodes(nodes)
setDraggedPositions(new Map())
setCollapsedNodes(new Set())
setCollapsedNodes(defaultCollapsed(nodes))
}

const effectiveNodes = useMemo(() => {
Expand Down Expand Up @@ -1100,11 +1120,18 @@ export function GraphView({
const zb = zoomBehaviorRef.current
if (!svg || !container || !zb || !nodes.length) return

// Only what is drawn. A folded node keeps its descendants in the layout — they still hold
// positions, they are simply not rendered — so measuring every node fits the view to a
// picture nobody is looking at. On the inventory that is the difference between framing
// twenty-eight groups and framing four thousand classes behind them.
const shown = nodes.filter((n) => !hiddenNodeIds.has(n.id))
const framed = shown.length ? shown : nodes

let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const n of nodes) {
for (const n of framed) {
minX = Math.min(minX, n.x - n.width / 2)
maxX = Math.max(maxX, n.x + n.width / 2)
minY = Math.min(minY, n.y - n.height / 2)
Expand All @@ -1122,7 +1149,7 @@ export function GraphView({
const ty = h / 2 - scale * cy
const tr = zoomIdentity.translate(tx, ty).scale(scale)
select(svg).call(zb.transform, tr)
}, [nodes])
}, [nodes, hiddenNodeIds])

const zoomBy = useCallback((factor: number) => {
const svg = svgRef.current
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/LeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ const CATEGORY_ICONS: Record<string, IconKey> = {
'Model ERD': 'box',
'Event Choreography': 'zap',
'AI Agents': 'zap',
Reachability: 'search',
Other: 'route',
}

Expand All @@ -346,6 +347,7 @@ function categoryBucket(tab: TabEntry): string {
if (tab.category === 'ERD') return 'Model ERD'
if (tab.category === 'Events') return 'Event Choreography'
if (tab.category === 'AI') return 'AI Agents'
if (tab.category === 'Reachability') return 'Reachability'
if (tab.category === 'Filament') {
const p = tab.panelId ?? ''
return p ? `Filament · ${p.charAt(0).toUpperCase()}${p.slice(1)} Panel` : 'Filament'
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/Legend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ const NODE_TYPES = [
{ type: 'filament_page', label: 'Filament Page', color: '#C084FC' },
{ type: 'filament_widget', label: 'Filament Widget', color: '#06B6D4' },
{ type: 'filament_relation_manager', label: 'Relation Manager', color: '#0891B2' },
{ type: 'entry_point', label: 'Entry point', color: '#22D3EE' },
{ type: 'unreached_class', label: 'Not reached', color: '#94A3B8' },
{ type: 'entry_point_group', label: 'Entry group', color: '#0E7490' },
{ type: 'unreached_group', label: 'Unreached group', color: '#475569' },
]

export function Legend() {
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useMemo, useRef, useCallback, useState } from 'react'
import type { GraphData, GraphNode, GraphEdge, FlowStep, DbQuery, CacheOperation, HttpCall } from '../types/graph'
import { UNFOLLOWABLE_REFERENCE_LABELS } from '../types/graph'
import { SECURITY_EXPOSURE_COLORS, SECURITY_EXPOSURE_COLORS_LIGHT, SECURITY_RISK_COLORS, SECURITY_ISSUE_META, SECURITY_SEVERITY_LABELS } from '../utils/graphConstants'
import { FlowchartView } from './FlowchartView'
import { FlowchartModal } from './FlowchartModal'
Expand All @@ -25,6 +26,10 @@ interface Props {
}

const TYPE_COLORS: Record<string, string> = {
entry_point: '#22D3EE',
entry_point_group: '#0E7490',
unreached_class: '#94A3B8',
unreached_group: '#475569',
route: '#4CAF50',
middleware: '#FF9800',
controller: '#2196F3',
Expand Down Expand Up @@ -343,6 +348,8 @@ export function Sidebar({ selectedId, graphData, theme, onClose, onStressChange
key !== 'job' &&
key !== 'deferredDefect' &&
key !== 'deferredDefectMessage' &&
key !== 'note' &&
key !== 'unfollowableReferences' &&
!(Array.isArray(val) && val.length === 0)
)

Expand All @@ -353,6 +360,14 @@ export function Sidebar({ selectedId, graphData, theme, onClose, onStressChange
const listener = node.data?.listener as import('../types/graph').ListenerNodeData | undefined
const job = node.data?.job as import('../types/graph').JobNodeData | undefined

// The Reachability tab's caveat, rendered next to the class rather than left in a heading
// three levels up the tree. A reader who clicks a class and is told only that nothing
// reaches it has been told the wrong thing.
const reachabilityNote = typeof node.data?.note === 'string' ? node.data.note : ''
const unfollowableReferences = Array.isArray(node.data?.unfollowableReferences)
? (node.data.unfollowableReferences as string[])
: []

const hasFlow = flowSteps.length > 0 || !!sequenceDiagram
const hasSource = !!filePath
const hasEdges = incomingEdges.length > 0 || outgoingEdges.length > 0
Expand Down Expand Up @@ -1023,6 +1038,25 @@ export function Sidebar({ selectedId, graphData, theme, onClose, onStressChange
</div>
)}

{reachabilityNote !== '' && (
<div className="sidebar-section">
<h3>What this means</h3>
<p className="reachability-note">{reachabilityNote}</p>
{unfollowableReferences.length > 0 && (
<>
<p className="reachability-note">
Brain did find this class referenced, in ways it cannot follow:
</p>
<ul className="reachability-references">
{unfollowableReferences.map(ref => (
<li key={ref}>{UNFOLLOWABLE_REFERENCE_LABELS[ref] ?? ref}</li>
))}
</ul>
</>
)}
</div>
)}

{erd && (
<div className="sidebar-section">
<h3>Model Schema</h3>
Expand Down
Loading
Loading