@distilled.cloud/core 1.0.0-rc.8 → 1.0.0-rc.9

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.
Files changed (57) hide show
  1. package/LICENSE +201 -0
  2. package/lib/codegen/cli.d.ts +8 -6
  3. package/lib/codegen/cli.d.ts.map +1 -1
  4. package/lib/codegen/cli.js +6 -62
  5. package/lib/codegen/cli.js.map +1 -1
  6. package/lib/codegen/graphql.d.ts +6 -0
  7. package/lib/codegen/graphql.d.ts.map +1 -1
  8. package/lib/codegen/graphql.js +14 -4
  9. package/lib/codegen/graphql.js.map +1 -1
  10. package/lib/codegen/openapi-cli.d.ts +17 -3
  11. package/lib/codegen/openapi-cli.d.ts.map +1 -1
  12. package/lib/codegen/openapi-cli.js +50 -48
  13. package/lib/codegen/openapi-cli.js.map +1 -1
  14. package/lib/codegen/openapi.d.ts +27 -1
  15. package/lib/codegen/openapi.d.ts.map +1 -1
  16. package/lib/codegen/openapi.js +81 -3
  17. package/lib/codegen/openapi.js.map +1 -1
  18. package/lib/codegen/patches.d.ts +65 -0
  19. package/lib/codegen/patches.d.ts.map +1 -0
  20. package/lib/codegen/patches.js +236 -0
  21. package/lib/codegen/patches.js.map +1 -0
  22. package/lib/codegen/patches.test.d.ts +2 -0
  23. package/lib/codegen/patches.test.d.ts.map +1 -0
  24. package/lib/codegen/patches.test.js +105 -0
  25. package/lib/codegen/patches.test.js.map +1 -0
  26. package/lib/codegen/proto.d.ts +121 -0
  27. package/lib/codegen/proto.d.ts.map +1 -0
  28. package/lib/codegen/proto.js +962 -0
  29. package/lib/codegen/proto.js.map +1 -0
  30. package/lib/codegen/rewrite-operation-ids.d.ts +131 -0
  31. package/lib/codegen/rewrite-operation-ids.d.ts.map +1 -0
  32. package/lib/codegen/rewrite-operation-ids.js +1079 -0
  33. package/lib/codegen/rewrite-operation-ids.js.map +1 -0
  34. package/lib/codegen/rewrite-operation-ids.test.d.ts +2 -0
  35. package/lib/codegen/rewrite-operation-ids.test.d.ts.map +1 -0
  36. package/lib/codegen/rewrite-operation-ids.test.js +533 -0
  37. package/lib/codegen/rewrite-operation-ids.test.js.map +1 -0
  38. package/lib/codegen/spec-path.d.ts +16 -0
  39. package/lib/codegen/spec-path.d.ts.map +1 -0
  40. package/lib/codegen/spec-path.js +101 -0
  41. package/lib/codegen/spec-path.js.map +1 -0
  42. package/lib/json-patch.d.ts +18 -11
  43. package/lib/json-patch.d.ts.map +1 -1
  44. package/lib/json-patch.js +63 -25
  45. package/lib/json-patch.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/codegen/cli.ts +14 -91
  48. package/src/codegen/graphql.ts +25 -3
  49. package/src/codegen/openapi-cli.ts +75 -59
  50. package/src/codegen/openapi.ts +118 -4
  51. package/src/codegen/patches.test.ts +130 -0
  52. package/src/codegen/patches.ts +291 -0
  53. package/src/codegen/proto.ts +1128 -0
  54. package/src/codegen/rewrite-operation-ids.test.ts +563 -0
  55. package/src/codegen/rewrite-operation-ids.ts +1206 -0
  56. package/src/codegen/spec-path.ts +115 -0
  57. package/src/json-patch.ts +82 -25
@@ -0,0 +1,1206 @@
1
+ /**
2
+ * Operation naming (dev-time only).
3
+ *
4
+ * Distilled SDK operations are verbNoun (`listApps`, `getApp`,
5
+ * `createMachine`). Upstream ids come in a handful of shapes and
6
+ * {@link toVerbNoun} only reorders the ones it can recognise with confidence:
7
+ *
8
+ * - go-swagger `Resource_action` / `Resource_Sub_action` (`Apps_list`)
9
+ * - REST `NounsAction` with a trailing CRUD verb (`ConfigsList`,
10
+ * `ContainerCreate`, `ServiceMembers_getMetrics`)
11
+ * - GraphQL `nounAction` (`projectCreate`)
12
+ * - `show` → `get`, `index` → `list`, `retrieve` → `get`
13
+ *
14
+ * Anything else is left as-is (lowerFirst only). Reordering a verb out of
15
+ * the middle of an id (`WatchCoreV1PodList` → `listWatch…`) produced worse
16
+ * names than the input, so it is not attempted; per-package
17
+ * {@link OperationIdRewrite} maps cover the tail.
18
+ *
19
+ * RFC-6902 patches that target `/paths/~1foo/get/operationId` break the
20
+ * moment upstream prefixes paths. Naming is a convert policy, not a patch.
21
+ */
22
+ export interface OperationIdContext {
23
+ readonly path: string;
24
+ readonly method: string;
25
+ }
26
+
27
+ /**
28
+ * Return the new operation name, or `undefined` to leave the current value.
29
+ * A `Record` is looked up by `"METHOD path"` first, then the spec's
30
+ * `operationId` — same id on PUT vs PATCH needs the path key.
31
+ */
32
+ export type OperationIdRewrite =
33
+ | Readonly<Record<string, string>>
34
+ | ((operationId: string, ctx: OperationIdContext) => string | undefined);
35
+
36
+ /** Verb spellings normalised on the way out. */
37
+ const VERB_ALIAS: Readonly<Record<string, string>> = {
38
+ show: "get",
39
+ index: "list",
40
+ retrieve: "get",
41
+ };
42
+
43
+ /**
44
+ * Rails `index` is a verb only when it is the whole id or a whole `_`
45
+ * segment (`index`, `Apps_index`). Joined into a camel id it is the noun
46
+ * (`InfoIndex`, `IndexCreate`, `IndexDocument`).
47
+ */
48
+ const isIndexVerb = (raw: string): boolean => raw.toLowerCase() === "index";
49
+
50
+ /**
51
+ * Unambiguous verbs. An id that STARTS with one is already verb-first and
52
+ * is never reordered (`WatchPodList`, `InsertCalendarList`,
53
+ * `BulkDeleteMessages`); one found in the middle of a resource name
54
+ * (`Apps_getOrCreate`) marks the id as a compound we leave alone.
55
+ */
56
+ const STRONG_VERBS = new Set([
57
+ "get",
58
+ "list",
59
+ "create",
60
+ "delete",
61
+ "update",
62
+ "patch",
63
+ "put",
64
+ "post",
65
+ "show",
66
+ "index",
67
+ "retrieve",
68
+ "watch",
69
+ "read",
70
+ "replace",
71
+ "connect",
72
+ "disconnect",
73
+ "insert",
74
+ "mutate",
75
+ "fetch",
76
+ "remove",
77
+ "add",
78
+ "upload",
79
+ "download",
80
+ "send",
81
+ "resend",
82
+ "validate",
83
+ "verify",
84
+ "enable",
85
+ "disable",
86
+ "start",
87
+ "stop",
88
+ "restart",
89
+ "cancel",
90
+ "search",
91
+ "purge",
92
+ "revoke",
93
+ "approve",
94
+ "reject",
95
+ "accept",
96
+ "invoke",
97
+ "trigger",
98
+ "deploy",
99
+ "redeploy",
100
+ "describe",
101
+ "generate",
102
+ "regenerate",
103
+ "rotate",
104
+ "restore",
105
+ "suspend",
106
+ "resume",
107
+ "activate",
108
+ "deactivate",
109
+ "archive",
110
+ "unarchive",
111
+ "publish",
112
+ "unpublish",
113
+ "subscribe",
114
+ "unsubscribe",
115
+ "register",
116
+ "unregister",
117
+ "authenticate",
118
+ "authorize",
119
+ "deauthorize",
120
+ "attach",
121
+ "detach",
122
+ "assign",
123
+ "unassign",
124
+ "import",
125
+ "export",
126
+ "copy",
127
+ "move",
128
+ "rename",
129
+ "retry",
130
+ "reset",
131
+ "refresh",
132
+ "encrypt",
133
+ "decrypt",
134
+ "sign",
135
+ "edit",
136
+ "bulk",
137
+ "execute",
138
+ "submit",
139
+ "apply",
140
+ "undelete",
141
+ "redeliver",
142
+ "invalidate",
143
+ "upsert",
144
+ "cordon",
145
+ "uncordon",
146
+ "exec",
147
+ "reclaim",
148
+ "modify",
149
+ "install",
150
+ "uninstall",
151
+ "migrate",
152
+ "terminate",
153
+ "reboot",
154
+ "rebuild",
155
+ "resize",
156
+ "clone",
157
+ "duplicate",
158
+ "acknowledge",
159
+ "unpause",
160
+ "unblock",
161
+ "unlink",
162
+ "unpin",
163
+ "unmute",
164
+ "unhide",
165
+ "unstar",
166
+ "unfollow",
167
+ "unshare",
168
+ "unban",
169
+ "unlock",
170
+ "unflag",
171
+ "unclaim",
172
+ ]);
173
+
174
+ /**
175
+ * Words that act as the verb when they TRAIL a resource (`MachinesStart`,
176
+ * `SecretkeysSet`, `JobsRun`) but are ordinary nouns when they lead
177
+ * (`ReleaseGet`, `RequestGet`, `SetGet`, `CheckRunsList`). Includes every
178
+ * strong verb.
179
+ */
180
+ const TRAILING_VERBS = new Set([
181
+ ...STRONG_VERBS,
182
+ "notify",
183
+ "set",
184
+ "check",
185
+ "run",
186
+ "wait",
187
+ "signal",
188
+ "fork",
189
+ "extend",
190
+ "test",
191
+ "sync",
192
+ "review",
193
+ "invite",
194
+ "join",
195
+ "leave",
196
+ "capture",
197
+ "refund",
198
+ "settle",
199
+ "void",
200
+ "preview",
201
+ "complete",
202
+ "confirm",
203
+ "abort",
204
+ "kill",
205
+ "scale",
206
+ "promote",
207
+ "demote",
208
+ "renew",
209
+ "recover",
210
+ "reveal",
211
+ "presign",
212
+ "provision",
213
+ "trim",
214
+ "vacuum",
215
+ "compact",
216
+ "flush",
217
+ "merge",
218
+ "dispatch",
219
+ "poll",
220
+ "redeem",
221
+ "claim",
222
+ "lint",
223
+ "toggle",
224
+ "pin",
225
+ "mute",
226
+ "hide",
227
+ "ban",
228
+ "block",
229
+ "lock",
230
+ "share",
231
+ "follow",
232
+ "star",
233
+ "pause",
234
+ "finalize",
235
+ "transfer",
236
+ "lookup",
237
+ "clear",
238
+ "convert",
239
+ "dismiss",
240
+ "reply",
241
+ "squash",
242
+ "commit",
243
+ "swap",
244
+ "recreate",
245
+ "seal",
246
+ "ping",
247
+ "login",
248
+ "logout",
249
+ ]);
250
+
251
+ /**
252
+ * Leading tokens that are verbs often enough that an id starting with one
253
+ * is left as-is unless a STRONG verb trails it (`QueryCreate` →
254
+ * `createQuery`, but `QueryRun` stays).
255
+ */
256
+ const WEAK_LEADING_VERBS = new Set([
257
+ ...TRAILING_VERBS,
258
+ "request",
259
+ "release",
260
+ "report",
261
+ "query",
262
+ "stream",
263
+ "open",
264
+ "close",
265
+ "head",
266
+ "options",
267
+ "compute",
268
+ "batch",
269
+ "count",
270
+ "link",
271
+ "mark",
272
+ "grant",
273
+ "aggregate",
274
+ "skip",
275
+ "filter",
276
+ "change",
277
+ "vote",
278
+ "debug",
279
+ "expire",
280
+ "process",
281
+ "redirect",
282
+ "estimate",
283
+ "calculate",
284
+ "evaluate",
285
+ "resolve",
286
+ "load",
287
+ "unload",
288
+ "backup",
289
+ "snapshot",
290
+ "rollback",
291
+ "flag",
292
+ "certify",
293
+ "checkout",
294
+ "checkin",
295
+ "suggest",
296
+ "predict",
297
+ "analyze",
298
+ "annotate",
299
+ "translate",
300
+ "recognize",
301
+ "detect",
302
+ "classify",
303
+ "simulate",
304
+ "take",
305
+ "end",
306
+ "reassign",
307
+ "consume",
308
+ "prepare",
309
+ "action",
310
+ "act",
311
+ "ask",
312
+ "answer",
313
+ "handle",
314
+ "trace",
315
+ "track",
316
+ "log",
317
+ ]);
318
+
319
+ /** Compound tokens the naive split would leave as `Secretkeys`. */
320
+ const TOKEN_ALIAS: Readonly<Record<string, string>> = {
321
+ secretkeys: "SecretKeys",
322
+ secretkey: "SecretKey",
323
+ };
324
+
325
+ const IRREGULAR_SINGULAR: Readonly<Record<string, string>> = {
326
+ processes: "process",
327
+ statuses: "status",
328
+ addresses: "address",
329
+ aliases: "alias",
330
+ indexes: "index",
331
+ indices: "index",
332
+ matrices: "matrix",
333
+ vertices: "vertex",
334
+ analyses: "analysis",
335
+ bases: "base",
336
+ cases: "case",
337
+ databases: "database",
338
+ releases: "release",
339
+ responses: "response",
340
+ licenses: "license",
341
+ courses: "course",
342
+ purchases: "purchase",
343
+ phases: "phase",
344
+ leases: "lease",
345
+ clauses: "clause",
346
+ causes: "cause",
347
+ pauses: "pause",
348
+ houses: "house",
349
+ children: "child",
350
+ people: "person",
351
+ media: "media",
352
+ data: "data",
353
+ metadata: "metadata",
354
+ schemas: "schema",
355
+ criteria: "criterion",
356
+ feet: "foot",
357
+ teeth: "tooth",
358
+ mice: "mouse",
359
+ geese: "goose",
360
+ };
361
+
362
+ /**
363
+ * Nouns whose plural and singular coincide, or product names that end in
364
+ * `s`. Never trimmed.
365
+ */
366
+ const UNCOUNTABLE = new Set([
367
+ "postgres",
368
+ "redis",
369
+ "kubernetes",
370
+ "ios",
371
+ "macos",
372
+ "windows",
373
+ "dns",
374
+ "tls",
375
+ "https",
376
+ "sms",
377
+ "cors",
378
+ "oidc",
379
+ "sso",
380
+ "saas",
381
+ "aws",
382
+ "gcs",
383
+ "ecs",
384
+ "eks",
385
+ "rds",
386
+ "sqs",
387
+ "sns",
388
+ "kms",
389
+ "ses",
390
+ "efs",
391
+ "ebs",
392
+ "status",
393
+ "series",
394
+ "timeseries",
395
+ "analytics",
396
+ "metrics",
397
+ "settings",
398
+ "credentials",
399
+ "permissions",
400
+ "news",
401
+ "canvas",
402
+ "lens",
403
+ "bonus",
404
+ "campus",
405
+ "census",
406
+ "focus",
407
+ "virus",
408
+ "corpus",
409
+ "chorus",
410
+ "genus",
411
+ "radius",
412
+ "consensus",
413
+ "apparatus",
414
+ "bus",
415
+ "gas",
416
+ "plus",
417
+ "minus",
418
+ "nexus",
419
+ "prometheus",
420
+ "chaos",
421
+ "cosmos",
422
+ "ethos",
423
+ "pathos",
424
+ "atlas",
425
+ "bias",
426
+ "gps",
427
+ "cds",
428
+ "css",
429
+ "sass",
430
+ "less",
431
+ "js",
432
+ "ts",
433
+ "os",
434
+ "fs",
435
+ "vs",
436
+ "as",
437
+ "is",
438
+ "has",
439
+ "was",
440
+ "this",
441
+ "always",
442
+ "sis",
443
+ "axis",
444
+ "basis",
445
+ "crisis",
446
+ "thesis",
447
+ "diagnosis",
448
+ "synthesis",
449
+ "emphasis",
450
+ "hypothesis",
451
+ "oasis",
452
+ "iris",
453
+ "tennis",
454
+ "chassis",
455
+ "debris",
456
+ "physics",
457
+ "ethics",
458
+ "economics",
459
+ "logistics",
460
+ "mathematics",
461
+ "politics",
462
+ "statistics",
463
+ "dynamics",
464
+ "graphics",
465
+ "robotics",
466
+ "genetics",
467
+ "linguistics",
468
+ "means",
469
+ "species",
470
+ "sheep",
471
+ "fish",
472
+ "deer",
473
+ "aircraft",
474
+ "software",
475
+ "hardware",
476
+ "firmware",
477
+ "middleware",
478
+ "access",
479
+ "address",
480
+ "progress",
481
+ "success",
482
+ "process",
483
+ "business",
484
+ "ingress",
485
+ "egress",
486
+ "express",
487
+ "compress",
488
+ "stress",
489
+ "witness",
490
+ "fitness",
491
+ "wellness",
492
+ "readiness",
493
+ "liveness",
494
+ "awareness",
495
+ "class",
496
+ "pass",
497
+ "mass",
498
+ "glass",
499
+ "grass",
500
+ "bypass",
501
+ "compass",
502
+ "kms",
503
+ "eos",
504
+ "nas",
505
+ "ras",
506
+ "sas",
507
+ "das",
508
+ "pas",
509
+ "s3",
510
+ "k8s",
511
+ "kubeadm",
512
+ ]);
513
+
514
+ /** Certificate variants that sit in front of the resource noun. */
515
+ const QUALIFIERS = new Set(["acme", "custom"]);
516
+
517
+ export const operationNameKey = (ctx: OperationIdContext): string =>
518
+ `${ctx.method.toUpperCase()} ${ctx.path}`;
519
+
520
+ /** Resolve {@link OperationIdRewrite} against method+path, then operationId. */
521
+ export const resolveOperationName = (
522
+ rewrite: OperationIdRewrite,
523
+ operationId: string,
524
+ ctx: OperationIdContext,
525
+ ): string | undefined => {
526
+ if (typeof rewrite === "function") return rewrite(operationId, ctx);
527
+ return rewrite[operationNameKey(ctx)] ?? rewrite[operationId];
528
+ };
529
+
530
+ /** `foo` → `Foo`; all-caps tokens (`CSI`, `API`) keep their casing. */
531
+ const pascalToken = (raw: string): string => {
532
+ const alias = TOKEN_ALIAS[raw.toLowerCase()];
533
+ if (alias !== undefined) return alias;
534
+ return raw.charAt(0).toUpperCase() + raw.slice(1);
535
+ };
536
+
537
+ /**
538
+ * Conservative singular. Only strips a trailing `s` when the word is a
539
+ * regular plural we are confident about; anything ambiguous is returned
540
+ * unchanged, because `getAlia` is worse than `getAliases`.
541
+ */
542
+ export const singularize = (raw: string): string => {
543
+ const lower = raw.toLowerCase();
544
+ const keepCase = (s: string): string =>
545
+ raw[0] === raw[0]?.toUpperCase()
546
+ ? s.charAt(0).toUpperCase() + s.slice(1)
547
+ : s;
548
+ const alias = TOKEN_ALIAS[lower];
549
+ if (alias !== undefined) {
550
+ return alias.endsWith("s") ? alias.slice(0, -1) : alias;
551
+ }
552
+ const irregular = IRREGULAR_SINGULAR[lower];
553
+ if (irregular !== undefined) return keepCase(irregular);
554
+ if (UNCOUNTABLE.has(lower)) return raw;
555
+ // All-caps acronyms (`IPs`, `CIDRs`, `DNS`) — only trim a lowercase `s`
556
+ // after an acronym.
557
+ if (/^[A-Z0-9]+s$/.test(raw)) return raw.slice(0, -1);
558
+ if (raw !== lower && raw.toUpperCase() === raw) return raw;
559
+ if (lower.length <= 3) return raw;
560
+ if (!lower.endsWith("s")) return raw;
561
+ // Vowel + `ys` is a regular plural (`keys`, `days`); consonant + `ys` is not.
562
+ if (/(?:ss|us|is|os|as|[^aeiou]ys)$/.test(lower)) return raw;
563
+ if (lower.endsWith("ies") && lower.length > 4) return `${raw.slice(0, -3)}y`;
564
+ if (/(?:ches|shes|xes|zes|sses)$/.test(lower)) return raw.slice(0, -2);
565
+ if (lower.endsWith("oes") && lower.length > 4) return raw.slice(0, -2);
566
+ // `-ses` after a vowel is usually a regular `-se` noun (`Response`,
567
+ // `Release`, `Database`), not an `-s` + `es` plural.
568
+ if (/[aeiou]ses$/.test(lower)) return raw.slice(0, -1);
569
+ if (lower.endsWith("ses")) return raw.slice(0, -2);
570
+ return raw.slice(0, -1);
571
+ };
572
+
573
+ const lowerFirst = (s: string): string =>
574
+ s.length === 0 ? s : s.charAt(0).toLowerCase() + s.slice(1);
575
+
576
+ /**
577
+ * Split on `_`, `-`, `/` and camel boundaries. Acronym runs stay together
578
+ * (`CSIDriver` → `CSI`, `Driver`; `APIGroup` → `API`, `Group`).
579
+ */
580
+ const splitIdent = (s: string): string[] => {
581
+ const parts: string[] = [];
582
+ for (const chunk of s.split(/[_/\-.\s]+/).filter(Boolean)) {
583
+ const split = chunk
584
+ .replace(/([a-z0-9])([A-Z])/g, "$1\0$2")
585
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1\0$2");
586
+ for (const p of split.split("\0")) {
587
+ if (p) parts.push(p);
588
+ }
589
+ }
590
+ return parts;
591
+ };
592
+
593
+ const isStrongVerb = (raw: string): boolean =>
594
+ STRONG_VERBS.has(raw.toLowerCase());
595
+ const isTrailingVerb = (raw: string): boolean =>
596
+ TRAILING_VERBS.has(raw.toLowerCase());
597
+ const isWeakLeadingVerb = (raw: string): boolean =>
598
+ WEAK_LEADING_VERBS.has(raw.toLowerCase());
599
+
600
+ const alias = (raw: string): string =>
601
+ VERB_ALIAS[raw.toLowerCase()] ?? raw.toLowerCase();
602
+
603
+ /** `version`-like tokens (`V1`, `v1beta1`, `V2`) are never nouns to singularise. */
604
+ const isVersionToken = (raw: string): boolean => /^[vV]\d/.test(raw);
605
+
606
+ /** Adverbs that sit between resource and verb: `HoldoutsPartialUpdate`. */
607
+ const MODIFIERS = new Set(["partial", "bulk", "batch", "all", "many"]);
608
+
609
+ /**
610
+ * `verb + Resource + Object`. Only the resource (last noun before the verb)
611
+ * is singularised, and only for non-list verbs (`getApp`, `listApps`,
612
+ * `listMachineEvents`). Parent tokens keep their spelling.
613
+ */
614
+ const assemble = (
615
+ verb: string,
616
+ nouns: readonly string[],
617
+ tail: readonly string[] = [],
618
+ ): string => {
619
+ // `App_Certificates_acme_create`: the qualifier trails the resource in
620
+ // the id but reads better in front of it (`createAppAcmeCertificate`).
621
+ let ordered = [...nouns];
622
+ if (ordered.length >= 2 && QUALIFIERS.has(ordered.at(-1)!.toLowerCase())) {
623
+ const q = ordered.pop()!;
624
+ ordered.splice(ordered.length - 1, 0, q);
625
+ }
626
+ const shaped = ordered.map((p, i) => {
627
+ const isLast = i === ordered.length - 1;
628
+ if (!isLast || isVersionToken(p)) return pascalToken(p);
629
+ if (verb === "list" && tail.length === 0) return pascalToken(p);
630
+ return pascalToken(singularize(p));
631
+ });
632
+ return verb + shaped.join("") + tail.map(pascalToken).join("");
633
+ };
634
+
635
+ /**
636
+ * Distilled SDK names are verbNoun (`listApps`, `getApp`, `createMachine`).
637
+ *
638
+ * - Already verb-first (`listSprites`, `GetObject`, `showContact`,
639
+ * `WatchPodList`) stays, with `show`/`index`/`retrieve` aliased.
640
+ * - Trailing action (`ConfigsList`, `PlansGet`, `ContainerCreate`,
641
+ * `VirtualMachinesStart`, `ReleaseGet`) moves the verb first and
642
+ * singularises the resource for non-list verbs.
643
+ * - go-swagger / autorest `Apps_list`, `App_Certificates_show`,
644
+ * `Machines_list_events`, `ServiceMembers_getMetrics`: the verb heads the
645
+ * first `_` segment after the resource.
646
+ * - Anything else (`AppGetOrCreate`, `VirtualMachines_createOrUpdate`,
647
+ * `DnsRecordsBatch`, `accountById`) is returned unchanged apart from
648
+ * lowerFirst. Use {@link OperationIdRewrite} for those.
649
+ */
650
+ export const toVerbNoun = (operationId: string): string => {
651
+ const trimmed = operationId.trim();
652
+ if (trimmed === "") return trimmed;
653
+
654
+ const segments = trimmed.split(/[_/]+/).filter(Boolean);
655
+ if (segments.length >= 2) {
656
+ // snake_case verb-first (`get_apps`, `list_all_users`): join.
657
+ const lead = splitIdent(segments[0]!);
658
+ if (lead.length === 1 && isStrongVerb(lead[0]!)) {
659
+ const rest = segments.slice(1).flatMap(splitIdent);
660
+ return alias(lead[0]!) + rest.map(pascalToken).join("");
661
+ }
662
+ // Segments before the verb are the resource by construction, so a
663
+ // verb-looking token inside them (`OpenIdConnectProvider_get`) is a
664
+ // noun. Only the object after the verb can make this a compound.
665
+ const verbSeg = segments.findIndex((seg, i) => {
666
+ if (i === 0) return false;
667
+ const tokens = splitIdent(seg);
668
+ const head = tokens[0] ?? "";
669
+ // `Apps_index` lists; `Apps_indexInfo` is about the index entity.
670
+ if (isIndexVerb(head)) return tokens.length === 1;
671
+ return isTrailingVerb(head);
672
+ });
673
+ if (verbSeg > 0) {
674
+ const head = segments.slice(0, verbSeg).flatMap(splitIdent);
675
+ const [verb, ...objectHere] = splitIdent(segments[verbSeg]!);
676
+ const object = [
677
+ ...objectHere,
678
+ ...segments.slice(verbSeg + 1).flatMap(splitIdent),
679
+ ];
680
+ const compound = object.some(
681
+ (t) => isStrongVerb(t) || /^(or|and)$/i.test(t),
682
+ );
683
+ if (!compound) return assemble(alias(verb!), head, object);
684
+ }
685
+ return lowerFirst(trimmed);
686
+ }
687
+
688
+ const parts = splitIdent(trimmed);
689
+ // `Index1`, `Index2`: a converter's duplicate-route suffix on a bare id.
690
+ if (parts.length <= 1 || parts.slice(1).every((p) => /^\d+$/.test(p))) {
691
+ return parts.length >= 1 && isTrailingVerb(parts[0]!)
692
+ ? alias(parts[0]!) + parts.slice(1).join("")
693
+ : lowerFirst(trimmed);
694
+ }
695
+
696
+ const first = parts[0]!;
697
+ const last = parts.at(-1)!;
698
+
699
+ // A camel id never uses Rails `index` as its verb: `InfoIndex`,
700
+ // `IndexCreate`, `IndexDocument` all name the index entity.
701
+ const strong = (raw: string) => !isIndexVerb(raw) && isStrongVerb(raw);
702
+ const trailing = (raw: string) => !isIndexVerb(raw) && isTrailingVerb(raw);
703
+ const weakLeading = (raw: string) =>
704
+ !isIndexVerb(raw) && isWeakLeadingVerb(raw);
705
+
706
+ if (strong(first)) {
707
+ return alias(first) + parts.slice(1).map(pascalToken).join("");
708
+ }
709
+ if (parts.some((p) => /^(or|and)$/i.test(p))) return lowerFirst(trimmed);
710
+
711
+ // Swagger tag-prefixed ids whose object repeats the tag (`issueListIssues`,
712
+ // `repoGetRepo`): the tag is redundant, drop it. Other tag-prefixed shapes
713
+ // (`userGetCurrent`, `notifyGetList`) are left for per-package
714
+ // `operationNames`; a middle verb is too often a noun (`environmentPatchCommit`).
715
+ if (
716
+ parts.length >= 3 &&
717
+ !weakLeading(first) &&
718
+ !isVersionToken(first) &&
719
+ strong(parts[1]!) &&
720
+ singularize(parts[2]!).toLowerCase() === singularize(first).toLowerCase() &&
721
+ !parts.slice(2).some(strong)
722
+ ) {
723
+ return alias(parts[1]!) + parts.slice(2).map(pascalToken).join("");
724
+ }
725
+
726
+ const reorder = weakLeading(first) ? strong(last) : trailing(last);
727
+ if (!reorder) {
728
+ return weakLeading(first)
729
+ ? alias(first) + parts.slice(1).map(pascalToken).join("")
730
+ : lowerFirst(trimmed);
731
+ }
732
+
733
+ const nouns = parts.slice(0, -1);
734
+ if (nouns.slice(1).some(strong)) return lowerFirst(trimmed);
735
+ const modifier = nouns.at(-1);
736
+ if (
737
+ nouns.length > 1 &&
738
+ modifier !== undefined &&
739
+ MODIFIERS.has(modifier.toLowerCase())
740
+ ) {
741
+ return assemble(alias(last), nouns.slice(0, -1), [modifier]);
742
+ }
743
+ return assemble(alias(last), nouns);
744
+ };
745
+
746
+ const COMPANION_SUFFIXES = [
747
+ "Request",
748
+ "Response",
749
+ "Input",
750
+ "Output",
751
+ "Error",
752
+ "Result",
753
+ ] as const;
754
+
755
+ const remapTargets = (
756
+ node: unknown,
757
+ mapping: ReadonlyMap<string, string>,
758
+ ): unknown => {
759
+ if (Array.isArray(node)) {
760
+ return node.map((item) => remapTargets(item, mapping));
761
+ }
762
+ if (node === null || typeof node !== "object") return node;
763
+ const out: Record<string, unknown> = {};
764
+ for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
765
+ if (key === "target" && typeof value === "string") {
766
+ out[key] = mapping.get(value) ?? value;
767
+ } else {
768
+ out[key] = remapTargets(value, mapping);
769
+ }
770
+ }
771
+ return out;
772
+ };
773
+
774
+ /** Version prefixes and API roots that carry no meaning in a name. */
775
+ const NOISE_SEGMENTS = new Set(["api", "rest", "v", "public"]);
776
+ const isNoiseSegment = (seg: string): boolean => {
777
+ const lower = seg.toLowerCase();
778
+ return NOISE_SEGMENTS.has(lower) || /^v\d+(\.\d+)*$/.test(lower);
779
+ };
780
+
781
+ const HTTP_METHOD_ID =
782
+ /^(get|post|put|patch|delete|head|options)(?=$|[-_./]|[A-Z0-9])/i;
783
+
784
+ /**
785
+ * Whether an OpenAPI `operationId` carries no information beyond the
786
+ * method and path: absent, or a mechanical restatement of the route such
787
+ * as `get-api-card`, `post_v1_users_id`, `getApiV1AnnotationLayer`. A
788
+ * hand-chosen id that merely starts with the method (`delete-artifact` for
789
+ * `/repos/{owner}/{repo}/actions/artifacts/{id}`) is NOT mechanical — it
790
+ * names a subset of the route on purpose — so the id must cover every
791
+ * literal path segment, and every id token must come from the route.
792
+ */
793
+ /**
794
+ * A stricter subclass of mechanical ids: nothing in the id was chosen by
795
+ * a person. Either there is no id, or the id is the route spelled out —
796
+ * `postV1AppsByAppIdPromote`, `get_v1_users_id`, `get-api-card`,
797
+ * `getCustomerById`. Such names are derived from the route alone.
798
+ *
799
+ * A `By…` clause counts as verbatim only when it names a whole parameter
800
+ * (`ByAppId` for `{appId}`) or is a bare `ById`. `get_webhook_by_token`
801
+ * for `/webhooks/{webhook_id}/{webhook_token}` is a chosen
802
+ * disambiguation, so its nouns are kept; likewise `deleteScheduledJob`.
803
+ */
804
+ export const isVerbatimRouteId = (
805
+ operationId: string | undefined,
806
+ ctx: OperationIdContext,
807
+ ): boolean => {
808
+ if (!operationId) return true;
809
+ if (!isMechanicalOperationId(operationId, ctx)) return false;
810
+ const id = stripTag(operationId);
811
+ const m = HTTP_METHOD_ID.exec(id);
812
+ const norm = (t: string) => singularize(t.toLowerCase()).toLowerCase();
813
+ const idTokens = splitIdent(id.slice(m ? m[0].length : 0)).map(norm);
814
+ const literal = new Set<string>();
815
+ const params: string[][] = [];
816
+ for (const seg of ctx.path.split(/[?#]/)[0]!.split("/")) {
817
+ for (const mm of seg.matchAll(/\{([^}]+)\}/g)) {
818
+ params.push(splitIdent(mm[1]!).map(norm));
819
+ }
820
+ for (const t of splitIdent(seg.replace(/\{[^}]*\}/g, " ")).map(norm)) {
821
+ literal.add(t);
822
+ }
823
+ }
824
+ const paramTokens = new Set(params.flat());
825
+ const clauses: string[][] = [[]];
826
+ for (const t of idTokens) {
827
+ if (t === "by") clauses.push([]);
828
+ else clauses.at(-1)!.push(t);
829
+ }
830
+ const [head, ...byClauses] = clauses;
831
+ if (!head!.every((t) => literal.has(t) || paramTokens.has(t))) return false;
832
+ // `ByAppIdPromote`: a whole parameter, then route literals.
833
+ return byClauses.every((c) => {
834
+ const lengths = [
835
+ ...params
836
+ .filter((p) => p.every((t, i) => t === c[i]))
837
+ .map((p) => p.length),
838
+ ...(c[0] === "id" ? [1] : []),
839
+ ];
840
+ return lengths.some(
841
+ (n) => n <= c.length && c.slice(n).every((t) => literal.has(t)),
842
+ );
843
+ });
844
+ };
845
+
846
+ /** `activity/get-feeds` → `get-feeds`: a tag prefix is not part of the name. */
847
+ const stripTag = (operationId: string): string =>
848
+ operationId.includes("/")
849
+ ? operationId.slice(operationId.lastIndexOf("/") + 1)
850
+ : operationId;
851
+
852
+ export const isMechanicalOperationId = (
853
+ operationId: string | undefined,
854
+ ctx: OperationIdContext,
855
+ ): boolean => {
856
+ if (!operationId) return true;
857
+ const id = stripTag(operationId);
858
+ const m = HTTP_METHOD_ID.exec(id);
859
+ if (!m || m[1]!.toLowerCase() !== ctx.method.toLowerCase()) return false;
860
+ const norm = (t: string) => singularize(t.toLowerCase()).toLowerCase();
861
+ const joiners = new Set(["by", "for", "of", "in", "with", "and", "to"]);
862
+ const idTokens = splitIdent(id.slice(m[0].length))
863
+ .map(norm)
864
+ .filter((t) => !joiners.has(t));
865
+ const segments = ctx.path.split(/[?#]/)[0]!.split("/").filter(Boolean);
866
+ // Route tokens in order; literals are required, params and `/api`,
867
+ // `/v2` roots are optional (`getApiV1Foo` and `getFoo` both count).
868
+ const route: Array<{ token: string; required: boolean }> = [];
869
+ for (const seg of segments) {
870
+ const param = /^\{(.*)\}$/.exec(seg);
871
+ const required = param === null && !isNoiseSegment(seg);
872
+ for (const t of splitIdent(param ? param[1]! : seg).map(norm)) {
873
+ route.push({ token: t, required });
874
+ }
875
+ }
876
+ if (idTokens.length === 0) return !route.some((r) => r.required);
877
+ // The id must read the route left to right, skipping only optional
878
+ // tokens. `delete-package-for-org` on /orgs/{org}/packages/… reads
879
+ // `package, org` — out of order — and stays a hand-written name.
880
+ let i = 0;
881
+ for (const r of route) {
882
+ if (i < idTokens.length && idTokens[i] === r.token) {
883
+ i++;
884
+ } else if (r.required) {
885
+ return false;
886
+ }
887
+ }
888
+ return i === idTokens.length;
889
+ };
890
+
891
+ /**
892
+ * Derive `verbNoun` from `METHOD /path` for operations whose operationId
893
+ * is missing or mechanical:
894
+ *
895
+ * GET /users → listUsers (when the 200 body is a
896
+ * collection; else getUsers)
897
+ * GET /users/{id} → getUser
898
+ * POST /users → createUser
899
+ * POST /users/{id} → updateUser (Stripe-style POST-to-update)
900
+ * PUT /users/{id} → putUser (replace semantics kept)
901
+ * PATCH /users/{id} → updateUser
902
+ * DELETE /users/{id} → deleteUser
903
+ * POST /users/{id}/reset → resetUser (trailing action segment)
904
+ * GET /orgs/{o}/repos → listOrgRepos (parents, singular, keep order)
905
+ *
906
+ * `/api`, `/v1`-style roots are dropped. Path parameters contribute nothing
907
+ * to the name; only literal segments do.
908
+ */
909
+ export interface PathNamingHints {
910
+ /**
911
+ * Whether the success response is a collection (array body, or an
912
+ * object whose only/primary member is an array). Decides `list` vs
913
+ * `get` for a GET on a route with no trailing parameter.
914
+ */
915
+ readonly returnsCollection?: boolean;
916
+ /**
917
+ * A mechanical operationId (`get-feeds`, `delete_v1_users_id`) whose
918
+ * noun tokens, after the method, should be used verbatim instead of
919
+ * being re-derived (and re-singularised) from the route.
920
+ */
921
+ readonly nouns?: string;
922
+ /**
923
+ * `nouns` is the route spelled out (`postV1AppsByAppIdPromote`), so
924
+ * parameter tokens embedded without a `By` (`get_v1_users_id`) and
925
+ * every `By…` clause are dropped, and the resource is singularised
926
+ * when a parameter follows it.
927
+ */
928
+ readonly verbatim?: boolean;
929
+ }
930
+
931
+ export const pathToVerbNoun = (
932
+ ctx: OperationIdContext,
933
+ hints: PathNamingHints = {},
934
+ ): string => {
935
+ const method = ctx.method.toLowerCase();
936
+ const segments = ctx.path.split(/[?#]/)[0]!.split("/").filter(Boolean);
937
+ const literal: string[] = [];
938
+ // `{id}`, `:id`, and composite segments such as `{slug}-{id}`.
939
+ const isParam = (seg: string) => /\{[^}]*\}/.test(seg) || seg.startsWith(":");
940
+ let endsWithParam = false;
941
+ for (let i = 0; i < segments.length; i++) {
942
+ const seg = segments[i]!;
943
+ if (isParam(seg)) {
944
+ endsWithParam = i === segments.length - 1;
945
+ continue;
946
+ }
947
+ endsWithParam = false;
948
+ if (isNoiseSegment(seg)) continue;
949
+ literal.push(seg);
950
+ }
951
+ if (literal.length === 0) return lowerFirst(method + "Root");
952
+
953
+ if (hints.nouns !== undefined) {
954
+ // `activity/get-feeds`: a tag prefix before the method is not a noun.
955
+ const id = hints.nouns.includes("/")
956
+ ? hints.nouns.slice(hints.nouns.lastIndexOf("/") + 1)
957
+ : hints.nouns;
958
+ const m = HTTP_METHOD_ID.exec(id);
959
+ const rest = m ? id.slice(m[0].length) : id;
960
+ // Route literals, both as written and singularised, so a parameter
961
+ // that echoes a literal (`{team}` under `/teams/`) is not mistaken
962
+ // for a parameter token.
963
+ const literal = new Set(
964
+ segments
965
+ .filter((seg) => !isParam(seg))
966
+ .flatMap((seg) => splitIdent(seg))
967
+ .flatMap((t) => [t.toLowerCase(), singularize(t).toLowerCase()]),
968
+ );
969
+ const routeNoise = new Set(
970
+ segments
971
+ .filter((seg) => !isParam(seg) && isNoiseSegment(seg))
972
+ .flatMap((seg) => splitIdent(seg).map((t) => t.toLowerCase())),
973
+ );
974
+ /** Whole parameter names as token lists (`{appId}` → ["app","id"]). */
975
+ const params = segments
976
+ .flatMap((seg) => [...seg.matchAll(/\{([^}]+)\}/g)])
977
+ .map((mm) => splitIdent(mm[1]!).map((t) => t.toLowerCase()));
978
+ const raw = splitIdent(rest);
979
+ /** Longest whole parameter name starting at raw[at], else 0. */
980
+ const matchParam = (at: number): number => {
981
+ let best = 0;
982
+ for (const prm of params) {
983
+ if (
984
+ prm.length > best &&
985
+ prm.every((t, k) => raw[at + k]?.toLowerCase() === t)
986
+ ) {
987
+ best = prm.length;
988
+ }
989
+ }
990
+ if (best === 0 && raw[at]?.toLowerCase() === "id") best = 1;
991
+ return best;
992
+ };
993
+ const tokens: string[] = [];
994
+ /** Index in `tokens` of a noun that a parameter immediately follows. */
995
+ const followedByParam = new Set<number>();
996
+ for (let i = 0; i < raw.length; i++) {
997
+ const t = raw[i]!;
998
+ const lower = t.toLowerCase();
999
+ // `getApiV1Foo` → drop the api/version root the id copied from the
1000
+ // route; a token the route does not have (`…DataAPI`) is kept.
1001
+ if (isNoiseSegment(t) && routeNoise.has(lower)) continue;
1002
+ let n = 0;
1003
+ let skip = 0;
1004
+ if (/^(by|for|of|in|with|and|to)$/.test(lower)) {
1005
+ n = matchParam(i + 1);
1006
+ skip = n + 1;
1007
+ // Non-verbatim ids only drop a TRAILING `ById`; a `By` in the
1008
+ // middle (`getBalanceByAsset`) is part of the chosen name.
1009
+ if (n > 0 && !hints.verbatim && i + skip !== raw.length) n = 0;
1010
+ } else if (hints.verbatim && i > 0) {
1011
+ // `get_v1_users_id`, `GetAccountsAccount`: a whole parameter name
1012
+ // with no `By`. When the parameter echoes the resource it follows
1013
+ // (`/accounts/{account}`, `/teams/{team}`) the previous token has
1014
+ // to be that resource; otherwise (`deleteProjectBranchCustomDomain`
1015
+ // on `/custom-domains/{domain}`) it is the literal.
1016
+ n = matchParam(i);
1017
+ skip = n;
1018
+ const echoes = raw
1019
+ .slice(i, i + n)
1020
+ .some((x) => literal.has(x.toLowerCase()));
1021
+ const prev = tokens.at(-1)?.toLowerCase();
1022
+ const prevIsResource =
1023
+ prev !== undefined &&
1024
+ raw
1025
+ .slice(i, i + n)
1026
+ .some(
1027
+ (x) =>
1028
+ singularize(prev) === singularize(x.toLowerCase()) ||
1029
+ prev === x.toLowerCase(),
1030
+ );
1031
+ if (echoes && !prevIsResource) n = 0;
1032
+ }
1033
+ if (n > 0) {
1034
+ if (tokens.length) followedByParam.add(tokens.length - 1);
1035
+ i += skip - 1;
1036
+ continue;
1037
+ }
1038
+ tokens.push(t);
1039
+ }
1040
+ if (hints.verbatim) {
1041
+ for (const i of followedByParam) tokens[i] = singularize(tokens[i]!);
1042
+ // `PostAccounts` → createAccount: a create makes one member.
1043
+ if (method === "post" && !endsWithParam && tokens.length > 0) {
1044
+ tokens[tokens.length - 1] = singularize(tokens.at(-1)!);
1045
+ }
1046
+ }
1047
+ if (tokens.length > 0) {
1048
+ // A POST that addresses one member (`POST /accounts/{account}`,
1049
+ // Stripe-style) updates it; a POST on a collection creates.
1050
+ const verb =
1051
+ method === "get"
1052
+ ? !endsWithParam && hints.returnsCollection === true
1053
+ ? "list"
1054
+ : "get"
1055
+ : method === "post"
1056
+ ? endsWithParam
1057
+ ? "update"
1058
+ : "create"
1059
+ : method === "patch"
1060
+ ? "update"
1061
+ : method;
1062
+ return verb + tokens.map(pascalToken).join("");
1063
+ }
1064
+ }
1065
+
1066
+ const tokens = literal.map((seg) => splitIdent(seg));
1067
+ const lastTokens = tokens.at(-1)!;
1068
+ // `/papers/index`, `/opensearch/index` address an index resource, not a
1069
+ // Rails `index` action.
1070
+ const lastIsVerb =
1071
+ lastTokens.length > 0 &&
1072
+ !isIndexVerb(lastTokens[0]!) &&
1073
+ (isTrailingVerb(lastTokens[0]!) || isStrongVerb(lastTokens[0]!)) &&
1074
+ !endsWithParam &&
1075
+ method === "post";
1076
+
1077
+ // POST /things/{id}/publish → publishThing; POST /login → login.
1078
+ if (lastIsVerb) {
1079
+ const [verb, ...rest] = lastTokens;
1080
+ const parents = tokens.slice(0, -1);
1081
+ const nouns = parents.map((t, i) =>
1082
+ i === parents.length - 1
1083
+ ? t.map((x, j) => (j === t.length - 1 ? singularize(x) : x))
1084
+ : t,
1085
+ );
1086
+ return (
1087
+ alias(verb!) +
1088
+ nouns.flat().map(pascalToken).join("") +
1089
+ rest.map(pascalToken).join("")
1090
+ );
1091
+ }
1092
+
1093
+ // GET is `list` only when the body is known to be a collection; an
1094
+ // unknown or non-JSON body (`/zen`, `/octocat`) is a plain `get`.
1095
+ const verb =
1096
+ method === "get"
1097
+ ? !endsWithParam && hints.returnsCollection === true
1098
+ ? "list"
1099
+ : "get"
1100
+ : method === "post"
1101
+ ? endsWithParam
1102
+ ? "update"
1103
+ : "create"
1104
+ : method === "patch"
1105
+ ? "update"
1106
+ : method === "put"
1107
+ ? "put"
1108
+ : method === "delete"
1109
+ ? "delete"
1110
+ : method;
1111
+ // A GET/DELETE/PATCH/PUT on a collection route (no trailing parameter)
1112
+ // addresses the collection itself — keep its plural. With a trailing
1113
+ // parameter the route addresses one member, and `create` always makes
1114
+ // one — singular.
1115
+ const plural = !endsWithParam && verb !== "create";
1116
+ const shaped = tokens.map((t, i) => {
1117
+ const isResource = i === tokens.length - 1;
1118
+ return t.map((x, j) => {
1119
+ const last = j === t.length - 1;
1120
+ if (!last) return pascalToken(x);
1121
+ // Parents are singular (`Org` in listOrgRepos).
1122
+ if (!isResource || !plural) return pascalToken(singularize(x));
1123
+ return pascalToken(x);
1124
+ });
1125
+ });
1126
+ return verb + shaped.flat().join("");
1127
+ };
1128
+
1129
+ /**
1130
+ * Rename operations (and every shape whose name starts with the operation
1131
+ * name — `FooRequest`, `FooResponse`, `FooRequestBody`, `FooResponseItemsList`)
1132
+ * in a Smithy model to verbNoun PascalCase. Mutates `model.shapes`.
1133
+ * Colliding names keep the original id and are reported.
1134
+ */
1135
+ export const verbNounSmithyModel = (model: {
1136
+ shapes?: Record<string, any>;
1137
+ }): { renamed: number; collisions: string[] } => {
1138
+ const shapes = model.shapes ?? {};
1139
+ const mapping = new Map<string, string>();
1140
+ const collisions: string[] = [];
1141
+ const taken = new Set(Object.keys(shapes));
1142
+
1143
+ const ops = Object.entries(shapes).filter(
1144
+ ([, def]) => def?.type === "operation",
1145
+ );
1146
+ const opLocals = new Set<string>();
1147
+ for (const [id] of ops) {
1148
+ const hash = id.indexOf("#");
1149
+ opLocals.add(hash >= 0 ? id.slice(hash + 1) : id);
1150
+ }
1151
+
1152
+ for (const [id] of ops) {
1153
+ const hash = id.indexOf("#");
1154
+ const ns = hash >= 0 ? id.slice(0, hash) : "";
1155
+ const local = hash >= 0 ? id.slice(hash + 1) : id;
1156
+ const camel = toVerbNoun(local);
1157
+ const nextLocal = camel.charAt(0).toUpperCase() + camel.slice(1);
1158
+ if (nextLocal === local) continue;
1159
+ const nextId = ns ? `${ns}#${nextLocal}` : nextLocal;
1160
+ if (taken.has(nextId) && !mapping.has(nextId)) {
1161
+ collisions.push(`${local} → ${nextLocal}`);
1162
+ continue;
1163
+ }
1164
+ if ([...mapping.values()].includes(nextId)) {
1165
+ collisions.push(`${local} → ${nextLocal}`);
1166
+ continue;
1167
+ }
1168
+ mapping.set(id, nextId);
1169
+ taken.add(nextId);
1170
+
1171
+ // Companions: `<Op>Request`, `<Op>Response…`, and anything derived
1172
+ // from them by the converters' `${opName}Request${Member}` naming.
1173
+ // Skip prefixes that are themselves another operation's name
1174
+ // (`Get` vs `GetObject`) — only exact suffix matches count there.
1175
+ for (const candidate of Object.keys(shapes)) {
1176
+ if (candidate === id || mapping.has(candidate)) continue;
1177
+ const cHash = candidate.indexOf("#");
1178
+ const cNs = cHash >= 0 ? candidate.slice(0, cHash) : "";
1179
+ const cLocal = cHash >= 0 ? candidate.slice(cHash + 1) : candidate;
1180
+ if (cNs !== ns || !cLocal.startsWith(local)) continue;
1181
+ const tail = cLocal.slice(local.length);
1182
+ if (tail === "") continue;
1183
+ const suffixed = COMPANION_SUFFIXES.some((s) => tail.startsWith(s));
1184
+ if (!suffixed) continue;
1185
+ // `ListAppsRequest` vs op `List` + `AppsRequest`: require the tail to
1186
+ // start with a companion suffix immediately after the op name.
1187
+ const to = ns ? `${ns}#${nextLocal}${tail}` : `${nextLocal}${tail}`;
1188
+ if (taken.has(to)) continue;
1189
+ mapping.set(candidate, to);
1190
+ taken.add(to);
1191
+ }
1192
+ }
1193
+
1194
+ if (mapping.size === 0) return { renamed: 0, collisions };
1195
+
1196
+ const nextShapes: Record<string, any> = {};
1197
+ for (const [id, def] of Object.entries(shapes)) {
1198
+ const newId = mapping.get(id) ?? id;
1199
+ nextShapes[newId] = remapTargets(def, mapping);
1200
+ }
1201
+ model.shapes = nextShapes;
1202
+ return {
1203
+ renamed: ops.filter(([opId]) => mapping.has(opId)).length,
1204
+ collisions,
1205
+ };
1206
+ };