Feat: show which work runs inside a database transaction - #117
Conversation
Benchmarkbase What the scan detectsDeterministic: both arms scan the same generated application on the same PHP version, and every repetition is checked to produce the same figures. A difference here is a change in what Brain detects, not machine noise — which is worth looking at rather than automatically worth fixing, since detecting more legitimately moves these.
No count changed on either corpus, including the per-node-type breakdown. TimingMedian wall clock on a shared CI runner. Noise is how far the base arm's own repetitions sat from its median, discounting the single worst on each side, with their full spread beside it; a delta inside the noise is not a measurable effect.
Phase split — Full scan — 1,188 filesA phase carries at least as much runner noise as the scan it is part of, so each one gets its own noise floor from the base arm's repetitions of that phase. Useful for locating a large move; a phase that is a few percent of the scan cannot be read at a few percent of accuracy.
Scans a synthetic Laravel application generated by |
laramint#117 pinned it on data['transactionId'], a single field this change replaces with a list of regions so a node can sit in every span that reached it. The invariant survives the rename and gets stronger: the mispairing is now structurally impossible rather than merely fixed, because no field is left for one span's identity to share with another span's marking. The mirror case now asserts convergence rather than which arrival won.
SanderMuller
left a comment
There was a problem hiding this comment.
Reviewed at dcfa706b. The design argument is right and I checked the part it all rests on: this really does add no node and no edge. One thing blocks, and it is one missing argument at one of three call sites.
The blocking one: most marked nodes carry no span identity
CallChainEdge is constructed in three places in MethodTracer. Two pass transactionId. The third, in the loop over $discovered hops at line 243, passes the two flags and not the id:
$edges[] = new CallChainEdge(
…
inTransaction: $hop['inTransaction'] ?? false,
inRollback: $hop['inRollback'] ?? false,
// transactionId is not passed here, so it defaults to null
);recordSpanState() binds with ??=, first write wins, so a node bound through that path keeps a null span for the rest of the build even when a later edge carries a real one. It is marked as inside a transaction and can never be drawn inside a region, which is the feature.
Measured on three applications, marked nodes against nodes that know which span they are in:
inTransaction |
with a transactionId |
after adding the argument | |
|---|---|---|---|
| A | 148 | 28 | 148 |
| B | 216 | 151 | 216 |
| C | 93 | 49 | 93 |
I added transactionId: $hop['transactionId'] ?? null, at that site and re-ran the builds: every marked node gains an id, node and edge counts do not move on any of the three, and the suite stays at 417 passed. That is the whole change I am asking for.
Nothing in the suite would have caught it, which is the part worth fixing alongside: delete the same argument from either of the two sites that do pass it and all 417 tests still pass. The stamping path from edge to node data has no test on it, and this is the second time that shape of bug has cost this branch. The commit message on dcfa706b describes finding it from the other side.
The claim everything rests on holds
No node and no edge. Node and edge id counts are identical on all three applications: 49,292, 21,054 and 31,225 on both sides. The output differs only by added keys, and the classification is exact: inTransaction and transactionId appear, nothing is removed, and every other key balances added-against-removed. A null arm on each application came out identical, so those are the change's numbers.
inRollback appears on none of the three, which fits what you say about the tracer's reach rather than contradicting it.
The detector, against shapes your tests do not cover
try { begin; writeA; commit } catch { rollBack; notifyFailure } writeA in span, notifyFailure in span + rollback, code after: clean
if ($flag) { begin; inside; commit } … afterIf inside in span, afterIf clean
begin with no commit in the method span runs to the end, as documented
commit with no begin nothing marked
foreach { begin; perRow; commit } … afterLoop perRow in span, afterLoop clean
begin; DB::transaction(fn () => inner); outer; commit both inside
$this->db->transaction(...) caught
DB::connection('tenant')->transaction(...) caught
$this->orders->transaction(...) correctly ignored
begin; work; rollBack; afterRollback work in span, afterRollback outside
Ten shapes, ten right answers, including the two you say the tests caught before shipping. The receiver check earns its place: a domain service with a transaction() method is the shape that would quietly swallow half a method, and it does not.
Everything else I looked at
The committed bundle reproduces: npm ci && npm run build leaves git status empty, one entry chunk and one stylesheet are added with their predecessors deleted, and the blade names files that exist. 417 passed (1159 assertions), PHPStan clean, Pint clean. CI is green with per-job results across 15 jobs.
Small thing: the body says 414 passed (1153 assertions), which was true before dcfa706b added three tests. Worth refreshing when you push the fix.
Verdict
Requesting changes for the one argument. Everything else here is sound, and the parts I most expected to find wrong (the topology claim, the receiver guard, the unbalanced-opener case) are all right. Add the id at that third site and I will approve on the re-review; I have the counts to check it against.
|
Fixed in The argument
Node and edge counts unchanged either side (4033 / 10295). "Nothing in the suite would have caught it"This is the part worth the time. The seam had unit tests on both ends and nothing across the middle:
Deleting the argument from any one of the three is now red: There is a negative control alongside it — a hop after the transaction closes must have neither the flag nor an id — so the rule cannot be satisfied by stamping everything. Building that fixture turned up one thing worth recording, since it cost a few attempts: Gate: Thanks for measuring it across three applications — the marked-versus-identified ratio is the framing that makes the bug obvious, and it is going straight into how I check this feature from now on. |
SanderMuller
left a comment
There was a problem hiding this comment.
Re-reviewed at 949ae73, against dcfa706 — the head my last review described.
The blocking finding is fixed, and fixed at the line it was at: transactionId now travels with the other two flags out of the controller-method construction site in MethodTracer.
The probe that produced the finding, re-run. A controller whose method wraps a service call in DB::transaction(...), and the same shape on an invokable controller:
before OrderService::createOrder inTransaction=yes transactionId=null
after OrderService::createOrder inTransaction=yes transactionId='App\Http\Controllers\OrderController::store#0'
Both callees now carry the id of the span their controller opened, which is the same id the opener carries — so they are in one span rather than each in none.
The fix commit measured on three applications, null arm first (the previous head against itself, identical):
nodes with inTransaction |
had no span id | after | distinct spans | spans holding one node | |
|---|---|---|---|---|---|
| A | 40 | 27 | 0 | 12 → 12 | 11 → 2 |
| B | 73 | 19 | 0 | 30 → 30 | 16 → 9 |
| C | 17 | 6 | 0 | 8 → 8 | 6 → 3 |
The only field that changes on any node across all three is data.transactionId — 27, 19 and 6 nodes. No node, edge, subgraph or tab is added, removed or otherwise touched, and the inTransaction count does not move either, so nothing newly counts as being inside a transaction. The number of distinct spans is identical on all three: the fix joins nodes to spans that already existed rather than inventing any. That is what I would have asked for as proof it does not over-correct, and it is what the gate says.
The test bites. Removing the one line again fails two tests — it carries the span identity from a controller hop onto its edge and it stamps that identity onto the node the builder makes — one at the edge level and one at the node level, which is the right pair. The third test in the file is the negative control and correctly stays green.
Suite green on this head: 420 passed (1167 assertions), PHPStan no errors, Pint clean, 15/15 CI.
Worth knowing for the branch that sits on this one: #130 replaces this single transactionId field with a list of regions, and its version of this call site inherits the fix. I raised the same finding there against its older base; that review's numbers were measured before this commit existed.
Approving.
SanderMuller
left a comment
There was a problem hiding this comment.
Head moved to e657389 while I was writing the review above, so this re-points the approval at the commit it actually describes.
The delta is one commit touching one file: .gitignore goes from docs/.vitepress/dist/.DS_Store back to docs/.vitepress/dist/ with .DS_Store on its own line. That is the fix I asked for on #130, landed here at the base of the stack, which is the right place for it. Checked in a worktree at this head:
.gitignore:10:docs/.vitepress/dist/ docs/.vitepress/dist/index.html
.gitignore:10:docs/.vitepress/dist/ docs/.vitepress/dist/.DS_Store
Both ignored again. No code changed, so everything measured in the review above still describes this head.
laramint#117 pinned it on data['transactionId'], a single field this change replaces with a list of regions so a node can sit in every span that reached it. The invariant survives the rename and gets stronger: the mispairing is now structurally impossible rather than merely fixed, because no field is left for one span's identity to share with another span's marking. The mirror case now asserts convergence rather than which arrival won.
laramint#117 pins it on a single transactionId field; this change replaces that with a region list so a node can sit in every span that reached it.
|
Following up on my approval rather than reversing it — the analysis is right and the tests bite. This is about cost, and it is one line of config rather than a change to the work. The numberThe Benchmark comment on this branch, at the current head:
Well outside the floor, and the phase split says where it goes: What makes it worth raisingThe generated corpus contains no transactions at all. I grepped So +8.2% is not what the feature costs to do its job. It is what an application pays to be told it has none — a full extra traversal of every method body, on every scan, and again on every watch-mode poll, to discover there is nothing to mark. An application that does use transactions pays that plus the real work. And it is the only expensive feature on the board with no switch. Comparing the open branches:
That is not a criticism of the design — it is the one place the pattern the rest of your branches follow was not applied. Why it matters more than eight percent soundsEvery open branch measures against the same merge base
That is an estimate assuming the passes are independent; the shared parse cache means they overlap, so the real figure needs The askA If the answer is "8% is worth it unconditionally", that is a legitimate answer and I would just ask for the number and the phase split to appear in the description, so the next person reading it knows what the tab costs. |
|
Done in You are right that this was the one place the pattern the rest of the branches follow was not applied, and the framing is the part I want to acknowledge: +8.2% on a corpus containing no Default is on, matching the other feature flags. A test pins that turning it off skips the detection without taking the chain with it — the hops are still traced, only the boundary is gone. Removing the gate turns it red. Gate: Note for whoever merges: #130 is stacked on this branch and will need a rebase to pick this up, as it did for the span-identity fix. |
SanderMuller
left a comment
There was a problem hiding this comment.
Re-reviewed at fc74154, against e657389. One commit, and it is the switch I asked for.
Off costs nothing, and I mean that literally rather than approximately. Three interleaved repetitions on one of the applications I measure with, base against this head with the flag on and with it off:
| wall (median of 3) | nodes | edges | nodes flagged inTransaction |
|
|---|---|---|---|---|
| merge base | 1.46 s | 2,889 | 5,218 | 0 |
head, transactions.enabled on |
1.57 s | 2,889 | 5,218 | 73 |
head, transactions.enabled off |
1.47 s | 2,889 | 5,218 | 0 |
The 0 → 73 → 0 column is the control: the switch really does move what is measured, so the timing either side of it means something.
And the off path is byte-identical to the merge base. I dumped the full graph of a real application on both and compared:
base vs head with transactions off BYTE-IDENTICAL
base vs head with transactions on 73 nodes changed, and only data.inTransaction and data.transactionId
No node, edge or tab added or removed on either side. So the flag skips the traversal rather than discarding its result, exactly as the config block says, and turning it on adds the two fields and nothing else.
The suite is green on this head — 421 passed (1169 assertions), PHPStan no errors, Pint clean — and the new test covers the off path.
On the default
The benchmark comment at this head still reads +9.1% (±0.7%) on the large scan with lifecycle at +34.2%, because the default is still on. That is your call and I said so; what I asked for in that case was the number and the phase split written down where a reader will find them, and the config block now carries both, with the honest sentence that the corpus contains no transaction at all. That is better than putting it in the description, since the config is what someone reads when they wonder why a scan got slower.
One thing for whoever merges
This block lands at the same anchor as #132's, and both leave the same comment stranded. In each branch the Model Observers heading and its whole explanation now sit above the new section, and 'observers' => [ follows with nothing above it — every other section in that file reads heading, comment, array. git merge-tree on the two heads reports a conflict in config/laravel-brain.php, so whichever merges second has to resolve it by hand; worth putting the observers comment back next to its array while doing so.
Approving, and re-pointing the approval at this head.
|
@webard conflicts. see also the other opened PRs. |
fc74154 to
9c6dc1d
Compare
laramint#117 pinned it on data['transactionId'], a single field this change replaces with a list of regions so a node can sit in every span that reached it. The invariant survives the rename and gets stronger: the mispairing is now structurally impossible rather than merely fixed, because no field is left for one span's identity to share with another span's marking. The mirror case now asserts convergence rather than which arrival won.
laramint#117 pins it on a single transactionId field; this change replaces that with a region list so a node can sit in every span that reached it.
9c6dc1d to
b077f8c
Compare
laramint#117 pinned it on data['transactionId'], a single field this change replaces with a list of regions so a node can sit in every span that reached it. The invariant survives the rename and gets stronger: the mispairing is now structurally impossible rather than merely fixed, because no field is left for one span's identity to share with another span's marking. The mirror case now asserts convergence rather than which arrival won.
laramint#117 pins it on a single transactionId field; this change replaces that with a region list so a node can sit in every span that reached it.
The graph knew what calls what and nothing about what is atomic. A service that
writes three tables inside one transaction looked exactly like one that writes them
in three, which is the difference between a bug and not a bug.
Nodes now carry `inTransaction` and `inRollback`, and the canvas draws a dashed frame
around the ones that have them.
**A span, not a step — and that is the whole design decision.** The obvious shape is
a `TRANSACTION START` / `TRANSACTION END` pair of nodes, and it is wrong three times
over. A transaction does not *do* anything, it *contains* what others do, so a node
beside a service reads as the service calling it. It would add two hops to every path
through it, quietly changing every fan-out, shortest path and blast radius the graph
reports. And `beginTransaction()` and `commit()` are frequently in different methods,
so there is often no single place to put either end.
An attribute plus a frame says the same thing and changes no topology at all: this
commit adds no node and no edge.
**Both forms, because both are written.** Measured on the application this was built
against: 103 closure transactions, 8 hand-rolled pairs, 7 rollbacks.
DB::transaction(fn () => ...) the body is the span
DB::beginTransaction() ... DB::commit() the statements between are the span
The closure form is a subtree and nests for free. The hand-rolled form is a *range
within a statement list* — `beginTransaction()` and `commit()` are siblings — which
cannot be read off the tree shape at all, only off statement order. They are found by
two different passes for that reason.
The method names are Laravel's: `transaction`, `beginTransaction`, `commit`,
`rollBack`. There is no `startTransaction` or `endTransaction` in the framework, and
a detector keyed on those would find nothing and say nothing.
**The rollback path is tracked separately**, because it is a different claim. A catch
block that calls `rollBack()` runs with the transaction already gone: a write there
survives, and an event fired there announces a failure rather than a fact. That is
not "inside the transaction", so it is not marked as such.
**The method that opens a span is marked too.** Marking only what a transaction calls
loses every span whose body calls nothing the tracer can resolve — on the application
measured, that alone took the result from 2 marked nodes to 5.
Two things the tests caught before they shipped:
- A span ran past the block that opened it. `if ($flag) { begin; …; commit; }` was
read as an opener at the outer level, so everything after the `if` was swallowed.
Only an *unbalanced* opener extends into its siblings now.
- `transaction()` is not a reserved word, and a domain service with a method of that
name would have put half of it inside a span that never existed. The receiver is
checked.
Nine unit tests cover both forms, the rollback path, nesting, and the two cases
above.
…o it can be drawn
The first cut framed each node separately, which for a span holding several things
drew several boxes and said "each of these is in a transaction" rather than "these
are in the same one". Measured on the application this was built against, that is
the ordinary case, not an edge case: the median span holds 8 calls, the largest 33,
and only 9 of 92 spans hold a single call.
Nodes now carry `transactionId` rather than a boolean, and the canvas draws one
dashed region per span, under the cards and the edges, labelled with what it is.
**The region is only drawn when it is true.** The layout arranges by call structure
and knows nothing about transactions, so a shape around a span's members can enclose
a node that was never in it — a confident lie, which is worse than saying less. Every
region is checked against every non-member and falls back to outlining each member
when it would enclose a stranger. The test uses the cards' corners, not their
centres: a wide node can sit with its centre outside a boundary and half its body
inside, which passes a centre test and still reads as enclosed by anyone looking at
it.
**Then the layouts were taught to make it drawable.** Checking is not enough if the
answer is usually no. `layoutDagre` now builds a compound graph and gives a span's
members a shared parent, so dagre places them as a unit; the breadth-first layout
orders each layer so members sit together, changing only the order within a layer and
never a node's level. Across the five layouts on one route:
before clustering hierarchical ✗ breadth-first ✗ force ✓ circle ✓ grid ✓
after hierarchical ✓ breadth-first ✗ force ✓ circle ✓ grid ✓
Breadth-first still falls back where a span's members sit on different levels — one
calling the other puts a whole layer between them, and ordering within a layer cannot
close that. The fallback is correct there rather than a defect to hide.
**Regions follow dragged nodes.** They were computed from the laid-out positions
while the cards were drawn from the dragged ones, so moving a node left its boundary
behind — a boundary claiming a membership no longer on screen. Computed from the same
positions the cards use now. Dragging a member far enough still turns the region into
one that would enclose a stranger, and it degrades to outlines exactly as it does
anywhere else.
… does Four things, all about the same problem: the boundary was carrying information that disappeared with it. **Members are always outlined; the hull is an addition.** Membership used to be told by the hull when it could be drawn and by outlines when it could not, so dragging a node far enough removed the only mark saying it had been in a transaction. Outlines are unconditional now and the hull is drawn on top where it is true, which leaves the question "was this in a transaction" answered the same way in every layout and at every moment. **The name follows the same rule.** With a hull it is written once, on the shape, because the shape already groups the members. Without one it goes on each member: a single floating label beside the topmost of several scattered outlines names nothing. **Regions are numbered**, so two on a canvas can be told apart and referred to. Numbered by span id rather than by discovery order, so a region keeps its number when the layout changes or a node moves — a label that renumbers itself is one nobody can point at. **And they are listed in both places the canvas lists what it draws:** the "Show on graph" footer, where they can be switched off like any node type, and the legend along the bottom of the canvas, which now names them when the canvas holds any. The footer entry is not a node type — the count is spans rather than nodes — and the legend entry sits after a separator rather than an arrow, because the chain there is a sequence a request passes through and a transaction is not a step in it. One bug this introduced and caught: gating the regions on the footer toggle made them vanish entirely, because the default set of visible types is built from node types and a transaction is not one. The default set now includes it explicitly.
Membership was being drawn twice wherever a hull could be drawn at all: the shape
around the group, and an outline around each member inside it. Two marks saying one
thing, and the outlines had nothing to add once a boundary already enclosed exactly
the same set.
Exactly one is drawn now, on the rule the name already followed: the hull where it
can be drawn — which is only where it encloses nothing that was not in the span — and
the outlines where it cannot. Resting state across the five layouts, and after
dragging a member far enough to spoil the hull:
hierarchical / force / circle / grid 1 hull, 0 outlines, 1 name on the hull
breadth-first 0 hulls, 2 outlines, 2 names on the nodes
after a drag 0 hulls, 2 outlines, 2 names on the nodes
A node can be reached from more than one span — a service called inside one transaction and again from a catch block that rolls back a different one. The identity was recorded first-one-wins while `inTransaction` and `inRollback` were set by any edge at all, so the node carried the first span's id and the second span's rollback marking: drawn inside a region it never rolled back. The three facts now travel together in one map, bound to the span that first reached the node, because they are only meaningful as a set — a flag describes the node *within* the region it is drawn in. No test covered the stamping path at all, which is why this survived. Three now do, including the mirror case so the rule cannot be satisfied by never reporting a rollback; restoring the old independent-flags shape turns the middle one red. Found while generalising this machinery for chain and batch regions, which hit the same defect from the other side.
CallChainEdge is built in three places in MethodTracer. trace() -- the controller path, and the one most route tabs are built from -- passed the two flags and not the id. recordSpanState() binds with ??=, so a node bound through it kept a null span for the rest of the build: marked as running inside a transaction, and unable to be drawn inside a region, which is the whole feature. Measured on a 60-module application: 6 marked nodes, 5 with a span, and 6 of 6 after. Node and edge counts do not move. Nothing in the suite would have caught it, so the seam now has a test that runs a fixture through all three call sites -- controller, closure route, and the deep recursion -- and asserts the identity reaches both the edge and the node the builder stamps. Deleting the argument from any one of the three now turns it red; before this, deleting it from either working site left all 417 tests green.
The file had no trailing newline, so appending .DS_Store fused the two patterns into docs/.vitepress/dist/.DS_Store. The whole VitePress build output stopped being ignored and only a stray .DS_Store inside it stayed covered.
…bled The detector walks every method body the tracer scans, whether or not the application opens a single transaction. Measured by the benchmark on a corpus containing no DB::transaction at all: +8.2% on a full 1,185-file scan, of which the lifecycle phase is +36%. That is the price of being told there is nothing to draw. Off skips the traversal rather than discarding its result, so off costs nothing. The chain is still traced either way -- turning the boundary off must not take the work it drew around off the graph with it, and a test pins that.
b077f8c to
6f812e9
Compare
laramint#117 pinned it on data['transactionId'], a single field this change replaces with a list of regions so a node can sit in every span that reached it. The invariant survives the rename and gets stronger: the mispairing is now structurally impossible rather than merely fixed, because no field is left for one span's identity to share with another span's marking. The mirror case now asserts convergence rather than which arrival won.
laramint#117 pins it on a single transactionId field; this change replaces that with a region list so a node can sit in every span that reached it.
The graph knew what calls what and nothing about what is atomic. A service that writes three tables inside one transaction looked exactly like one that writes them in three — which is the difference between a bug and not a bug.
Nodes now carry
inTransaction,inRollbackand atransactionId, and the canvas draws a boundary around the work that shares one.A span, not a step — the decision everything else follows from
The obvious shape is a
TRANSACTION START/TRANSACTION ENDpair of nodes. It is wrong three times over:beginTransaction()andcommit()are frequently in different methods, so there is often no single place to put either end. Eight of the spans measured here are that shape.An attribute plus a region says the same thing and changes no topology at all: this PR adds no node and no edge.
Both forms, because both are written
Measured on the application this was built against: 103 closure transactions, 8 hand-rolled pairs, 7 rollbacks.
The closure form is a subtree and nests for free. The hand-rolled form is a range within a statement list —
beginTransaction()andcommit()are siblings — which cannot be read off the tree shape at all, only off statement order. Two passes, for that reason.The method names are Laravel's:
transaction,beginTransaction,commit,rollBack. There is nostartTransactionorendTransactionin the framework, and a detector keyed on those would find nothing and say nothing.The rollback path is tracked separately, because it is a different claim. A catch block that calls
rollBack()runs with the transaction already gone: a write there survives, and an event fired there announces a failure rather than a fact.One region per span, and only when it is true
A boolean would have been enough to tint a node. It is not enough to draw a region, because a region has to know which transaction — and the ordinary case is several things in one. The median span holds 8 calls, the largest 33, and only 9 of 92 hold a single call.
The layout arranges by call structure and knows nothing about transactions, so a shape around a span's members can enclose a node that was never in it. Every region is checked against every non-member, using the cards' corners rather than their centres — a wide node can sit with its centre outside a boundary and half its body inside, which passes a centre test and still reads as enclosed by anyone looking.
Then the layouts were taught to make the answer usually yes:
layoutDagrebuilds a compound graph and gives a span's members a shared parent, so dagre places them as a unit; the breadth-first layout orders each layer so members sit together, changing only the order within a layer and never a node's level.Breadth-first still falls back where a span's members sit on different levels — one calling the other puts a whole layer between them, and ordering within a layer cannot close that. The fallback is correct there rather than a defect being hidden.
Exactly one mark is drawn at a time. The hull where it can be, member outlines where it cannot, and the name goes with whichever is showing: once on the hull, or once on each member when there is no hull to hang it on. Regions are numbered so two on a canvas can be told apart, numbered by span id rather than discovery order so the number survives a relayout or a drag.
It can be switched off, in the places the canvas already lists what it draws
Show on graph gains a
Transactionsrow — same click, same behaviour as every node type beside it, and on by default. It is not a node type, so its count is a number of spans rather than of nodes.The canvas legend names them when the canvas holds any, after a separator rather than an arrow: the chain there is a sequence a request passes through, and a transaction is not a step in it. Its dot is hollow and dashed rather than filled, because every other entry is a kind of node and this is a boundary drawn around them.
Coverage, plainly
Five nodes are marked across nine tabs on the application measured, against 92 transaction spans in its source. The limit is the call chain's reach — it follows what a route or command reaches, and transactions live mostly in listeners and jobs it does not. The detector itself is not the limit: run directly over a file the chain never reaches, it finds the span and places 4 of 27 calls inside it. Nothing here changes how far the tracer goes.
Things the tests caught before they shipped
if ($flag) { begin; …; commit; }was read as an opener at the outer level, so everything after theifwas swallowed. Only an unbalanced opener extends into its siblings.transaction()is not a reserved word. A domain service with a method of that name would have put half of it inside a span that never existed. The receiver is checked.Nine unit tests cover both forms, the rollback path, nesting, and the first two above.
414 passed (1153 assertions), PHPStan clean, Pint clean,tsc -bandeslintclean. One entry chunk and one stylesheet ship; the superseded ones are deleted.