@workos/oagen-emitters 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,488 @@
1
+ import type { ApiSpec, EmitterContext, Enum, Model, Service } from '@workos/oagen';
2
+ import { assignModelsToServices, collectFieldDependencies, planOperation, walkTypeRef } from '@workos/oagen';
3
+ import { fileName } from './naming.js';
4
+ import { detectDiscriminators } from '../shared/model-utils.js';
5
+
6
+ /**
7
+ * Walk every operation across all services and tally, per schema, the set of
8
+ * services that transitively reference it. Schemas referenced by more than one
9
+ * service are "shared" — they should be emitted under common/ rather than
10
+ * the first alphabetical service that happens to use them.
11
+ *
12
+ * Transitive walk for models follows model->model field references AND
13
+ * discriminator variant mappings to a fixed point; enums are leaves.
14
+ */
15
+ export function findSharedSchemas(spec: ApiSpec): { models: Set<string>; enums: Set<string> } {
16
+ const modelsByName = new Map(spec.models.map((m) => [m.name, m]));
17
+ const modelToServices = new Map<string, Set<string>>();
18
+ const enumToServices = new Map<string, Set<string>>();
19
+
20
+ const note = (map: Map<string, Set<string>>, name: string, service: string): void => {
21
+ let bucket = map.get(name);
22
+ if (!bucket) {
23
+ bucket = new Set();
24
+ map.set(name, bucket);
25
+ }
26
+ bucket.add(service);
27
+ };
28
+
29
+ for (const service of spec.services) {
30
+ const directModels = new Set<string>();
31
+ const directEnums = new Set<string>();
32
+ const collect = (ref: unknown): void => {
33
+ walkTypeRef(ref as never, {
34
+ model: (r) => directModels.add(r.name),
35
+ enum: (r) => directEnums.add(r.name),
36
+ });
37
+ };
38
+
39
+ for (const op of service.operations) {
40
+ if (op.requestBody) collect(op.requestBody);
41
+ collect(op.response);
42
+ for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
43
+ collect(p.type);
44
+ }
45
+ if (op.pagination) collect(op.pagination.itemType);
46
+ for (const err of op.errors) {
47
+ if (err.type) collect(err.type);
48
+ }
49
+ for (const sr of op.successResponses ?? []) {
50
+ collect(sr.type);
51
+ }
52
+ }
53
+
54
+ // Transitively expand model references via field types AND discriminator
55
+ // variant mappings (dispatchers route to variants without listing them as
56
+ // fields, so plain field-walking misses them).
57
+ const queue = [...directModels];
58
+ while (queue.length > 0) {
59
+ const name = queue.pop()!;
60
+ const model = modelsByName.get(name);
61
+ if (!model) continue;
62
+ for (const field of model.fields) {
63
+ walkTypeRef(field.type as never, {
64
+ model: (r) => {
65
+ if (!directModels.has(r.name)) {
66
+ directModels.add(r.name);
67
+ queue.push(r.name);
68
+ }
69
+ },
70
+ enum: (r) => directEnums.add(r.name),
71
+ });
72
+ }
73
+ const disc = (model as { discriminator?: { property: string; mapping: Record<string, string> } }).discriminator;
74
+ if (disc?.mapping) {
75
+ for (const variantName of Object.values(disc.mapping)) {
76
+ if (!directModels.has(variantName)) {
77
+ directModels.add(variantName);
78
+ queue.push(variantName);
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ for (const name of directModels) note(modelToServices, name, service.name);
85
+ for (const name of directEnums) note(enumToServices, name, service.name);
86
+ }
87
+
88
+ const sharedModels = new Set<string>();
89
+ for (const [name, services] of modelToServices) {
90
+ if (services.size >= 2) sharedModels.add(name);
91
+ }
92
+ const sharedEnums = new Set<string>();
93
+ for (const [name, services] of enumToServices) {
94
+ if (services.size >= 2) sharedEnums.add(name);
95
+ }
96
+
97
+ return { models: sharedModels, enums: sharedEnums };
98
+ }
99
+
100
+ /**
101
+ * Final placement decisions for every model and enum in the spec. Computed
102
+ * once and consumed by every Python emitter pass (models, enums, resources,
103
+ * tests) so they all agree on which symbols live in `common/` and which live
104
+ * in a service directory.
105
+ */
106
+ export interface SchemaPlacement {
107
+ /** Model -> service. Models in common/ are absent. */
108
+ modelToService: Map<string, string>;
109
+ /** Enum -> service. Enums in common/ are absent. */
110
+ enumToService: Map<string, string>;
111
+ /** Pre-relocation model -> service. Used to attach BC re-exports to the natural service barrel. */
112
+ originalModelToService: Map<string, string>;
113
+ /** Pre-relocation enum -> service. */
114
+ originalEnumToService: Map<string, string>;
115
+ /** Models relocated to common/ (the union of initial sharing + closure expansion). */
116
+ relocatedModels: Set<string>;
117
+ /** Enums relocated to common/. */
118
+ relocatedEnums: Set<string>;
119
+ /** Model alias name -> canonical model name (Python-only structural dedup). */
120
+ modelAliases: Map<string, string>;
121
+ /** Enum alias name -> canonical enum name. */
122
+ enumAliases: Map<string, string>;
123
+ }
124
+
125
+ export function computeSchemaPlacement(spec: ApiSpec, ctx: EmitterContext): SchemaPlacement {
126
+ // Annotate models with implicit discriminators so the closure can follow
127
+ // dispatcher → variant edges. detectDiscriminators is idempotent.
128
+ const annotatedModels = detectDiscriminators(spec.models);
129
+ if (annotatedModels !== spec.models) {
130
+ spec = { ...spec, models: annotatedModels };
131
+ }
132
+ const modelsByName = new Map(spec.models.map((m) => [m.name, m]));
133
+ const hintedModels = new Set(Object.keys(ctx.modelHints ?? {}));
134
+
135
+ const originalModelToService = assignModelsToServices(spec.models, spec.services, ctx.modelHints);
136
+ const originalEnumToService = assignEnumsToServicesNatural(spec.enums, spec.services);
137
+
138
+ // Precompute Python-specific structural alias maps so the closure can
139
+ // promote a canonical when its alias is shared.
140
+ const modelAliases = computeModelAliases(spec);
141
+ const enumAliases = computeEnumAliases(spec.enums);
142
+
143
+ const initial = findSharedSchemas(spec);
144
+
145
+ // Ensure aliases imply their canonical: if the alias is shared, the canonical
146
+ // must follow it into common/, otherwise the alias file would import from a
147
+ // service directory.
148
+ for (const [aliasName, canonicalName] of modelAliases) {
149
+ if (initial.models.has(aliasName)) initial.models.add(canonicalName);
150
+ }
151
+ for (const [aliasName, canonicalName] of enumAliases) {
152
+ if (initial.enums.has(aliasName)) initial.enums.add(canonicalName);
153
+ }
154
+
155
+ // Initial common/-bound set: everything findSharedSchemas flagged (minus
156
+ // hinted models — direct shares respect explicit pins) plus everything that
157
+ // is unassigned from the natural placement and not pinned.
158
+ const sharedModels = new Set<string>();
159
+ for (const name of initial.models) {
160
+ if (!hintedModels.has(name)) sharedModels.add(name);
161
+ }
162
+ for (const model of spec.models) {
163
+ if (!originalModelToService.has(model.name) && !hintedModels.has(model.name)) {
164
+ sharedModels.add(model.name);
165
+ }
166
+ }
167
+ const sharedEnums = new Set(initial.enums);
168
+ for (const enumDef of spec.enums) {
169
+ if (!originalEnumToService.has(enumDef.name)) sharedEnums.add(enumDef.name);
170
+ }
171
+
172
+ // Closure: any model/enum referenced by a model that ends up in common/
173
+ // must also be in common/, otherwise the emitted common/ file would reach
174
+ // back into a service package and create a circular-import hazard. Hints
175
+ // are *not* a stop signal here — the closure is structural. If a hinted
176
+ // model is reachable from common/, leaving it pinned to a service would
177
+ // re-introduce the back-edge. BC for the pinned import path is preserved
178
+ // via re-exports from the natural service barrel.
179
+ let changed = true;
180
+ while (changed) {
181
+ changed = false;
182
+ for (const name of sharedModels) {
183
+ const model = modelsByName.get(name);
184
+ if (!model) continue;
185
+ const deps = collectEmittedDependencies(model, modelAliases);
186
+ for (const dep of deps.models) {
187
+ if (sharedModels.has(dep)) continue;
188
+ if (!modelsByName.has(dep)) continue;
189
+ sharedModels.add(dep);
190
+ changed = true;
191
+ }
192
+ for (const dep of deps.enums) {
193
+ if (sharedEnums.has(dep)) continue;
194
+ sharedEnums.add(dep);
195
+ changed = true;
196
+ }
197
+ }
198
+ }
199
+
200
+ // Build final assignment maps by relocating shared symbols to common/
201
+ // (i.e. removing them from the per-service assignment).
202
+ const modelToService = new Map(originalModelToService);
203
+ for (const name of sharedModels) {
204
+ modelToService.delete(name);
205
+ }
206
+ const enumToService = new Map(originalEnumToService);
207
+ for (const name of sharedEnums) {
208
+ enumToService.delete(name);
209
+ }
210
+
211
+ // relocatedModels = models with a natural service that ended up in common/.
212
+ // Models without a natural service were never in a service barrel, so they
213
+ // don't need a BC re-export. Hinted models DO get re-exported from the
214
+ // hinted service so existing imports keep resolving.
215
+ const relocatedModels = new Set<string>();
216
+ for (const name of sharedModels) {
217
+ if (!originalModelToService.has(name)) continue;
218
+ relocatedModels.add(name);
219
+ }
220
+ const relocatedEnums = new Set<string>();
221
+ for (const name of sharedEnums) {
222
+ if (!originalEnumToService.has(name)) continue;
223
+ relocatedEnums.add(name);
224
+ }
225
+
226
+ return {
227
+ modelToService,
228
+ enumToService,
229
+ originalModelToService,
230
+ originalEnumToService,
231
+ relocatedModels,
232
+ relocatedEnums,
233
+ modelAliases,
234
+ enumAliases,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Dependencies the emitter will materialize for a model's generated file.
240
+ * Captures alias canonicals, discriminator variants, and field-level model+enum
241
+ * references so the placement closure can decide whether the dependency must
242
+ * also live in `common/`.
243
+ */
244
+ function collectEmittedDependencies(
245
+ model: Model,
246
+ modelAliases: Map<string, string>,
247
+ ): { models: Set<string>; enums: Set<string> } {
248
+ const models = new Set<string>();
249
+ const enums = new Set<string>();
250
+
251
+ const canonical = modelAliases.get(model.name);
252
+ if (canonical) {
253
+ models.add(canonical);
254
+ return { models, enums };
255
+ }
256
+
257
+ const disc = (model as { discriminator?: { property: string; mapping: Record<string, string> } }).discriminator;
258
+ if (disc?.mapping) {
259
+ for (const variant of Object.values(disc.mapping)) models.add(variant);
260
+ }
261
+
262
+ const fieldDeps = collectFieldDependencies(model);
263
+ for (const m of fieldDeps.models) models.add(m);
264
+ for (const e of fieldDeps.enums) enums.add(e);
265
+
266
+ return { models, enums };
267
+ }
268
+
269
+ interface ModelUsage {
270
+ requestOnly: Set<string>;
271
+ response: Set<string>;
272
+ mixed: Set<string>;
273
+ }
274
+
275
+ function collectModelUsage(spec: ApiSpec): ModelUsage {
276
+ const request = new Set<string>();
277
+ const response = new Set<string>();
278
+
279
+ for (const service of spec.services) {
280
+ for (const op of service.operations) {
281
+ const plan = planOperation(op);
282
+ if (plan.responseModelName) response.add(plan.responseModelName);
283
+ if (op.pagination?.itemType.kind === 'model') response.add(op.pagination.itemType.name);
284
+ if (op.requestBody?.kind === 'model') request.add(op.requestBody.name);
285
+ if (op.requestBody?.kind === 'union') {
286
+ for (const variant of op.requestBody.variants ?? []) {
287
+ if (variant.kind === 'model') request.add(variant.name);
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ const mixed = new Set<string>();
294
+ for (const name of request) if (response.has(name)) mixed.add(name);
295
+ const requestOnly = new Set([...request].filter((name) => !mixed.has(name)));
296
+ const responseOnly = new Set([...response].filter((name) => !mixed.has(name)));
297
+
298
+ return { requestOnly, response: responseOnly, mixed };
299
+ }
300
+
301
+ function compareAliasPriority(left: string, right: string, usage: ModelUsage): number {
302
+ const score = (name: string): number => {
303
+ if (usage.response.has(name)) return 0;
304
+ if (usage.mixed.has(name)) return 1;
305
+ if (usage.requestOnly.has(name)) return 2;
306
+ return 3;
307
+ };
308
+
309
+ const diff = score(left) - score(right);
310
+ if (diff !== 0) return diff;
311
+ return left.localeCompare(right);
312
+ }
313
+
314
+ function canAliasModels(canonical: string, alias: string, usage: ModelUsage): boolean {
315
+ // Aliases that snake_case-collide with their canonical would self-import.
316
+ if (fileName(canonical) === fileName(alias)) return false;
317
+ // Don't alias across the request/response boundary — they may evolve apart.
318
+ if (
319
+ (usage.response.has(canonical) && usage.requestOnly.has(alias)) ||
320
+ (usage.response.has(alias) && usage.requestOnly.has(canonical))
321
+ ) {
322
+ return false;
323
+ }
324
+ return true;
325
+ }
326
+
327
+ /**
328
+ * Compute the Python emitter's structural model dedup map: alias -> canonical.
329
+ * Mirrors the logic in models.ts so the placement closure can promote
330
+ * canonicals when their aliases are shared.
331
+ */
332
+ export function computeModelAliases(spec: ApiSpec): Map<string, string> {
333
+ const recursiveHashes = buildRecursiveHashMap(spec.models, spec.enums);
334
+ const usage = collectModelUsage(spec);
335
+
336
+ const hashGroups = new Map<string, string[]>();
337
+ for (const model of spec.models) {
338
+ if (model.fields.length === 0 && !(model as { discriminator?: unknown }).discriminator) {
339
+ // skipped by emit anyway when listmeta/wrapper, but we rely on emit-side filter.
340
+ }
341
+ const hash = recursiveHashes.get(model.name) ?? '';
342
+ if (!hashGroups.has(hash)) hashGroups.set(hash, []);
343
+ hashGroups.get(hash)!.push(model.name);
344
+ }
345
+
346
+ const aliasOf = new Map<string, string>();
347
+ for (const [, names] of hashGroups) {
348
+ if (names.length <= 1) continue;
349
+ const sorted = [...names].sort((a, b) => compareAliasPriority(a, b, usage));
350
+ const canonical = sorted[0];
351
+ for (let i = 1; i < sorted.length; i++) {
352
+ if (canAliasModels(canonical, sorted[i], usage)) {
353
+ aliasOf.set(sorted[i], canonical);
354
+ }
355
+ }
356
+ }
357
+ return aliasOf;
358
+ }
359
+
360
+ /**
361
+ * Compute the Python emitter's structural enum dedup map: alias -> canonical.
362
+ * Mirrors the logic in enums.ts.
363
+ */
364
+ export function computeEnumAliases(enums: Enum[]): Map<string, string> {
365
+ const hashGroups = new Map<string, string[]>();
366
+ for (const enumDef of enums) {
367
+ const hash = [...enumDef.values]
368
+ .map((v) => String(v.value))
369
+ .sort()
370
+ .join('|');
371
+ if (!hashGroups.has(hash)) hashGroups.set(hash, []);
372
+ hashGroups.get(hash)!.push(enumDef.name);
373
+ }
374
+
375
+ const aliasOf = new Map<string, string>();
376
+ for (const [, names] of hashGroups) {
377
+ if (names.length <= 1) continue;
378
+ const sorted = [...names].sort();
379
+ const canonical = sorted[0];
380
+ for (let i = 1; i < sorted.length; i++) {
381
+ aliasOf.set(sorted[i], canonical);
382
+ }
383
+ }
384
+ return aliasOf;
385
+ }
386
+
387
+ /**
388
+ * Recursive structural hashing for models so dedup runs against deeply-equal
389
+ * shapes, not just same-named ones. Cycles fall back to the model name.
390
+ */
391
+ function buildRecursiveHashMap(models: Model[], enums: Enum[]): Map<string, string> {
392
+ const modelByName = new Map(models.map((m) => [m.name, m]));
393
+ const hashCache = new Map<string, string>();
394
+ const visiting = new Set<string>();
395
+
396
+ const enumVH = new Map<string, string>();
397
+ for (const e of enums) {
398
+ enumVH.set(
399
+ e.name,
400
+ [...e.values]
401
+ .map((v) => String(v.value))
402
+ .sort()
403
+ .join('|'),
404
+ );
405
+ }
406
+
407
+ function modelHash(name: string): string {
408
+ const cached = hashCache.get(name);
409
+ if (cached != null) return cached;
410
+ if (visiting.has(name)) return `m:${name}`;
411
+ visiting.add(name);
412
+
413
+ const model = modelByName.get(name);
414
+ if (!model) {
415
+ visiting.delete(name);
416
+ return `m:${name}`;
417
+ }
418
+
419
+ const hash = [...model.fields]
420
+ .sort((a, b) => a.name.localeCompare(b.name))
421
+ .map((f) => `${f.name}:${deepTypeHash(f.type)}:${f.required}`)
422
+ .join('|');
423
+
424
+ visiting.delete(name);
425
+ hashCache.set(name, hash);
426
+ return hash;
427
+ }
428
+
429
+ function deepTypeHash(ref: any): string {
430
+ switch (ref.kind) {
431
+ case 'primitive':
432
+ return `p:${ref.type}${ref.format ? `:${ref.format}` : ''}`;
433
+ case 'model':
434
+ return `m:{${modelHash(ref.name)}}`;
435
+ case 'enum': {
436
+ const vh = enumVH.get(ref.name);
437
+ return vh != null ? `e:{${vh}}` : `e:${ref.name}`;
438
+ }
439
+ case 'array':
440
+ return `a:${deepTypeHash(ref.items)}`;
441
+ case 'nullable':
442
+ return `n:${deepTypeHash(ref.inner)}`;
443
+ case 'union':
444
+ return `u:${(ref.variants ?? [])
445
+ .map((v: any) => deepTypeHash(v))
446
+ .sort()
447
+ .join(',')}`;
448
+ case 'map':
449
+ return `d:${deepTypeHash(ref.valueType)}`;
450
+ case 'literal':
451
+ return `l:${String(ref.value)}`;
452
+ default:
453
+ return 'unknown';
454
+ }
455
+ }
456
+
457
+ for (const model of models) modelHash(model.name);
458
+ return hashCache;
459
+ }
460
+
461
+ /**
462
+ * Natural enum-to-service assignment without sharing logic — the first service
463
+ * (alphabetically by spec order) to reference an enum wins.
464
+ */
465
+ function assignEnumsToServicesNatural(enums: Enum[], services: Service[]): Map<string, string> {
466
+ const enumNames = new Set(enums.map((e) => e.name));
467
+ const enumToService = new Map<string, string>();
468
+
469
+ for (const service of services) {
470
+ const refs = new Set<string>();
471
+ const collect = (ref: any): void => {
472
+ walkTypeRef(ref, { enum: (r: any) => refs.add(r.name) });
473
+ };
474
+ for (const op of service.operations) {
475
+ if (op.requestBody) collect(op.requestBody);
476
+ collect(op.response);
477
+ for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
478
+ collect(p.type);
479
+ }
480
+ }
481
+ for (const name of refs) {
482
+ if (!enumNames.has(name)) continue;
483
+ if (!enumToService.has(name)) enumToService.set(name, service.name);
484
+ }
485
+ }
486
+
487
+ return enumToService;
488
+ }
@@ -8,7 +8,7 @@ import type {
8
8
  Model,
9
9
  ResolvedOperation,
10
10
  } from '@workos/oagen';
11
- import { planOperation, toSnakeCase, assignModelsToServices } from '@workos/oagen';
11
+ import { planOperation, toSnakeCase } from '@workos/oagen';
12
12
  import {
13
13
  className,
14
14
  fileName,
@@ -22,7 +22,6 @@ import { resolveResourceClassName, bodyParamName } from './resources.js';
22
22
  import { buildServiceAccessPaths } from './client.js';
23
23
  import { generateFixtures, generateModelFixture } from './fixtures.js';
24
24
  import { isListWrapperModel, isListMetadataModel } from './models.js';
25
- import { assignEnumsToServices } from './enums.js';
26
25
  import {
27
26
  groupByMount,
28
27
  buildResolvedLookup,
@@ -32,6 +31,7 @@ import {
32
31
  } from '../shared/resolved-ops.js';
33
32
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
34
33
  import { pythonLiteral } from './wrappers.js';
34
+ import { computeSchemaPlacement } from './shared-schemas.js';
35
35
 
36
36
  /**
37
37
  * Resolve the Python class name to use for isinstance checks on paginated items.
@@ -227,15 +227,14 @@ function generateServiceTest(
227
227
  });
228
228
 
229
229
  // Group imports by their actual service directory (models may live in different services)
230
- const modelToServiceMap = assignModelsToServices(spec.models, spec.services, ctx.modelHints);
230
+ const placement = computeSchemaPlacement(spec, ctx);
231
+ const modelToServiceMap = placement.modelToService;
232
+ const enumToServiceMap = placement.enumToService;
231
233
  const mountDirMap = buildMountDirMap(ctx);
232
234
  const resolveModelDir = (modelName: string) => {
233
235
  const svc = modelToServiceMap.get(modelName);
234
236
  return svc ? (mountDirMap.get(svc) ?? 'common') : 'common';
235
237
  };
236
-
237
- // Group enum imports by service directory
238
- const enumToServiceMap = assignEnumsToServices(spec.enums, spec.services);
239
238
  const resolveEnumDir = (enumName: string) => {
240
239
  const svc = enumToServiceMap.get(enumName);
241
240
  return svc ? (mountDirMap.get(svc) ?? 'common') : 'common';
@@ -1402,7 +1401,10 @@ function generateModelRoundTripTests(spec: ApiSpec, ctx: EmitterContext): Genera
1402
1401
  );
1403
1402
  if (models.length === 0) return null;
1404
1403
 
1405
- const modelToService = assignModelsToServices(spec.models, spec.services, ctx.modelHints);
1404
+ // The round-trip test imports models from their *natural* (pre-relocation)
1405
+ // service so existing callers keep working — those imports resolve via the
1406
+ // BC re-exports that the model emitter writes into each service barrel.
1407
+ const modelToService = computeSchemaPlacement(spec, ctx).originalModelToService;
1406
1408
  const roundTripDirMap = buildMountDirMap(ctx);
1407
1409
  const resolveDir = (irService: string | undefined) =>
1408
1410
  irService ? (roundTripDirMap.get(irService) ?? 'common') : 'common';
@@ -293,7 +293,10 @@ function emitMethod(args: {
293
293
  const n = safeParamName(q.name);
294
294
  if (seenParamNames.has(n)) continue;
295
295
  seenParamNames.add(n);
296
- const defaultVal = q.name === 'order' ? rubyStringLit('desc') : 'nil';
296
+ // Spec is the source of truth for defaults. If the OpenAPI parameter has
297
+ // a `default`, surface it in the Ruby keyword arg signature; otherwise
298
+ // default to nil so the param is omitted from the request unless set.
299
+ const defaultVal = q.default != null ? rubyDefaultLiteral(q.default) : 'nil';
297
300
  sigParts.push(`${n}: ${defaultVal}`);
298
301
  }
299
302
 
@@ -883,6 +886,15 @@ function rubyStringLit(s: string): string {
883
886
  return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
884
887
  }
885
888
 
889
+ /** Render an arbitrary spec-default value as a Ruby literal for a method-arg default. */
890
+ function rubyDefaultLiteral(value: unknown): string {
891
+ if (typeof value === 'string') return rubyStringLit(value);
892
+ if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'nil';
893
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
894
+ if (value === null) return 'nil';
895
+ return 'nil';
896
+ }
897
+
886
898
  /**
887
899
  * Build a Ruby double-quoted string expression for the `else raise ArgumentError`
888
900
  * arm of a parameter-group dispatcher. Lists the expected variant classes and