Skip to content

fix(analyzer): concrete discriminator bases join their variant unions (#93) - #94

Merged
TheAsda merged 3 commits into
masterfrom
fix/concrete-discriminator-base
Oct 17, 2026
Merged

TheAsda merged 3 commits into
masterfrom
fix/concrete-discriminator-base

Conversation

@TheAsda

@TheAsda TheAsda commented Oct 16, 2026

Copy link
Copy Markdown
Owner

Summary

Three discriminator bugs shared one root cause: the registry never registered a concrete base as a family member, while the ref seam rewrote every out-of-spine base $ref to {Base}Variant — a union that wasn't guaranteed to exist, didn't contain the base, and collided with children's literals when the base was self-mapped.

 buildDiscriminatorRegistry(family):
-  members = mapping ∪ implicit oneOf/anyOf refs
+  if base is concrete (no oneOf/anyOf, or listed in its own mapping)
+    members = base ∪ mapping ∪ implicit oneOf/anyOf refs ∪ implicit direct-allOf children
 translateRefThroughSeam($ref to base):
-  → {Base}Variant                     # even when no union is emitted
+  same-family spine + member base → Omit<Base, '$disc'>
+  empty family                    → bare base name (no union emitted)
+  otherwise                       → {Base}Variant

Concrete-base literal precedence: self-mapping key > own const > single-value enum > raw schema name.

Evidence

  • Before (generated from the OAS Pet/Cat/Dog example and the Entity/EntityDetails spec in Discriminator families with a concrete base: undeclared {Base}Variant, never-typed children, base missing from its union #93, tsc --strict on the output):
    • error TS2304: Cannot find name 'PetVariant' — 3 sites; no PetVariant type was ever emitted
    • EntityVariant rendered as EntityDetails alone → Find typed EntityDetails[]
    • self-mapped base: EntityDetails reduced to 'never' because property '"$type"' has conflicting types
  • After (npx vitest run tests/integration/discriminator-concrete-base.test.ts — 20/20, full suite 1667/1667):
    • export type PetVariant = Pet | Cat | Dog;
    • export type EntityVariant = Entity | EntityDetails;, Find → EntityVariant[], details endpoint → exactly EntityDetails
    • self-mapped and non-self-mapped Entity specs produce byte-identical contracts
    • every new fixture compiles under tsc --strict --noEmit, plus P1–P4 payload/narrowing assertions (tests/integration/discriminator-concrete-base.test.ts)

Intentional snapshot updates: existing families with concrete bases now include the base in their {Base}Variant unions and strip the discriminant from children via Omit (discriminator-allof, mega-spec, data-types-keywords, discriminator-cross-variant).

Merge Danger

Door: two-way — analyzer-only codegen change, reverted by reverting the commit; no runtime/CLI surface changes.

Blast radius: generated output shape. Any consumer whose specs have concrete discriminator bases will see their variant unions gain the base as a member and children switch to Omit<Base, '$disc'> — type-narrowing code written against the old (buggy) unions may need updating. The manual type-assertion replicas were re-transcribed from regenerated contracts to match.

Closes #93

…#93)

Root cause: the registry only registered mapped and oneOf/anyOf-ref
members, while translateRefThroughSeam rewrote every out-of-spine base
ref to '{Base}Variant' without checking that the union is ever emitted,
and same-family spine refs ignored base membership entirely. Combined,
that produced the three reported failures: refs to an unmapped concrete
base named an undeclared union type; a self-mapped base intersected its
child's literal into 'never'; mapping-children-only families omitted the
base from its own union.

- register a concrete base (no oneOf/anyOf, or listed in its own
  mapping) as a family member; literal precedence: self-mapping key >
  own const > single-value enum > raw schema name
- register implicit direct-allOf children as variants (grandchildren
  excluded); member order: mapping -> oneOf/anyOf -> allOf children,
  deduped by ref and type name
- inside same-family spines a member base renders Omit<Base,'disc'>
  (membership checked before the bare-name fallback) and the
  '$disc: string' placeholder is skipped for member bases
- empty families fall back to bare base refs and emit no union type
- rules identical for 3.0 and 3.1 (registry and mapper are
  version-agnostic)

Intentional snapshot updates: families with concrete bases now include
the base in their '{Base}Variant' unions and strip the discriminant from
children via Omit. Manual type-assertion replicas re-transcribed from
regenerated contracts.

Closes #93
@TheAsda

TheAsda commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

Summary

This PR fixes three discriminator bugs sharing one root cause: buildDiscriminatorRegistry never registered a concrete base (no oneOf/anyOf, or self-mapped) as a family member, while the ref seam rewrote every base $ref to {Base}Variant — a union that wasn't guaranteed to exist and excluded the base itself. Now a concrete base joins its family (members: base → mapping → implicit oneOf/anyOf refs → implicit direct-allOf children), spine refs to a member base render as Omit<Base, '$disc'> (inline expansion for union bases), empty families fall back to the bare base name, and the base's literal resolves via self-mapping key > own const > single-value enum > name. The old TS2304: Cannot find name 'PetVariant' and never-collapsed self-mapped bases are gone; self-mapped and non-self-mapped Entity fixtures produce byte-identical contracts.

Findings

  • [minor] Pre-existing circularity remains in the path this PR touches: a union base with a self-mapping and direct allOf children produces Circle = (Circle | Square) & … → TS2456 (src/analyzer/schema-mapper.ts:530, inline fallback in mapSameFamilySpineRef). Not a regression — base e3d0555 fails this shape with 3 errors vs 2 now — but worth a follow-up issue; niche input (oneOf + self-mapping on the same base).
  • [minor] Any direct allOf child of a discriminator base now unconditionally becomes a variant with a name-derived literal when the spec declares none (src/analyzer/discriminator-registry.ts:231, :349) — a fabricated runtime value for composition-style allOf mixins. Documented in the changeset and consistent with the D3 fallback for unmapped oneOf members, but consumers with mixin-style inheritance get wider unions than their specs imply.
  • [nit] Two stale doc comments still say "mapping order, then unmapped oneOf/anyOf refs" — actual order now starts with the base and ends with implicit allOf children (src/analyzer/discriminator-registry.ts:141, :376).
  • [nit] The new base-literal conflict warning (self-mapping key vs own const/enum, src/analyzer/discriminator-registry.ts:291 vicinity) isn't pinned by a test.

Verified independently: full suite 1667/1667, check/lint/format:check clean; classic oneOf-base specs generate byte-identical output vs e3d0555; allOf-grandchildren correctly excluded; re-transcribed cross-variant-assignability replica matches regenerated contracts and compiles under tsc --strict; changeset + AGENTS.md updated.

Verdict

✅ Approve — the fix is correct, well-tested, and regression-free on classic patterns; the findings are a pre-existing edge case and documentation nits, none blocking.

…t warning

Review nits from PR #94: two doc comments still described the pre-#93
member order, and the base self-mapping vs own-literal conflict warning
had no test pinning it.
@TheAsda

TheAsda commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

Thanks for the review — all four findings addressed in 6709522 + #95:

Suite now 1669/1669 (two new warning tests); check/lint/format clean.

@TheAsda
TheAsda merged commit 7a29f34 into master Oct 17, 2026
5 checks passed
@TheAsda
TheAsda deleted the fix/concrete-discriminator-base branch October 17, 2026 09:38
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.

Discriminator families with a concrete base: undeclared {Base}Variant, never-typed children, base missing from its union

1 participant