Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 80 additions & 18 deletions javascript/selenium-webdriver/project_bidi_schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@
* (input.Origin's "viewport" / "pointer"), so a binding can reject a wrong string, not just a
* wrong primitive β€” the tightest check the schema affords for a bare-scalar union arm.
*
* Each structured (`record` / `union`) type additionally carries `outbound` /
* `inbound`: reachable (by a pure `ref` walk) from some command's `params`, and from
* some command's `result` or an event's `params`, respectively. A binding gives a
* send-side accessor only to `outbound` types. Both flags are independent, so all four
* combinations occur β€” including `(false, false)` for a type in no message (a flattened
* base, an envelope), which correctly gets no accessor.
*
* Types the normalizer synthesized for anonymous CDDL constructs additionally
* carry `{ synthetic: true, owner, label }`: `owner` is the type the construct
* was lifted out of and `label` is the member name within it, so a binding can
Expand Down Expand Up @@ -533,6 +540,54 @@ export function buildSpecLinks(dfnsDocs, anchors = {}) {
}
}

// Every type name a *type expression* references (the value of a `field.type`, or a
// list element / map value type), descending through list, map, inline union arms, and
// inline record fields. Shared by refsOfNode and checkSchema.
function refsInType(node) {
if (!node) return []
if (node.ref) return [node.ref]
if (node.list) return refsInType(node.list)
if (node.map) return refsInType(node.map)
if (node.union) return node.union.flatMap(refsInType)
if (node.record) return node.record.flatMap((f) => refsInType(f.type))
return []
}

// Every type name a projected *type node* (a named `schema.types` entry) references: a
// record's field and map-value refs, a union's variant (and selector) refs, an alias's
// target refs. Composition is already resolved upstream, so this ref adjacency is
// complete for a reachability walk.
function refsOfNode(node) {
if (!node) return []
if (node.kind === 'record') {
const refs = node.fields.flatMap((f) => refsInType(f.type))
if (node.map) refs.push(...refsInType(node.map))
return refs
}
if (node.kind === 'union') {
const refs = [...node.variants]
if (node.selector?.variants) refs.push(...node.selector.variants.map((v) => v.ref))
if (node.selector?.default) refs.push(node.selector.default)
return refs
}
if (node.kind === 'alias') return refsInType(node.type)
return []
}

// The transitive closure of a set of root type names over refsOfNode. An unknown name
// (a ref with no type entry) terminates that branch.
function reachableTypes(roots, types) {
const seen = new Set()
const stack = [...roots]
while (stack.length) {
const name = stack.pop()
if (seen.has(name) || !types[name]) continue
seen.add(name)
for (const r of refsOfNode(types[name])) stack.push(r)
}
return seen
}

/**
* Build the flat, binding-neutral schema from the raw AST and command/event model.
* @param {object[]} ast The parsed CDDL AST (array of definition nodes).
Expand Down Expand Up @@ -602,6 +657,19 @@ export function projectSchema(ast, model, links = {}) {
}
}

// Per-type directionality (see the header block): reachable from a command's params
// (outbound) vs from a command's result or an event's params (inbound), closed over
// the same ref edges the integrity check walks β€” no name heuristics.
const outboundRoots = commands.map((c) => c.params?.ref).filter(Boolean)
const inboundRoots = [...commands.map((c) => c.result?.ref), ...events.map((e) => e.params?.ref)].filter(Boolean)
const outboundReach = reachableTypes(outboundRoots, types)
const inboundReach = reachableTypes(inboundRoots, types)
for (const [name, node] of Object.entries(types))
if (node.kind === 'record' || node.kind === 'union') {
node.outbound = outboundReach.has(name)
node.inbound = inboundReach.has(name)
}

// Per-domain module links, for a binding that emits one class/namespace per domain.
const domains = {}
for (const domain of Object.keys(model)) {
Expand Down Expand Up @@ -668,20 +736,6 @@ function extractVendor(types) {
export function checkSchema(schema) {
const errors = []
const has = (name) => Object.hasOwn(schema.types, name)
const refsIn = (node) =>
!node
? []
: node.ref
? [node.ref]
: node.list
? refsIn(node.list)
: node.map
? refsIn(node.map)
: node.union
? node.union.flatMap(refsIn)
: node.record
? node.record.flatMap((f) => refsIn(f.type))
: []
const hasUnknown = (node) =>
!node
? false
Expand Down Expand Up @@ -709,7 +763,7 @@ export function checkSchema(schema) {
? node.union.some(hasEmptyInlineRecord)
: false
const report = (where, node) => {
for (const r of refsIn(node)) if (!has(r)) errors.push(`${where}: unresolved type ${r}`)
for (const r of refsInType(node)) if (!has(r)) errors.push(`${where}: unresolved type ${r}`)
if (hasUnknown(node)) errors.push(`${where}: projected to an unknown primitive (unhandled CDDL type)`)
if (hasEmptyInlineRecord(node)) errors.push(`${where}: projected an empty inline record (dropped type reference)`)
}
Expand Down Expand Up @@ -758,14 +812,14 @@ export function checkSchema(schema) {
if (node.kind === 'record') {
const envelopeRoot = envelopeResultUnion(node, schema.types)
for (const f of node.fields)
for (const r of refsIn(f.type))
for (const r of refsInType(f.type))
if (correlated.has(r) && !(f.name === 'result' && f.type.ref === r && r === envelopeRoot))
leak(`${name}.${f.name}`, r)
if (node.map) for (const r of refsIn(node.map)) if (correlated.has(r)) leak(`${name}.*`, r)
if (node.map) for (const r of refsInType(node.map)) if (correlated.has(r)) leak(`${name}.*`, r)
} else if (node.kind === 'union' && !node.selector?.correlated) {
for (const v of node.variants) if (correlated.has(v)) leak(name, v)
} else if (node.kind === 'alias') {
for (const r of refsIn(node.type)) if (correlated.has(r)) leak(name, r)
for (const r of refsInType(node.type)) if (correlated.has(r)) leak(name, r)
}
}
return errors
Expand Down Expand Up @@ -905,5 +959,13 @@ export function checkCompleteness(rawAst, schema) {
for (const known of KNOWN_INCOMPLETE) {
if (emitted.has(known)) errors.push(`stale KNOWN_INCOMPLETE entry (now emitted, remove it): ${known}`)
}
// Every structured type must carry both directionality flags β€” a missing one means
// the pass skipped a node. `(false, false)` is a valid combination (a type in no
// message: an envelope, a grouping union, a flattened base), not an error.
for (const [name, node] of Object.entries(schema.types)) {
if (node.kind !== 'record' && node.kind !== 'union') continue
if (typeof node.outbound !== 'boolean' || typeof node.inbound !== 'boolean')
errors.push(`${name}: missing directionality flag (inbound/outbound)`)
}
return errors
}
102 changes: 102 additions & 0 deletions javascript/selenium-webdriver/project_bidi_schema_test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ describe('projectType (list / union / alias defs)', () => {
],
},
objectOnly: true, // both arms are records
inbound: false, // no model β†’ reachable from no message root
outbound: false,
})
})
it('projects a single-member dispatch choice group as an alias to its ref', () => {
Expand Down Expand Up @@ -485,6 +487,106 @@ describe('schema signals (objectOnly / extensible / enum primitive)', () => {
})
})

describe('directionality (inbound / outbound per structured type)', () => {
const union = (name, refs) => ({
Type: 'variable',
Name: name,
IsChoiceAddition: false,
Comments: [],
PropertyType: refs.map(ref),
})
// A command (params x.DoParams β†’ result x.DoResult) and an event (params x.HappenedParams)
// seed the walk. x.Both is referenced from both params and result; x.NoMessage from neither.
// x.LocalNode and x.RemoteNode are structural look-alikes (same `type: "node"`) reached
// only through params vs only through result, so they must land on opposite sides.
const ast = [
group('x.DoParams', [
field('cfg', [ref('x.OutOnly')]),
field('shared', [ref('x.Both')]),
field('lv', [ref('x.LocalValue')]),
]),
group('x.OutOnly', [field('a', ['text'])]),
group('x.Both', [field('b', ['text'])]),
group('x.DoResult', [
field('info', [ref('x.InOnly')]),
field('note', [ref('x.Both')]),
field('rv', [ref('x.RemoteValue')]),
]),
group('x.InOnly', [field('c', ['text'])]),
group('x.HappenedParams', [field('d', ['text'])]),
group('x.NoMessage', [field('e', ['text'])]),
union('x.LocalValue', ['x.LocalNode', 'x.LocalString']),
group('x.LocalNode', [field('type', [lit('node')]), field('v', ['text'])]),
group('x.LocalString', [field('type', [lit('string')]), field('v', ['text'])]),
union('x.RemoteValue', ['x.RemoteNode', 'x.RemoteString']),
group('x.RemoteNode', [field('type', [lit('node')]), field('v', ['text'])]),
group('x.RemoteString', [field('type', [lit('string')]), field('v', ['text'])]),
]
const model = {
x: {
commands: [{ method: 'x.doThing', name: 'doThing', params: 'x.DoParams', result: 'x.DoResult' }],
events: [{ method: 'x.happened', name: 'happened', params: 'x.HappenedParams' }],
},
}
const schema = projectSchema(ast, model)
const dir = (n) => ({ inbound: schema.types[n].inbound, outbound: schema.types[n].outbound })

it('marks a params-only record outbound (send side)', () => {
assert.deepEqual(dir('x.OutOnly'), { inbound: false, outbound: true })
assert.deepEqual(dir('x.DoParams'), { inbound: false, outbound: true })
})

it('marks a result/event-only payload inbound (receive side)', () => {
assert.deepEqual(dir('x.InOnly'), { inbound: true, outbound: false })
assert.deepEqual(dir('x.DoResult'), { inbound: true, outbound: false })
assert.deepEqual(dir('x.HappenedParams'), { inbound: true, outbound: false })
})

it('marks a type reached from both params and result as both (Cookie-shaped)', () => {
assert.deepEqual(dir('x.Both'), { inbound: true, outbound: true })
})

it('leaves a type reachable from no message at (false, false)', () => {
assert.deepEqual(dir('x.NoMessage'), { inbound: false, outbound: false })
})

it('splits structural look-alikes by reachability, not by name (LocalValue vs RemoteValue variant)', () => {
assert.deepEqual(dir('x.LocalNode'), { inbound: false, outbound: true }) // reached via params
assert.deepEqual(dir('x.RemoteNode'), { inbound: true, outbound: false }) // reached via result
assert.deepEqual(dir('x.LocalValue'), { inbound: false, outbound: true })
assert.deepEqual(dir('x.RemoteValue'), { inbound: true, outbound: false })
})

it('passes both validators (flags present on every structured type, (false,false) not an error)', () => {
assert.deepEqual(checkSchema(schema), [])
assert.deepEqual(checkCompleteness(ast, schema), [])
})

it('fails completeness when a structured type is missing a directionality flag', () => {
const broken = projectSchema(ast, model)
delete broken.types['x.OutOnly'].outbound
assert.ok(
checkCompleteness(ast, broken).some((e) => /x\.OutOnly: missing directionality flag/.test(e)),
'a stripped flag must fail closed',
)
})

it('does not flag enums or aliases (only record/union carry directionality)', () => {
// An enum and an alias are leaves/pass-throughs, not constructed message parts.
const s = projectSchema(
[
{ Type: 'variable', Name: 'x.E', IsChoiceAddition: false, Comments: [], PropertyType: [lit('a'), lit('b')] },
{ Type: 'variable', Name: 'x.A', IsChoiceAddition: false, Comments: [], PropertyType: [ref('x.OutOnly')] },
group('x.OutOnly', [field('a', ['text'])]),
],
{},
)
assert.equal(s.types['x.E'].inbound, undefined)
assert.equal(s.types['x.A'].outbound, undefined)
assert.deepEqual(checkCompleteness([], s), [])
})
})

describe('checkCompleteness (input vs output, generator-independent)', () => {
it('fails when a command/event present in the AST is missing from the schema', () => {
const astWithExtra = [
Expand Down