Skip to content

feat(Builder): a merger registry, and one entry per key in the Specification - #2220

Open
DerManoMann wants to merge 3 commits into
zircote:masterfrom
DerManoMann:feat/merge-pass
Open

DerManoMann wants to merge 3 commits into
zircote:masterfrom
DerManoMann:feat/merge-pass

Conversation

@DerManoMann

Copy link
Copy Markdown
Collaborator

Depends on #2219, and includes its commits until that merges — review the last three only, or wait and I will rebase.

Overview

Two attributes can claim one key. Two operations on the same path and method, two schemas named Pet — a scan finds one, a withSpecification() hook contributes the other, an inheritance clone makes a third. Nothing decided between them, so the compiler decided by accident: it writes each into a PHP array and keeps whichever it wrote last, silently. Classic reports the same collision as an error.

It was not even one rule. compilePaths() writes operations with = and folds path items in with +, so the last operation for a path and method won while the first path item for a path did. One map, one build, opposite rules, nothing reported either way. Webhooks were the quietest case: same last-wins, and no diagnostic of any kind, since a webhook operation gets no operationId and so never trips the accidental uniqueness warning that operations happen to have.

This gives the decision an owner. A merger says what makes two attributes the same one and which survives; the pass applies the chain to the Specification's own collections and writes one entry per key back, so what reaches the compiler has no collisions left and the compiler's incidental rules stop deciding anything. The shipped merger keeps the later entry and says so, which is what was happening anyway — now stated once, for every collection with a key, and reported.

Only the root collections. That is where the halves come from different places and something has to choose. Two entries inside one attribute were written in one place by one author, so the compiler keeps the last of them as it always has.

Warnings a spec build produced were also being dropped: Result carried the compiler's diagnostics and nothing the pipeline said, so a merger's report would have gone nowhere.

Changes

  • Contracts\MergerInterface — supports(), identity(), merge(); a chain, first match wins, the shape ResolverInterface has
  • Augmenter\Merge — the pass, first in the reduce phase and again as the last default pipe
  • Merge\LastWins — the catch-all, keying each root collection the way the document does and reporting a collision with both locations
  • Builder::withMergers() and getDefaultMergers(), ordered by Utils\TypedList like the augmenters
  • Spec\Origin on every attribute, carrying what produced it plus an open bag the pipeline never reads; getSourceLocation() reports the label where there is no reflector, in place of unknown
  • Utils\Pipeline takes a logger after construction and runs an optional hook after each pipe
  • Builder collects what the spec pipeline logs into Result
  • compilePaths() documents what its union still decides now that duplicate path items are gone
  • Mergers documented in the extension points guide and the Builder reference; the reference pages list the shipped merger and the new pipe

One output change: a PathItem against a PathItem for one path moves from first-wins to last-wins, the same rule as everything else. Nothing can have relied on it, since neither behaviour was documented or reported.

`setLogger()` via `LoggerAwareInterface`, so a logger can be handed over after
the pipeline was built — the builder has the build's logger only by then, and a
`setLogger()` call after `withAugmenters()` used to leave the pipeline on the
null logger.

`process()` takes an optional callable run after every pipe, on what that pipe
left. A caller that has to know what each pipe added cannot wait until the end
to ask, because the pipe reading the answer may be in the pipeline itself.
An attribute declared in source carries a reflector and needs nothing more. One
produced by anything else had no answer at all, and reported its location as the
word `unknown`.

`Spec\Origin` gives it one: a `producer` label naming the kind of step that
added it — `assembler`, `contribution`, `resolver`, `augmenter`, `hybrid
bridge` — which `getSourceLocation()` reports where there is no reflector.
Beside it is an open bag, keyed by whoever writes to it, that the pipeline never
reads.

`Builder::stamp()` applies a label after each step that can add, and each pass
leaves nothing unlabelled, so an attribute without one is what that step just
added. Only reflector-less attributes are stamped, and a label already set is
kept: these are defaults, and anything adding to the specification is free to
say something more exact.

`Origin` is immutable, because `Augmenter\PathItems` and
`Augmenter\Inheritance` clone attributes and a clone taking a stamp must not
rewrite its original's.
…ication

Two attributes claiming one key reached the compiler together, which wrote both
into a PHP array and kept whichever it wrote last. Nothing said so outside the
component buckets, and it was not even one rule: path items folded with `+`, so
for those the *first* won.

`Augmenter\Merge` decides instead. It groups each root collection on the
claiming merger's `identity()`, folds each group in producer order and writes one
entry per key back, so the compiler never sees a collision. It runs first in the
reduce phase, the earliest point every identity exists, and again as the last
default pipe, for what a late augmenter added.

`Merge\LastWins` is the catch-all: it claims every type, keys each collection
the way the document does — component key, path and method, webhook and method,
path, tag name — and on a collision keeps the later entry and warns naming both
halves. Positional lists have no key and pass through.

`Builder::withMergers()` configures the chain, the fourth default the builder
holds through a hook of its own.

A spec build now collects what its pipeline logs, so a merger's warning reaches
`Result`; the compiler keeps its own logger and nothing is collected twice.

One output change: a `PathItem` against a `PathItem` for one path moves from
first-wins to last-wins, the same rule as everything else.

A collision inside one attribute is left alone: those halves were written in one
place by one author, and the compiler keeps the last of them as it always has.
@DerManoMann
DerManoMann marked this pull request as ready for review September 30, 2026 00:19

This branch has not been deployed

No deployments
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.

1 participant