Repository navigation
feat(codegen): add AST shape normalizers and inline conditional variant hoisting - #86
Conversation
63a971e to
8ebe945
Compare
57d290f to
63aa281
Compare
3ce648d to
7ac4684
Compare
63aa281 to
831c2df
Compare
61e89d2 to
f5391fa
Compare
831c2df to
2d102e4
Compare
damaz91
left a comment
There was a problem hiding this comment.
Great work on Stage 4 inline conditional variant hoisting and Stage 6 AST shape normalizers! Running hoist_inline_conditional_variants before Stage 5 directional slicing and distinguishing structural shape variants from scalar if/then constraints is a very clean design.
Left a few inline comments on edge cases around $ref property merging, discriminator UCP_ANNOTATIONS handling, parent allOf preservation during variant hoisting, and compound if conditions.
2d102e4 to
b338761
Compare
f5391fa to
1cfab4d
Compare
b338761 to
6ff6704
Compare
18f89c0 to
280bc37
Compare
6ccf31b to
aca7679
Compare
aca7679 to
e2d223a
Compare
280bc37 to
20358f7
Compare
e2d223a to
816d836
Compare
20358f7 to
802c9a3
Compare
802c9a3 to
6e678c1
Compare
5767e71 to
f53e59e
Compare
6e678c1 to
ca2b208
Compare
ptiper
left a comment
There was a problem hiding this comment.
Approving. Nice work, this stage is well put together.
I used an AI assistant (Antigravity) to help with this review. It compared this PR against its own base branch (feat/codegen-profile-mode), not main, and I ran the full test suite and clippy on the branch: all green. The comments below are about schema shapes that do not exist today but could show up as new verticals are added.
Two inline comments that I think are worth a small fix plus a test. Three smaller notes here so they do not get lost:
- Inline branch titles can collide:
used_titlesis written to but never checked before a title is stamped (around L1340). ThreeanyOfbranches requiring["id","a"],["id","b"],["code"]give the first two the same title<Parent>Id. Falling back to the next field, thenVariant{n}, when a title is taken would fix it. singularize_pascal: the"ses"rule removes two letters, which turnsPurchases,Releases,Leases,Licenses,CasesintoPurchas,Releas,Leas,Licens,Cas. A small exception forases/eases/enseswould cover it.attach_default_if_constgives integer constants"type": "number". Using"integer"when the value has no fraction would let typed generators emitint.
All fine to defer if you want to land the stack first; say so and I will open issues to track them.
| let Some(obj) = val.as_object() else { | ||
| return false; | ||
| }; | ||
| if obj.contains_key("if") || obj.contains_key("contains") { |
There was a problem hiding this comment.
This returns false as soon as the object has a top-level if, before looking at properties, allOf, or $ref. So whether a type gets a request version depends on whether the author put an if/then at the top level or inside allOf.
Example in the corpus: common/types/location.json has a top-level if and inherits id with "ucp_request": "required" from location_summary.json. Its request version is not empty, but LocationCreateRequest / LocationUpdateRequest are dropped here (and the test at generate_types_test.rs:396 expects that). Harmless today because Location only appears in responses. A future capability that accepts a Location in a request would silently get no request type. Stage 8 already removes request types nobody references, so this early return is not needed for that.
Suggestion: remove the early return and let if / contains simply not count as content:
let has_props = ...;
let has_composition = ["allOf", "oneOf", "anyOf"].iter().any(|k| {
obj.get(*k).and_then(Value::as_array)
.is_some_and(|a| a.iter().any(is_non_empty_request_slice))
});
let has_ref = ...;
has_props || has_composition || has_refTotal and Totals still come out empty with this. Happy to be corrected if the early return is there for another reason.
There was a problem hiding this comment.
Fixed in 11d30b1 — removed the early return on if/contains in is_non_empty_request_slice so root-level if schemas with properties/refs keep their request slices while pure if/then and contains branches continue to return false.
| continue; | ||
| } | ||
|
|
||
| let is_inline_flattenable = branch.as_object().is_some_and(|b| { |
There was a problem hiding this comment.
When a branch is flattened, only properties, required, dependentRequired, minProperties, maxProperties and propertyNames are carried over. Anything else on that branch is silently lost: not, dependentSchemas, patternProperties, and an explicit additionalProperties: false (the result then gets additionalProperties: true).
No schema today has these inside a flattenable branch, but allOf: [{ "not": { "required": ["a", "b"] } }] ("not both at once") is a very common pattern and would be the first thing a new vertical reaches for.
Suggestion: either add not, dependentSchemas, patternProperties (and additionalProperties == false) to the list of keywords that make a branch non-flattenable, here and in is_flattenable_base_object, so the branch stays in kept_all_of; or carry them over in merge_props_and_reqs_from_object. A unit test with a not branch that must survive would lock it in.
There was a problem hiding this comment.
Fixed in 11d30b1 — is_inline_flattenable and is_flattenable_base_object now exclude not, dependentSchemas, patternProperties, unevaluatedProperties, and non-true additionalProperties so those constraint branches stay in kept_all_of (covered by unit test).
e9bad62 to
b7b8c96
Compare
5aa23b8 to
9140b28
Compare
|
Thanks @ptiper! Also addressed the 3 notes from your review body in
|
…ct titles, and preserve hoisted discriminator required
…ired across allOf, stamp inline object additionalProperties, and guard singularize_pascal
…t, branch titles, singularize)
11d30b1 to
0941a3f
Compare
Summary
Implements Task 6 (PR 3a) of the two-layer
ucp-schemacode-generation pipeline: pre-slicing inline conditional variant hoisting (src/codegen/reify.rs) and post-slicing AST shape normalizers (src/codegen/normalizer.rs), stacked on #85.Changes
src/codegen/reify.rs):hoist_inline_conditional_variantsandderive_variant_namebefore Stage 4 (slice_all_schemas).allOfbranches containingif: { properties: { <disc>: { const | enum([single]) } } }with an inlinethenobject (!then.contains_key("$ref")) that introduces new properties or overrides a property's$reforitems.$ref(skipping scalar value-range constraints such asexclusiveMaximum: 0inTotal).ShippingMethod,PickupMethod,Oauth2Provider), deep-merging inlinethen.propertiesover inherited parent properties (items,type,ucp_request,ucp_response,uniqueItems), propagating parentdependentRequiredconstraints, setting the discriminatorconst, and rewritingthento{"$ref": "#/$defs/<VariantName>"}so Stage 4 directional slicing slices hoisted root variants alongside their parent union.src/codegen/normalizer.rs):strip_contains_allof: RemovesallOfentries that only assert arraycontainsconstraints (such asTotals.allOf) so downstream code generators emit clean list types (list[Total]) instead of wrapper classes.distribute_bare_anyof_properties: Distributes parentpropertiesandrequiredinto bareanyOf: [{required: [...]}]constraint branches (ValueConstraint,StayCreateRequest), isolating$refproperties (allOf: [{"$ref": ...}]) when extra keys are merged.normalize_scalar_or_array_unions: RewritesoneOfunions of a scalar/ref branchSandarray<S>(such asCapabilityBase.properties.extends) toanyOf: [S, array<S>]so both single values and arrays remain valid without exclusiveoneOfevaluation overhead.normalize_presence_if_then_to_dependent_required: Lowers property-presenceif(required: [...]oranyOf: [{required: [...]}]) +then: {required: [...]}rules insideallOfinto standard JSON Schema 2020-12dependentRequiredmaps (Location.timezonewhenhoursorexception_hoursis present).flatten_single_object_allof: Fixed-point pass that flattens object schemas withallOfreferences to non-union base definitions (ShippingDestination,LocationDestination,CardPaymentInstrument,LookupLocation,UcpPlatformSchema,Totals.items), merging properties (additionalProperties,items,const,default,uniqueItems) and object-level constraints (dependentRequired,minProperties,maxProperties,propertyNames) while preserving top-level non-$refif/then/notconstraints.Stacked PR Series Roadmap
$refrewriting (src/codegen/normalizer.rs)$refalignment (src/codegen/normalizer.rs)generate_types()library API (src/codegen/{mod,reachability,hoist,compose}.rs)generate-typessubcommand (src/bin/ucp-schema.rs,tests/generate_types_test.rs).well-known/ucp--profilescoping (src/codegen/profile.rs)src/codegen/normalizer.rs,src/codegen/reify.rs)<Union>Base) (src/codegen/reify.rs)src/codegen/reify.rs)generate_models.sh,postprocess_models.py,src/ucp_sdk/models/)generate_models.sh,scripts/generate-zod-from-types.mjs,src/spec_generated.ts)ucp-schema generate-openapi,src/openapi/mod.rs)Test Plan
cargo fmt --checkcargo test --all-targetscargo clippy --all-targets -- -D warnings