Skip to content

Convert NavigationStyleProjectionTrait into a collaborator (#1327) - #1333

Merged
chubes4 merged 1 commit into
trunkfrom
refactor-1327-navigation-style-projector
Aug 29, 2026
Merged

Convert NavigationStyleProjectionTrait into a collaborator (#1327)#1333
chubes4 merged 1 commit into
trunkfrom
refactor-1327-navigation-style-projector

Conversation

@chubes4

@chubes4 chubes4 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Slice 7 for #242, workstream 1. Closes #1327. Follows #1310, #1312, #1315, #1316, #1317, #1318.

NavigationStyleProjectionTrait was a 1,319-line single-consumer mixin. Per #1318's finding that trait extraction moves code across a file boundary without changing the $this surface, it is now NavigationStyleProjector behind a 10-operation NavigationStyleProjectionContext, with no $this access 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 from HtmlTransformer, 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.

navigationSourceOwnershipClasses moved into the projector. It lived on HtmlTransformer but was reached only from this trait, and is pure string work with no $this dependency. Threading it through the context would have widened the surface to describe a method nothing else uses. It leaves the transformer entirely.

materializeStylesheetAsset moved the other way, into HtmlTransformer. 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 named NavigationStyleProjector would 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: materializedAssets was used only inside materializeStylesheetAsset, so it left the surface with it.

A constant the dependency analysis missed

self::EMPTY_RUNTIME_TARGET_CLASS is defined on HtmlTransformer and referenced from the trait. The $this->-based dependency counting in #1327 does not see self:: constants, so this was invisible until the contract suite hit it.

It is now public, referenced as HtmlTransformer::EMPTY_RUNTIME_TARGET_CLASS, following the existing ButtonLinkDispatcher::POSITIONED_FRAGMENT_LINK_CARRIER_CLASS convention 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

serializedBlocks SHA-256 plus block, diagnostic, fallback, asset-count, asset-key and coverage fingerprints, captured across all fixture documents on the clean base and recaptured after:

383 documents — DIFFERING: 0 — threw: 0

                baseline    after
blocks             1,631    1,631
diagnostics        6,772    6,772
fallbacks          1,037    1,037
assets             3,239    3,239

composer test exit 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_css would not exercise the code under change. The harness concatenates each fixture's stylesheets and passes them through static_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

trunk after
HtmlTransformer.php 12,711 12,771
Named methods on the class 515 516
Methods in the transformer's object scope 548 516
Object scope (class + single-consumer mixins) 18,253 16,994

The 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() via HtmlTransformer — 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.

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.
@chubes4

chubes4 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Correction: the markup fingerprint in the description above was vacuous, and the re-run

While 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.

TransformerResult::toArray() emits snake_case keys. The harness read $result['serializedBlocks']; the actual key is serialized_blocks. With a ?? '' fallback, every document hashed sha256(''). So the reported "serializedBlocks SHA-256 — 0 differing" compared 383 identical hashes of the empty string on both sides. It could not have detected a markup change.

The other fields in that fingerprint used correct keys and were genuinely compared: blocks, diagnostics, fallbacks, assets, asset keys and coverage, with the non-zero totals shown above. So the run was not worthless — but the single most important signal, the emitted block markup, was not actually checked.

Re-run with the corrected harness

The harness now fails hard if serialized_blocks is absent rather than falling back to an empty string, and records markup length alongside the hash so a vacuous capture is self-evident.

Baseline captured on a clean trunk worktree at 24b115ac, compared against this branch at 0f10c2cb:

documents      : 383
markup bytes   : 22,047,755
distinct hashes: 383/383      (fingerprint is discriminating)
DIFFERING      : 0
threw          : 0

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 composer test covers the parity and contract suites independently of this harness.

Why this got through, and what changes

The 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 TransformerResult::toArray() for the next slice; it then rebuilt the harness, recaptured a clean-trunk baseline in a separate worktree, and re-ran the comparison. Reviewed by a human before posting.

@chubes4
chubes4 merged commit f3be7ab into trunk Aug 29, 2026
10 checks passed
@chubes4
chubes4 deleted the refactor-1327-navigation-style-projector branch August 29, 2026 01:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Convert NavigationStyleProjectionTrait into a collaborator, and order the remaining workstream 1 slices by coupling (#242)

1 participant