{"id":51791705,"url":"https://github.com/sandermuller/richter","last_synced_at":"2026-07-23T01:00:37.378Z","repository":{"id":371964962,"uuid":"1302646214","full_name":"SanderMuller/richter","owner":"SanderMuller","description":null,"archived":false,"fork":false,"pushed_at":"2026-07-21T19:22:34.000Z","size":1900,"stargazers_count":3,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-22T00:33:06.744Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/SanderMuller.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-07-16T10:28:36.000Z","updated_at":"2026-07-21T19:24:32.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/SanderMuller/richter","commit_stats":null,"previous_names":["sandermuller/richter"],"tags_count":10,"template":false,"template_full_name":null,"purl":"pkg:github/SanderMuller/richter","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/SanderMuller%2Frichter","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/SanderMuller%2Frichter/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/SanderMuller%2Frichter/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/SanderMuller%2Frichter/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/SanderMuller","download_url":"https://codeload.github.com/SanderMuller/richter/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/SanderMuller%2Frichter/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35783461,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-07-20T02:08:10.276Z","status":"online","status_checked_at":"2026-07-22T02:00:06.236Z","response_time":124,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-07-20T23:00:28.473Z","updated_at":"2026-07-23T01:00:37.341Z","avatar_url":"https://github.com/SanderMuller.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"![Richter: measure the reach of a code change](richter.png)\n\n# Richter\n\n[![Latest Version on Packagist](https://img.shields.io/packagist/v/sandermuller/richter.svg?style=flat-square)](https://packagist.org/packages/sandermuller/richter)\n[![Tests](https://img.shields.io/github/actions/workflow/status/SanderMuller/richter/run-tests.yml?branch=main\u0026label=tests\u0026style=flat-square)](https://github.com/SanderMuller/richter/actions/workflows/run-tests.yml)\n[![PHPStan](https://img.shields.io/github/actions/workflow/status/SanderMuller/richter/phpstan.yml?branch=main\u0026label=phpstan\u0026style=flat-square)](https://github.com/SanderMuller/richter/actions/workflows/phpstan.yml)\n[![Total Downloads](https://img.shields.io/packagist/dt/sandermuller/richter.svg?style=flat-square)](https://packagist.org/packages/sandermuller/richter)\n[![License](https://img.shields.io/packagist/l/sandermuller/richter.svg?style=flat-square)](LICENSE)\n[![Laravel Compatibility](https://badge.laravel.cloud/badge/sandermuller/richter?style=flat)](https://packagist.org/packages/sandermuller/richter)\n\nMeasures the magnitude of impact of code changes in a Laravel codebase. Like the Richter scale, but for your PHP.\n\nBuilt on [Laravel Brain](https://github.com/laramint/laravel-brain)'s static analysis, Richter constructs a directed code graph of your application (routes, controllers, jobs, listeners, policies, resources, Blade views, Eloquent relations). It reads two things off that graph:\n\n- **The blast radius of a symbol:** its callers (what breaks if you change it) and its dependencies (what it reaches).\n- **What the current branch diff touches:** the HTTP/CLI entry points and flows the changed files reach, plus a coarse risk level.\n\nYou can use those results three ways:\n\n- **CLI:** a self-review aid before you push.\n- **MCP:** Richter registers a local `richter` server exposing both analyses read-only, so a coding agent can check a symbol's blast radius or triage the branch diff mid-review without shelling out.\n- **CI and PR review:** run `richter:detect-changes` against the pull request's base ref and post the report for the reviewer, human or agent.\n\nRichter is advisory by default: `richter:detect-changes` exits 0, and a low or empty result is a signal, not a guarantee of no impact. Opt into a CI gate with `--fail-on` / `--fail-on-unresolved` when you want a non-zero exit (see [Gating in CI](#gating-in-ci)).\n\n## What it's for\n\nRichter shows what a change reaches, before you or your reviewer have to guess.\n\n- **Catch what you missed, before review.** Run `richter:detect-changes` on your branch and read the entry points and flows the diff reaches. Anything you didn't expect it to touch is worth a look before you open the PR.\n- **Turn reach into a test-coverage prompt.** Every reached entry point is tagged `[test-referenced]` or `[⚠ no test references this]`, and a referenced entry point whose referencing tests contain no behavioural assertion the scan recognises is tagged `[test-referenced — no behavioural assertion found]` — a heuristic prompt, not a coverage verdict. An entry point whose behaviour you changed with nothing referencing it is a place to add a test; the tag flags a missing reference, not proof the code is untested.\n- **Hand the reviewer your blast radius.** Drop the report into the pull request description, or let a coding agent read it over MCP, so review starts from what the change reaches instead of a cold diff.\n- **Size a refactor first.** Before you rename or rework a symbol, `richter:impact \"App\\Models\\User\"` lists its callers (what breaks if you change it) and its dependencies (what it reaches).\n\nThe analysis never executes your application's routes, jobs, or commands. It is static analysis over a code graph, fast enough to run on every branch. It does, however, autoload classes from the analyzed checkout (to resolve constants, relation names, and queue interfaces), and autoloading runs a file's top-level code. Treat a checkout you would not `composer install` on as one you should not analyze either.\n\n## Coverage beyond Brain\n\nRichter adds two things over Laravel Brain alone: the tooling above (CLI, MCP, and CI/PR review) and wider graph coverage. On coverage, it traces the edges a route-anchored analysis misses:\n\n- queue dispatches, including unresolvable ones;\n- `$listen`-registered event listeners;\n- container bindings and interface implementations;\n- policy references (`$user-\u003ecan(PostPolicy::UPDATE, …)` and `@can(...)` in Blade);\n- API resource composition;\n- custom validation rules;\n- trait usage;\n- eager-load relation strings;\n- view-to-view includes;\n- frontend endpoint references — Wayfinder imports, Ziggy calls, endpoint literals in changed TS/JS/Vue files and Blade inline scripts (opt-in, see [Frontend changes](#frontend-changes-wayfinder--ziggy)).\n\n## Installation\n\n```bash\ncomposer require --dev sandermuller/richter\n```\n\nRequires PHP 8.4+ and Laravel 12 or 13. Optionally publish the config:\n\n```bash\nphp artisan vendor:publish --tag=richter-config\n```\n\n## Usage\n\n### Blast radius of a symbol\n\n```bash\nphp artisan richter:impact \"App\\Services\\PostPublisher\"\nphp artisan richter:impact PostPublisher                     # substrings work too\nphp artisan richter:impact \"App\\Services\\PostPublisher\" --json       # machine-readable, for scripting\nphp artisan richter:impact \"App\\Services\\PostPublisher\" --markdown   # PR-ready markdown\n```\n\nPrints the symbol's callers (what breaks if you change it) and its dependencies (what it reaches), breadth-first. Each hop shows its depth (`d1`, `d2`, …) and the edge it was reached through, so a caller chain reads back to the entry point one hop at a time:\n\n```text\nCallers (what breaks if you change \"App\\Services\\PostPublisher\"):\n  d1  App\\Http\\Controllers\\PostController::publish  (via action-to-service)  — app/Http/Controllers/PostController.php\n  d2  App\\Http\\Controllers\\PostController  (via controller-to-action)  — app/Http/Controllers/PostController.php\n  d3  route::POST::/posts/{post}/publish  (via route-to-controller)  — routes/web.php:24\n\nDependencies (what \"App\\Services\\PostPublisher\" reaches):\n  d1  App\\Events\\PostPublished  (via action-to-event)  — app/Events/PostPublished.php\n```\n\nEvery hop carries its defining file (and line, when known), project-relative — no grepping to find what a report names.\n\nWith `--json`, stdout is a single document (`{target, callers, dependencies}`, each hop `{depth, node, via, file?, line?}`), or `{\"error\": \"…\"}` on failure.\n\n### Advisory change impact of the current diff\n\n```bash\nphp artisan richter:detect-changes                        # diffs against richter.default_base\nphp artisan richter:detect-changes --base=origin/develop\nphp artisan richter:detect-changes --explain              # show how each entry point reaches the change\nphp artisan richter:detect-changes --json                 # machine-readable, for scripting or CI\nphp artisan richter:detect-changes --markdown             # PR-ready markdown, for descriptions and comments\n```\n\nAgainst the default `HEAD`, the diff is the working tree compared to the merge-base with `--base` — staged and unstaged edits are included, not just what's committed, so running this before you commit still sees your changes. (Passing an explicit non-`HEAD` ref instead replays that ref's committed tree.) The one gap `git diff` can't close is a brand-new file that was never `git add`-ed: it shows in no diff form, so a stderr-only note flags any such untracked file under `app/`, `resources/views/`, or a configured frontend root — never on stdout, so `--json`/`--markdown` output stays exactly the report.\n\nResolves which class members the branch changed (member-level, not file-level: a one-method change seeds that method, not the whole class), walks the graph, and reports:\n\n- the entry points the change can reach — routes, commands, jobs, listeners, middleware, and Livewire/Filament component classes (a Blade-mounted component or Filament resource/page/widget is a user-facing surface even without a `route::` node) — each tagged `[test-referenced]` or `[⚠ no test references this]`;\n- findings in the changed source itself, such as an eager-load or relation string that names no relation on any model. A missing comma between two relation constants is the classic case: `Post::OWNER . User::PROFILE` concatenates to `ownerprofile`, a name Eloquent silently never resolves;\n- a coarse risk level (`low` / `medium` / `high`);\n- honest degradation: a change that cannot be placed in the graph reads **UNRESOLVED**, never as a falsely reassuring \"no impact\", and an unfollowable dispatch makes a queue job read \"unknown\", not \"none\".\n\n```text\nChanged files:\n  app/Models/Post.php (4 graph nodes)\n  app/Services/CategoryImporter.php (0 graph nodes)  (coverage incomplete for this area — UNRESOLVED, not \"no impact\")\n\nEntry points reached: 2 (some changed files are in an area not yet graphed — see UNRESOLVED above)\n  - command::categories:sync  (app/Console/Commands/SyncCategories.php)  [test-referenced]\n  - route::PATCH::/api/posts/{post}  (routes/api.php:41)  [⚠ no test references this]  [authed]\n\nRelated models (association reach — context, not risk): 1\n  - App\\Models\\Category\n\nFindings (in the changed source itself):\n  ! app/Models/Post.php: eager-load string 'ownerprofile': segment 'ownerprofile' is not a method on any model — check the relation name (a broken constant concatenation reads exactly like this)\n\nImpacted nodes: 7\nRisk: MEDIUM (advisory — not a gate)\n```\n\nWith `--explain`, each reached entry point carries the shortest call chain down to the changed code. That is the difference between knowing a change reaches `PATCH /api/posts/{post}` and seeing exactly which controller and service carry it there:\n\n```text\nEntry points reached: 1\n  - route::PATCH::/api/posts/{post}  [⚠ no test references this]\n      ↳ route::PATCH::/api/posts/{post} →(route-to-controller) App\\Http\\Controllers\\PostController::update →(action-to-service) App\\Services\\PostPublisher::publish\n```\n\nA self-listed entry class (a changed job or listener that *is* the entry surface rather than being reached from the change) deliberately carries no chain.\n\nReached routes also inherit [Laravel Brain](https://github.com/laramint/laravel-brain)'s security surface as advisory annotation: the exposure level renders inline (`[public]`, `[guest]`, `[authed]`, `[admin]`) and any statically detected issues render under the route:\n\n```text\n  - route::POST::/webhooks/payments  (routes/api.php:12)  [⚠ no test references this]  [public]\n      ⚠ PUBLIC_WRITE (high): POST route with no auth middleware\n```\n\nThis is annotation only — it never feeds the risk level or a `--fail-on` gate, it exists for routes only (Brain classifies nothing else), and false positives are suppressed where Brain's own config says so (`laravel-brain.security.trusted_route_names` / `trusted_route_uris`). A Livewire, Filament, or queue entry point never carries one of these tags at all — that absence means *not classified*, never \"public\" or \"unauthenticated\"; its real exposure comes from mount-time `authorize()` calls, middleware, or route placement the graph doesn't model.\n\nPennant feature gating is annotated the same way. A route guarded by `EnsureFeaturesAreActive`\nrenders its flags inline (`[gated: ai-coach]`, a 🚩 badge in markdown, `entryPointGates` in JSON),\nand a changed member or Blade view that itself checks a flag (`Feature::active(...)`, `@feature`)\nnotes it under Findings — a flag-gated change has a smaller live blast radius than the raw graph\nsuggests, and the reviewer should know. Route detection reads statically visible middleware\n(a string alias like `'features:ai-coach'` or an FQCN-string form); the runtime-built\n`EnsureFeaturesAreActive::using(...)` expression is invisible to static route parsing. Only the\n`Feature` facade, `@feature`, and any `feature_gate_methods`-configured wrapper method are\nrecognised — a project convention like `FeatureToggle::BETA_DASHBOARD-\u003eisActive()` needs an\nallowlist entry (see [Configuration](#configuration)) before it is noted.\n\nWith `--markdown`, the report renders as GitHub-flavoured markdown: a risk badge up front, changed files as a table, entry points as a review checklist with their file:line, test tags and exposure badges, and long lists collapsed into `\u003cdetails\u003e` instead of truncated. The result is ready to paste into (or post onto) a pull request. `--markdown --explain` composes.\n\nWith `--json`, stdout is a single JSON document (the full, uncapped report) with these top-level keys, or `{\"error\": \"…\"}` if the diff can't be resolved:\n\n| Key | Type | Meaning |\n|---|---|---|\n| `base` | string | the ref the diff was taken against |\n| `changed` | object | `{file: graph-node count}` per changed file |\n| `coverage` | object | `{file: \"analyzed\" \\| \"unresolved\"}` per changed file |\n| `entryPoints` | string[] | entry-point nodes the change reaches |\n| `entryPointPaths` | object | per reached entry point, the shortest call chain down to the changed code as `{node, via, file?, line?}` hops; a self-listed entry class carries no chain |\n| `entryPointLocations` | object | per entry point, its defining `{file, line?}` (project-relative), when known |\n| `entryPointSecurity` | object | per reached route, Brain's security surface `{exposure, riskLevel, issues[]}` — advisory annotation, routes only, never an input to `risk` or the gate; a Livewire/Filament/queue entry point has no key here at all, meaning \"not classified,\" never \"public\" |\n| `entryPointGates` | object | per reached route, the Pennant feature flags gating it — advisory annotation, never an input to `risk` or the gate |\n| `entryPointTestReferences` | object | per reached entry point, `\"referenced\"` / `\"referenced-no-behavioural-assertion\"` / `\"unreferenced\"`; an entry point whose reference state cannot be determined is omitted from the map — advisory annotation, never an input to `risk`, the gate, or `affected-tests` selection |\n| `impacted` | int | count of risk-bearing nodes reached |\n| `relatedModels` | string[] | models reached only via association edges (context, not risk) |\n| `risk` | string | `\"low\"` / `\"medium\"` / `\"high\"` |\n| `lowConfidence` | bool | a changed member couldn't be pinned, so part of the estimate is coarse |\n| `coarseCapApplied` | bool | a low-confidence `high` was capped to `medium` |\n| `findings` | string[] | source-level findings, as shown above |\n| `unresolved` | bool | any changed file is UNRESOLVED |\n| `gate` | object | present only under a `--fail-on*` flag (see [Gating in CI](#gating-in-ci)) |\n\n#### Risk levels\n\nRisk is a coarse, advisory signal, deliberately simple so `--fail-on` stays predictable:\n\n| Level | Condition |\n|---|---|\n| `high` | ≥ 3 entry points reached, **or** ≥ 20 impacted nodes |\n| `medium` | ≥ 1 entry point reached, ≥ 5 impacted nodes, **or** the diff changes an entry-point class (job, listener, command, Livewire, observer, middleware) |\n| `low` | everything else |\n\nAssociation edges (model relationships, trait usage, `declares`) are reach and context, not risk. They never count toward the impacted-node total, so touching a hub model or trait can't saturate a change to `high` on breadth alone.\n\nA separate guard covers low confidence. When a changed member can't be pinned to a graph node and only a coarse class-level seed is available, a resulting `high` is capped to `medium` (`coarseCapApplied`). A low-confidence estimate shouldn't drive the top level on its own.\n\n### Gating in CI\n\n`detect-changes` is advisory by default (exit 0). Two opt-in flags turn it into a gate:\n\n- `--fail-on=\u003clow|medium|high\u003e` exits non-zero when the reported risk is at least that level (see [Risk levels](#risk-levels)).\n- `--fail-on-unresolved` exits non-zero when any changed file is **UNRESOLVED** (changed code the graph can't place). It works independently of the risk threshold.\n\nEither flag also fails an un-assessable diff (a broken or invalid base ref) rather than letting it pass as \"no impact\". Add `--json` and stdout carries a `gate` object alongside the report.\n\nA pull-request check that surfaces the blast radius and fails on high-risk or unplaceable changes:\n\n```yaml\nname: Impact\non: pull_request\n\njobs:\n  richter:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0   # detect-changes diffs against the base ref, so it must be in history\n      - uses: shivammathur/setup-php@v2\n        with:\n          php-version: '8.4'\n      - run: composer install --no-interaction --prefer-dist\n      - run: cp .env.example .env \u0026\u0026 php artisan key:generate   # detect-changes boots the app to build the graph\n      - run: php artisan richter:detect-changes --base=${{ github.event.pull_request.base.sha }} --fail-on=high --fail-on-unresolved\n```\n\nNo GitHub Action ships with the package. `detect-changes` is a plain Artisan command, so wire it into whatever pipeline you already run.\n\n\u003e **Note:** `detect-changes` runs `php artisan`, so it boots your Laravel application to build the graph. The job needs whatever booting the app normally requires: typically an `.env` (`cp .env.example .env`) and an `APP_KEY` (`php artisan key:generate`), as above. Without them the command fails to boot before it can analyse anything.\n\nThe workflow analyzes the pull request's code, and analysis autoloads classes from that checkout (see above). For a public repository, keep the trigger on `pull_request` (never `pull_request_target` with a privileged token) so fork-submitted code runs without access to your secrets.\n\n### Affected-test selection\n\n```bash\nphp artisan richter:affected-tests                        # human-readable selection\nphp artisan richter:affected-tests --base=origin/develop\nphp artisan richter:affected-tests --json                 # {base, determinable, reasons, tests, frontendTests, unreferencedEntryPoints}\nphp artisan test $(php artisan richter:affected-tests --plain)   # simple form — coarse but safe\n```\n\nDiffs the same way `detect-changes` does — against `HEAD`, staged and unstaged edits are included,\nso the selection reflects what's actually on disk before a commit exists to diff against. An\nuntracked (never `git add`-ed) file under `app/`, `resources/views/`, or a frontend root is one\n`git diff` cannot see (see above), so here it makes the selection **undeterminable** (exit 2) rather\nthan emit a narrowed set that silently omits it — the stderr note still fires, and `git add`-ing the\nfile includes it. The note is stderr-only, never on stdout, so `--plain`/`--json` stay clean.\n\nThe simple form only ever errs toward running more: both an undetermined selection and a\ndetermined-but-empty one leave `$(…)` empty, and an argument-less runner executes the full suite.\nTo also skip the run when the selection is determined and empty, branch on the exit code:\n\n```bash\ntests=$(php artisan richter:affected-tests --plain); status=$?\nif [ \"$status\" -eq 0 ] \u0026\u0026 [ -z \"$tests\" ]; then echo \"No affected tests.\"\nelif [ \"$status\" -eq 0 ]; then php artisan test $tests\nelse php artisan test; fi   # exit 2: not determinable — full suite\n```\n\nInverts the test-reference index into a selection: the test files that reference any entry point\nthe diff reaches, plus the tests that import any changed **or reached** class (a unit test of an\nintermediate caller never touches an entry point). A test naming a Livewire component by string\n(`Livewire::test('admin.dashboard')`, the `livewire()` helper) counts as referencing\n`App\\Livewire\\Admin\\Dashboard` via the default naming convention. A `schedule::` entry resolves\nthrough the command it runs. Only conventionally-named `*Test.php` files are selected — helpers and fixtures\nunder `tests/` never end up as runner arguments, and an entry point whose only references live in\na support trait blocks determination rather than silently dropping the tests using that trait.\nSelection is reference-based recall, not proof of coverage — reached entry points nothing\nreferences contribute nothing, and the report says how many those are.\n\nIt fails safe, and the exit code is the contract:\n\n| Exit | Meaning |\n|---|---|\n| `0` | Selection determined (possibly empty). |\n| `2` | **Not determinable — run the full suite.** Any UNRESOLVED file, low-confidence seed, unfollowable dispatch, uncheckable entry point, or an untracked relevant file `git diff` can't see trips this; the reasons are printed (text) or carried in `reasons` (JSON). |\n| `1` | Usage or unexpected error. |\n\nIn `--plain` mode an undeterminable run prints nothing, so the command-substitution form degrades\nto the full suite by construction — as does a determined-but-empty selection, which is why the\nexit-code branch above is the precise form.\n\n### Frontend changes (Wayfinder / Ziggy)\n\nOpt-in — point `frontend.roots` at your frontend source in `config/richter.php`:\n\n```php\n'frontend' =\u003e [\n    'roots' =\u003e ['resources/js'],\n],\n```\n\nChanged `.ts`/`.tsx`/`.js`/`.jsx`/`.vue` files are then scanned for the backend endpoints they\nreference, and those routes are reported as touched entry points — with their location, exposure\nand gate annotations, feeding `richter:affected-tests` — while `risk` and `impacted` stay\nuntouched: a frontend edit does not change backend behaviour, and the report says so explicitly.\nDetected references:\n\n- **[Wayfinder](https://github.com/laravel/wayfinder) imports** —\n  `@/actions/App/Http/Controllers/PostController` resolves through the router's action index\n  (method-precise; aliased, default, invokable and `import type` forms included), and\n  `@/routes/posts` route imports plus Ziggy `route('name')` calls resolve through the route\n  names. Wayfinder's generated trees (`actions/`, `routes/`, `wayfinder/` under each root) and\n  Ziggy's generated route map (`ziggy.js`) are excluded as regeneration churn, and `.d.ts`\n  declaration files are never scanned — see `frontend.generated_paths` above.\n- **Endpoint strings**, matched against the app's route templates: plain literals\n  (`axios.post('/posts')`) and backtick templates whose interpolations wildcard one segment\n  (`` fetch(`/posts/${id}`) `` matches `/posts/{post}`). A `/`-leading literal or template only\n  counts as the **first argument of an allowlisted HTTP/route callee** — `route`, `fetch`,\n  `axios`, `useFetch`, `$http`, `$` (jQuery), `window`, `page`/`cy` (Playwright/Cypress\n  navigation) by default, plus `frontend.http_callees` — matched on the callee's leading\n  identifier before a `.method` (`axios.get(...)`, `$http.post(...)`, `window.fetch(...)`,\n  `page.goto(...)`). A verb-named call pins the HTTP method, whether the verb is the callee\n  itself (`post('/x')`) or its `.method` segment (`axios.post('/x')`); anything unrecognisable\n  stays method-agnostic and never narrows the match. Inline `\u003cscript\u003e` blocks in changed Blade\n  views get the same literal scan. Gating on the callee means a constants file, nav-link config,\n  i18n helper (`translate('/preferences')`), or any other non-HTTP call is never mistaken for an\n  endpoint call — and a project-custom HTTP wrapper needs registering via `frontend.http_callees`\n  before its literals seed. A few idioms are a documented, deliberate recall loss: a URL assigned\n  to a variable and used later (`const URL = '/x'; fetch(URL)`), an options object's `url`\n  property (`axios({ url: '/x' })`), and the `request(method, url)` second-argument idiom (the\n  URI's callee can no longer be identified once it isn't the call's first argument).\n\nFrontend spec files (`*.test.*`, `*.spec.*`, `*.cy.*` under the roots, or `frontend.test_paths`)\nreferencing a touched route surface in `richter:affected-tests` as an advisory `frontendTests`\nlist for the JS runner — never in `--plain` (which feeds the PHP runner), and never a\ndeterminability input.\n\nThe scan is regex-based and says so when it can't see: a dynamic `route(`…`)` argument or an\nunmatched Wayfinder action import marks the file UNRESOLVED (and `richter:affected-tests` exits\n`2`), while an unmatched `route('name')` string simply isn't a reference — `routes/` modules and\n`route()` helpers collide with frontend-router idioms, so unmatched names never guess. Before a\ndynamic argument taints the file, it gets one resolution attempt against a same-module\n`const`/`enum` string constant (`route(ROUTES.player)` resolves when `ROUTES` is a flat `const`\nwith exactly one `player` member); anything less certain — `let`, multiple declarations, imported\nconstants, nested bodies — keeps the fail-safe.\n\nThe bridge also runs in reverse, without any configuration: a changed backend member that\nrenders an Inertia page (`Inertia::render('Posts/Show')`, the `inertia()` helper) is noted\nunder Findings with the resolved page file under `frontend.pages_path` — or with an explicit\n\"no page file found\" when the component doesn't resolve, which usually means a renamed or\ndeleted page.\n\n### Scoring accuracy against replayable history\n\n```bash\nphp artisan richter:benchmark\nphp artisan richter:benchmark --case=TICKET-123\nphp artisan richter:benchmark:add abc1234\nphp artisan richter:benchmark:add abc1234 --control\n```\n\nReplays historical fix commits (configured in `richter.benchmark_cases`) through the report: bug fixtures must resolve and reach an entry point; benign controls cap the risk a harmless change may report. Run it after changing the graph or tracers. A control flipping green→red is a regression in trustworthiness.\n\n`richter:benchmark:add` scaffolds a case from a historical fix commit: it dry-runs the commit through the same replay, reports what it would score today, and prints a paste-ready `benchmark_cases` entry. It never edits the config file.\n\nEach case in `config/richter.php`:\n\n```php\n'benchmark_cases' =\u003e [\n    [\n        'key' =\u003e 'TICKET-123',                 // label, and the --case selector\n        'fix_commit' =\u003e 'abc1234',             // commit whose diff is replayed through the report\n        'bug_class' =\u003e 'background-job change (data not copied on duplication)',\n        'expect_signal' =\u003e true,               // bug fixture: must resolve and reach an entry point\n        'max_risk' =\u003e 'high',                  // caps the risk a control (expect_signal: false) may report\n    ],\n],\n```\n\n### Graph cache\n\nBuilding the code graph is the dominant cost of every command. Richter caches the built graph on disk (default: `storage/framework/cache/richter/graph.json`), keyed by a content fingerprint of everything the build reads: `app/`, `routes/`, `resources/views`, the relevant config, and the package versions. Any input change rebuilds automatically, so a hit can only ever serve the graph the current code produces; there is no TTL to tune and no stale window.\n\n- The cache is on by default; set `richter.cache.enabled` to `false` to disable it.\n- `--no-cache` (on every command) bypasses it for one run, the escape hatch for an input the fingerprint doesn't cover.\n- A corrupt or mismatched cache file reads as a miss and is rebuilt; it never fails a run.\n- `--profile` (on `richter:detect-changes`) forces a fresh build and prints a phase-by-phase timing split to stderr, for judging where build time goes on a given codebase.\n\n### MCP server\n\nWhen [`laravel/mcp`](https://github.com/laravel/mcp) is installed, Richter registers a local MCP server named `richter` exposing two read-only tools: `impact` (blast radius of a symbol) and `detect-changes` (advisory impact of the current branch diff). A coding agent can then triage changes without shelling out to Artisan. Because the MCP session holds the graph cache in memory, repeated tool calls in one review don't rebuild the graph. Both tools also return MCP structured content in the same shape as the CLI `--json` output, so an agent can branch on fields instead of parsing prose. The supported range is `laravel/mcp` `^0.8||^0.9`; `composer.json` carries a matching `conflict` entry so an unvalidated release fails at resolution time rather than at boot.\n\nPoint Claude Code, Cursor, or any MCP client at the Artisan entry point, e.g. in `.mcp.json`:\n\n```json\n{\n    \"mcpServers\": {\n        \"richter\": {\n            \"command\": \"php\",\n            \"args\": [\"artisan\", \"mcp:start\", \"richter\"]\n        }\n    }\n}\n```\n\n## Configuration\n\n`config/richter.php`:\n\n| Key | Default | Purpose |\n|---|---|---|\n| `default_base` | `origin/main` | Git ref `richter:detect-changes` diffs against when `--base` is omitted. |\n| `dispatch_helpers` | `[]` | Project-custom global job-dispatch helper functions (e.g. `dispatch_with_retries`) the dispatch tracer should follow. |\n| `feature_gate_methods` | `[]` | `FQCN::method` allowlist of project wrappers around Pennant (e.g. `App\\Enums\\FeatureToggle::isActive`) — an `EnumCase-\u003emethod()` call then annotates the change as flag-gated, alongside the built-in `Feature` facade / `@feature` support. |\n| `entry_point_roots` | `Jobs`, `Listeners`, `Console/Commands`, `Filament`, `Helpers`, `Http/Middleware`, `Livewire`, `Observers` | Directories under `app/` traced as entry points beyond Brain's route-anchored graph (graph tracing only; the analyzer's risk-floor namespace heuristics are fixed). |\n| `frontend.roots` | `[]` (off) | Frontend roots whose changed TS/JS/Vue files are scanned for Wayfinder/Ziggy endpoint references (see [Frontend changes](#frontend-changes-wayfinder--ziggy)). |\n| `frontend.generated_paths` | `actions`, `routes`, `wayfinder`, `ziggy.js` | Wayfinder's generated trees and Ziggy's generated route map under each frontend root — excluded from scanning as regeneration churn. Each entry matches a directory, an exact file, or a `*`-glob (crosses `/`). `.d.ts` files are always excluded, regardless of this list. |\n| `frontend.pages_path` | `resources/js/Pages` | Where Inertia page components live — a changed member rendering a page is noted under Findings with the resolved file. |\n| `frontend.test_paths` | `[]` (the frontend roots) | Directories scanned for frontend spec files whose endpoint references feed `richter:affected-tests`' advisory `frontendTests` list. |\n| `frontend.http_callees` | `[]` | Extra JS/TS callees, beyond the built-in `route`/`fetch`/`axios`/`useFetch`/`$http`/`$`/`window`/`page`/`cy`, whose call-argument string literals count as backend endpoints. Matched on the callee's leading identifier, e.g. `myHttpClient` for `myHttpClient.post(...)`. |\n| `cache.enabled` | `true` | On-disk graph cache, keyed by a content fingerprint of the build inputs (see [Graph cache](#graph-cache)). |\n| `cache.directory` | `null` | Cache location; `null` means `storage/framework/cache/richter`. |\n| `benchmark_cases` | `[]` | Replayable accuracy fixtures for `richter:benchmark`. |\n\nFilament coverage is class-level: resources, pages and widgets surface as entry points (and their\ncomputed HTTP routes come in through Laravel Brain when Filament is installed), but individual\ntable/bulk actions are not modelled as separate entry points.\n\nRichter assumes standard Laravel conventions: the `App\\` root namespace, `app/Models`, `app/Policies`, `resources/views`, and `tests/`.\n\n## Testing\n\n```bash\ncomposer test        # test suite only\ncomposer qa-check    # read-only pre-push gate: Rector + Pint dry-runs, PHPStan, tests — mirrors CI\n```\n\n`composer qa` is the auto-fixing variant — it rewrites the working tree (Rector, Pint), so use\n`qa-check` when you only want to verify.\n\n## Changelog\n\nSee [CHANGELOG](CHANGELOG.md) for what changed per release.\n\n## License\n\nMIT. See [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsandermuller%2Frichter","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsandermuller%2Frichter","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsandermuller%2Frichter/lists"}