linquant / render-loop
A render regression net for PHP apps: capture a page's rendered HTML, change it, re-check β only the real change shows, the noise (DebugBar, scripts, CSRF tokens, timestamps) is masked. Semantic HTML diff, automatic noise calibration, golden-master mode. CLI + JSON, usable by a human or an AI agent.
Requires
- php: >=8.2
- ext-curl: *
- ext-dom: *
- ext-json: *
- ext-libxml: *
- sebastian/diff: ^5.1 || ^6.0 || ^7.0
- symfony/console: ^6.4 || ^7.0 || ^8.0
- symfony/css-selector: ^6.4 || ^7.0 || ^8.0
Requires (Dev)
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.4.0
- v0.3.0
- dev-fix/flaky-snapshot-key
- dev-fix/issue-22-golden-verify-json
- dev-fix/issue-25-nth-of-type-quadratic
- dev-fix/issue-20-changes-dup-selector
- dev-fix/issue-19-invalid-css-selectors
- dev-fix/issue-21-root-selector-multiple-matches
- dev-fix/issue-26-keep-scanning
- dev-fix/issue-17-unique-selector
- dev-fix/issue-11-demo-port
- dev-fix/issue-27-manifest-unknown-fields
- dev-fix/issue-15-3xx-redirect
- dev-fix/issue-23-ssrf-http-host
- dev-fix/issue-18-corrupt-json
- dev-fix/issue-24-snapshot-id-traversal
- dev-fix/issue-16-folder-collision
- dev-fix/issue-10-stderr
- dev-feat/php74
- dev-feat/capture-ajax
- dev-feat/assertion
This package is auto-updated.
Last update: 2026-10-31 13:46:44 UTC
README
RenderLoop * β» see only the real change*
Did my edit do exactly what I intended β and nothing else? π―
A render feedback loop for PHP apps. Capture a page's rendered HTML, change
the code, then re-check β only the real change surfaces. The noise (DebugBar,
<script>, CSRF tokens, timestamps, chrome) is masked automatically.
It is a semantic HTML diff, not a pixel/screenshot diff, so it needs no headless browser for server-rendered pages and stays cheap to run β in a shell, in CI, or as the feedback signal an AI coding agent reads after it edits your code.
At a glance
| π§© What | A semantic HTML diff for server-rendered PHP pages |
| β‘ No | headless browser, no screenshots, no pixel diff |
| π€ For | humans and AI coding agents (machine-readable contract) |
| π Masks | DebugBar, CSRF tokens, timestamps, site chrome β by selector |
| πͺ Cheap | token-budgeted JSON summary, runs in a shell or in CI |
π The loop
πΈ capture "before"
β
βΌ
βββββββββββββββββββββββββ
β you / an agent edit β
βββββββββββββ¬ββββββββββββ
βΌ
π re-check
β
βΌ
β¨ semantic diff Β· noise masked β©
β
βββββββββββ΄βββββββββββ
β only the REAL ββββΆ β
intended change
β change surfaces ββββΆ β οΈ anything else = regression
βββββββββββββββββββββββ
- Capture the rendered state before touching anything (
-t before). - Change the code (by hand, or let an agent do it).
- Re-check β RenderLoop re-renders, masks the noise, and shows a diff that should contain only your intended change. Anything else is a regression.
π What it catches that git diff doesn't
git diff shows your edit. RenderLoop compares the rendered state before
and after, so it also reveals indirect or concurrent effects: a shared
include that changed, a common function, a SQL query that empties a table, an
edit someone else made on the same page. That is its reason to exist on large
legacy codebases where a page has no test.
git diff RenderLoop
ββββββββ ββββββββββ
shows YOUR edit β shows the RENDERED change
- $price = 1295; article > p.price
+ $price = 995; "$1295.00" β "$995.00"
+ caught: a shared include
silently emptied the cart π
π€ Built for AI coding agents
RenderLoop gives an autonomous agent a tight, cheap, high-signal feedback loop: "did my edit do exactly what I intended, and nothing else?"
An agent can discover the whole CLI without parsing help text:
render-loop -a capabilities -f json returns a machine-readable contract of every
action β its required options, the shape of its JSON output, and its exit-code
semantics. And render-loop -a advise (add -f json) returns the modus
operandi itself β the loop to follow, how to read the output, and the pitfalls β
so the agent learns the how, not just the what. (The same guidance lives
in AGENTS.md for agents that read the repo first.)
Every action takes -f json. The summary (lines_added, lines_removed,
hunks, tags_touched, changes) is built for a token-budgeted reader β read
the summary, only unfold the full diff when it is non-empty. On failure the
message goes to stderr and stdout stays empty, so json_decode never sees a
human-readable error. The loop an agent should follow:
capture -t beforethe page it is about to touch,- make the change, and make sure the server serves the new file (watch opcache),
recheck -f jsonand assert the diff is empty or contains only the intended change β readsummaryfirst, unfold the full diff only when it is non-empty,- or go further and let RenderLoop make the call: declare the scope with
--expect-selectorand branch on theassertionverdict (see below).
The changes array is the semantic part of that summary: one entry per
localized change, so a reader never has to parse the unified diff to know what
happened.
"changes": [ {"kind": "text", "selector": "article.product-detail > p.price", "context": "Aeron Chair", "before": "$1295.00", "after": "$995.00"} ]
selectorβ a best-effort CSS path to the element, degrading from#idtotag.classto a positionaltag:nth-of-type(n).contextβ the nearest heading / label text, the anchor that stays readable on legacy markup with no id or class (the<h1>, a<th>, the previous<td>of a key/value row).nullwhen nothing nearby qualifies.kindβtext(content changed),addedorremoved(a node appeared or disappeared), each with the relevantbefore/after.
changes is an enrichment, never a replacement: the unified diff is always the
floor. On a structural insertion that cascades the positional paths, changes
comes back empty and you read the diff β see the limitations below.
Note the exit code: recheck (and compare) return 0 whether or not there is
a diff, so an agent should decide from the JSON summary (lines_added /
lines_removed). If you prefer to branch on the exit code instead, pass
--exit-code: the command then returns non-zero when the diff is non-empty, like
git diff --exit-code. (golden-verify already exits non-zero on a regression.)
π― Assert the blast radius
Rather than eyeballing the diff, an agent can declare the only selector its edit
may touch and get a binary verdict. recheck and compare accept
--expect-selector (repeatable); the command exits non-zero when any rendered
change escapes that scope, and the JSON gains an assertion block that isolates
the leak:
render-loop -a recheck -u "http://localhost/page.php" --expect-selector='.price' -f json
"assertion": { "expected_selectors": [".price"], "satisfied": false, "inside_scope_changed": true, "reason": "1 change(s) outside the expected scope", "out_of_scope": { "lines_added": 1, "lines_removed": 1, "hunks": 1, "diff": "β¦" } }
Declaring the intent before seeing the diff is what stops an agent from
rationalizing an unexpected change away β the PHPUnit "assert, don't eyeball"
idea applied to a render. It is a per-edit blast-radius guard, not a frozen
regression net (that is golden master, below). satisfied: true only means
nothing visible leaked outside the selector β not that the edit is correct, and
not that non-rendered effects are fine; a wrong or missing selector surfaces as
satisfied: false ("matched no element"), never a false green. It is built by
temporarily masking the expected selectors and re-diffing, so it leans on the
reliable unified diff, and masking stays selector-based.
π¦ Install
composer require linquant/render-loop
Requires PHP 7.4+ with ext-dom, ext-libxml, ext-json, and ext-curl
(curl is only needed for the fetch-based capture; the push strategy works
without it). On PHP 7.4, Composer selects Symfony 5.4 and sebastian/diff 4;
on PHP 8.2+ it selects the current versions.
β‘ CLI quickstart
# 1. capture the state BEFORE your change vendor/bin/render-loop -a capture -u "http://localhost:8000/my_page.php" -t before # 2. β¦ you (or an agent) modify the page β¦ # 3. re-check β the diff must contain ONLY the intended change vendor/bin/render-loop -a recheck -u "http://localhost:8000/my_page.php"
Other actions:
render-loop -a compare --from=<id> --to=<id> # diff two precise snapshots render-loop -a recheck -u <url> --expect-selector='.price' # assert the change stays inside .price (exit != 0 if it leaks) render-loop -a list [-u <url>] [-f json] # list snapshots render-loop -a calibrate -u <url> # suggest noise selectors to mask render-loop -a calibrate -u <url> --apply # β¦and write them into the config render-loop -a mask-add --selector='.chat-widget' # add your own noise selectors render-loop -a capabilities -f json # machine-readable contract of every action render-loop -a advise [-f json] # modus operandi + pitfalls for an AI agent cat rendered.html | render-loop -a store -u <url> -t before # push HTML for a JS/SPA page
All useful options & authenticated pages
Useful options: -d/--dir (snapshot directory), -c/--config (normalizer config
file β point it at your project's config, not the one bundled under vendor/),
--allow-host (extra allowed hosts), --cookie-name (session cookie name,
default PHPSESSID), -f/--format (table|json), --max-hunks, --samples
(number of calibration captures, >= 2), --apply (calibrate writes its findings
to the config), --selector (mask-add, repeatable), --exit-code (exit non-zero
when the diff is non-empty, like git diff --exit-code), --expect-selector
(recheck/compare, repeatable β assert the change stays within this selector;
exit non-zero and report the leak otherwise).
Authenticated pages: pass the session cookie value via the
RENDERLOOP_COOKIE environment variable β never as a CLI argument, which would
be visible in ps:
RENDERLOOP_COOKIE=<value> render-loop -a capture -u "http://localhost:8000/account.php"
π§° Programmatic usage
The engine is framework-agnostic. Wire it up in a few lines:
use RenderLoop\Model\SnapshotRepository; use RenderLoop\Service\{HtmlCompareService, HtmlNormalizer, HtmlDiffer, NormalizerConfig, CurlCaptureStrategy, PushedHtmlStrategy, UrlGuard}; $service = new HtmlCompareService( new SnapshotRepository('/var/snapshots'), new HtmlNormalizer(NormalizerConfig::default()), new HtmlDiffer(), ); // server-rendered page (curl); bounded to the allowed hosts (anti-SSRF) $fetch = new CurlCaptureStrategy(new UrlGuard(['localhost'])); $service->capture('http://localhost/page.php', $fetch, 'before'); // β¦ change your code β¦ $result = $service->recheck('http://localhost/page.php', $fetch); if (!$result->diff->isIdentical()) { echo $result->diff->unifiedDiff; // what changed print_r($result->diff->summary()); // compact summary (great for an AI) } // JS/SPA page: push the HTML already rendered by a headless browser $service->capture('http://localhost/app', new PushedHtmlStrategy($renderedHtml), 'before');
Capture strategies
CaptureStrategy is the extension point:
CurlCaptureStrategyβ fetches a server-rendered page. Bounded to an allow-list of hosts (UrlGuard), attaches an optional session cookie, and never follows redirects (a cross-origin redirect would bypass the guard).PushedHtmlStrategyβ takes HTML already rendered elsewhere (a headless browser for a Vue/React page). Implement the interface for anything else.
π Noise masking & calibration
Before comparing, the HTML is normalized: <script>/<style>/comments are
stripped, then CSS selectors (the normalizer config) remove noise nodes
(DebugBar, CSRF tokens, site chrome). Masking is always by selector, never by
regex on content β a price dropping to 0.00 must stay visible in the diff,
not be erased as noise.
The config file defaults to the bundled config/normalizer.json, but once the
library is installed that lives under vendor/ (read-only). Point -c/--config
at a file in your own project so the mask is yours to version and edit.
calibrate finds the selectors for you: it captures the same page a few times
without any change; whatever differs between identical captures is noise
(timestamps, counters, tokens). --samples=N captures more than twice to catch
slower-moving noise, and --apply writes the findings straight into the config
instead of printing them:
render-loop -a calibrate -u <url> --samples=4 --apply -c normalizer.json
An agent can also declare its own noise, without waiting for calibration β it knows a chat widget or a cookie banner is noise:
render-loop -a mask-add --selector='.chat-widget' --selector='.cookie-banner' -c normalizer.json
Both --apply and mask-add are idempotent (no duplicate selectors) and write to
the -c/--config file.
π Golden master (legacy pages without tests)
Pin the current rendered output of a set of representative inputs before a refactor, then prove it hasn't drifted (approval testing / characterization):
render-loop -a golden-record --suite=product # pin current output (before refactor) # β¦ refactor β¦ render-loop -a golden-verify --suite=product # exit != 0 if any output drifted
The manifest golden/<suite>.json declares the cases and the mask; the
normalized output lands in golden/<suite>/<case>.html β commit it, the diff
reads back in git like an .approved file. Capture against frozen data so
the diff reflects code, not data.
π₯οΈ Web UI example
A framework-agnostic web UI ships under examples/web:
php -S localhost:8000 -t examples/web
# then open http://localhost:8000/ and watch any page served by this server
It is the reference for integrating the engine behind your own controller: it uses plain superglobals instead of a framework base controller, so you can see exactly which seams to wire.
ποΈ Try the whole loop
A runnable demo app lives under
examples/demo-shop: a tiny PHP shop with deliberate noise
and a script that walks (and asserts) every scenario β noise masking, a real
edit, an indirect effect, calibrate, golden master, and a scope assertion
(--expect-selector: green when confined, red when a change leaks out).
bash examples/demo-shop/run-demo.sh # from the repo root
π Security
The capture attaches the user's session cookie, so a free-form URL would let a
caller exfiltrate what that user sees to a third-party host. UrlGuard therefore
restricts capture to an http(s) scheme and an allow-list of hosts (the local
origin). Keep that list tight. See SECURITY.md.
β οΈ Limitations (honest)
- Covers rendering only β a computation that goes to the database, an export, a PDF or an API stays invisible.
- Golden master covers only the declared inputs and assumes deterministic rendering (frozen data + mask). For a critical computation, extract the function and unit-test it; the loop complements tests, it does not replace them.
- The
changessummary is best-effort on messy markup: with no id or class theselectoris a positional path, and a structural insertion that shifts those paths makeschangesfall back to empty. The unifieddiffis the signal you can always rely on;changesonly ever adds to it.
π€ Contributing
Contributions are welcome β see CONTRIBUTING.md. Run
composer test and composer phpstan before opening a pull request. Security
issues: please follow SECURITY.md.
π License
MIT β see LICENSE.