blob: 06dd3147cc92e2f1e4cb0975c0ce2f8bab1ef408 [file] [edit]
// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
/**
* Project the normalized BiDi AST + command/event model into one flat,
* binding-neutral schema for the generated Ruby / Java / Python clients.
* The normalizer has already removed the awkward CDDL shapes, so this is a
* straight mapping into a small vocabulary:
*
* type node: { kind: 'record', fields: [field], map?, extensible? }
* | { kind: 'enum', values: [string] }
* | { kind: 'union', variants: [ref], selector }
* | { kind: 'alias', type }
* selector: { by, variants: [{ value, ref }], default? } // discriminated
* | { ordered: [{ ref, requires: [key] }] } // structural, spec order
* | { correlated: true } // resolved by request id, not the payload
* field: { name, wire, required, type }
* type ref: { primitive } | { const } | { ref } | { enum } | { list } | { map, extensible? } | { union }
* any ref may also carry `nullable: true` (a `/ null` alternative). On a
* record node, `map` is the value type of `* key => value` entries and
* `extensible: true` marks an open `* text => any` record.
*
* 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
* keep the flat name or nest it (e.g. `Owner::Label`) without parsing the key.
*/
import { pathToFileURL } from 'node:url'
import { normalizeAst } from './normalize_bidi_ast.mjs'
// Note: the CDDL has no prose-only "exactly one of" constraints — every
// mutual-exclusivity case (e.g. session.unsubscribe) is a CDDL choice, which the
// normalizer turns into a `union`. A scan of all spec comments confirms none, so
// no separate constraint representation is carried.
// Events that parse into the AST but are not wired into the model because the
// upstream bluetooth spec does not fully define them. This is an external spec
// issue, not a Selenium/buildModel bug, and is intentionally not fixed here.
// Allowlisted so it does not fail the build; checkCompleteness() flags an entry
// as stale once it becomes emitted (e.g. after the spec is fixed upstream), so
// this list cannot silently rot.
const KNOWN_INCOMPLETE = new Set(['bluetooth.characteristicEventGenerated', 'bluetooth.descriptorEventGenerated'])
const PRIMITIVES = {
text: 'string',
tstr: 'string',
uint: 'integer',
int: 'integer',
nint: 'integer',
float: 'number',
bool: 'boolean',
null: 'null',
}
// CDDL prelude types surface as group refs but are builtins, not defined types.
const PRELUDE = {
number: 'number',
any: 'any',
bytes: 'string',
bstr: 'string',
nil: 'null',
tdate: 'string',
uri: 'string',
}
const typeList = (t) => (Array.isArray(t) ? t : t === undefined || t === null ? [] : [t])
const isLiteral = (e) => e && typeof e === 'object' && e.Type === 'literal'
const isRef = (e) => e && typeof e === 'object' && e.Type === 'group' && typeof e.Value === 'string'
// A `null` keyword or a `nil` prelude ref in a union means the value may be null.
const isNullAlt = (e) =>
e === 'null' || (e && typeof e === 'object' && e.Type === 'group' && PRELUDE[e.Value] === 'null')
function projectRef(type) {
const all = typeList(type)
// A missing type (undefined/empty input, e.g. a malformed array element or map
// value) is not a `null` value — fail closed so checkSchema's unknown guard
// catches it instead of silently producing a valid-looking `null`.
if (all.length === 0) return { primitive: 'unknown' }
const entries = all.filter((e) => !isNullAlt(e))
const node =
entries.length > 1
? entries.every(isLiteral)
? { enum: entries.map((e) => e.Value) }
: { union: entries.map(projectEntry) }
: projectEntry(entries[0])
if (entries.length < all.length) node.nullable = true // a `null` alternative means the value may be null
return node
}
function projectEntry(e) {
if (typeof e === 'string') return { primitive: PRIMITIVES[e] ?? e }
if (!e || typeof e !== 'object') return { primitive: 'unknown' }
// A control operator (`.ge` / `.default` / `.le` …) wraps the real type as
// `{ Type: <innerType>, Operator: {...} }`; the constraint does not change the
// type, so project the inner type.
if (e.Type && typeof e.Type === 'object') return projectEntry(e.Type)
if (e.Type === 'literal') return { const: e.Value }
if (e.Type === 'group' && e.Value) return e.Value in PRELUDE ? { primitive: PRELUDE[e.Value] } : { ref: e.Value }
if (e.Type === 'group' && Array.isArray(e.Properties)) {
// An inline group that only wraps anonymous ref(s) — e.g. a union arm
// `{ DateLocalValue }` — is that ref (or a union of them), not a record.
const refs = unionMemberRefs(e)
if (refs) return refs.length === 1 ? { ref: refs[0] } : { union: refs.map((r) => ({ ref: r })) }
return {
record: e.Properties.flat()
.filter((p) => p?.Name)
.map(projectField),
}
}
if (e.Type === 'array') return { list: projectRef(e.Values?.[0]?.Type) }
if (e.Type === 'map') return { map: projectRef(e.ValueType ?? e.Values?.[0]?.Type), extensible: true }
if (e.Type === 'range') {
const intRange = Number.isInteger(e.Value?.Min?.Value) && Number.isInteger(e.Value?.Max?.Value)
return { primitive: intRange ? 'integer' : 'number' } // e.g. js-uint (0..MAX) vs scale (0.1..2)
}
return { primitive: PRIMITIVES[e.Type] ?? 'unknown' }
}
function projectField(prop) {
return { name: prop.Name, wire: prop.Name, required: (prop.Occurrence?.n ?? 1) >= 1, type: projectRef(prop.Type) }
}
// A group whose members are all anonymous refs (a top-level `a // b // c`
// choice, e.g. session.ProxyConfiguration, or a single-member dispatch root
// like LogEvent) carries those refs, not named fields. Returns the ref names,
// or null if it is a normal record.
function unionMemberRefs(def) {
const flat = (def.Properties ?? []).flat()
if (flat.length < 1) return null
const refs = []
for (const p of flat) {
if (!p || typeof p !== 'object' || p.Name) return null
const e = Array.isArray(p.Type) ? p.Type[0] : p.Type
if (!e || e.Type !== 'group' || !e.Value) return null
refs.push(e.Value)
}
return refs
}
function projectType(def) {
if (def.Type === 'variable') {
const pt = def.PropertyType ?? []
if (pt.length && pt.every(isLiteral)) return { kind: 'enum', values: pt.map((e) => e.Value) }
// A union of refs is a union even when some arms are inline groups wrapping a
// ref (e.g. script.LocalValue's date/regexp arms): projectRef resolves those to
// refs, so promote the all-ref result to a first-class union (it gets a selector)
// rather than leaving it an alias-to-union the bindings would have to re-detect.
const projected = projectRef(def.PropertyType)
if (projected.union?.every((m) => m.ref) && !projected.nullable)
return { kind: 'union', variants: projected.union.map((m) => m.ref) }
return { kind: 'alias', type: projected }
}
if (def.Type === 'group') {
const refs = unionMemberRefs(def)
if (refs) return refs.length === 1 ? { kind: 'alias', type: { ref: refs[0] } } : { kind: 'union', variants: refs }
return projectRecord(def)
}
// Top-level list/map (or any non-group, non-variable def) becomes an alias to
// its element type, so the element type is not lost (e.g. script.ListLocalValue).
return { kind: 'alias', type: projectEntry(def) }
}
/**
* Project a CDDL group into a record. A property with `Occurrence.m === null` is
* an unbounded entry (`* key => value`), not a scalar field: `* text => any` marks
* the record extensible, `* text => T` becomes a typed map, and an unbounded group
* spread is folded in. Everything else is a normal field.
*/
function projectRecord(def) {
const record = { kind: 'record', fields: [] }
for (const prop of (def.Properties ?? []).flat()) {
if (!prop || typeof prop !== 'object') continue
// `m === null` is overloaded in this parser: a key-typed entry is a map
// (`* text => value`); an anonymous entry is a structural spread; everything
// else is just an optional field (the `?` quantifier). Only the first two
// are not real fields.
if (prop.Occurrence?.m === null && (!prop.Name || prop.Name in PRIMITIVES || prop.Name in PRELUDE)) {
if (prop.Name in PRIMITIVES || prop.Name in PRELUDE) {
const value = projectRef(prop.Type)
if (value.primitive === 'any') record.extensible = true
else record.map = value
}
continue
}
if (prop.Name) record.fields.push(projectField(prop))
}
return record
}
const typeRef = (name) => (name ? { ref: name } : null)
// Map a command's method to the params type its normalized envelope record carries
// (skipping EmptyParams, which means no real params). This recovers params the
// model builder drops when a command declares an inline `params: { ... }` object
// instead of a named group ref (the normalizer hoists that object to a real type,
// but the model still records `params: null`).
function commandEnvelopeParams(types) {
const params = new Map()
for (const t of Object.values(types)) {
if (t.kind !== 'record') continue
const method = t.fields.find((f) => f.name === 'method' && f.type.const !== undefined)?.type.const
const ref = t.fields.find((f) => f.name === 'params')?.type.ref
if (method && ref && ref !== 'EmptyParams') params.set(method, ref)
}
return params
}
// Resolve a union member to its leaf record names, following nested unions and
// single-ref aliases. Every BiDi union bottoms out in records, so this is total.
function unionLeaves(ref, types, seen = new Set()) {
if (seen.has(ref)) return []
seen.add(ref)
const t = types[ref]
if (!t) return []
if (t.kind === 'record') return [ref]
if (t.kind === 'union') return t.variants.flatMap((v) => unionLeaves(v, types, seen))
if (t.kind === 'alias' && t.type?.ref) return unionLeaves(t.type.ref, types, seen)
return []
}
// The constant value a record pins on wire key `k`, as `{ value }` (a string or
// `null`), or `{ open: true }` when the field exists but is not constant (a base
// type acting as the catch-all, e.g. log.GenericLogEntry.type), or null when the
// key is absent.
function discriminatorValue(rec, k) {
const f = rec.fields.find((x) => x.name === k)
if (!f) return null
if (f.type.const !== undefined) return { value: f.type.const }
if (f.type.primitive === 'null') return { value: null }
return { open: true }
}
// What an immediate union member contributes to a discriminator on `key`:
// { tagged: [{ value, ref }] } — it (or, for a sub-union, each of its leaves)
// pins a constant value on `key`; a clean tagged sub-union is flattened up.
// { default: ref } — it carries no `key` (e.g. RemoteReference inside LocalValue)
// or an open base type on `key` (e.g. log.GenericLogEntry): the catch-all.
// null — it neither tags cleanly nor defaults cleanly, so `key` is not a usable
// discriminator for this union.
function tagContribution(ref, key, types) {
const t = types[ref]
if (!t) return null
if (t.kind === 'alias' && t.type?.ref) return tagContribution(t.type.ref, key, types)
if (t.kind === 'record') {
const d = discriminatorValue(t, key)
if (!d || d.open) return { default: ref }
return { tagged: [{ value: d.value, ref }] }
}
if (t.kind === 'union') {
const leaves = unionLeaves(ref, types)
const ds = leaves.map((l) => discriminatorValue(types[l], key))
if (ds.every((d) => d?.value !== undefined))
return { tagged: leaves.map((l, i) => ({ value: ds[i].value, ref: l })) }
if (ds.every((d) => d === null)) return { default: ref } // a whole sub-union with no `key` at all
return null
}
return null
}
/**
* Derive how a wire payload selects one variant of a union, so every binding runs
* the same dispatch instead of re-deriving it (and silently depending on emit
* order). Two shapes:
* { by, variants: [{ value, ref }], default? } — a discriminated union: look up
* payload[by] among `variants` (value is a string or null), else `default`.
* `default` may itself be a union (e.g. LocalValue's untyped RemoteReference
* arm), whose own selector finishes the dispatch.
* { ordered: [{ ref, requires }] } — a structural union with no shared
* discriminator: the first variant whose `requires` keys are all present wins.
* Order is the CDDL choice order (the spec's priority), made explicit here.
*/
function unionSelector(name, types) {
const variants = types[name].variants
const constKeys = new Set()
for (const leaf of variants.flatMap((v) => unionLeaves(v, types)))
for (const f of types[leaf].fields)
if (discriminatorValue(types[leaf], f.name)?.value !== undefined) constKeys.add(f.name)
for (const key of constKeys) {
const contributions = variants.map((v) => tagContribution(v, key, types))
if (contributions.some((c) => c === null)) continue // some member can't be placed on this key
const tagged = contributions.flatMap((c) => c.tagged ?? [])
const defaults = contributions.filter((c) => c.default).map((c) => c.default)
if (defaults.length > 1 || tagged.length === 0) continue // ambiguous catch-all, or nothing to tag
const values = tagged.map((e) => JSON.stringify(e.value))
if (new Set(values).size !== values.length) continue // values collide — not a clean tag
const selector = { by: key, variants: tagged }
if (defaults.length === 1) selector.default = defaults[0]
return selector
}
// No shared discriminator: dispatch by required-field presence, in spec order.
// Resolve each variant through aliases/sub-unions to its leaf records (as the
// discriminator path does) and require the fields required in every leaf, so an
// alias-to-record variant is not left with an empty (always-matching) predicate.
const requiresOf = (ref) => {
const leaves = unionLeaves(ref, types).map(
(l) => new Set(types[l].fields.filter((f) => f.required).map((f) => f.name)),
)
return leaves.length ? [...leaves[0]].filter((k) => leaves.every((s) => s.has(k))) : []
}
return { ordered: variants.map((ref) => ({ ref, requires: requiresOf(ref) })) }
}
// A structural selector can dispatch a payload only when every arm has a required
// field to test AND no arm's `requires` is a subset of a later arm's (which would
// shadow it under first-match) — the same validity checkSelector enforces, used
// here so the correlated walk treats an undispatchable union as a result grouping.
function orderedIsDispatchable(ordered) {
for (let i = 0; i < ordered.length; i++) {
if (ordered[i].requires.length === 0) return false
for (let j = i + 1; j < ordered.length; j++)
if (ordered[j].requires.length && ordered[i].requires.every((k) => ordered[j].requires.includes(k))) return false
}
return true
}
// The command-response envelope is the record that pairs a `result` union with the
// request `id` that correlates it — that id is what makes its result request-
// dispatched rather than payload-dispatched. Returns the result union's name, or
// null. The `id` requirement is what excludes a plain payload type that merely has
// a `result` field (e.g. script.EvaluateResultSuccess, which has no request id).
function envelopeResultUnion(record, types) {
if (record?.kind !== 'record' || !record.fields.some((f) => f.name === 'id' && f.required)) return null
const result = record.fields.find(
(f) => f.name === 'result' && f.required && f.type.ref && types[f.type.ref]?.kind === 'union',
)
return result ? result.type.ref : null
}
// The command-result hierarchy is dispatched by request id, not by inspecting the
// payload (a response is matched to the command that produced it), so those unions
// must not carry a payload selector. They can't be found from the model alone —
// void commands record `result: null`, erasing whole result unions (e.g. every
// emulation result) — so identify them structurally from the response envelope's
// `result` union (envelopeResultUnion), then walk the variant tree, marking each
// union that has no payload discriminator. The discriminator guard stops the walk
// at a result that IS payload-dispatched (e.g. script.EvaluateResult on `type`),
// leaving its selector intact. Requires provisional selectors to already be set.
function correlatedUnions(types) {
const roots = new Set()
for (const t of Object.values(types)) {
const root = envelopeResultUnion(t, types)
if (root) roots.add(root)
}
// A union is payload-dispatched — and so must keep its selector — when it has a
// discriminator OR a structural selector that can actually distinguish its arms
// (matching the validity the gate enforces). The result groupings fail this
// (their arms share/lack distinguishing fields), so the walk passes through them.
const payloadDispatched = (sel) => Boolean(sel?.by || (sel?.ordered && orderedIsDispatchable(sel.ordered)))
const correlated = new Set()
const mark = (name) => {
const t = types[name]
if (!t || t.kind !== 'union' || correlated.has(name) || payloadDispatched(t.selector)) return
correlated.add(name)
t.variants.forEach(mark)
}
roots.forEach(mark)
return correlated
}
/**
* 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).
* @param {object} model The binding-neutral command/event model (per-domain).
* @returns {{schemaVersion: number, commands: object[], events: object[], types: object}} The schema.
*/
export function projectSchema(ast, model) {
const types = {}
for (const def of normalizeAst(ast)) {
if (!def?.Name) continue
const node = projectType(def)
// Types the normalizer minted for anonymous CDDL constructs (hoisted enums /
// inline records, union arms) carry their decomposition so a binding can name
// or nest them idiomatically without parsing the synthetic key. `owner` is the
// type the construct was lifted out of; `label` is the member name within it.
if (def['x-selenium-synthetic']) {
node.synthetic = true
node.owner = def['x-selenium-owner']
node.label = def['x-selenium-label']
}
types[def.Name] = node
}
for (const [name, node] of Object.entries(types))
if (node.kind === 'union') node.selector = unionSelector(name, types)
// Override the result-grouping unions: they are dispatched by request id, so a
// payload selector for them is meaningless (and would be empty/ambiguous).
for (const name of correlatedUnions(types)) types[name].selector = { correlated: true }
const commands = []
const events = []
const envelopeParams = commandEnvelopeParams(types)
for (const [domain, entry] of Object.entries(model)) {
for (const c of entry.commands ?? [])
commands.push({
domain,
method: c.method,
name: c.name,
// Prefer the envelope's params (it captures inline params the model drops).
params: typeRef(envelopeParams.get(c.method) ?? c.params),
result: typeRef(c.result),
})
for (const e of entry.events ?? [])
events.push({ domain, method: e.method, name: e.name, params: typeRef(envelopeParams.get(e.method) ?? e.params) })
}
return { schemaVersion: 1, commands, events, types }
}
/**
* Fail-closed validation: every type reference resolves, and no type projects to
* `unknown` (which would mean an unhandled CDDL form) — across command/event
* params and results, record fields, record maps, union variants, and aliases.
* @param {object} schema The projected schema (`{commands, events, types}`).
* @returns {string[]} One message per problem; empty when valid.
*/
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
: node.primitive === 'unknown'
? true
: node.list
? hasUnknown(node.list)
: node.map
? hasUnknown(node.map)
: node.union
? node.union.some(hasUnknown)
: node.record
? node.record.some((f) => hasUnknown(f.type))
: false
const hasEmptyInlineRecord = (node) =>
!node
? false
: Array.isArray(node.record)
? node.record.length === 0 || node.record.some((f) => hasEmptyInlineRecord(f.type))
: node.list
? hasEmptyInlineRecord(node.list)
: node.map
? hasEmptyInlineRecord(node.map)
: node.union
? node.union.some(hasEmptyInlineRecord)
: false
const report = (where, node) => {
for (const r of refsIn(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)`)
}
for (const c of [...schema.commands, ...schema.events]) {
report(c.method, c.params)
report(c.method, c.result ?? null)
}
// A command/event whose envelope record carries real params must surface them —
// guards the model builder's gap where an inline `params: {...}` (vs a named ref)
// was dropped, leaving it parameterless while its type still required them.
const envelopeParams = commandEnvelopeParams(schema.types)
for (const c of [...schema.commands, ...schema.events]) {
const expected = envelopeParams.get(c.method)
if (expected && c.params?.ref !== expected)
errors.push(`${c.method}: params ${c.params?.ref ?? 'null'} does not match required envelope params ${expected}`)
}
for (const [name, node] of Object.entries(schema.types)) {
if (node.synthetic && !has(node.owner)) errors.push(`${name}: synthetic owner ${node.owner} does not resolve`)
if (node.kind === 'record') {
for (const f of node.fields) report(`${name}.${f.name}`, f.type)
if (node.map) report(`${name}.*`, node.map)
} else if (node.kind === 'union') {
for (const v of node.variants) if (!has(v)) errors.push(`${name}: unresolved variant ${v}`)
errors.push(...checkSelector(name, node.selector, has))
} else if (node.kind === 'alias') {
report(name, node.type)
}
}
// A `correlated` union is resolved by request id, which only holds at the command
// response envelope's `result` position. If one is reachable anywhere else — a
// non-`result` field, a `result` field on a record that is not the envelope (no
// request id), a map/list/nested element, an alias, or a variant of a
// non-correlated union — it would actually need payload dispatch, and marking it
// correlated silently drops its selector. Fail closed so a misclassification (or
// a too-broad envelope match) cannot ship.
const correlated = new Set(
Object.entries(schema.types)
.filter(([, t]) => t.kind === 'union' && t.selector?.correlated)
.map(([n]) => n),
)
const leak = (where, r) =>
errors.push(`${where}: correlated union ${r} is reachable as a value (needs a payload selector)`)
for (const [name, node] of Object.entries(schema.types)) {
if (node.kind === 'record') {
const envelopeRoot = envelopeResultUnion(node, schema.types)
for (const f of node.fields)
for (const r of refsIn(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)
} 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)
}
}
return errors
}
// Validate a union's selector: every referenced variant resolves, a discriminated
// selector has distinct values and at most one default, a structural selector
// dispatches on something. Keeps a malformed selector from shipping silently.
function checkSelector(name, selector, has) {
const errors = []
if (!selector) return [`${name}: union has no selector`]
if (selector.correlated) return [] // resolved by request id, not the payload — nothing to dispatch
if (selector.by) {
const values = selector.variants.map((v) => JSON.stringify(v.value))
if (new Set(values).size !== values.length) errors.push(`${name}: selector has duplicate discriminator values`)
for (const v of selector.variants)
if (!has(v.ref)) errors.push(`${name}: selector variant ${v.ref} does not resolve`)
if (selector.default && !has(selector.default))
errors.push(`${name}: selector default ${selector.default} does not resolve`)
} else if (selector.ordered) {
// A structural selector must actually dispatch from the payload: every arm needs
// a distinguishing required field, and no arm's `requires` may be a subset of a
// later arm's — that would shadow the later arm under first-match. A union that
// cannot satisfy this is not payload-dispatchable and must be `correlated`.
selector.ordered.forEach((v, i) => {
if (!has(v.ref)) errors.push(`${name}: selector variant ${v.ref} does not resolve`)
if (!v.requires.length)
errors.push(`${name}: structural selector arm ${v.ref} has no required fields to dispatch on`)
for (let j = i + 1; j < selector.ordered.length; j++) {
const w = selector.ordered[j]
if (v.requires.length && w.requires.length && v.requires.every((k) => w.requires.includes(k)))
errors.push(`${name}: structural selector arm ${v.ref} shadows ${w.ref} (requires is a subset)`)
}
})
} else {
errors.push(`${name}: selector is neither discriminated, structural, nor correlated`)
}
return errors
}
// ============================================================
// CLI: raw ast + model → flat schema (validated)
// node project_bidi_schema.mjs --ast <ast.json> --model <model.json> --dump-schema <out.json>
// ============================================================
async function main() {
const { parseArgs } = await import('node:util')
const { readFileSync, writeFileSync } = await import('node:fs')
const { resolve } = await import('node:path')
// Under Bazel the js_binary wrapper chdir's to BAZEL_BINDIR, but $(location)
// inputs are execroot-relative and already carry that prefix — strip it so the
// path is not doubled. Mirrors resolveInputPath() in generate_bidi.mjs.
const resolveInput = (p) => {
if (!process.env.BAZEL_BINDIR) return resolve(p)
const prefix = process.env.BAZEL_BINDIR.replaceAll('\\', '/') + '/'
const norm = p.replaceAll('\\', '/')
return resolve(norm.startsWith(prefix) ? norm.slice(prefix.length) : norm)
}
const { values: args } = parseArgs({
options: { ast: { type: 'string' }, model: { type: 'string' }, 'dump-schema': { type: 'string' } },
})
if (!args.ast || !args.model || !args['dump-schema']) {
console.error('Usage: project_bidi_schema.mjs --ast <ast.json> --model <model.json> --dump-schema <out.json>')
process.exit(1)
}
const ast = JSON.parse(readFileSync(resolveInput(args.ast), 'utf8'))
const model = JSON.parse(readFileSync(resolveInput(args.model), 'utf8'))
const schema = projectSchema(ast, model)
// Generation is the gate: a broken or incomplete schema fails the build.
const errors = [...checkSchema(schema), ...checkCompleteness(ast, schema)]
if (errors.length) {
console.error('BiDi schema validation failed:')
errors.forEach((e) => console.error(` ${e}`))
process.exit(1)
}
writeFileSync(resolve(args['dump-schema']), JSON.stringify(schema, null, 2) + '\n', 'utf8')
console.log(
` ${schema.commands.length} commands, ${schema.events.length} events, ${Object.keys(schema.types).length} types → ${args['dump-schema']}`,
)
}
// Run main() when invoked as the entry module. Uses an argv comparison rather
// than `import.meta.main`, which is only available on newer Node versions.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((err) => {
console.error(err)
process.exit(1)
})
}
/**
* Independent completeness check: re-derive every command/event method straight
* from the raw AST (a leaf def carries a literal `method` property) and assert it
* survived into the schema. This compares input to output without trusting the
* generator, so a dropped command/event fails the build even if generation and
* its own checkSchema agree. Run as a Bazel test over committed fixtures.
* @param {object[]} rawAst The parsed CDDL AST (pre-normalization).
* @param {object} schema The projected schema to check against.
* @returns {string[]} One message per dropped or stale-allowlisted method; empty when complete.
*/
export function checkCompleteness(rawAst, schema) {
const emitted = new Set([...schema.commands, ...schema.events].map((c) => c.method))
const errors = []
for (const def of rawAst) {
const methodProp = (def.Properties ?? []).flat().find((p) => p?.Name === 'method')
const literal = methodProp && (Array.isArray(methodProp.Type) ? methodProp.Type[0] : methodProp.Type)
if (literal?.Type !== 'literal') continue
if (!emitted.has(literal.Value) && !KNOWN_INCOMPLETE.has(literal.Value))
errors.push(`dropped from schema: ${literal.Value}`)
}
// Self-cleaning: if a known-incomplete method is now emitted, the entry is
// stale and must be removed — so the allowlist cannot silently rot.
for (const known of KNOWN_INCOMPLETE) {
if (emitted.has(known)) errors.push(`stale KNOWN_INCOMPLETE entry (now emitted, remove it): ${known}`)
}
return errors
}