2323 *
2424 * type node: { kind: 'record', fields: [field] }
2525 * | { kind: 'enum', values: [string] }
26- * | { kind: 'union', variants: [ref] }
26+ * | { kind: 'union', variants: [ref], selector }
2727 * | { kind: 'alias', type }
28+ * selector: { by, variants: [{ value, ref }], default? } // discriminated
29+ * | { ordered: [{ ref, requires: [key] }] } // structural, spec order
30+ * | { correlated: true } // resolved by request id, not the payload
2831 * field: { name, wire, required, type }
2932 * type ref: { primitive } | { const } | { ref } | { enum } | { list } | { map, extensible? } | { union }
3033 *
@@ -147,8 +150,14 @@ function projectType(def) {
147150 if ( def . Type === 'variable' ) {
148151 const pt = def . PropertyType ?? [ ]
149152 if ( pt . length && pt . every ( isLiteral ) ) return { kind : 'enum' , values : pt . map ( ( e ) => e . Value ) }
150- if ( pt . length > 1 && pt . every ( isRef ) ) return { kind : 'union' , variants : pt . map ( ( e ) => e . Value ) }
151- return { kind : 'alias' , type : projectRef ( def . PropertyType ) }
153+ // A union of refs is a union even when some arms are inline groups wrapping a
154+ // ref (e.g. script.LocalValue's date/regexp arms): projectRef resolves those to
155+ // refs, so promote the all-ref result to a first-class union (it gets a selector)
156+ // rather than leaving it an alias-to-union the bindings would have to re-detect.
157+ const projected = projectRef ( def . PropertyType )
158+ if ( projected . union ?. every ( ( m ) => m . ref ) && ! projected . nullable )
159+ return { kind : 'union' , variants : projected . union . map ( ( m ) => m . ref ) }
160+ return { kind : 'alias' , type : projected }
152161 }
153162 if ( def . Type === 'group' ) {
154163 const refs = unionMemberRefs ( def )
@@ -189,6 +198,127 @@ function projectRecord(def) {
189198
190199const typeRef = ( name ) => ( name ? { ref : name } : null )
191200
201+ // Resolve a union member to its leaf record names, following nested unions and
202+ // single-ref aliases. Every BiDi union bottoms out in records, so this is total.
203+ function unionLeaves ( ref , types , seen = new Set ( ) ) {
204+ if ( seen . has ( ref ) ) return [ ]
205+ seen . add ( ref )
206+ const t = types [ ref ]
207+ if ( ! t ) return [ ]
208+ if ( t . kind === 'record' ) return [ ref ]
209+ if ( t . kind === 'union' ) return t . variants . flatMap ( ( v ) => unionLeaves ( v , types , seen ) )
210+ if ( t . kind === 'alias' && t . type ?. ref ) return unionLeaves ( t . type . ref , types , seen )
211+ return [ ]
212+ }
213+
214+ // The constant value a record pins on wire key `k`, as `{ value }` (a string or
215+ // `null`), or `{ open: true }` when the field exists but is not constant (a base
216+ // type acting as the catch-all, e.g. log.GenericLogEntry.type), or null when the
217+ // key is absent.
218+ function discriminatorValue ( rec , k ) {
219+ const f = rec . fields . find ( ( x ) => x . name === k )
220+ if ( ! f ) return null
221+ if ( f . type . const !== undefined ) return { value : f . type . const }
222+ if ( f . type . primitive === 'null' ) return { value : null }
223+ return { open : true }
224+ }
225+
226+ // What an immediate union member contributes to a discriminator on `key`:
227+ // { tagged: [{ value, ref }] } — it (or, for a sub-union, each of its leaves)
228+ // pins a constant value on `key`; a clean tagged sub-union is flattened up.
229+ // { default: ref } — it carries no `key` (e.g. RemoteReference inside LocalValue)
230+ // or an open base type on `key` (e.g. log.GenericLogEntry): the catch-all.
231+ // null — it neither tags cleanly nor defaults cleanly, so `key` is not a usable
232+ // discriminator for this union.
233+ function tagContribution ( ref , key , types ) {
234+ const t = types [ ref ]
235+ if ( ! t ) return null
236+ if ( t . kind === 'alias' && t . type ?. ref ) return tagContribution ( t . type . ref , key , types )
237+ if ( t . kind === 'record' ) {
238+ const d = discriminatorValue ( t , key )
239+ if ( ! d || d . open ) return { default : ref }
240+ return { tagged : [ { value : d . value , ref } ] }
241+ }
242+ if ( t . kind === 'union' ) {
243+ const leaves = unionLeaves ( ref , types )
244+ const ds = leaves . map ( ( l ) => discriminatorValue ( types [ l ] , key ) )
245+ if ( ds . every ( ( d ) => d ?. value !== undefined ) )
246+ return { tagged : leaves . map ( ( l , i ) => ( { value : ds [ i ] . value , ref : l } ) ) }
247+ if ( ds . every ( ( d ) => d === null ) ) return { default : ref } // a whole sub-union with no `key` at all
248+ return null
249+ }
250+ return null
251+ }
252+
253+ /**
254+ * Derive how a wire payload selects one variant of a union, so every binding runs
255+ * the same dispatch instead of re-deriving it (and silently depending on emit
256+ * order). Two shapes:
257+ * { by, variants: [{ value, ref }], default? } — a discriminated union: look up
258+ * payload[by] among `variants` (value is a string or null), else `default`.
259+ * `default` may itself be a union (e.g. LocalValue's untyped RemoteReference
260+ * arm), whose own selector finishes the dispatch.
261+ * { ordered: [{ ref, requires }] } — a structural union with no shared
262+ * discriminator: the first variant whose `requires` keys are all present wins.
263+ * Order is the CDDL choice order (the spec's priority), made explicit here.
264+ */
265+ function unionSelector ( name , types ) {
266+ const variants = types [ name ] . variants
267+ const constKeys = new Set ( )
268+ for ( const leaf of variants . flatMap ( ( v ) => unionLeaves ( v , types ) ) )
269+ for ( const f of types [ leaf ] . fields )
270+ if ( discriminatorValue ( types [ leaf ] , f . name ) ?. value !== undefined ) constKeys . add ( f . name )
271+
272+ for ( const key of constKeys ) {
273+ const contributions = variants . map ( ( v ) => tagContribution ( v , key , types ) )
274+ if ( contributions . some ( ( c ) => c === null ) ) continue // some member can't be placed on this key
275+ const tagged = contributions . flatMap ( ( c ) => c . tagged ?? [ ] )
276+ const defaults = contributions . filter ( ( c ) => c . default ) . map ( ( c ) => c . default )
277+ if ( defaults . length > 1 || tagged . length === 0 ) continue // ambiguous catch-all, or nothing to tag
278+ const values = tagged . map ( ( e ) => JSON . stringify ( e . value ) )
279+ if ( new Set ( values ) . size !== values . length ) continue // values collide — not a clean tag
280+ const selector = { by : key , variants : tagged }
281+ if ( defaults . length === 1 ) selector . default = defaults [ 0 ]
282+ return selector
283+ }
284+
285+ // No shared discriminator: dispatch by required-field presence, in spec order.
286+ return {
287+ ordered : variants . map ( ( ref ) => {
288+ const t = types [ ref ]
289+ const requires = t ?. kind === 'record' ? t . fields . filter ( ( f ) => f . required ) . map ( ( f ) => f . name ) : [ ]
290+ return { ref, requires }
291+ } ) ,
292+ }
293+ }
294+
295+ // The command-result hierarchy is dispatched by request id, not by inspecting the
296+ // payload (a response is matched to the command that produced it), so those unions
297+ // must not carry a payload selector. They can't be found from the model alone —
298+ // void commands record `result: null`, erasing whole result unions (e.g. every
299+ // emulation result) — so identify them structurally: starting from the union(s) a
300+ // `result` field points at (the response envelope's grouping), walk the variant
301+ // tree, marking each union that has no payload discriminator. The discriminator
302+ // guard stops the walk at a result that IS payload-dispatched (e.g.
303+ // script.EvaluateResult on `type`, or a RemoteValue reached through a result),
304+ // leaving its selector intact. Requires provisional selectors to already be set.
305+ function correlatedUnions ( types ) {
306+ const roots = new Set ( )
307+ for ( const t of Object . values ( types ) )
308+ if ( t . kind === 'record' )
309+ for ( const f of t . fields )
310+ if ( f . name === 'result' && f . type . ref && types [ f . type . ref ] ?. kind === 'union' ) roots . add ( f . type . ref )
311+ const correlated = new Set ( )
312+ const mark = ( name ) => {
313+ const t = types [ name ]
314+ if ( ! t || t . kind !== 'union' || correlated . has ( name ) || t . selector ?. by ) return
315+ correlated . add ( name )
316+ t . variants . forEach ( mark )
317+ }
318+ roots . forEach ( mark )
319+ return correlated
320+ }
321+
192322/**
193323 * Build the flat, binding-neutral schema from the raw AST and command/event model.
194324 * @param {object[] } ast The parsed CDDL AST (array of definition nodes).
@@ -211,6 +341,11 @@ export function projectSchema(ast, model) {
211341 }
212342 types [ def . Name ] = node
213343 }
344+ for ( const [ name , node ] of Object . entries ( types ) )
345+ if ( node . kind === 'union' ) node . selector = unionSelector ( name , types )
346+ // Override the result-grouping unions: they are dispatched by request id, so a
347+ // payload selector for them is meaningless (and would be empty/ambiguous).
348+ for ( const name of correlatedUnions ( types ) ) types [ name ] . selector = { correlated : true }
214349
215350 const commands = [ ]
216351 const events = [ ]
@@ -291,10 +426,71 @@ export function checkSchema(schema) {
291426 if ( node . map ) report ( `${ name } .*` , node . map )
292427 } else if ( node . kind === 'union' ) {
293428 for ( const v of node . variants ) if ( ! has ( v ) ) errors . push ( `${ name } : unresolved variant ${ v } ` )
429+ errors . push ( ...checkSelector ( name , node . selector , has ) )
294430 } else if ( node . kind === 'alias' ) {
295431 report ( name , node . type )
296432 }
297433 }
434+
435+ // A `correlated` union is resolved by request id, which only holds at the command
436+ // response position. If one is reachable anywhere else — a non-`result` field, a
437+ // map/list/nested element, an alias, or a variant of a non-correlated union — it
438+ // would actually need payload dispatch, and marking it correlated silently drops
439+ // its selector. Fail closed so a future misclassification cannot ship.
440+ const correlated = new Set (
441+ Object . entries ( schema . types )
442+ . filter ( ( [ , t ] ) => t . kind === 'union' && t . selector ?. correlated )
443+ . map ( ( [ n ] ) => n ) ,
444+ )
445+ const leak = ( where , r ) =>
446+ errors . push ( `${ where } : correlated union ${ r } is reachable as a value (needs a payload selector)` )
447+ for ( const [ name , node ] of Object . entries ( schema . types ) ) {
448+ if ( node . kind === 'record' ) {
449+ for ( const f of node . fields )
450+ for ( const r of refsIn ( f . type ) )
451+ if ( correlated . has ( r ) && ! ( f . name === 'result' && f . type . ref === r ) ) leak ( `${ name } .${ f . name } ` , r )
452+ if ( node . map ) for ( const r of refsIn ( node . map ) ) if ( correlated . has ( r ) ) leak ( `${ name } .*` , r )
453+ } else if ( node . kind === 'union' && ! node . selector ?. correlated ) {
454+ for ( const v of node . variants ) if ( correlated . has ( v ) ) leak ( name , v )
455+ } else if ( node . kind === 'alias' ) {
456+ for ( const r of refsIn ( node . type ) ) if ( correlated . has ( r ) ) leak ( name , r )
457+ }
458+ }
459+ return errors
460+ }
461+
462+ // Validate a union's selector: every referenced variant resolves, a discriminated
463+ // selector has distinct values and at most one default, a structural selector
464+ // dispatches on something. Keeps a malformed selector from shipping silently.
465+ function checkSelector ( name , selector , has ) {
466+ const errors = [ ]
467+ if ( ! selector ) return [ `${ name } : union has no selector` ]
468+ if ( selector . correlated ) return [ ] // resolved by request id, not the payload — nothing to dispatch
469+ if ( selector . by ) {
470+ const values = selector . variants . map ( ( v ) => JSON . stringify ( v . value ) )
471+ if ( new Set ( values ) . size !== values . length ) errors . push ( `${ name } : selector has duplicate discriminator values` )
472+ for ( const v of selector . variants )
473+ if ( ! has ( v . ref ) ) errors . push ( `${ name } : selector variant ${ v . ref } does not resolve` )
474+ if ( selector . default && ! has ( selector . default ) )
475+ errors . push ( `${ name } : selector default ${ selector . default } does not resolve` )
476+ } else if ( selector . ordered ) {
477+ // A structural selector must actually dispatch from the payload: every arm needs
478+ // a distinguishing required field, and no arm's `requires` may be a subset of a
479+ // later arm's — that would shadow the later arm under first-match. A union that
480+ // cannot satisfy this is not payload-dispatchable and must be `correlated`.
481+ selector . ordered . forEach ( ( v , i ) => {
482+ if ( ! has ( v . ref ) ) errors . push ( `${ name } : selector variant ${ v . ref } does not resolve` )
483+ if ( ! v . requires . length )
484+ errors . push ( `${ name } : structural selector arm ${ v . ref } has no required fields to dispatch on` )
485+ for ( let j = i + 1 ; j < selector . ordered . length ; j ++ ) {
486+ const w = selector . ordered [ j ]
487+ if ( v . requires . length && w . requires . length && v . requires . every ( ( k ) => w . requires . includes ( k ) ) )
488+ errors . push ( `${ name } : structural selector arm ${ v . ref } shadows ${ w . ref } (requires is a subset)` )
489+ }
490+ } )
491+ } else {
492+ errors . push ( `${ name } : selector is neither discriminated, structural, nor correlated` )
493+ }
298494 return errors
299495}
300496
0 commit comments