fix(bundler): hoist duplicate titled enums to $defs in bundled schemas#3170
Conversation
Adds hoistDuplicateInlineEnums() post-processing step in scripts/build-schemas.cjs. After resolveRefs inlines every \$ref, the same pure-enum schema can appear at multiple paths in the bundled output. json-schema-to-typescript sees two structurally identical inline shapes and emits Foo + Foo1, creating the AgeVerificationMethod1 numbered-suffix codegen artifact. The fix: two-pass algorithm — collect titled pure-enum schemas (type === 'string', Array.isArray(enum), keys <= 4) appearing 2+ times, then replace every occurrence with \$ref to a root \$defs entry. Untitled enums are left inline (no meaningful name to derive). Complex objects deferred to RFC on x-hoist markers. See #3145. https://claude.ai/code/session_01SfY8L5D1NJJd6oiZqr74KS
Independent post-PR review (code-reviewer + ad-tech-protocol-expert)Two reviewers from the SDK side took a fresh pass. Both converge on the same blocker: Required before merge1. Keyword: Bundled schemas declare Three replacements in the new function:
The 2. Changeset frontmatter is empty ``````
3. Add at least one regression test Bundler is critical infrastructure with zero test coverage on this new pass. ~30 lines of vitest covering: titled enum referenced 3× → assert Should fix or justify4. Fingerprint loses per-site 5. Title collisions silently produce Confirmed safe
Versioning
Triggered by independent SDK-side review (ad-tech-protocol-expert + code-reviewer) — both ran on the full diff, not just the description. |
…t collisions
Code review on this PR flagged two correctness gaps in hoistDuplicateInlineEnums:
1. The `keys.length <= 4` cap excluded enums carrying common annotations
(`title`, `description`, `default`, `examples`, `const`, `deprecated`),
leaving Foo/Foo1 duplicates intact for those. Drop the cap — the existing
exclude list already filters out object/array/composition shapes.
2. Fingerprint of `{type, enum}` collapsed two enums sharing values but
differing in `title`, with the first-seen schema winning. That would
silently rename one of them to the other's def name. Include `title`
in the fingerprint so distinct-titled enums stay distinct.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Code-review pass covered correctness; two findings applied in 9aff56b:
Reviewer also flagged: no unit tests for Ready for review. |
Adds 11 unit tests covering the four reviewer-flagged branches and the two
correctness fixes applied during review:
Branches:
- ≥2-occurrence titled enum is hoisted and references replaced
- Untitled enum left inline (no Foo1 risk for unnamed types)
- Single-occurrence enum left inline (no churn)
- Name collisions with existing $defs get suffixed
Correctness fixes:
- isPureEnum no longer caps on key count (default/examples/deprecated/const
all preserved through hoisting)
- Fingerprint includes title (two enums with identical values but
different titles stay distinct, no silent rename)
Plus three structural tests: enum-value order preservation, $defs/array
walk symmetry, and composition-keyword exclusion.
Required exporting hoistDuplicateInlineEnums from build-schemas.cjs and
gating main() behind require.main === module so tests can import the
function without triggering a full build run. Wired into the main test
chain via npm run test:build-schemas-hoist-enums.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Addressed reviewer ask for unit tests in cd9d5b1 — 11 tests in Reviewer-flagged branches:
The two correctness fixes from the prior commit:
Plus three structural tests:
Required exporting |
…3316) The previous workflow filtered PR comments out via `github.event.issue.pull_request == null` with a comment claiming "auto-fix handles those" — but no auto-fix workflow exists, and the slack-routing only routes `/triage` slash commands. PR review feedback (like the reviewer summaries on #3170/#3174/#3225/#3226 earlier today) sat unactioned because nothing else was wired to pick them up. Drop the filter. Route PR comments to the same triage routine, with two adaptations on the payload so the routine can branch: - `is_pr: true|false` flag at the top of the prompt - `pr` block with head_ref, base_ref, draft, state when is_pr=true - MODE directive when is_pr=true: "apply requested fix as a follow-up commit on the PR's head branch, or post a reply if it's a question" Self-loop guard widened to skip comments containing "Fixed by Claude Code" in addition to the existing "Triaged by Claude Code" — so the routine's own follow-up replies on PRs don't re-fire it. Concurrency group already keys on `issue.number`, which GitHub assigns sequentially across both issues and PRs, so PR runs won't collide with issue runs even when numbers happen to match. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ct schemas (#4630) * feat(schemas): x-hoist opt-in marker for canonically shared object schemas (#4557) Opt-in companion to the pure-enum auto-hoist (`hoistDuplicateInlineEnums`, #3170). Spec authors set `x-hoist: true` on a source schema's root; the bundler moves it to root `$defs`, replaces every inline occurrence with a `$ref`, and strips the directive from bundled output. Why opt-in for complex objects: structural identity ≠ semantic identity (BriefAsset and VASTAsset share fields today but represent different lifecycle concepts). Auto-hoisting would lock in coupling that's hard to walk back. `x-hoist` is the deliberate exception, not the default — and per-type decisions stay separate from landing the mechanism. Behavior: - Hoists at any occurrence count (>=1). The marker declares intent; honoring it for single uses means adding a second reference later doesn't change the codegen surface. - `title` is required — missing/empty -> build error (the directive is meant to be deliberate). - Collision suffixing (`PriceBlock2`) when a `$defs` name already exists, matching the enum-hoist convention. - Different titles never collapse, even with structurally identical bodies — preserves the BriefAsset/VASTAsset distinction by default. - Recurses into hoisted defs so nested markers also collapse to refs. Dry-run validation: marking `core/duration.json` and rebuilding hoisted 6 inline copies in `create-media-buy-request` (and 5 other media-buy bundles) into a single `$defs.Duration` entry, with Ajv compiling cleanly and `x-hoist` absent from output. Reverted before commit — no source schemas opt in here. Per-type decisions ship in follow-ups. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(schemas): address review on x-adcp-hoist directive - Rename `x-hoist` → `x-adcp-hoist` to match the `x-adcp-*` namespacing convention documented in schema-extensions.mdx. Prevents collision with any future cross-vendor `x-hoist` keyword and matches the precedent set by `x-adcp-validation`. - Strip stray `x-adcp-hoist` markers from bundled output even when they live inside a pre-existing $defs block (which Pass 1 skips). The directive must never leak into bundled artifacts regardless of where it was authored. - Reject two distinct schemas marked with the same `title` — the collision-suffix path would silently rename one of them, defeating the directive's "canonical name" guarantee. Pre-existing $defs collisions still suffix (that case is a non-marker name collision, not a spec author bug). - Wire `test:build-schemas-hoist-marked` into the npm `test` chain so the unit tests actually run in CI. - Add changeset for the bundler mechanism. - Add `docs/reference/schema-extensions.mdx` entry covering directive contract, bundler behavior, SDK/codegen impact, and conformance — the canonical reference for source-tree consumers who dereference source schemas directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refs #3145
Adds
hoistDuplicateInlineEnums()post-processing step inscripts/build-schemas.cjs. AfterresolveRefsinlines every$ref, the same pure-enum schema can appear at multiple paths in the bundled output —json-schema-to-typescriptsees two structurally identical inline shapes and emitsAgeVerificationMethod+AgeVerificationMethod1, making the generated type look like a versioned duplicate that doesn't exist.What the fix does: Two-pass algorithm — (1) walk the bundled schema collecting titled pure-enum schemas (
type === 'string',Array.isArray(enum),keys.length <= 4) that appear at 2+ distinct non-$defslocations; (2) hoist each to root$defsand replace every inline occurrence with a$refpointer. Untitled enums are left inline — no meaningful PascalCase name can be derived, andInlineEnumNis worse than the status quo.Scope (per @bokelley's decision in #3145): Pure-enum schemas only. Complex object schemas (
BriefAsset1,VASTAsset1,DAASTAsset1,CatalogAsset1) are intentionally out of scope — those type lineages are sometimes intentionally distinct even when fields align; hoisting them globally creates cross-tool coupling the source schemas don't express. An RFC with anx-hoist: trueopt-in marker is the right path for those.Non-breaking justification: The bundled schemas are generated artifacts, not wire format. No AdCP message payload contains the bundled schema blob. Changing inline enum shapes to
$refpointers in the bundled output is a type-generation concern only — validators produce identical accept/reject decisions either way.$defsin draft-07 schemas: The bundled schemas declare"$schema": "http://json-schema.org/draft-07/schema#"while the hoist writes to$defs(a 2019-09 keyword). This is pre-existing behavior established byhoistNestedDefsToRoot(issue #2648) — the repo's Ajv instance runsstrict: falseandjson-schema-to-typescriptv10+ resolves$defsregardless of$schema. This diff does not introduce a new violation class.Result (verified against latest bundled output):
media-buy/update-media-buy-request.json:AgeVerificationMethodinline x2 →$defsentry + 2×$refcore/tasks-get-response.json: same fixSDK note: Consumers who already generated types from a bundled snapshot that emitted
AgeVerificationMethod1will need the alias re-export (AgeVerificationMethod as AgeVerificationMethod1) inadcp-clientfor one minor version. That work lives inadcp-client#942, not in this diff.Pre-PR review:
collectarray branch now checks array elements directly (symmetry withreplacebranch); (2)fingerprintuses order-preservingenumarray (sorting would silently merge semantically distinct enums sharing values); (3) empty-title guard added. One nit (pre-populaterootDefsbefore replace pass) incorporated.$defs-in-draft-07 pattern is pre-existing and tolerated by Ajvstrict: falseandjson-schema-to-typescriptv10+; fingerprint no-sort is correct;hoistNestedDefsToRoot→hoistDuplicateInlineEnumsordering is sound.Changeset:
--empty(no protocol bump — bundler script change, schema wire format and validation semantics unchanged).Session: https://claude.ai/code/session_01SfY8L5D1NJJd6oiZqr74KS
Generated by Claude Code