Skip to content

Commit a87d7b9

Browse files
committed
[js] Add per-union selection model to BiDi schema (discriminator, structural, correlated)
1 parent 57d6531 commit a87d7b9

2 files changed

Lines changed: 408 additions & 7 deletions

File tree

javascript/selenium-webdriver/project_bidi_schema.mjs

Lines changed: 199 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,11 @@
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

190199
const 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

Comments
 (0)