v0.4.0 PHP 8.2+ MIT CLI + JSON

RenderLoop — see the real change.

RenderLoop captures the rendered HTML of a PHP page, you change the code, then re-check: noise is masked and only the real change surfaces. A semantic HTML diff, with no headless browser, readable by humans and AI agents alike.

90 tests PHPStan level 10 CI PHP 8.2 → 8.5 11 CLI actions
Example RenderLoop session: capture before a change, then recheck as JSON showing a single price change.
~/my-project
# 1. capture BEFORE touching the code
$ render-loop -a capture \
    -u http://localhost:8000/cart -t before
Snapshot saved: localhost_cart_1792491901.2345

# 2. … you change the code …

# 3. re-check, as JSON
$ render-loop -a recheck \
    -u http://localhost:8000/cart -f json
{
  "summary": {
    "lines_added": 1,
    "lines_removed": 1,
    "hunks": 1,
    "changes": [
      { "kind": "text",
        "selector": "article > p.price",
        "context": "Aeron Chair",
        "before": "€1,295.00",
        "after": "€995.00" }
    ]
  },
  "diff": "@@ -41,7 +41,7 @@"
}

One change. Everything else is identical.
The problem

git diff shows your edit. Not its effect.

On a legacy PHP page with no test, a source diff says nothing about what the page actually renders. A changed shared include, a common function, a query that empties a cart: none of it shows in a code diff. RenderLoop compares the rendered state before and after, and makes everything that moved visible — including what you did not touch.

git diffthe source

public function price(): string - return '1295.00'; + return '995.00';

What you wrote. Nothing more.

RenderLoopthe render

article.product-detail > p.price - €1,295.00 + €995.00

+ a shared include emptied the cart elsewhere on the page. RenderLoop sees it.

The loop

Three commands, one true signal.

The principle fits in one sentence: capture the rendered state before touching the code, then re-check after. Any unintended difference is a regression.

1

Capture the before state

One HTTP request, one timestamped snapshot. Nothing to instrument in the application.

render-loop -a capture -u "http://localhost:8000/page.php" -t before
2

Change the code

By hand, or by an agent. Just make sure the server serves the new file: opcache can lag by a second or two.

# your edit, then restart the server if needed
3

Re-check

RenderLoop re-renders the page, masks noise and diffs. The summary comes first; unfold the full diff only when there is something.

render-loop -a recheck -u "http://localhost:8000/page.php" -f json
4

Declare the blast radius — optional

State upfront which selector your edit is allowed to touch. The verdict is binary, and a leak exits non-zero.

render-loop -a recheck -u "http://localhost:8000/page.php" \
  --expect-selector='.price' -f json
Features

What pixel diffing cannot do.

No screenshots, no visual tolerance threshold: normalized HTML, compared, with noise declared explicitly.

Semantic diff, not pixel diff

HTML is normalized then compared structurally. No headless browser, no screenshot, no arbitrary threshold.

HtmlNormalizer · HtmlDiffer

Noise masked by selector

DebugBar, CSRF tokens, timestamps, banners: masked by CSS selector, never by content regex. A price dropping to 0.00 stays visible.

normalizer.json · mask-add

Automatic calibration

calibrate captures the same page several times without changing anything. Whatever differs is noise, and the selectors are derived for you.

calibrate --samples=N --apply

Golden master

Pin the rendered output of legacy pages with no tests, refactor, then prove it did not drift. The golden file is committed and read back in git.

golden-record · golden-verify

Declared blast radius

--expect-selector turns “I think it is fine” into a verdict. Any change outside the expected selector fails the command.

assertion.satisfied

Built for agents

Token-budgeted JSON output, a machine contract via capabilities, a modus operandi via advise, and a semantic summary of changes.

-f json · -a advise
The output

A contract a machine can read.

The actions recheck, compare, list, calibrate, mask-add, capabilities and advise accept -f json. Only recheck and compare return this summary, designed for a token-budgeted reader: read the counters first, unfold the full diff only when it is non-empty. capture, store, golden-record and golden-verify always write text.

Summary first

lines_added, lines_removed, hunks, tags_touched and changes: one entry per localized change, with its CSS selector, readable context and before / after values.

The diff remains the reference

changes is a best-effort enrichment on messy markup. A structural insertion can make the array fall back to empty: the unified diff is always reliable.

See the full JSON schema

{
  "summary": {
    "lines_added": 1,
    "lines_removed": 1,
    "hunks": 1,
    "truncated": false,
    "tags_touched": ["p"],
    "changes": [
      {
        "kind": "text",
        "selector": "article.product-detail > p.price",
        "context": "Aeron Chair",
        "before": "€1,295.00",
        "after": "€995.00"
      }
    ]
  },
  "diff": "@@ -41,7 +41,7 @@ ..."
}
{
  "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": "..."
    }
  }
}
Golden master

A safety net for pages nobody dares to touch.

You cannot write a unit test for a legacy page whose only observable behaviour is its render. You can pin its current output, refactor, then verify it did not move.

  • A golden/<suite>.json manifest declares the cases and the mask.
  • Normalized output goes to golden/<suite>/<case>.html and is committed.
  • golden-verify checks every case and exits non-zero if at least one drifts.
  • Capture against frozen data: the diff must reflect code, not data.

This is characterization, not specification: we do not claim the output is correct, only that it did not change by accident.

golden master
# before the refactor: pin the output
$ render-loop -a golden-record --suite=product
Golden recorded: 4 case(s), noise mask = 3 selector(s).

# … refactor …

$ render-loop -a golden-verify --suite=product
OK product-card
OK product-list
CHANGED product-detail — lines_added 2, lines_removed 1
exit 1
AI agents

A tight feedback loop for an agent.

An autonomous agent needs a short, information-dense, token-cheap signal. RenderLoop answers one question after an edit: “did my change do exactly what it had to, and nothing else?”.

Discover without guessing

-a capabilities -f json describes every action, its required options, output shape and exit-code semantics. -a advise gives the modus operandi and pitfalls.

render-loop -a capabilities -f json
render-loop -a advise -f json

Decide on the JSON

recheck and compare exit 0 even with a diff. The agent reads the summary, or passes --exit-code to get git diff --exit-code behaviour.

render-loop -a recheck -u <url> --exit-code
render-loop -a recheck -u <url> \
  --expect-selector='.price' -f json
90PHPUnit tests
Level 10PHPStan, no exception
8.2 → 8.5PHP tested in CI
6asserted demo scenarios
Install

Two minutes, no service to host.

A Composer library and a binary. RenderLoop runs in your shell, in CI, or from your own PHP controller.

installation
# 1. the library
$ composer require linquant/render-loop

# 2. capture, edit, re-check
$ vendor/bin/render-loop -a capture -u "http://localhost:8000/my-page" -t before
$ vendor/bin/render-loop -a recheck -u "http://localhost:8000/my-page" -f json

# 3. agents can discover the rest
$ vendor/bin/render-loop -a capabilities -f json

Requirements

PHP
>= 8.2
Extensions
dom, libxml, json, curl
Binary
vendor/bin/render-loop
Dependencies
symfony/console, symfony/css-selector, sebastian/diff
License
MIT

Composer requires ext-curl at install time. At runtime, only the HTTP capture uses it; the “push” strategy does not call curl.

What RenderLoop does not do

  • It covers rendering only: a database write, an export, a PDF or an API response stays invisible.
  • Golden master covers only the declared inputs, and assumes deterministic rendering.
  • It does not replace unit tests. It complements them.
FAQ

The questions asked before trying it.

Does RenderLoop need a headless browser?

No for server-rendered pages: one HTTP request is enough. For a JavaScript page or a SPA, the HTML is rendered elsewhere and pushed with -a store, which reads it from standard input.

How is it different from a git diff?

git diff shows your source edit. RenderLoop compares the rendered state before and after, so it also reveals indirect effects: a shared include, a common function, a query that empties a table, a concurrent edit on the same page.

Does it replace PHPUnit?

No. RenderLoop observes the render, not computations or side effects. For a critical computation, extract the function and unit-test it. The loop is a third safety net, not a substitute.

How do I keep a timestamp from polluting the diff?

Noise is masked by CSS selector. calibrate captures the same page several times without changes: whatever differs is noise, and the selectors are derived. mask-add also lets you declare a known widget by hand.

What about pages behind a login?

The session cookie value goes through RENDERLOOP_COOKIE, never as a command argument, so it does not show up in ps. The cookie name is set with --cookie-name.

Golden master, concretely?

golden-record pins the current rendered output of a suite of cases. golden-verify replays it and exits non-zero if it drifted. This is characterization: it guarantees no accidental change, not correctness.