From ec13b243318fff5db6a6ed2c5877b06bfbc9dd6f Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Thu, 4 Jun 2026 21:25:44 +0100 Subject: [PATCH] Generate RegisteredNodes category unions; remove HAND_WRITTEN_UNIONS Teaches the generator a new union-emission mode for category unions whose `registered` twin contains members the standalone `` doesn't \u2014 i.e. the categories that use Rust's `#[derive(RegisteredNodes)]` registered/standalone split. New helpers in `unions.ts`: `isRegisteredCategoryUnion` decides when this mode applies (true for `value`/`type`/`contextualValue`, false for `link`/`count`/`discriminator`/`pdaSeed`); `getRegisteredOnlyLeafKinds` returns the `#[registered]`-only variants in spec order. A new `registeredUnionPage.ts` fragment renderer emits `#[derive(RegisteredNodes)] #[node_union] pub enum Registered { \u2026 #[registered] \u2026 }`, with standalone variants alphabetical and `#[registered]` variants in spec order (functionally equivalent to the hand-written file). The orchestrator in `index.ts` dispatches to the registered renderer when applicable. With the generator now able to emit `RegisteredValueNode` (and, in future PRs, `RegisteredTypeNode` / `RegisteredContextualValueNode`), `HAND_WRITTEN_UNIONS` becomes empty and is deleted. The hand-written `value_nodes/value_node.rs` shrinks to a `#[cfg(test)] mod tests` block (the two `kind_from_*` smoke tests), and `value_nodes/mod.rs` drops the now-redundant `pub use value_node::*;`. As a small companion change, `page.ts`'s import-grouping rule is generalised from "only `crate::*`" to "any shared module prefix" so the new `use codama_nodes_derive::{node_union, RegisteredNodes};` is grouped automatically (no more special case for `crate::*`). The override surface is now down to three maps: `CATEGORY_ROUTING`, `CATEGORY_DIRECTORIES`, and `FIELD_TYPE_OVERRIDES`. --- codama-nodes/src/generated/value_nodes/mod.rs | 2 + .../src/generated/value_nodes/value_node.rs | 31 ++++++++ codama-nodes/src/value_nodes/mod.rs | 6 -- codama-nodes/src/value_nodes/value_node.rs | 34 +-------- spec-generators/src/defaults.ts | 17 ----- spec-generators/src/fragments/index.ts | 1 + spec-generators/src/fragments/page.ts | 38 ++++++---- .../src/fragments/registeredUnionPage.ts | 76 +++++++++++++++++++ spec-generators/src/index.ts | 29 ++++--- spec-generators/src/unions.ts | 42 ++++++++-- spec-generators/test/generate.test.ts | 21 +++-- spec-generators/test/unions.test.ts | 40 +++++++++- 12 files changed, 242 insertions(+), 95 deletions(-) create mode 100644 codama-nodes/src/generated/value_nodes/value_node.rs create mode 100644 spec-generators/src/fragments/registeredUnionPage.ts diff --git a/codama-nodes/src/generated/value_nodes/mod.rs b/codama-nodes/src/generated/value_nodes/mod.rs index 9007851a..7d7e434f 100644 --- a/codama-nodes/src/generated/value_nodes/mod.rs +++ b/codama-nodes/src/generated/value_nodes/mod.rs @@ -15,6 +15,7 @@ mod string_value_node; mod struct_field_value_node; mod struct_value_node; mod tuple_value_node; +mod value_node; pub use array_value_node::*; pub use boolean_value_node::*; @@ -33,3 +34,4 @@ pub use string_value_node::*; pub use struct_field_value_node::*; pub use struct_value_node::*; pub use tuple_value_node::*; +pub use value_node::*; diff --git a/codama-nodes/src/generated/value_nodes/value_node.rs b/codama-nodes/src/generated/value_nodes/value_node.rs new file mode 100644 index 00000000..5c0e0308 --- /dev/null +++ b/codama-nodes/src/generated/value_nodes/value_node.rs @@ -0,0 +1,31 @@ +use crate::{ + ArrayValueNode, BooleanValueNode, BytesValueNode, ConstantValueNode, EnumValueNode, HasKind, + MapEntryValueNode, MapValueNode, NoneValueNode, NumberValueNode, PublicKeyValueNode, + SetValueNode, SomeValueNode, StringValueNode, StructFieldValueNode, StructValueNode, + TupleValueNode, +}; +use codama_nodes_derive::{node_union, RegisteredNodes}; + +#[derive(RegisteredNodes)] +#[node_union] +pub enum RegisteredValueNode { + Array(ArrayValueNode), + Boolean(BooleanValueNode), + Bytes(BytesValueNode), + Constant(ConstantValueNode), + Enum(EnumValueNode), + Map(MapValueNode), + None(NoneValueNode), + Number(NumberValueNode), + PublicKey(PublicKeyValueNode), + Set(SetValueNode), + Some(SomeValueNode), + String(StringValueNode), + Struct(StructValueNode), + Tuple(TupleValueNode), + + #[registered] + MapEntry(MapEntryValueNode), + #[registered] + StructField(StructFieldValueNode), +} diff --git a/codama-nodes/src/value_nodes/mod.rs b/codama-nodes/src/value_nodes/mod.rs index d9b6d184..40a66d8c 100644 --- a/codama-nodes/src/value_nodes/mod.rs +++ b/codama-nodes/src/value_nodes/mod.rs @@ -16,12 +16,6 @@ mod struct_value_node; mod tuple_value_node; mod value_node; -// `value_node.rs` hosts the hand-written `RegisteredValueNode` (with -// the `#[derive(RegisteredNodes)]` registered/standalone split) which -// can't be reproduced mechanically — re-export it so the category -// union stays reachable at the crate root. -pub use value_node::*; - // `number_value_node.rs` keeps the bespoke `Number` enum (with its // custom `serde(from/into = "JsonNumber")` + the 8 `From` // impls); the rest of the module is constructors + tests. Re-export diff --git a/codama-nodes/src/value_nodes/value_node.rs b/codama-nodes/src/value_nodes/value_node.rs index d8cded9b..c949a969 100644 --- a/codama-nodes/src/value_nodes/value_node.rs +++ b/codama-nodes/src/value_nodes/value_node.rs @@ -1,38 +1,6 @@ -use crate::{ - ArrayValueNode, BooleanValueNode, BytesValueNode, ConstantValueNode, EnumValueNode, HasKind, - MapEntryValueNode, MapValueNode, NoneValueNode, NumberValueNode, PublicKeyValueNode, - SetValueNode, SomeValueNode, StringValueNode, StructFieldValueNode, StructValueNode, - TupleValueNode, -}; -use codama_nodes_derive::{node_union, RegisteredNodes}; - -#[derive(RegisteredNodes)] -#[node_union] -pub enum RegisteredValueNode { - Array(ArrayValueNode), - Boolean(BooleanValueNode), - Bytes(BytesValueNode), - Constant(ConstantValueNode), - Enum(EnumValueNode), - Map(MapValueNode), - None(NoneValueNode), - Number(NumberValueNode), - PublicKey(PublicKeyValueNode), - Set(SetValueNode), - Some(SomeValueNode), - String(StringValueNode), - Struct(StructValueNode), - Tuple(TupleValueNode), - - #[registered] - StructField(StructFieldValueNode), - #[registered] - MapEntry(MapEntryValueNode), -} - #[cfg(test)] mod tests { - use super::*; + use crate::{HasKind, NoneValueNode, RegisteredValueNode, ValueNode}; #[test] fn kind_from_standalone() { diff --git a/spec-generators/src/defaults.ts b/spec-generators/src/defaults.ts index 7995558e..e0be7b10 100644 --- a/spec-generators/src/defaults.ts +++ b/spec-generators/src/defaults.ts @@ -51,23 +51,6 @@ export const CATEGORY_DIRECTORIES: ReadonlyMap = new Map([ ['value', 'value_nodes'], ]); -/** - * Spec union names the generator must NOT emit because their Rust - * counterpart is bespoke hand-written code that can't be reproduced - * mechanically. - * - * - `valueNode` is the category dispatch union for the `value` - * category, but in Rust it's the auto-derived standalone twin of - * `RegisteredValueNode` (`#[derive(RegisteredNodes)]`), which has - * the `registered`/`standalone` split with `#[registered]` - * variants. The generator doesn't model that yet; the - * hand-written `value_nodes/value_node.rs` stays canonical. - * - * Per-node structs in these categories are still generated normally; - * only the category union itself is skipped. - */ -export const HAND_WRITTEN_UNIONS: ReadonlySet = new Set(['valueNode']); - /** * Per-field Rust-type overrides for cases where the spec's TypeExpr * maps to a bespoke Rust type that can't be expressed mechanically. diff --git a/spec-generators/src/fragments/index.ts b/spec-generators/src/fragments/index.ts index 2fa0788e..c1403da1 100644 --- a/spec-generators/src/fragments/index.ts +++ b/spec-generators/src/fragments/index.ts @@ -6,5 +6,6 @@ export * from './modPage'; export * from './nodePage'; export * from './nodeStructFragment'; export * from './page'; +export * from './registeredUnionPage'; export * from './typeExpr'; export * from './unionPage'; diff --git a/spec-generators/src/fragments/page.ts b/spec-generators/src/fragments/page.ts index d0565f43..210db704 100644 --- a/spec-generators/src/fragments/page.ts +++ b/spec-generators/src/fragments/page.ts @@ -4,9 +4,12 @@ import { type Fragment, fragment, type ImportMap, importMapToString, mergeFragme * Render a Rust source page from a body fragment: prepend a `use` * block built from the fragment's import map, then the body content. * - * `use crate::Foo;` lines are collapsed into a single - * `use crate::{Foo, Bar};` to match the hand-written convention; - * non-`crate::` paths stay on their own lines, sorted alphabetically. + * Imports sharing the same module prefix are collapsed into a single + * grouped `use ::{A, B};` line (e.g. `use crate::{Foo, Bar};`, + * `use codama_nodes_derive::{node_union, RegisteredNodes};`) to match + * the hand-written convention. Single-import modules stay on their + * own line (`use crate::Foo;`). Lines are sorted alphabetically by + * module path. * * This grouping has to happen here because stable `rustfmt` (the * codama-rs toolchain) sorts `use` lines but won't merge them — @@ -19,23 +22,32 @@ export function getPageFragment(body: Fragment): Fragment { } function formatImports(importMap: ImportMap): string { + // Each line is `use ;`. Split each path into `::`, + // grouping by module so multiple imports from the same module collapse. const lines = importMapToString(importMap) .split('\n') .filter(line => line !== ''); - const crateRefs: string[] = []; - const other: string[] = []; + const byModule = new Map(); + const ungroupable: string[] = []; for (const line of lines) { - const match = /^use crate::([A-Za-z0-9_]+);$/.exec(line); - if (match) crateRefs.push(match[1]); - else other.push(line); + const match = /^use (.+)::([A-Za-z0-9_]+);$/.exec(line); + if (!match) { + ungroupable.push(line); + continue; + } + const [, mod, name] = match; + const names = byModule.get(mod) ?? []; + names.push(name); + byModule.set(mod, names); } - const output: string[] = []; - if (crateRefs.length > 0) { - const sorted = [...crateRefs].toSorted((a, b) => a.localeCompare(b)); - output.push(sorted.length === 1 ? `use crate::${sorted[0]};` : `use crate::{${sorted.join(', ')}};`); + const grouped: string[] = []; + for (const [mod, names] of byModule) { + const sorted = [...names].toSorted((a, b) => a.localeCompare(b)); + grouped.push(sorted.length === 1 ? `use ${mod}::${sorted[0]};` : `use ${mod}::{${sorted.join(', ')}};`); } - output.push(...other.toSorted((a, b) => a.localeCompare(b))); + + const output = [...grouped, ...ungroupable].toSorted((a, b) => a.localeCompare(b)); return output.join('\n'); } diff --git a/spec-generators/src/fragments/registeredUnionPage.ts b/spec-generators/src/fragments/registeredUnionPage.ts new file mode 100644 index 00000000..bd10e310 --- /dev/null +++ b/spec-generators/src/fragments/registeredUnionPage.ts @@ -0,0 +1,76 @@ +import { pascalCase } from '@codama/fragments'; +import { addFragmentImports, type Fragment, fragment, mergeFragments } from '@codama/fragments/rust'; +import type { Spec, UnionSpec } from '@codama/spec'; + +import { flattenNodeUnion, getRegisteredOnlyLeafKinds } from '../unions'; +import { use } from './helpers'; + +/** + * The body for a category union that has `#[registered]`-only + * variants (e.g. `value`'s `RegisteredValueNode`). Emits: + * + * `#[derive(RegisteredNodes)] #[node_union] pub enum Registered { + * + * + * #[registered] + * + * … + * }` + * + * The `RegisteredNodes` derive macro produces the standalone twin + * (`X`) plus the `From`/`TryFrom` bridges between them, so the + * generated file replaces the entire hand-written `value_node.rs` + * pattern. + * + * Standalone variants are sorted alphabetically by their stripped + * variant name (consistent with `unionPage.ts`); `#[registered]` + * variants follow in spec declaration order (the `registered` + * union's member order). The blank line between the two sections is + * preserved by `rustfmt`. + */ +export function getRegisteredUnionPageFragment(union: UnionSpec, spec: Spec): Fragment { + const enumName = `Registered${pascalCase(union.name)}`; + const suffix = pascalCase(union.name); + + const registeredOnlyKinds = new Set(getRegisteredOnlyLeafKinds(union, spec)); + const standaloneLeaves = [...flattenNodeUnion(union, spec)]; + const standaloneVariants = standaloneLeaves + .map(node => ({ kind: node.kind, name: variantNameForKind(node.kind, suffix) })) + .toSorted((a, b) => a.name.localeCompare(b.name)); + const registeredOnlyVariants = getRegisteredOnlyLeafKinds(union, spec).map(kind => ({ + kind, + name: variantNameForKind(kind, suffix), + })); + + // Sanity check: the registered-only kinds must not overlap with the standalone set. + for (const v of registeredOnlyVariants) { + if (!registeredOnlyKinds.has(v.kind)) { + throw new Error(`unexpected variant kind "${v.kind}" while building ${enumName}`); + } + } + + const standaloneLines = mergeFragments( + standaloneVariants.map(v => fragment`${v.name}(${use(`crate::${pascalCase(v.kind)}`)}),`), + parts => parts.join('\n'), + ); + const registeredLines = mergeFragments( + registeredOnlyVariants.map(v => fragment`#[registered]\n${v.name}(${use(`crate::${pascalCase(v.kind)}`)}),`), + parts => parts.join('\n'), + ); + + // Two sections separated by a blank line so rustfmt preserves the + // visual split between standalone and `#[registered]` variants. + const body = mergeFragments([standaloneLines, registeredLines], parts => parts.join('\n\n')); + + // The `RegisteredNodes` derive macro expansion calls `.kind()` on + // each variant, which requires `HasKind` to be in scope. + return addFragmentImports( + fragment`#[derive(${use('codama_nodes_derive::RegisteredNodes')})]\n#[${use('codama_nodes_derive::node_union')}]\npub enum ${enumName} {\n${body}\n}`, + ['crate::HasKind'], + ); +} + +function variantNameForKind(kind: string, suffix: string): string { + const pascal = pascalCase(kind); + return pascal.endsWith(suffix) ? pascal.slice(0, pascal.length - suffix.length) : pascal; +} diff --git a/spec-generators/src/index.ts b/spec-generators/src/index.ts index 1ad4fa05..26c7f6ef 100644 --- a/spec-generators/src/index.ts +++ b/spec-generators/src/index.ts @@ -12,7 +12,13 @@ import { type Fragment } from '@codama/fragments/rust'; import { getSpec, type Spec } from '@codama/spec'; import { CATEGORY_ROUTING } from './defaults'; -import { getModPagesRenderMap, getNodePageFragment, getPageFragment, getUnionPageFragment } from './fragments'; +import { + getModPagesRenderMap, + getNodePageFragment, + getPageFragment, + getRegisteredUnionPageFragment, + getUnionPageFragment, +} from './fragments'; import { buildRenderScope, type GenerateOptions, @@ -21,15 +27,9 @@ import { validateRenderOptions, } from './options'; import { getRepoDirectory } from './repoDirectory'; -import { getEmittableUnions } from './unions'; +import { getEmittableUnions, isRegisteredCategoryUnion } from './unions'; -export { - CATEGORY_DIRECTORIES, - type CategoryRouting, - CATEGORY_ROUTING, - FIELD_TYPE_OVERRIDES, - HAND_WRITTEN_UNIONS, -} from './defaults'; +export { CATEGORY_DIRECTORIES, type CategoryRouting, CATEGORY_ROUTING, FIELD_TYPE_OVERRIDES } from './defaults'; export { buildRenderScope, type GenerateOptions, @@ -105,7 +105,16 @@ function getSpecPagesRenderMap(spec: Spec, scope: RenderScope): RenderMap` twin has extra + // `#[registered]`-only members (currently `value`, and in + // future `type` / `contextualValue`) are emitted via the + // `RegisteredNodes` derive; the standalone twin is then + // auto-derived. Other unions take the plain `#[node_union]` + // path. + const fragment = isRegisteredCategoryUnion(union, spec) + ? getRegisteredUnionPageFragment(union, spec) + : getUnionPageFragment(union, spec); + entries[path] = getPageFragment(fragment); } } diff --git a/spec-generators/src/unions.ts b/spec-generators/src/unions.ts index af469c65..8537ee9d 100644 --- a/spec-generators/src/unions.ts +++ b/spec-generators/src/unions.ts @@ -1,8 +1,6 @@ import { pascalCase } from '@codama/fragments'; import type { NodeSpec, Spec, UnionSpec } from '@codama/spec'; -import { HAND_WRITTEN_UNIONS } from './defaults'; - /** * Spec unions starting with `registered` are category-registry unions * (e.g. `registeredLinkNode`); the Rust crate exposes one flattened @@ -22,10 +20,6 @@ const REGISTERED_UNION_PREFIX = 'registered'; * twin AND referenced by at least one node attribute somewhere * in the spec. This rule is derived from the spec; no hand-list. * - * Unions in {@link HAND_WRITTEN_UNIONS} are skipped — their Rust - * counterpart is bespoke (e.g. `valueNode` → `RegisteredValueNode` - * with `#[derive(RegisteredNodes)]`). - * * Sorted alphabetically by name for stable output. */ export function getEmittableUnions(category: Spec['categories'][number], spec: Spec): readonly UnionSpec[] { @@ -33,11 +27,45 @@ export function getEmittableUnions(category: Spec['categories'][number], spec: S const allUnionNames = new Set(spec.categories.flatMap(c => c.unions).map(u => u.name)); return category.unions .filter(u => !u.name.startsWith(REGISTERED_UNION_PREFIX)) - .filter(u => !HAND_WRITTEN_UNIONS.has(u.name)) .filter(u => hasRegisteredTwin(u.name, allUnionNames) || isInlineUnion(u, allUnionNames, referenced)) .toSorted((a, b) => a.name.localeCompare(b.name)); } +/** + * `true` when the standalone `union` is the twin of a + * `registered` that contains members the standalone + * doesn't (i.e. the `registered` has at least one + * `#[registered]`-only variant). Such unions need the + * `#[derive(RegisteredNodes)]` emission mode — see + * `registeredUnionPage.ts`. Categories like `link`/`count` whose + * registered twin is identical to the standalone do NOT match. + */ +export function isRegisteredCategoryUnion(union: UnionSpec, spec: Spec): boolean { + const twin = spec.categories + .flatMap(c => c.unions) + .find(u => u.name === `${REGISTERED_UNION_PREFIX}${pascalCase(union.name)}`); + if (!twin) return false; + const standaloneKinds = new Set([...flattenNodeUnion(union, spec)].map(n => n.kind)); + const twinKinds = [...flattenNodeUnion(twin, spec)].map(n => n.kind); + return twinKinds.some(k => !standaloneKinds.has(k)); +} + +/** + * The leaf node kinds that are present in the `registered` twin + * but NOT in the standalone `` — i.e. the variants that must be + * marked `#[registered]` in the emitted enum. Returned in spec + * declaration order (the `registered` union's member order), + * since that order is deterministic and has no semantic effect. + */ +export function getRegisteredOnlyLeafKinds(union: UnionSpec, spec: Spec): readonly string[] { + const twin = spec.categories + .flatMap(c => c.unions) + .find(u => u.name === `${REGISTERED_UNION_PREFIX}${pascalCase(union.name)}`); + if (!twin) return []; + const standaloneKinds = new Set([...flattenNodeUnion(union, spec)].map(n => n.kind)); + return [...flattenNodeUnion(twin, spec)].map(n => n.kind).filter(k => !standaloneKinds.has(k)); +} + /** * `true` when `union` is an inline / synthetic union: it has no * `registered` sibling anywhere in the spec AND it is diff --git a/spec-generators/test/generate.test.ts b/spec-generators/test/generate.test.ts index 28b1fd3b..b55606e8 100644 --- a/spec-generators/test/generate.test.ts +++ b/spec-generators/test/generate.test.ts @@ -80,15 +80,24 @@ describe('getRenderMap', () => { 'value_nodes/struct_field_value_node.rs', 'value_nodes/struct_value_node.rs', 'value_nodes/tuple_value_node.rs', + 'value_nodes/value_node.rs', ]); }); - it('does NOT emit the `valueNode` category union (HAND_WRITTEN_UNIONS skip)', () => { - // `value`'s category union is hand-written - // (`RegisteredValueNode` with `#[derive(RegisteredNodes)]`), - // not mechanically generatable. - const keys = [...map.keys()]; - expect(keys).not.toContain('value_nodes/value_node.rs'); + it('emits the `value` category union via the RegisteredNodes derive (standalone + #[registered] split)', () => { + // Render-map content is pre-format (no indentation); `cargo fmt` + // restores it on disk. + const entry = getFromRenderMap(map, 'value_nodes/value_node.rs'); + expect(entry.content).toContain('use codama_nodes_derive::{node_union, RegisteredNodes};'); + expect(entry.content).toContain('#[derive(RegisteredNodes)]'); + expect(entry.content).toContain('#[node_union]'); + expect(entry.content).toContain('pub enum RegisteredValueNode {'); + // Standalone variants (alphabetical). + expect(entry.content).toContain('Array(ArrayValueNode),'); + expect(entry.content).toContain('Tuple(TupleValueNode),'); + // `#[registered]`-only variants follow. + expect(entry.content).toContain('#[registered]\nMapEntry(MapEntryValueNode),'); + expect(entry.content).toContain('#[registered]\nStructField(StructFieldValueNode),'); }); it('per-node pages resolve their crate imports to a grouped `use crate::{…}` block plus the macro line', () => { diff --git a/spec-generators/test/unions.test.ts b/spec-generators/test/unions.test.ts index 75a84449..08655eb3 100644 --- a/spec-generators/test/unions.test.ts +++ b/spec-generators/test/unions.test.ts @@ -1,7 +1,13 @@ import { getSpec } from '@codama/spec'; import { describe, expect, it } from 'vitest'; -import { flattenNodeUnion, getEmittableUnions, getInlineUnionStripSuffix } from '../src/unions'; +import { + flattenNodeUnion, + getEmittableUnions, + getInlineUnionStripSuffix, + getRegisteredOnlyLeafKinds, + isRegisteredCategoryUnion, +} from '../src/unions'; const spec = getSpec(); const linkCategory = spec.categories.find(c => c.name === 'link')!; @@ -35,8 +41,36 @@ describe('getEmittableUnions', () => { } }); - it('skips HAND_WRITTEN_UNIONS even when they have a registered twin (e.g. value/valueNode)', () => { - expect(getEmittableUnions(valueCategory, spec).map(u => u.name)).not.toContain('valueNode'); + it('emits the `value` category union (handled by the RegisteredNodes renderer, dispatched in `index.ts`)', () => { + expect(getEmittableUnions(valueCategory, spec).map(u => u.name)).toContain('valueNode'); + }); +}); + +describe('isRegisteredCategoryUnion', () => { + it('is true when the registered twin has extra members (e.g. value/valueNode)', () => { + const valueNode = valueCategory.unions.find(u => u.name === 'valueNode')!; + expect(isRegisteredCategoryUnion(valueNode, spec)).toBe(true); + }); + + it('is false when registered mirrors the standalone (e.g. link/linkNode, pdaSeed/pdaSeedNode)', () => { + const linkNode = linkCategory.unions.find(u => u.name === 'linkNode')!; + const pdaSeedNode = pdaSeedCategory.unions.find(u => u.name === 'pdaSeedNode')!; + expect(isRegisteredCategoryUnion(linkNode, spec)).toBe(false); + expect(isRegisteredCategoryUnion(pdaSeedNode, spec)).toBe(false); + }); +}); + +describe('getRegisteredOnlyLeafKinds', () => { + it('returns the kinds present in registered but absent from standalone , in spec order', () => { + const valueNode = valueCategory.unions.find(u => u.name === 'valueNode')!; + // spec `registeredValueNode` adds `mapEntryValueNode` then + // `structFieldValueNode` after the standalone members. + expect(getRegisteredOnlyLeafKinds(valueNode, spec)).toEqual(['mapEntryValueNode', 'structFieldValueNode']); + }); + + it('returns an empty list for unions whose registered twin matches the standalone', () => { + const linkNode = linkCategory.unions.find(u => u.name === 'linkNode')!; + expect(getRegisteredOnlyLeafKinds(linkNode, spec)).toEqual([]); }); });