Skip to content

[GEN-E3] union binding returns its decisions; nested-union and object-or-array defects refuse or bind correctly (PR 1/3) - #425

Merged
Blind-Striker merged 1 commit into
masterfrom
feat/gen-e3-unions
Oct 17, 2026
Merged

Blind-Striker merged 1 commit into
masterfrom
feat/gen-e3-unions

Conversation

@Blind-Striker

@Blind-Striker Blind-Striker commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Union binding becomes named collaborators that return their decisions, and two latent union defects refuse by name instead of producing wrong code. Generated output is byte-identical.

Closes: P39, P36, P37, P45. This is GEN-E3 PR 1 of 3, following the approved plan (faz4/GEN-E3/plan.md, amendments included). PR 2 (operations, with #414) and PR 3 (graph and statics) follow serially.

What changes

  • One binder per union kind. SchemaPlanBinder goes from 810 lines to 166. Each new binder returns its plan and its membership edges:

    • MarkedUnionBinder (321 lines);
    • ErrorUnionBinder (94);
    • ObjectModelBinder (129).
  • The graph replaces the accumulators. UnionInheritanceGraph (70 lines) assembles base types and fixed markers from the returned edges. It replaces the shared inheritance and fixedMarkers accumulators and their post-pass, which are gone (git grep).

  • Errors stay on the existing collector. Per amendment F3, no result-local error batches yet; those are PR 2's FacetResult. The new records are UnionBindingResult, ErrorUnionBindingResult, SchemaBindingContext and one UnionChildEdge, which carries a nullable fixed marker.

  • The error union is not a schema node. The graph never resolves parents, stream-cause membership or a fixed marker for the synthetic error union. A real component keyed OpenCodeError therefore cannot wire itself onto IOpenCodeError. Without this, that component would make the error union inherit from itself, and the base-chain walk loops. A regression row pins it. Master never wired the error union, so this closes a gap the new graph would otherwise open.

  • P36: two shapes refuse with one named error ("nested union has multiple parents 'A' and 'B'") instead of silently keeping the last parent:

    • a nested union under two parents;
    • a stream-cause union with a union parent.

    The refused union's membership claims are dropped before the graph is published, so no "absent union" errors follow from its leaves.

  • P37: an unmarked union of a free-form object and an array of unrestricted values binds to JsonElement. Before, it bound to an unspecified object, which cannot hold the array. A typed object-or-array pair, such as Config.ModelEncoded.cost, stays a structural union. Nothing at the pin has the free-form pair. The maintainer was told about this direction and may reverse it (spec decision 1).

  • P45: each error-spine spelling has one owner.

    • ErrorMarkerPolicy owns the C# spellings: Tag, IOpenCodeError and the unknown names.
    • The _tag wire literal is owned on the Ingestion side, by ErrorStyleClassifier.WireName, and ErrorMarkerPolicy.WireName refers to it. Nothing in Ingestion references Binding, so the Binding → Ingestion direction holds.
    • The two Emission "IOpenCodeError" literals now read the owner.
  • Canon: protocol-and-generation.md gains the P37 sentence, as its own paragraph after the standalone-not rules.

Orders (byte-identical output depends on them)

Each order the emitted code depends on is kept, and a test pins it:

Order Test Mutation proof
Record base lists: graph-key order, then branch order, error union last ModelEmitterTests: emitted base list reversed order → red; restored (cmp) → 9/9
Error variants: by curated type name, not tag or key (three schemas where key, type-name and tag orders all differ) SpecBinderTests sort by Tag → red; restored → green
First-claim hoisted carrier: by depth, then name (the depth-zero union sorts last by name) UnionMemberHoistBinderTests name-only order → red; restored → green

The union variant order, registry order and diagnostic text and order are unchanged; both reviews checked them side by side against master.

Tests

  • Red then green:
    • P37 (1 → 1).

    • Both P36 rows went red twice:

      • first because the old binder did not refuse;
      • then against the first draft, which produced 3 errors instead of 1.

      They now assert the full ordered Errors list, exactly one error.

    • The OpenCodeError-key regression: it was red with a false multiple-parent refusal plus two absent-union errors, and is green as a clean bind.

  • Per-schema error decisions: one row asserts the exact Variants and Refusals for an admitted schema, a schema with no name and a schema with a bad marker. Deleting either recording call turns it red. PR 2's ErrorMapBinder consumes them.
  • Existing-test edits: only the plan's §9 P37 row: unspecified object → JsonElement, and the test name …As_An_Unspecified_Object → …As_JsonElement. No other existing assertion or snapshot changed.
    • The order fixtures, the P36 rows and one compile-forced arrange line (a constructor that gained two set arguments) changed only inside this PR's own new tests, between its two runs.
  • E0 guards:
    • every new collection assertion uses CollectionOrdering.Matching;
    • no Contains, and no IsTrue over Any/All;
    • both TST-E1 site lists stay empty.

Clone baseline (one fingerprint swap, proven)

  • The swap: tests.baseline.json exchanges ccafdf2207f06e4f for 0555dcc5c34a8475. The pair is SpecBinderTests.cs:1673-1678 ↔ :1779-1784, two pre-existing arrange prefixes. P37's allowed test rename shifted the token window.
  • The proof: with master's SpecBinderTests.cs restored at this head, verify-clones passes against master's baseline with 0 new clones. The clone count is the same, 664.
  • Tests duplication is 5.66%, under the 6% ceiling. Tools has 0 new clones.

Budget (against the merge-base)

  • Hand-written tools: +702 / −696, +6 net, against a cap of +120.
  • Tests: 240 added, against a cap of 350.

Local check and review

  • Local check at this head, on dev-01, after a root Release build: slopwatch 0 issues; the Release build with 0 warnings; whitespace format passing; verify-clones passing (with the swap above); --help; generate --verify current; the whole Tools.Tests project (guards included) at 2004/0. The style pass caught two using-order slips (ErrorUnionBinder, ModelEmitterTests). After the fix, both format passes, the all-framework build, ModelEmitterTests (9/0) and SpecBinderTests (157/0) were rerun green. Bun 1.4.3, off the 1.4.2 pin. CI is the full gate.
  • verify.log: the harness ran the guards, 14 touched classes and generate --verify on the committed tree for both runs (manual §5.3). The only red line was verify-clones on the proven swap above.
    • The fix-up's first generate --verify hit a shell timeout mid-format. The rerun recovered to a byte-identical src/ through GEN-E8's re-runnable write, with no hand edits.
  • Review: the coordinator's Claude review and a fresh cross-family review. Both found the extraction faithful. Fixed here:
    • the error-union key (rated High by the cross-family review);
    • the P36 cascade;
    • the two order fixtures that could not tell the wrong sort;
    • the untested per-schema decisions;
    • set lookups;
    • two comments that had lost their reason in the move;
    • member order;
    • doc comments;
    • a constants-restating test, deleted (SLOP-T03).

Provenance: Generator lane / opencode dispatch harness (openai/gpt-6.1-sol-1m) for the plan, the implementation and the fix-up, coordinated, reviewed and finished in Claude Code.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

…ent union defects refuse by name

Marked, error and object binding move out of SchemaPlanBinder (810 -> 166 lines) into
MarkedUnionBinder, ErrorUnionBinder and ObjectModelBinder. Each returns its plan and its
membership edges, and UnionInheritanceGraph assembles base types and fixed markers from them,
replacing the shared inheritance and fixed-marker accumulators and their post-pass. Every order
the emitted code depends on is kept, and a test pins each: record base lists (graph-key order,
then branch order, the error union last), error variants by type name, and first-claim hoisted
carriers by depth before name. Generated output is byte-identical.

- P36: a nested union under two parents, or a stream-cause union with a union parent, refuses
  with one named error instead of silently taking the last parent.
- P37: a free-form object-or-array union binds to JsonElement, the representation upstream's
  schema admits; a typed object-or-array pair stays a structural union.
- P45: each error-spine spelling has one owner. The '_tag' wire literal is owned on the
  Ingestion side and ErrorMarkerPolicy refers to it, keeping the Binding -> Ingestion direction.
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