hellpat / watson
PR blast-radius analyser for PHP. Standalone dev-only CLI that introspects Symfony / Laravel apps from the outside. Reports which routes / commands / jobs / listeners / tests a git diff reaches.
Requires
- php: ^8.4
- ext-json: *
- roave/better-reflection: ^6.0
- symfony/console: ^6.4|^7.0|^8.0
- symfony/process: ^6.4|^7.0|^8.0
Requires (Dev)
- behat/behat: ^3.14
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-05-04 21:00:36 UTC
README
PR blast-radius analyzer for PHP. Standalone dev-only CLI that introspects Symfony / Laravel apps from the outside. Reports which routes, commands, jobs, message handlers, and tests a diff actually reaches.
composer require --dev hellpat/watson
git diff --name-only origin/main...HEAD | vendor/bin/watson blastradius --format=md
watson does not shell out to git. You pipe a file list (or a unified diff) in, watson tells you which framework entry points are reached. Works with git diff, svn diff, GitHub-Action diff payloads, or a hand-curated list.
Output formats
Same data, four shapes. Pick the one that fits the consumer.
--format=text (default — human terminal)
=====================================================================
watson php symfony (root: /abs/path)
=====================================================================
[list-entrypoints]
3 entry point(s):
- symfony.route home App\Controller\HelloController::home
- symfony.command app:ping App\Command\PingCommand::execute
- symfony.message_handler App\Message\Ping App\MessageHandler\PingHandler::__invoke
--format=md (markdown for PR descriptions / LLM prompts)
# watson — php symfony _tool watson v0.3.0_ Root: `/abs/path` ## list-entrypoints _v0.3.0_ **3 entry points** | kind | name | handler | |---|---|---| | `symfony.route` | `home` | `App\Controller\HelloController::home` (`src/Controller/HelloController.php:12`) | | `symfony.command` | `app:ping` | `App\Command\PingCommand::execute` (`src/Command/PingCommand.php:15`) | | `symfony.message_handler` | `App\Message\PingMessage` | `App\MessageHandler\PingHandler::__invoke` (`src/MessageHandler/PingHandler.php:13`) |
--format=json (machine contract)
{
"tool": "watson",
"version": "0.3.0",
"language": "php",
"framework": "symfony",
"context": {"root": "/abs/path"},
"analyses": [
{
"name": "list-entrypoints",
"version": "0.3.0",
"ok": true,
"result": {
"entry_points": [
{
"kind": "symfony.route",
"name": "home",
"handler_fqn": "App\\Controller\\HelloController::home",
"handler_path": "/abs/path/src/Controller/HelloController.php",
"handler_line": 12,
"source": "runtime",
"extra": {"path": "/", "methods": ["GET"]}
}
]
}
}
]
}
--format=tok (token-optimized for LLM pipes)
Tab-separated, no JSON keys, no whitespace padding. Header lines start with #. Per-row layout: kind \t name \t handler_fqn \t relative/path:line \t extra (extra is HTTP-method + path for routes, message FQN for handlers, empty otherwise).
# watson 0.3.0 list-entrypoints php/symfony root=/abs/path
# entrypoints=3
# kinds: sc=symfony.command smh=symfony.message_handler sr=symfony.route
# fields: kind\tname\thandler\tpath:line\textra
sr home App\Controller\HelloController::home src/Controller/HelloController.php:12 GET /
sc app:ping App\Command\PingCommand::execute src/Command/PingCommand.php:15
smh App\Message\PingMessage App\MessageHandler\PingHandler::__invoke src/MessageHandler/PingHandler.php:13
Roughly half the token cost of pretty-printed JSON.
Recipes
Each block below is a description followed by the command. All examples assume composer require --dev hellpat/watson is done.
Pre-merge — review prompts piped to an LLM
# 1. Auto-review focused only on what changed # LLM is told the affected entry points; flags risky areas. git diff --name-only origin/main...HEAD | vendor/bin/watson blastradius --format=md | llm \ --system "Review this PR. Focus only on the affected entry points listed below. Flag anything risky around auth, money handling, or user-visible behaviour." # 2. Generate a manual testing guide # Turns the blast radius into a concrete click-through checklist for QA. git diff --name-only origin/main...HEAD | vendor/bin/watson blastradius --format=md | llm \ --system "You are a senior dev. Given these affected entry points, write a concise manual testing guide: list the scenarios a reviewer must click through, the edge cases most likely to break, and any data shape that needs verifying." # 3. Coverage gap check — is the change covered by e2e / feature tests? # `--scope=all` includes phpunit.test entries so the LLM can cross-reference. git diff --name-only origin/main...HEAD | vendor/bin/watson blastradius --scope=all --format=json | llm \ --system "The JSON contains affected entry points (routes / commands / jobs / message handlers) AND every phpunit.test in the repo. Cross-reference: which affected entry points have at least one test that exercises them, and which don't? Output a markdown table; flag gaps as 'NEEDS COVERAGE'." # 4. Tight CI loop — routes only, one-line summary # `--scope=routes` skips the messenger / jobs / tests scans. git diff --name-only origin/main...HEAD | vendor/bin/watson blastradius --scope=routes --format=md | llm \ --system "Summarise which user-facing routes change in this PR. One line each." # 5. Risk-rank the change # Same input as (1), different rubric. git diff --name-only origin/main...HEAD | vendor/bin/watson blastradius --format=md | llm \ --system "Rate this PR's risk (low / med / high) and explain in 3 bullets. Consider: blast radius across kinds, whether async paths (jobs / message handlers) are involved, whether a test exists for every affected route."
Post-release — observability MCP correlation
After a deploy, pipe the just-shipped entry points into an LLM that has an observability MCP server wired up — e.g. Better Stack MCP (claude mcp add betterstack --transport http https://mcp.betterstack.com). The LLM gets the surface that changed and live metrics — it can correlate the two without you copy-pasting route names into a dashboard.
# 1. Latency regression on routes that shipped in the last release # Diffs two release tags so you only ask about routes that actually changed. git diff --name-only v1.4.0..v1.5.0 | vendor/bin/watson blastradius --scope=routes --format=md \ --base=v1.4.0 --head=v1.5.0 | llm \ --system "These routes shipped in v1.5.0. Use Better Stack MCP: for each route, query p50 / p95 latency since the deploy timestamp and compare to the previous 24h baseline. Flag any route whose p95 grew >20% or whose error rate doubled." # 2. Error / exception regression after deploy # Wider scope so jobs and message handlers are also checked for new exceptions. git diff --name-only v1.4.0..v1.5.0 | vendor/bin/watson blastradius --format=md \ --base=v1.4.0 --head=v1.5.0 | llm \ --system "These entry points (routes / commands / jobs / message handlers) shipped in v1.5.0. Use Better Stack MCP error tracking to: - list new exception classes seen on any affected handler since deploy, - count occurrences vs the prior 24h, - group by handler FQN and rank by impact." # 3. Open incidents touching the changed surface # Uses list-entrypoints (the full registry) since incidents may not align # with a specific release window. vendor/bin/watson list-entrypoints --scope=routes --format=md | llm \ --system "Use Better Stack MCP to list all currently-open incidents. For each incident, identify which (if any) of the entry points below is involved. Output a markdown table mapping incident → affected entry point with a one-line summary."
Install
composer require --dev hellpat/watson
No bundle, no service provider, no config/bundles.php entry. watson auto-detects Symfony vs Laravel by walking up from CWD looking for bin/console or artisan.
Requirements: PHP 8.4+. Symfony 6.4 / 7.x / 8.x or Laravel 10 / 11 / 12. git is not a watson dependency — watson only reads the diff you pipe in. If your diff source is git, you'll have it for that reason.
Commands
watson blastradius
Reads a list of changed files from stdin (or --files=) and reports which entry points reach them. watson does not run git; the caller is responsible for picking the diff source.
| input shape | when to use |
|---|---|
git diff --name-only <a>...<b> | watson blastradius |
most common — pipe git diff --name-only output as one path per line |
git diff <a>...<b> | watson blastradius --unified-diff |
full unified diff on stdin; watson extracts post-image filenames |
watson blastradius --files=path/a --files=path/b |
no git involved — pre-computed list, GitHub-Action payload, etc. |
git diff --cached --name-only | watson blastradius |
staged-only review — --cached lives on the caller's git, not watson |
When run in an interactive shell with no input piped and no --files, watson exits with a usage hint instead of silently producing zero results.
watson list-entrypoints
Snapshot every entry point the framework has registered: routes, commands, message handlers, jobs (Laravel), tests. Same options as blastradius, minus the diff-input flags.
watson <cmd> --help
$ watson blastradius --help
Description:
Report which routes, commands, jobs, and message handlers are reached by
a list of changed files (read from stdin, or --files=).
Usage:
blastradius [options]
Options:
--files=FILES Explicit file path (repeatable, or comma-separated).
Bypasses stdin. (multiple values allowed)
--unified-diff Parse stdin as a unified diff (e.g. `git diff …`) instead
of a newline-separated path list.
--base=BASE Cosmetic label shown as the diff base in rendered output
--head=HEAD Cosmetic label shown as the diff head in rendered output
--project=PROJECT Project root (defaults to walking up from CWD)
--format=FORMAT text (human terminal) | md (PRs/LLMs) | json (machine)
| tok (token-optimized for LLM pipes) [default: "text"]
--scope=SCOPE routes (cheapest) | all (+ commands / jobs / message
handlers / tests) [default: "all"]
--app-env=APP-ENV APP_ENV passed to bin/console / artisan [default: "dev"]
$ watson list-entrypoints --help
Description:
Snapshot every route, command, job, message handler, and test the framework
has registered.
Usage:
list-entrypoints [options]
Options:
--project=PROJECT Project root (defaults to walking up from CWD)
--format=FORMAT text (human terminal) | md (PRs/LLMs) | json (machine)
| tok (token-optimized for LLM pipes) [default: "text"]
--scope=SCOPE routes (cheapest) | all (+ commands / jobs / message
handlers / tests) [default: "all"]
--app-env=APP-ENV APP_ENV passed to bin/console / artisan [default: "dev"]
How watson reads your app
| kind | source |
|---|---|
symfony.route |
bin/console debug:router --format=json |
symfony.command |
bin/console debug:container --tag=console.command --format=json (vendor filtered) |
symfony.message_handler |
bin/console debug:container --tag=messenger.message_handler --format=json (vendor filtered, message inferred via reflection on the handler's first param when the tag's handles is null) |
laravel.route |
php artisan route:list --json |
laravel.command |
inline php -r runner that boots Laravel and dumps Artisan::all() (vendor filtered) |
laravel.job |
AST scan of app/Jobs/ for ShouldQueue implementers |
phpunit.test |
AST scan of tests/ for PHPUnit\Framework\TestCase subclasses |
watson is a CLI binary, not a bundle/provider. AST scans go through roave/better-reflection — watson never require_onces your app's source.
Pipeline
flowchart TD
GD["git diff"] --> CF["changed files"]
APP["target app"] --> ASK["ask framework<br/>(debug:router, debug:container,<br/>route:list, Laravel boot)"]
ASK --> EP["entry points"]
EP --> AST["AST → handler file:line<br/>(Better Reflection)"]
CF --> X(("intersect"))
AST --> X
X --> AEP["affected entry points"]
AEP --> R["render<br/>text / md / json / tok"]
Loading
License
MIT. See LICENSE.