Skip to content

feat(types): inline-union value arrays for anonymous string-literal unions (#932)#938

Merged
bokelley merged 2 commits into
mainfrom
bokelley/inline-enum-extraction
Apr 25, 2026
Merged

feat(types): inline-union value arrays for anonymous string-literal unions (#932)#938
bokelley merged 2 commits into
mainfrom
bokelley/inline-enum-extraction

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Summary

Closes #932. Companion to the named-enum exports landed in 5.17 (#931). The earlier shipment covered every spec enum that has a stable named type (MediaChannelValues, PacingValues, etc., 122 total). This adds the inline anonymous unions inside named schemas — exactly the cases where consumers were re-declaring spec literal sets in their own validation code:

// Before — drift bait, hand-maintained on the consumer side.
const VALID_IMAGE_FORMATS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'tiff', 'pdf', 'eps']);
const VALID_VIDEO_CONTAINERS = new Set(['mp4', 'webm', 'mov', 'avi', 'mkv']);

// After — authoritative, drift-detected.
import {
  ImageAssetRequirements_FormatsValues,
  VideoAssetRequirements_ContainersValues,
} from '@adcp/client/types';

Naming convention

Every z.union([z.literal(...), ...]) (or z.array(...)-wrapped variant) inside a named object schema gets ${ParentSchema}_${PropertyName}Values. Property names PascalCase. Property paths that reference a named enum (e.g. unit: DimensionUnitSchema.optional()) are intentionally skipped — those already have ${TypeName}Values exports from enums.generated.ts.

Coverage

104 inline-union arrays across 51 parent schemas. Includes all user-flagged cases:

  • ImageAssetRequirements_FormatsValues (jpg, jpeg, png, gif, webp, svg, avif, tiff, pdf, eps)
  • VideoAssetRequirements_FormatsValues, _ContainersValues (mp4, webm, mov, avi, mkv), _CodecsValues, _FrameRateTypeValues, _ScanTypeValues, _GopTypeValues, _MoovAtomPositionValues, _AudioCodecsValues, _AudioChannelsValues
  • AudioAssetRequirements_FormatsValues (mp3, aac, wav, ogg, flac), _ChannelsValues
  • 90+ more across Account, AppItem, BrandResolveRequest, CatalogItemError, etc.

Implementation

New script scripts/generate-inline-enum-arrays.ts walks the compiled Zod schemas via runtime introspection (Zod 4 _def) rather than regex on the generated TS — cleaner and future-proofs against codegen output format changes. Output goes to src/lib/types/inline-enums.generated.ts. Wired into generate-zod-schemas (runs after Zod codegen since it depends on the schemas being current).

The extractor reads source TS (schemas.generated.ts) directly via tsx rather than the dist/ build output, so it can run as part of pre-build codegen without a circular dependency on build:lib.

Test plan

  • npm run build:lib — clean compilation, including the new generated file
  • node --test test/lib/inline-enum-arrays.test.js — 7/7 pass
    • User-flagged exports present
    • User-flagged values match the spec exactly (image formats + video containers explicit assertions)
    • Every emitted Values array is non-empty + all-strings
    • Cross-validation: every literal in every Values array round-trips through the parent Zod schema property (catches drift)
    • Dedup gate: named-enum references (e.g. unit: DimensionUnitSchema) are NOT re-emitted as inline-union exports
    • Public surface: exports reachable via @adcp/client/types
  • npm run test:lib — 4973 pass, 0 fail, 6 skipped (pre-existing)
  • npm run format:check — clean
  • tsc --noEmit — clean

Behavior unchanged

Pure addition. No public-API rename, no breaking change to enums.generated.ts, no behavior change in createAdcpServer or any other module. Adapters can drop their hand-maintained VALID_IMAGE_FORMATS-style constants in a follow-up PR.

🤖 Generated with Claude Code

bokelley and others added 2 commits April 25, 2026 05:06
…nions

Closes #932. Companion to the named-enum exports landed in 5.17 (#931).
Where the earlier shipment covered every spec enum with a stable named
type (`MediaChannelValues`, `PacingValues`, etc.), this adds the inline
anonymous unions inside named schemas — exactly the cases where adapters
were re-declaring spec literal sets (image formats, video containers,
audio formats, codecs, channels, etc.) as drift bait in their own code.

Naming: every `z.union([z.literal(...), ...])` inside a named object
schema gets `${ParentSchema}_${PropertyName}Values` (PascalCased).
Property paths that reference a named enum (e.g. `unit:
DimensionUnitSchema.optional()`) are intentionally skipped — those
already have `${TypeName}Values` exports from `enums.generated.ts`.

Implementation: `scripts/generate-inline-enum-arrays.ts` walks the
compiled Zod schemas via runtime introspection (Zod 4 `_def`) rather
than regex on the generated TS — cleaner and future-proofs against
codegen output format changes. Wired into `generate-zod-schemas` so it
runs after Zod codegen. New test cross-validates every emitted array
against the parent Zod schema property — drift in either side fails
fast.

104 inline-union arrays across 51 parent schemas, including all
user-flagged cases (Image/Video/AudioAssetRequirements_FormatsValues,
VideoAssetRequirements_ContainersValues / _CodecsValues, etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three review-driven hardenings:

1. Drop `'pipe'` from UNWRAP_TYPES. `z.pipe(in, out)` chains transforms
   and the inner `in` schema may not match the wire-accepted set after
   `out` runs — unwrapping it would silently extract the wrong half for
   `string → enum` style transforms. Current `schemas.generated.ts` has
   no `z.pipe(...)` constructions; if a future codegen change adds one,
   the extractor now correctly bails at a non-`union` core rather than
   emitting a wrong literal set, and the bumped floor catches the count
   collapse.

2. Add fingerprint-based fallback to the named-enum dedup gate.
   Identity-based check is exact and fast in the common case (Zod
   re-uses schema instances on property access), but breaks if codegen
   ever clones — e.g., a future `json-schema-to-zod` pass that inlines
   a referenced type as a fresh union. The fingerprint set keyed by
   sorted-literal-tuple catches that regression and prevents duplicate
   emissions of named-enum values under wrapped property names. This
   correctly drops 4 entries that previously double-emitted named
   enums under inline names: ListAccountsRequest_StatusValues
   (== AccountStatusValues), ReportingWebhook_ReportingFrequencyValues
   (== ReportingFrequencyValues), VASTAssetRequirements_VastVersionValues
   (== VASTVersionValues), WebhookAssetRequirements_MethodsValues
   (== HTTPMethodValues). Net: 100 inline arrays, no consumer-facing
   regression (each dropped name's values are reachable via the
   matching named-enum export).

3. Bump floor from <20 to <90. Catches partial-regression scenarios
   the previous floor only caught for catastrophic collapse.

Plus test improvements:
- Cross-validation regex switched from first-underscore split to
  last-underscore split, so parents with SCREAMING_SNAKE prefixes
  (e.g. `RATE_LIMITEDDetails`) get cross-validated instead of
  silently skipped.
- New negative-case test pins the invariant that mixed-type unions
  (string + number, all-boolean) are skipped — defensive against a
  future spec change adding non-string members to a string-literal
  enum.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley

Copy link
Copy Markdown
Contributor Author

Ran three expert reviews in parallel (code-reviewer, dx-expert, ad-tech-protocol-expert). Convergence: ship the convention. Three pre-merge hardenings applied in c2c6283, four follow-up issues filed.

Pre-merge fixes (c2c6283)

  • Dropped 'pipe' from UNWRAP_TYPES (code-reviewer). z.pipe(in, out) chains transforms; unwrapping in would silently extract the wrong half for string → enum style cases. Current schemas.generated.ts has zero pipes (verified), so this is latent-footgun removal, not a live-bug fix.
  • Fingerprint fallback on the dedup gate (code-reviewer). Identity-based check stays as the fast path; sorted-literal-tuple fingerprint catches the regression where Zod ever clones a schema on property access. Side effect: drops 4 redundant entries (104 → 100) that were previously double-emitting named-enum values under wrapped names — ListAccountsRequest_StatusAccountStatusValues, ReportingWebhook_ReportingFrequencyReportingFrequencyValues, VASTAssetRequirements_VastVersionVASTVersionValues, WebhookAssetRequirements_MethodsHTTPMethodValues. Each dropped name's values stay reachable via the matching named-enum export.
  • Floor bumped from <20 to <90 (code-reviewer). Catches partial-regression, not just catastrophic collapse.
  • Test cross-validation regex switched to last-underscore split (code-reviewer). Parents with SCREAMING_SNAKE prefixes (RATE_LIMITEDDetails) now get cross-validated instead of silently skipped.
  • Negative-case test added — pins the invariant that mixed-type unions (string + number, boolean) are skipped.

Deferred to follow-up issues

  • chore: release package #939 — Hoist remaining inline duplicates into named enums (dx-expert). Account_PaymentTermsValues and GetAccountFinancialsSuccess_PaymentTermsValues are byte-identical; RightsConstraint/RightsPricingOption _PeriodValues ditto; AudioAsset.channels vs VideoAsset.audio_channels; Create/UpdateMediaBuySuccess._ValidActionsValues; the IAB-style _ObjectiveValues set. ~8–10 of the remaining 100 are duplicates that should be hoisted upstream into named enums (extends feat(server): idempotency: 'disabled' mode + standalone enum value arrays #931's convention).
  • 5.17.0 framework dispatch: bundled storyboards fail their own request schemas (sync_plans, list_property_lists, delete_property_list) #940 — Rename RATE_LIMITEDDetails and other SCREAMING_SNAKE schema names (code-reviewer + dx-expert). The RATE_LIMITED parent name is propagating a discriminator value into a public type name (RATE_LIMITEDDetails_ScopeValues). Same root cause as the AgeVerificationMethod1 numbered-dupe in core.generated.ts — it's a json-schema-to-typescript artifact, fixable upstream.
  • adcontextprotocol/adcp#TBD — Mark vast_version x-extensible: true (protocol-expert). IAB ships VAST 4.3 (and 5.0 working draft); pinning a closed [2.0..4.2] set in the SDK risks rejecting conforming traffic on the next spec rev. Concrete OpenRTB-style drift case the protocol expert flagged.
  • adcontextprotocol/adcp#TBD — Document enum stability policy (protocol-expert). Spec uses x-extensible: true to flag append-only taxonomies, but doesn't have a one-paragraph statement saying "absent = closed/breaking-to-extend." Worth pinning explicitly so SDK consumers and adapter authors know the contract these as const arrays carry.

Spot-check verdict

Protocol expert verified all 100 arrays match the spec (image formats, video containers/codecs/audio_codecs, audio formats/channels, vast_version, webhook methods, destination_type, image color_space). One legitimate quirk: AudioAsset.channels (mono, stereo, 5.1, 7.1) and AudioAssetRequirements.channels (mono, stereo only) differ deliberately — that's the spec, not an extractor bug.

Convergence

Three experts, three lenses (correctness, DX, protocol). Zero blockers, all "ship-with-targeted-fixes." The capability-gate footgun pattern from PR #931 didn't repeat here — this PR's contract is "expose values that exist in the spec; consumers handle drift via versioning" — narrower and easier to keep honest.

@bokelley

Copy link
Copy Markdown
Contributor Author

Correction to issue numbers in my prior comment — the deferred follow-ups landed as #941 (hoist duplicate inline-union literal sets to named enums) and #942 (rename SCREAMING_SNAKE schema artifacts). The two adcontextprotocol/adcp spec-side issues (vast_version x-extensible + enum stability policy) still pending.

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.

Codegen: extract inline anonymous string-literal unions to const arrays

1 participant