Convert NavigationStyleProjectionTrait into a collaborator (#1327) - #1333
Conversation
NavigationStyleProjectionTrait was a 1,319-line single-consumer mixin, so its methods shared one mutable object scope with the rest of HtmlTransformer. It is now NavigationStyleProjector behind a 10-operation NavigationStyleProjectionContext, with no $this access to the transformer. Two members moved rather than being threaded through the context: - navigationSourceOwnershipClasses was reached only from this code and is pure string work, so it moves into the projector and leaves the transformer entirely. - materializeStylesheetAsset moves the other way, into HtmlTransformer. Three of its four call sites are engine-support and author stylesheets, so it is transformer-owned rather than a navigation concern. The projector reaches it through the context. EMPTY_RUNTIME_TARGET_CLASS becomes public. The transformer emits the marker and the projector targets it in editor static-state CSS, following the ButtonLinkDispatcher::POSITIONED_FRAGMENT_LINK_CARRIER_CLASS convention where the emitter owns the constant. Trait deleted, no shim. 32 methods leave the transformer's object scope, which drops from 18,253 to 16,994 lines across the class and its remaining single-consumer mixins. Behavior preservation: serializedBlocks SHA-256 plus block, diagnostic, fallback, asset and coverage fingerprints captured across all 383 fixture documents with their stylesheets attached, before and after -- 0 differing, 0 throwing. composer test exit 0.
Correction: the markup fingerprint in the description above was vacuous, and the re-runWhile measuring the next slice I found a defect in the harness that produced this PR's evidence, and it invalidates one of the claims made above.
The other fields in that fingerprint used correct keys and were genuinely compared: Re-run with the corrected harnessThe harness now fails hard if Baseline captured on a clean The conclusion is unchanged — this refactor is behavior-preserving — but it is now actually evidenced. The 383 distinct hashes over 22 MB of markup are what makes the comparison meaningful; previously that figure would have been 1. CI remains green across PHP 8.2–8.5, and Why this got through, and what changesThe harness was validated for reach — I instrumented the nine entry points and confirmed they fire — but not for discrimination. Coverage of the code under change says nothing about whether the recorded signal can distinguish outputs. A fingerprint that is constant across every input passes any before/after comparison. Carried into the remaining slices: assert the fingerprint varies across the corpus before trusting a zero-diff result. A single distinct hash across 383 documents is the tell. AI assistance disclosure: this correction was found and drafted by Claude Sonnet 4.6 running in Claude Code, operated by @chubes4. The defect surfaced while the AI was reading |
Slice 7 for #242, workstream 1. Closes #1327. Follows #1310, #1312, #1315, #1316, #1317, #1318.
NavigationStyleProjectionTraitwas a 1,319-line single-consumer mixin. Per #1318's finding that trait extraction moves code across a file boundary without changing the$thissurface, it is nowNavigationStyleProjectorbehind a 10-operationNavigationStyleProjectionContext, with no$thisaccess to the transformer.#1327 picked this trait by measuring lines-removed per external dependency rather than by size. It ranked highest of the five remaining (110 lines/dep vs
FormDispatchTrait's 62), and the boundary held up: 9 entry points, every one reached only fromHtmlTransformer, so this was a single-consumer migration rather than #1318's 292-call-site fan-out.Two members moved in opposite directions
Neither was in the original plan; both came out of reading the actual call sites.
navigationSourceOwnershipClassesmoved into the projector. It lived onHtmlTransformerbut was reached only from this trait, and is pure string work with no$thisdependency. Threading it through the context would have widened the surface to describe a method nothing else uses. It leaves the transformer entirely.materializeStylesheetAssetmoved the other way, intoHtmlTransformer. It lived in the navigation trait, but three of its four call sites are engine-support and author stylesheets — it is transformer-owned asset materialization that happened to be filed under navigation. Moving it into a class namedNavigationStyleProjectorwould have meant the transformer calling$this->navigationStyleProjector->materializeStylesheetAsset()to write its author CSS. It stays with the transformer; the projector reaches it through the context.That removal is also why the context is 10 operations rather than the 12 dependencies #1327 measured:
materializedAssetswas used only insidematerializeStylesheetAsset, so it left the surface with it.A constant the dependency analysis missed
self::EMPTY_RUNTIME_TARGET_CLASSis defined onHtmlTransformerand referenced from the trait. The$this->-based dependency counting in #1327 does not seeself::constants, so this was invisible until the contract suite hit it.It is now
public, referenced asHtmlTransformer::EMPTY_RUNTIME_TARGET_CLASS, following the existingButtonLinkDispatcher::POSITIONED_FRAGMENT_LINK_CARRIER_CLASSconvention where the class that emits a marker owns the constant and readers reference it. Here the transformer emits the marker and the projector targets it in editor static-state CSS.Worth carrying into the remaining slices: extend the dependency measurement to
self::/static::constants before estimating a trait, not just$this->.Behavior preservation
serializedBlocksSHA-256 plus block, diagnostic, fallback, asset-count, asset-key and coverage fingerprints, captured across all fixture documents on the clean base and recaptured after:composer testexit 0.Two details about how that baseline was built, because they changed the result:
Stylesheets are attached. Navigation style projection reads author CSS, so a corpus run with no
static_csswould not exercise the code under change. The harness concatenates each fixture's stylesheets and passes them throughstatic_css; 82 of 87 fixtures carry CSS.The harness was verified to reach the code before it was trusted. All 9 entry points were temporarily instrumented and confirmed to fire within the first 60 documents, rather than assuming corpus breadth implies coverage.
Where the corpus was not enough
The undefined-constant fault above did not surface in the 383-document run — it reported 0 throwing while the fault was live, because the branch holding that constant sits behind a conditional the corpus never enters. The contract suite caught it.
Recording this because these slices lean on the corpus as the primary behavior gate: it proves output identity across a wide input surface, but it is not a branch-coverage instrument. Corpus green plus suite green is the real bar; corpus green alone is not.
Impact
HtmlTransformer.phpThe class grows 60 lines — the property, the constructor wiring, the context factory and the relocated
materializeStylesheetAsset— while 1,259 lines and 32 methods leave the shared mutable scope. Same shape as #1318, which grew the file by 37 lines while removing 2,822 from the object scope.Remaining single-consumer mixins, by the #1327 ranking:
FormDispatchTrait(1,865 / 30 deps),SvgMaterializationTrait(983 / 20),NavigationToggleSuppressionTrait(781 / 10),ElementConversionTrait(594 / 89 — not a collaborator candidate, see #1327).Test updates
One test reflected on
navigationAuthorStyleRules()viaHtmlTransformer— the #1197 memory-bound contract. It now reaches the method through the transformer's collaborator, so it still exercises the real wiring including the context closure that resolves the running transform's session state. The method stays private on the projector and is reached by reflection rather than widening its API for a test, following #1318. A sweep for other tests referencing any of the 34 moved methods found none.AI assistance disclosure: implemented and drafted by Claude Sonnet 4.6 running in Claude Code, operated by @chubes4. The AI measured the trait's dependency surface, captured and verified the pre-change corpus baseline, performed the extraction, migrated the call sites, diagnosed the undefined-constant and moved-method-reflection failures surfaced by the suite, and wrote this description. Reviewed by a human before opening.