@apifuse/provider-sdk 2.2.0-beta.50 → 2.2.0-beta.52

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 (17) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/cli/migrate-operation-declaration.js +282 -14
  3. package/package.json +1 -1
  4. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-hoisted-array-spread.ts.txt +15 -0
  5. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-imported-array-spread.ts.txt +9 -0
  6. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-mixed-array-spread.ts.txt +24 -0
  7. package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-canonical-en.json +1 -0
  8. package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-canonical-ja.json +1 -0
  9. package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-canonical-ko.json +1 -0
  10. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-binding-ambiguous.ts.txt +12 -0
  11. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-imported-other.ts.txt +7 -0
  12. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-imported.ts.txt +5 -0
  13. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-key-ambiguous.ts.txt +19 -0
  14. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-shorthand.ts.txt +9 -0
  15. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-typed.ts.txt +11 -0
  16. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-unrelated.ts.txt +13 -0
  17. package/src/cli/migrate-operation-declaration.ts +319 -9
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.2.0-beta.52
4
+
5
+ - Release candidate for main commit 6e64ae0ad2e735b3de0c115f5ea29517efcf4ff2.
6
+
7
+ ## 2.2.0-beta.51
8
+
9
+ - Release candidate for main commit a1269b0a7161618e5fc25e532aaa45de0efd632f.
10
+
3
11
  ## 2.2.0-beta.50
4
12
 
5
13
  - Release candidate for main commit 341c2f0e55a4159cac423fec0bf4f84625191731.
@@ -62,6 +62,7 @@ export function migrateOperationDeclaration(sourceText, fileName, options = {})
62
62
  };
63
63
  }
64
64
  const constObjects = collectModuleConstObjects(source);
65
+ const constArrays = collectModuleConstArrays(source);
65
66
  const discovery = discoverOperationSites(source, fileName, constObjects, options.operationIds);
66
67
  if (discovery.refusals.length > 0) {
67
68
  return { status: "refused", refusals: discovery.refusals };
@@ -70,7 +71,7 @@ export function migrateOperationDeclaration(sourceText, fileName, options = {})
70
71
  const todos = [];
71
72
  const refusals = [];
72
73
  for (const site of discovery.sites) {
73
- const plan = planOperationMigration(source, fileName, site, constObjects, options.localeFiles ?? []);
74
+ const plan = planOperationMigration(source, fileName, site, constObjects, constArrays, options.localeFiles ?? []);
74
75
  if ("refusal" in plan) {
75
76
  refusals.push(plan.refusal);
76
77
  continue;
@@ -87,7 +88,11 @@ export function migrateOperationDeclaration(sourceText, fileName, options = {})
87
88
  operations: discovery.sites.length,
88
89
  };
89
90
  }
90
- const code = applyEdits(sourceText, edits);
91
+ const normalizedEdits = normalizeEdits(edits, fileName);
92
+ if ("refusal" in normalizedEdits) {
93
+ return { status: "refused", refusals: [normalizedEdits.refusal] };
94
+ }
95
+ const code = applyEdits(sourceText, normalizedEdits.edits);
91
96
  const outputRefusal = verifyOperationDeclarationRewrite(code, fileName);
92
97
  if (outputRefusal !== undefined) {
93
98
  return {
@@ -109,7 +114,7 @@ export function verifyOperationDeclarationRewrite(code, fileName) {
109
114
  return undefined;
110
115
  return refusal(fileName, "<file>", "codemod_syntax", `Transform output did not parse: ${outputError}`);
111
116
  }
112
- function planOperationMigration(source, fileName, site, constObjects, localeFiles) {
117
+ function planOperationMigration(source, fileName, site, constObjects, constArrays, localeFiles) {
113
118
  if (site.object.properties.some(ts.isSpreadAssignment)) {
114
119
  return {
115
120
  refusal: refusal(fileName, site.operationKey, "non_literal", "Top-level operation spreads cannot be rewritten without changing their shared declaration."),
@@ -216,7 +221,7 @@ function planOperationMigration(source, fileName, site, constObjects, localeFile
216
221
  if (merged.insert !== undefined)
217
222
  addInsertion(insertions, "docs", merged.insert);
218
223
  }
219
- const examples = planExamples(top.byName.get("inputExamples"), top.byName.get("examples"), fileName, site, source, localeFiles);
224
+ const examples = planExamples(top.byName.get("inputExamples"), top.byName.get("examples"), fileName, site, source, constArrays, localeFiles);
220
225
  if ("refusal" in examples)
221
226
  return examples;
222
227
  const edits = [...examples.edits];
@@ -376,7 +381,7 @@ function mergeFlatAndNested(field, flat, nested, fileName, operationKey, source,
376
381
  }
377
382
  return {};
378
383
  }
379
- function planExamples(inputExamples, existingExamples, fileName, site, source, localeFiles) {
384
+ function planExamples(inputExamples, existingExamples, fileName, site, source, constArrays, localeFiles) {
380
385
  if (inputExamples === undefined)
381
386
  return { edits: [], localeTodos: [] };
382
387
  if (existingExamples !== undefined) {
@@ -388,12 +393,15 @@ function planExamples(inputExamples, existingExamples, fileName, site, source, l
388
393
  if (array === undefined || !ts.isArrayLiteralExpression(array)) {
389
394
  return nonLiteral(fileName, site.operationKey, "inputExamples must be an array literal.");
390
395
  }
391
- if (array.elements.length > 0 && !site.operationIdProven) {
396
+ const expanded = expandArrayElements(array, constArrays, fileName, site.operationKey);
397
+ if ("refusal" in expanded)
398
+ return expanded;
399
+ if (expanded.elements.length > 0 && !site.operationIdProven) {
392
400
  return {
393
401
  refusal: refusal(fileName, site.operationKey, "operation_id_unresolved", "inputExamples requires an exact operation id proven from a static operations map."),
394
402
  };
395
403
  }
396
- if (array.elements.length > 0 && !localeFiles.includes("locales/en.json")) {
404
+ if (expanded.elements.length > 0 && !localeFiles.includes("locales/en.json")) {
397
405
  return {
398
406
  refusal: refusal(fileName, site.operationKey, "missing_english_locale", "inputExamples cannot be migrated because locales/en.json does not exist."),
399
407
  };
@@ -409,8 +417,8 @@ function planExamples(inputExamples, existingExamples, fileName, site, source, l
409
417
  end: propertyNameNode.getEnd(),
410
418
  text: "examples",
411
419
  });
412
- for (let index = 0; index < array.elements.length; index += 1) {
413
- const element = array.elements[index];
420
+ for (let index = 0; index < expanded.elements.length; index += 1) {
421
+ const element = expanded.elements[index];
414
422
  const object = element === undefined ? undefined : unwrapExpression(element);
415
423
  if (object === undefined || !ts.isObjectLiteralExpression(object)) {
416
424
  return nonLiteral(fileName, site.operationKey, `inputExamples[${index}] must be an object literal.`);
@@ -696,6 +704,51 @@ function collectModuleConstObjects(source) {
696
704
  }
697
705
  return objects;
698
706
  }
707
+ function collectModuleConstArrays(source) {
708
+ const arrays = new Map();
709
+ for (const statement of source.statements) {
710
+ if (!ts.isVariableStatement(statement))
711
+ continue;
712
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0)
713
+ continue;
714
+ for (const declaration of statement.declarationList.declarations) {
715
+ if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined)
716
+ continue;
717
+ const expression = unwrapExpression(declaration.initializer);
718
+ if (expression !== undefined && ts.isArrayLiteralExpression(expression)) {
719
+ arrays.set(declaration.name.text, expression);
720
+ }
721
+ }
722
+ }
723
+ return arrays;
724
+ }
725
+ function expandArrayElements(array, constArrays, fileName, operationKey, seen = new Set()) {
726
+ if (seen.has(array)) {
727
+ return nonLiteral(fileName, operationKey, "A module-level array spread is recursive.");
728
+ }
729
+ seen.add(array);
730
+ const elements = [];
731
+ for (const element of array.elements) {
732
+ if (!ts.isSpreadElement(element)) {
733
+ elements.push(element);
734
+ continue;
735
+ }
736
+ const expression = unwrapExpression(element.expression);
737
+ const spreadArray = expression !== undefined && ts.isArrayLiteralExpression(expression)
738
+ ? expression
739
+ : expression !== undefined && ts.isIdentifier(expression)
740
+ ? constArrays.get(expression.text)
741
+ : undefined;
742
+ if (spreadArray === undefined) {
743
+ return nonLiteral(fileName, operationKey, "inputExamples contains an unresolved array spread.");
744
+ }
745
+ const expanded = expandArrayElements(spreadArray, constArrays, fileName, operationKey, new Set(seen));
746
+ if ("refusal" in expanded)
747
+ return expanded;
748
+ elements.push(...expanded.elements);
749
+ }
750
+ return { elements };
751
+ }
699
752
  function expandMembers(object, constObjects, fileName, operationKey, seen = new Set()) {
700
753
  if (seen.has(object)) {
701
754
  return nonLiteral(fileName, operationKey, "A module-level object spread is recursive.");
@@ -929,6 +982,21 @@ function applyEdits(sourceText, edits) {
929
982
  }
930
983
  return code;
931
984
  }
985
+ function normalizeEdits(edits, fileName) {
986
+ const unique = new Map();
987
+ for (const edit of edits) {
988
+ const key = `${edit.start}:${edit.end}`;
989
+ const previous = unique.get(key);
990
+ if (previous === undefined) {
991
+ unique.set(key, edit);
992
+ continue;
993
+ }
994
+ if (previous.text !== edit.text) {
995
+ return nonLiteral(fileName, "<shared>", "A module-level input examples array is shared by operations that need different locale keys.");
996
+ }
997
+ }
998
+ return { edits: [...unique.values()] };
999
+ }
932
1000
  function indentationAt(text, position) {
933
1001
  const lineStart = text.lastIndexOf("\n", position - 1) + 1;
934
1002
  return text.slice(lineStart, position).match(/^\s*/)?.[0] ?? "";
@@ -1020,6 +1088,9 @@ export function migrateOperationDeclarationRepository(providerRootInput, options
1020
1088
  if (refusals.length > 0) {
1021
1089
  return { status: "refused", providerRoot, refusals };
1022
1090
  }
1091
+ for (const [path, code] of renderLocaleCatalogWrites(providerRoot, todos)) {
1092
+ pendingWrites.set(path, code);
1093
+ }
1023
1094
  if (pendingWrites.size === 0) {
1024
1095
  return {
1025
1096
  status: "unchanged",
@@ -1054,6 +1125,89 @@ export function renderLocaleTodoSidecar(todos) {
1054
1125
  }
1055
1126
  return `${JSON.stringify({ schemaVersion: 1, localeFiles }, null, 2)}\n`;
1056
1127
  }
1128
+ function renderLocaleCatalogWrites(providerRoot, todos) {
1129
+ const todosByFile = new Map();
1130
+ for (const todo of todos) {
1131
+ const fileTodos = todosByFile.get(todo.localeFile) ?? [];
1132
+ fileTodos.push(todo);
1133
+ todosByFile.set(todo.localeFile, fileTodos);
1134
+ }
1135
+ const englishPath = "locales/en.json";
1136
+ const englishTodos = todosByFile.get(englishPath);
1137
+ if (englishTodos === undefined)
1138
+ return new Map();
1139
+ const english = readLocaleCatalog(join(providerRoot, englishPath));
1140
+ applyLocaleTodos(english, englishTodos);
1141
+ const writes = new Map([
1142
+ [join(providerRoot, englishPath), renderCanonicalLocaleCatalog(english)],
1143
+ ]);
1144
+ for (const [localeFile, fileTodos] of todosByFile) {
1145
+ if (localeFile === englishPath)
1146
+ continue;
1147
+ const catalog = readLocaleCatalog(join(providerRoot, localeFile));
1148
+ applyLocaleTodos(catalog, fileTodos);
1149
+ writes.set(join(providerRoot, localeFile), renderCanonicalLocaleCatalog(reorderLikeReference(english, catalog)));
1150
+ }
1151
+ return writes;
1152
+ }
1153
+ function readLocaleCatalog(path) {
1154
+ const value = JSON.parse(readFileSync(path, "utf8"));
1155
+ if (!isRecord(value))
1156
+ throw new Error(`${path} must contain a JSON object.`);
1157
+ return value;
1158
+ }
1159
+ function applyLocaleTodos(catalog, todos) {
1160
+ for (const todo of todos) {
1161
+ const segments = todo.key.split(".");
1162
+ const leaf = segments.pop();
1163
+ if (leaf === undefined)
1164
+ continue;
1165
+ let cursor = catalog;
1166
+ for (const segment of segments) {
1167
+ const child = cursor[segment];
1168
+ if (isRecord(child)) {
1169
+ cursor = child;
1170
+ continue;
1171
+ }
1172
+ const created = {};
1173
+ cursor[segment] = created;
1174
+ cursor = created;
1175
+ }
1176
+ cursor[leaf] = todo.originalProse;
1177
+ }
1178
+ }
1179
+ /** Match provider-contract's canonical locale serialization exactly. */
1180
+ function renderCanonicalLocaleCatalog(value) {
1181
+ return `${JSON.stringify(value, null, 2)}\n`;
1182
+ }
1183
+ /**
1184
+ * Put shared keys first in English order, then retain locale-only authored order.
1185
+ * Arrays keep their shape and use the English item at the same index as a reference.
1186
+ */
1187
+ function reorderLikeReference(reference, value) {
1188
+ if (Array.isArray(value)) {
1189
+ const referenceArray = Array.isArray(reference) ? reference : [];
1190
+ return value.map((item, index) => reorderLikeReference(referenceArray[index], item));
1191
+ }
1192
+ if (!isRecord(value))
1193
+ return value;
1194
+ const referenceRecord = isRecord(reference) ? reference : {};
1195
+ const ordered = {};
1196
+ for (const key of Object.keys(referenceRecord)) {
1197
+ if (Object.hasOwn(value, key)) {
1198
+ ordered[key] = reorderLikeReference(referenceRecord[key], value[key]);
1199
+ }
1200
+ }
1201
+ for (const [key, child] of Object.entries(value)) {
1202
+ if (!Object.hasOwn(ordered, key)) {
1203
+ ordered[key] = reorderLikeReference(Object.hasOwn(referenceRecord, key) ? referenceRecord[key] : undefined, child);
1204
+ }
1205
+ }
1206
+ return ordered;
1207
+ }
1208
+ function isRecord(value) {
1209
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1210
+ }
1057
1211
  function findLocaleTodoConflict(todos) {
1058
1212
  const values = new Map();
1059
1213
  for (const todo of todos) {
@@ -1075,7 +1229,10 @@ function collectSourceFiles(root) {
1075
1229
  if (entry.isDirectory()) {
1076
1230
  if (SOURCE_SKIP_DIRECTORIES.has(entry.name))
1077
1231
  continue;
1078
- walk(join(directory, entry.name));
1232
+ const child = join(directory, entry.name);
1233
+ if (existsSync(join(child, ".git")))
1234
+ continue;
1235
+ walk(child);
1079
1236
  continue;
1080
1237
  }
1081
1238
  if (!entry.name.endsWith(".ts"))
@@ -1099,6 +1256,8 @@ function collectLocaleFiles(root) {
1099
1256
  }
1100
1257
  function buildOperationIdIndex(sourceFiles) {
1101
1258
  const result = new Map();
1259
+ const idsByPath = new Map();
1260
+ const ambiguousBindingsByPath = new Map();
1102
1261
  const sources = new Map();
1103
1262
  for (const path of sourceFiles) {
1104
1263
  const source = parseSource(path, readFileSync(path, "utf8"));
@@ -1107,12 +1266,38 @@ function buildOperationIdIndex(sourceFiles) {
1107
1266
  }
1108
1267
  const record = (path, binding, operationId) => {
1109
1268
  const map = result.get(path) ?? new Map();
1110
- const previous = map.get(binding);
1111
- if (previous === undefined || previous === operationId)
1112
- map.set(binding, operationId);
1113
- else
1269
+ const ids = idsByPath.get(path) ?? new Map();
1270
+ const ambiguousBindings = ambiguousBindingsByPath.get(path) ?? new Set();
1271
+ // An operation id is usable only when it identifies exactly one binding.
1272
+ // Keep an explicit null marker for an ambiguous id so a later occurrence
1273
+ // cannot accidentally make it usable again.
1274
+ const previousBinding = ids.get(operationId);
1275
+ if (ids.has(operationId)) {
1276
+ if (previousBinding !== undefined &&
1277
+ previousBinding !== null &&
1278
+ previousBinding !== binding) {
1279
+ map.delete(previousBinding);
1280
+ map.delete(binding);
1281
+ ambiguousBindings.add(previousBinding);
1282
+ ambiguousBindings.add(binding);
1283
+ ids.set(operationId, null);
1284
+ }
1285
+ }
1286
+ else {
1287
+ ids.set(operationId, binding);
1288
+ }
1289
+ // A binding registered under two different ids is likewise ambiguous.
1290
+ const previousId = map.get(binding);
1291
+ if (previousId !== undefined && previousId !== operationId) {
1114
1292
  map.delete(binding);
1293
+ ambiguousBindings.add(binding);
1294
+ }
1295
+ else if (!ambiguousBindings.has(binding) && ids.get(operationId) === binding) {
1296
+ map.set(binding, operationId);
1297
+ }
1115
1298
  result.set(path, map);
1299
+ idsByPath.set(path, ids);
1300
+ ambiguousBindingsByPath.set(path, ambiguousBindings);
1116
1301
  };
1117
1302
  for (const [path, source] of sources) {
1118
1303
  const imports = collectImports(source, path, sources);
@@ -1135,9 +1320,92 @@ function buildOperationIdIndex(sourceFiles) {
1135
1320
  record(imported.path, imported.exportedName, operationId);
1136
1321
  }
1137
1322
  }
1323
+ // Some providers keep their operation registry in a same-file const with
1324
+ // an arbitrary name (for example, `companionsOperations`). This scan is
1325
+ // deliberately ID-only: it accepts only static object members whose value
1326
+ // is a same-file operation binding, and never evaluates spreads, factories,
1327
+ // or imported values.
1328
+ const operationBindings = collectModuleOperationBindings(source);
1329
+ const importedBindings = collectImportedBindingNames(source);
1330
+ for (const entry of collectStaticOperationRegistryEntries(source, operationBindings)) {
1331
+ if (importedBindings.has(entry.binding))
1332
+ continue;
1333
+ record(path, entry.binding, entry.operationId);
1334
+ }
1138
1335
  }
1139
1336
  return result;
1140
1337
  }
1338
+ function collectModuleOperationBindings(source) {
1339
+ const bindings = new Set();
1340
+ for (const statement of source.statements) {
1341
+ if (!ts.isVariableStatement(statement))
1342
+ continue;
1343
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0)
1344
+ continue;
1345
+ for (const declaration of statement.declarationList.declarations) {
1346
+ if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined)
1347
+ continue;
1348
+ const initializer = unwrapExpression(declaration.initializer);
1349
+ if (initializer !== undefined &&
1350
+ ts.isCallExpression(initializer) &&
1351
+ isOperationHelperCall(initializer)) {
1352
+ bindings.add(declaration.name.text);
1353
+ }
1354
+ }
1355
+ }
1356
+ return bindings;
1357
+ }
1358
+ function collectImportedBindingNames(source) {
1359
+ const bindings = new Set();
1360
+ for (const statement of source.statements) {
1361
+ if (!ts.isImportDeclaration(statement))
1362
+ continue;
1363
+ const clause = statement.importClause;
1364
+ if (clause?.name !== undefined)
1365
+ bindings.add(clause.name.text);
1366
+ const named = clause?.namedBindings;
1367
+ if (named === undefined)
1368
+ continue;
1369
+ if (ts.isNamespaceImport(named)) {
1370
+ bindings.add(named.name.text);
1371
+ continue;
1372
+ }
1373
+ for (const element of named.elements)
1374
+ bindings.add(element.name.text);
1375
+ }
1376
+ return bindings;
1377
+ }
1378
+ function collectStaticOperationRegistryEntries(source, operationBindings) {
1379
+ const entries = [];
1380
+ for (const statement of source.statements) {
1381
+ if (!ts.isVariableStatement(statement))
1382
+ continue;
1383
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0)
1384
+ continue;
1385
+ for (const declaration of statement.declarationList.declarations) {
1386
+ if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined)
1387
+ continue;
1388
+ const object = unwrapExpression(declaration.initializer);
1389
+ if (object === undefined || !ts.isObjectLiteralExpression(object))
1390
+ continue;
1391
+ for (const property of object.properties) {
1392
+ if (ts.isSpreadAssignment(property))
1393
+ continue;
1394
+ const name = property.name;
1395
+ if (name === undefined || (!ts.isIdentifier(name) && !ts.isStringLiteral(name)))
1396
+ continue;
1397
+ const value = propertyValue(property);
1398
+ const binding = unwrapExpression(value);
1399
+ if (binding === undefined || !ts.isIdentifier(binding))
1400
+ continue;
1401
+ if (!operationBindings.has(binding.text))
1402
+ continue;
1403
+ entries.push({ operationId: name.text, binding: binding.text });
1404
+ }
1405
+ }
1406
+ }
1407
+ return entries;
1408
+ }
1141
1409
  function collectImports(source, containingPath, sources) {
1142
1410
  const imports = new Map();
1143
1411
  for (const statement of source.statements) {
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.50",
2
+ "version": "2.2.0-beta.52",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
@@ -0,0 +1,15 @@
1
+ const LIST_INPUT_EXAMPLES = [
2
+ {
3
+ scenario: "List a full year",
4
+ rationale: "Shows the common calendar query",
5
+ input: { year: 2026 },
6
+ },
7
+ ] as const;
8
+
9
+ const listOperation = defineOperation<ProviderContext>()({
10
+ annotations: { readOnly: true },
11
+ inputExamples: [...LIST_INPUT_EXAMPLES],
12
+ input: InputSchema,
13
+ output: OutputSchema,
14
+ handler,
15
+ });
@@ -0,0 +1,9 @@
1
+ import { LIST_INPUT_EXAMPLES } from "./shared.js";
2
+
3
+ const importedExamplesOperation = defineOperation<ProviderContext>()({
4
+ annotations: { readOnly: true },
5
+ inputExamples: [...LIST_INPUT_EXAMPLES],
6
+ input: InputSchema,
7
+ output: OutputSchema,
8
+ handler,
9
+ });
@@ -0,0 +1,24 @@
1
+ const SHARED_INPUT_EXAMPLES = [
2
+ {
3
+ scenario: "Shared middle example",
4
+ input: { page: 2 },
5
+ },
6
+ ] as const;
7
+
8
+ const mixedOperation = defineOperation<ProviderContext>()({
9
+ annotations: { readOnly: true },
10
+ inputExamples: [
11
+ {
12
+ scenario: "Literal first example",
13
+ input: { page: 1 },
14
+ },
15
+ ...SHARED_INPUT_EXAMPLES,
16
+ {
17
+ scenario: "Literal last example",
18
+ input: { page: 3 },
19
+ },
20
+ ],
21
+ input: InputSchema,
22
+ output: OutputSchema,
23
+ handler,
24
+ });
@@ -0,0 +1 @@
1
+ {"meta":{"displayName":"Canonical fixture"},"operations":{"alpha":{"title":"Alpha"},"search":{"description":"English description","title":"English title","steps":[{"first":"one","second":"two"}]},"omega":{"title":"Omega"}},"tail":"English tail","enOnly":"Keep this last"}
@@ -0,0 +1 @@
1
+ {"jaOnlyFirst":"最初","operations":{"search":{"steps":[{"second":"二","first":"一"}],"title":"検索","jaOnly":"順序を保持","description":"日本語の説明"},"omega":{"title":"オメガ"},"jaOnlyOperation":{"title":"日本語のみ"},"alpha":{"title":"アルファ"}},"tail":"日本語の末尾","meta":{"displayName":"正規 fixture"},"jaOnlyLast":"最後"}
@@ -0,0 +1 @@
1
+ {"koOnlyFirst":"첫 번째","tail":"한국어 꼬리말","operations":{"omega":{"title":"오메가"},"search":{"title":"검색","steps":[{"second":"둘","first":"하나"}],"description":"한국어 설명","koOnly":"순서 유지"},"alpha":{"title":"알파"},"koOnlyOperation":{"title":"한국어 전용"}},"meta":{"displayName":"정규 fixture"},"koOnlyLast":"마지막"}
@@ -0,0 +1,12 @@
1
+ const operation = defineOperation<ProviderContext>()({
2
+ annotations: { readOnly: true },
3
+ inputExamples: [{ scenario: "Ambiguous", input: {} }],
4
+ input: InputSchema,
5
+ output: OutputSchema,
6
+ handler,
7
+ });
8
+
9
+ const registry = {
10
+ first: operation,
11
+ second: operation,
12
+ };
@@ -0,0 +1,7 @@
1
+ export const importedOperation = defineOperation<ProviderContext>()({
2
+ annotations: { readOnly: true },
3
+ inputExamples: [{ scenario: "Imported", input: {} }],
4
+ input: InputSchema,
5
+ output: OutputSchema,
6
+ handler,
7
+ });
@@ -0,0 +1,5 @@
1
+ import { importedOperation } from "./other";
2
+
3
+ const registry = {
4
+ imported: importedOperation,
5
+ };
@@ -0,0 +1,19 @@
1
+ const firstOperation = defineOperation<ProviderContext>()({
2
+ annotations: { readOnly: true },
3
+ inputExamples: [{ scenario: "First", input: {} }],
4
+ input: InputSchema,
5
+ output: OutputSchema,
6
+ handler,
7
+ });
8
+ const secondOperation = defineOperation<ProviderContext>()({
9
+ annotations: { readOnly: true },
10
+ inputExamples: [{ scenario: "Second", input: {} }],
11
+ input: InputSchema,
12
+ output: OutputSchema,
13
+ handler,
14
+ });
15
+
16
+ const registry = {
17
+ shared: firstOperation,
18
+ "shared": secondOperation,
19
+ };
@@ -0,0 +1,9 @@
1
+ const searchOperation = defineStreamOperation({
2
+ annotations: { readOnly: true },
3
+ inputExamples: [{ scenario: "Search", input: {} }],
4
+ input: InputSchema,
5
+ output: OutputSchema,
6
+ handler,
7
+ });
8
+
9
+ const arbitraryRegistry = { searchOperation };
@@ -0,0 +1,11 @@
1
+ const permissionsRevoke = defineOperation<ProviderContext>()({
2
+ annotations: { readOnly: true },
3
+ inputExamples: [{ scenario: "Revoke permissions", input: {} }],
4
+ input: InputSchema,
5
+ output: OutputSchema,
6
+ handler,
7
+ });
8
+
9
+ export const companionsOperations: Record<string, OperationDefinition> = {
10
+ "permissions-revoke": permissionsRevoke,
11
+ };
@@ -0,0 +1,13 @@
1
+ const detailOperation = defineOperation<ProviderContext>()({
2
+ annotations: { readOnly: true },
3
+ inputExamples: [{ scenario: "Detail", input: {} }],
4
+ input: InputSchema,
5
+ output: OutputSchema,
6
+ handler,
7
+ });
8
+
9
+ const unrelated = {
10
+ detail: detailOperation,
11
+ count: 42,
12
+ created: makeValue(),
13
+ };
@@ -154,6 +154,7 @@ export function migrateOperationDeclaration(
154
154
  }
155
155
 
156
156
  const constObjects = collectModuleConstObjects(source);
157
+ const constArrays = collectModuleConstArrays(source);
157
158
  const discovery = discoverOperationSites(source, fileName, constObjects, options.operationIds);
158
159
  if (discovery.refusals.length > 0) {
159
160
  return { status: "refused", refusals: discovery.refusals };
@@ -168,6 +169,7 @@ export function migrateOperationDeclaration(
168
169
  fileName,
169
170
  site,
170
171
  constObjects,
172
+ constArrays,
171
173
  options.localeFiles ?? [],
172
174
  );
173
175
  if ("refusal" in plan) {
@@ -187,7 +189,12 @@ export function migrateOperationDeclaration(
187
189
  };
188
190
  }
189
191
 
190
- const code = applyEdits(sourceText, edits);
192
+ const normalizedEdits = normalizeEdits(edits, fileName);
193
+ if ("refusal" in normalizedEdits) {
194
+ return { status: "refused", refusals: [normalizedEdits.refusal] };
195
+ }
196
+
197
+ const code = applyEdits(sourceText, normalizedEdits.edits);
191
198
  const outputRefusal = verifyOperationDeclarationRewrite(code, fileName);
192
199
  if (outputRefusal !== undefined) {
193
200
  return {
@@ -224,6 +231,7 @@ function planOperationMigration(
224
231
  fileName: string,
225
232
  site: OperationSite,
226
233
  constObjects: ReadonlyMap<string, TS.ObjectLiteralExpression>,
234
+ constArrays: ReadonlyMap<string, TS.ArrayLiteralExpression>,
227
235
  localeFiles: readonly string[],
228
236
  ): PlannedOperation | { readonly refusal: OperationDeclarationRefusal } {
229
237
  if (site.object.properties.some(ts.isSpreadAssignment)) {
@@ -398,6 +406,7 @@ function planOperationMigration(
398
406
  fileName,
399
407
  site,
400
408
  source,
409
+ constArrays,
401
410
  localeFiles,
402
411
  );
403
412
  if ("refusal" in examples) return examples;
@@ -653,6 +662,7 @@ function planExamples(
653
662
  fileName: string,
654
663
  site: OperationSite,
655
664
  source: TS.SourceFile,
665
+ constArrays: ReadonlyMap<string, TS.ArrayLiteralExpression>,
656
666
  localeFiles: readonly string[],
657
667
  ):
658
668
  | { readonly edits: readonly TextEdit[]; readonly localeTodos: readonly LocaleTodo[] }
@@ -672,7 +682,9 @@ function planExamples(
672
682
  if (array === undefined || !ts.isArrayLiteralExpression(array)) {
673
683
  return nonLiteral(fileName, site.operationKey, "inputExamples must be an array literal.");
674
684
  }
675
- if (array.elements.length > 0 && !site.operationIdProven) {
685
+ const expanded = expandArrayElements(array, constArrays, fileName, site.operationKey);
686
+ if ("refusal" in expanded) return expanded;
687
+ if (expanded.elements.length > 0 && !site.operationIdProven) {
676
688
  return {
677
689
  refusal: refusal(
678
690
  fileName,
@@ -682,7 +694,7 @@ function planExamples(
682
694
  ),
683
695
  };
684
696
  }
685
- if (array.elements.length > 0 && !localeFiles.includes("locales/en.json")) {
697
+ if (expanded.elements.length > 0 && !localeFiles.includes("locales/en.json")) {
686
698
  return {
687
699
  refusal: refusal(
688
700
  fileName,
@@ -709,8 +721,8 @@ function planExamples(
709
721
  text: "examples",
710
722
  });
711
723
 
712
- for (let index = 0; index < array.elements.length; index += 1) {
713
- const element = array.elements[index];
724
+ for (let index = 0; index < expanded.elements.length; index += 1) {
725
+ const element = expanded.elements[index];
714
726
  const object = element === undefined ? undefined : unwrapExpression(element);
715
727
  if (object === undefined || !ts.isObjectLiteralExpression(object)) {
716
728
  return nonLiteral(
@@ -1070,6 +1082,66 @@ function collectModuleConstObjects(source: TS.SourceFile): Map<string, TS.Object
1070
1082
  return objects;
1071
1083
  }
1072
1084
 
1085
+ function collectModuleConstArrays(source: TS.SourceFile): Map<string, TS.ArrayLiteralExpression> {
1086
+ const arrays = new Map<string, TS.ArrayLiteralExpression>();
1087
+ for (const statement of source.statements) {
1088
+ if (!ts.isVariableStatement(statement)) continue;
1089
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
1090
+ for (const declaration of statement.declarationList.declarations) {
1091
+ if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue;
1092
+ const expression = unwrapExpression(declaration.initializer);
1093
+ if (expression !== undefined && ts.isArrayLiteralExpression(expression)) {
1094
+ arrays.set(declaration.name.text, expression);
1095
+ }
1096
+ }
1097
+ }
1098
+ return arrays;
1099
+ }
1100
+
1101
+ function expandArrayElements(
1102
+ array: TS.ArrayLiteralExpression,
1103
+ constArrays: ReadonlyMap<string, TS.ArrayLiteralExpression>,
1104
+ fileName: string,
1105
+ operationKey: string,
1106
+ seen = new Set<TS.ArrayLiteralExpression>(),
1107
+ ): { readonly elements: TS.Expression[] } | { readonly refusal: OperationDeclarationRefusal } {
1108
+ if (seen.has(array)) {
1109
+ return nonLiteral(fileName, operationKey, "A module-level array spread is recursive.");
1110
+ }
1111
+ seen.add(array);
1112
+ const elements: TS.Expression[] = [];
1113
+ for (const element of array.elements) {
1114
+ if (!ts.isSpreadElement(element)) {
1115
+ elements.push(element);
1116
+ continue;
1117
+ }
1118
+ const expression = unwrapExpression(element.expression);
1119
+ const spreadArray =
1120
+ expression !== undefined && ts.isArrayLiteralExpression(expression)
1121
+ ? expression
1122
+ : expression !== undefined && ts.isIdentifier(expression)
1123
+ ? constArrays.get(expression.text)
1124
+ : undefined;
1125
+ if (spreadArray === undefined) {
1126
+ return nonLiteral(
1127
+ fileName,
1128
+ operationKey,
1129
+ "inputExamples contains an unresolved array spread.",
1130
+ );
1131
+ }
1132
+ const expanded = expandArrayElements(
1133
+ spreadArray,
1134
+ constArrays,
1135
+ fileName,
1136
+ operationKey,
1137
+ new Set(seen),
1138
+ );
1139
+ if ("refusal" in expanded) return expanded;
1140
+ elements.push(...expanded.elements);
1141
+ }
1142
+ return { elements };
1143
+ }
1144
+
1073
1145
  function expandMembers(
1074
1146
  object: TS.ObjectLiteralExpression,
1075
1147
  constObjects: ReadonlyMap<string, TS.ObjectLiteralExpression>,
@@ -1357,6 +1429,29 @@ function applyEdits(sourceText: string, edits: readonly TextEdit[]): string {
1357
1429
  return code;
1358
1430
  }
1359
1431
 
1432
+ function normalizeEdits(
1433
+ edits: readonly TextEdit[],
1434
+ fileName: string,
1435
+ ): { readonly edits: readonly TextEdit[] } | { readonly refusal: OperationDeclarationRefusal } {
1436
+ const unique = new Map<string, TextEdit>();
1437
+ for (const edit of edits) {
1438
+ const key = `${edit.start}:${edit.end}`;
1439
+ const previous = unique.get(key);
1440
+ if (previous === undefined) {
1441
+ unique.set(key, edit);
1442
+ continue;
1443
+ }
1444
+ if (previous.text !== edit.text) {
1445
+ return nonLiteral(
1446
+ fileName,
1447
+ "<shared>",
1448
+ "A module-level input examples array is shared by operations that need different locale keys.",
1449
+ );
1450
+ }
1451
+ }
1452
+ return { edits: [...unique.values()] };
1453
+ }
1454
+
1360
1455
  function indentationAt(text: string, position: number): string {
1361
1456
  const lineStart = text.lastIndexOf("\n", position - 1) + 1;
1362
1457
  return text.slice(lineStart, position).match(/^\s*/)?.[0] ?? "";
@@ -1481,6 +1576,9 @@ export function migrateOperationDeclarationRepository(
1481
1576
  if (refusals.length > 0) {
1482
1577
  return { status: "refused", providerRoot, refusals };
1483
1578
  }
1579
+ for (const [path, code] of renderLocaleCatalogWrites(providerRoot, todos)) {
1580
+ pendingWrites.set(path, code);
1581
+ }
1484
1582
 
1485
1583
  if (pendingWrites.size === 0) {
1486
1584
  return {
@@ -1521,6 +1619,103 @@ export function renderLocaleTodoSidecar(todos: readonly LocaleTodo[]): string {
1521
1619
  return `${JSON.stringify({ schemaVersion: 1, localeFiles }, null, 2)}\n`;
1522
1620
  }
1523
1621
 
1622
+ function renderLocaleCatalogWrites(
1623
+ providerRoot: string,
1624
+ todos: readonly LocaleTodo[],
1625
+ ): Map<string, string> {
1626
+ const todosByFile = new Map<string, LocaleTodo[]>();
1627
+ for (const todo of todos) {
1628
+ const fileTodos = todosByFile.get(todo.localeFile) ?? [];
1629
+ fileTodos.push(todo);
1630
+ todosByFile.set(todo.localeFile, fileTodos);
1631
+ }
1632
+
1633
+ const englishPath = "locales/en.json";
1634
+ const englishTodos = todosByFile.get(englishPath);
1635
+ if (englishTodos === undefined) return new Map();
1636
+
1637
+ const english = readLocaleCatalog(join(providerRoot, englishPath));
1638
+ applyLocaleTodos(english, englishTodos);
1639
+ const writes = new Map<string, string>([
1640
+ [join(providerRoot, englishPath), renderCanonicalLocaleCatalog(english)],
1641
+ ]);
1642
+
1643
+ for (const [localeFile, fileTodos] of todosByFile) {
1644
+ if (localeFile === englishPath) continue;
1645
+ const catalog = readLocaleCatalog(join(providerRoot, localeFile));
1646
+ applyLocaleTodos(catalog, fileTodos);
1647
+ writes.set(
1648
+ join(providerRoot, localeFile),
1649
+ renderCanonicalLocaleCatalog(reorderLikeReference(english, catalog)),
1650
+ );
1651
+ }
1652
+ return writes;
1653
+ }
1654
+
1655
+ function readLocaleCatalog(path: string): Record<string, unknown> {
1656
+ const value: unknown = JSON.parse(readFileSync(path, "utf8"));
1657
+ if (!isRecord(value)) throw new Error(`${path} must contain a JSON object.`);
1658
+ return value;
1659
+ }
1660
+
1661
+ function applyLocaleTodos(catalog: Record<string, unknown>, todos: readonly LocaleTodo[]): void {
1662
+ for (const todo of todos) {
1663
+ const segments = todo.key.split(".");
1664
+ const leaf = segments.pop();
1665
+ if (leaf === undefined) continue;
1666
+ let cursor = catalog;
1667
+ for (const segment of segments) {
1668
+ const child = cursor[segment];
1669
+ if (isRecord(child)) {
1670
+ cursor = child;
1671
+ continue;
1672
+ }
1673
+ const created: Record<string, unknown> = {};
1674
+ cursor[segment] = created;
1675
+ cursor = created;
1676
+ }
1677
+ cursor[leaf] = todo.originalProse;
1678
+ }
1679
+ }
1680
+
1681
+ /** Match provider-contract's canonical locale serialization exactly. */
1682
+ function renderCanonicalLocaleCatalog(value: unknown): string {
1683
+ return `${JSON.stringify(value, null, 2)}\n`;
1684
+ }
1685
+
1686
+ /**
1687
+ * Put shared keys first in English order, then retain locale-only authored order.
1688
+ * Arrays keep their shape and use the English item at the same index as a reference.
1689
+ */
1690
+ function reorderLikeReference(reference: unknown, value: unknown): unknown {
1691
+ if (Array.isArray(value)) {
1692
+ const referenceArray = Array.isArray(reference) ? reference : [];
1693
+ return value.map((item, index) => reorderLikeReference(referenceArray[index], item));
1694
+ }
1695
+ if (!isRecord(value)) return value;
1696
+
1697
+ const referenceRecord = isRecord(reference) ? reference : {};
1698
+ const ordered: Record<string, unknown> = {};
1699
+ for (const key of Object.keys(referenceRecord)) {
1700
+ if (Object.hasOwn(value, key)) {
1701
+ ordered[key] = reorderLikeReference(referenceRecord[key], value[key]);
1702
+ }
1703
+ }
1704
+ for (const [key, child] of Object.entries(value)) {
1705
+ if (!Object.hasOwn(ordered, key)) {
1706
+ ordered[key] = reorderLikeReference(
1707
+ Object.hasOwn(referenceRecord, key) ? referenceRecord[key] : undefined,
1708
+ child,
1709
+ );
1710
+ }
1711
+ }
1712
+ return ordered;
1713
+ }
1714
+
1715
+ function isRecord(value: unknown): value is Record<string, unknown> {
1716
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1717
+ }
1718
+
1524
1719
  function findLocaleTodoConflict(
1525
1720
  todos: readonly LocaleTodo[],
1526
1721
  ): OperationDeclarationRefusal | undefined {
@@ -1548,7 +1743,9 @@ function collectSourceFiles(root: string): string[] {
1548
1743
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
1549
1744
  if (entry.isDirectory()) {
1550
1745
  if (SOURCE_SKIP_DIRECTORIES.has(entry.name)) continue;
1551
- walk(join(directory, entry.name));
1746
+ const child = join(directory, entry.name);
1747
+ if (existsSync(join(child, ".git"))) continue;
1748
+ walk(child);
1552
1749
  continue;
1553
1750
  }
1554
1751
  if (!entry.name.endsWith(".ts")) continue;
@@ -1571,6 +1768,8 @@ function collectLocaleFiles(root: string): string[] {
1571
1768
 
1572
1769
  function buildOperationIdIndex(sourceFiles: readonly string[]): Map<string, Map<string, string>> {
1573
1770
  const result = new Map<string, Map<string, string>>();
1771
+ const idsByPath = new Map<string, Map<string, string | null>>();
1772
+ const ambiguousBindingsByPath = new Map<string, Set<string>>();
1574
1773
  const sources = new Map<string, TS.SourceFile>();
1575
1774
  for (const path of sourceFiles) {
1576
1775
  const source = parseSource(path, readFileSync(path, "utf8"));
@@ -1579,10 +1778,40 @@ function buildOperationIdIndex(sourceFiles: readonly string[]): Map<string, Map<
1579
1778
 
1580
1779
  const record = (path: string, binding: string, operationId: string): void => {
1581
1780
  const map = result.get(path) ?? new Map<string, string>();
1582
- const previous = map.get(binding);
1583
- if (previous === undefined || previous === operationId) map.set(binding, operationId);
1584
- else map.delete(binding);
1781
+ const ids = idsByPath.get(path) ?? new Map<string, string | null>();
1782
+ const ambiguousBindings = ambiguousBindingsByPath.get(path) ?? new Set<string>();
1783
+
1784
+ // An operation id is usable only when it identifies exactly one binding.
1785
+ // Keep an explicit null marker for an ambiguous id so a later occurrence
1786
+ // cannot accidentally make it usable again.
1787
+ const previousBinding = ids.get(operationId);
1788
+ if (ids.has(operationId)) {
1789
+ if (
1790
+ previousBinding !== undefined &&
1791
+ previousBinding !== null &&
1792
+ previousBinding !== binding
1793
+ ) {
1794
+ map.delete(previousBinding);
1795
+ map.delete(binding);
1796
+ ambiguousBindings.add(previousBinding);
1797
+ ambiguousBindings.add(binding);
1798
+ ids.set(operationId, null);
1799
+ }
1800
+ } else {
1801
+ ids.set(operationId, binding);
1802
+ }
1803
+
1804
+ // A binding registered under two different ids is likewise ambiguous.
1805
+ const previousId = map.get(binding);
1806
+ if (previousId !== undefined && previousId !== operationId) {
1807
+ map.delete(binding);
1808
+ ambiguousBindings.add(binding);
1809
+ } else if (!ambiguousBindings.has(binding) && ids.get(operationId) === binding) {
1810
+ map.set(binding, operationId);
1811
+ }
1585
1812
  result.set(path, map);
1813
+ idsByPath.set(path, ids);
1814
+ ambiguousBindingsByPath.set(path, ambiguousBindings);
1586
1815
  };
1587
1816
 
1588
1817
  for (const [path, source] of sources) {
@@ -1603,10 +1832,91 @@ function buildOperationIdIndex(sourceFiles: readonly string[]): Map<string, Map<
1603
1832
  else record(imported.path, imported.exportedName, operationId);
1604
1833
  }
1605
1834
  }
1835
+
1836
+ // Some providers keep their operation registry in a same-file const with
1837
+ // an arbitrary name (for example, `companionsOperations`). This scan is
1838
+ // deliberately ID-only: it accepts only static object members whose value
1839
+ // is a same-file operation binding, and never evaluates spreads, factories,
1840
+ // or imported values.
1841
+ const operationBindings = collectModuleOperationBindings(source);
1842
+ const importedBindings = collectImportedBindingNames(source);
1843
+ for (const entry of collectStaticOperationRegistryEntries(source, operationBindings)) {
1844
+ if (importedBindings.has(entry.binding)) continue;
1845
+ record(path, entry.binding, entry.operationId);
1846
+ }
1606
1847
  }
1607
1848
  return result;
1608
1849
  }
1609
1850
 
1851
+ type StaticOperationRegistryEntry = {
1852
+ readonly operationId: string;
1853
+ readonly binding: string;
1854
+ };
1855
+
1856
+ function collectModuleOperationBindings(source: TS.SourceFile): ReadonlySet<string> {
1857
+ const bindings = new Set<string>();
1858
+ for (const statement of source.statements) {
1859
+ if (!ts.isVariableStatement(statement)) continue;
1860
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
1861
+ for (const declaration of statement.declarationList.declarations) {
1862
+ if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue;
1863
+ const initializer = unwrapExpression(declaration.initializer);
1864
+ if (
1865
+ initializer !== undefined &&
1866
+ ts.isCallExpression(initializer) &&
1867
+ isOperationHelperCall(initializer)
1868
+ ) {
1869
+ bindings.add(declaration.name.text);
1870
+ }
1871
+ }
1872
+ }
1873
+ return bindings;
1874
+ }
1875
+
1876
+ function collectImportedBindingNames(source: TS.SourceFile): ReadonlySet<string> {
1877
+ const bindings = new Set<string>();
1878
+ for (const statement of source.statements) {
1879
+ if (!ts.isImportDeclaration(statement)) continue;
1880
+ const clause = statement.importClause;
1881
+ if (clause?.name !== undefined) bindings.add(clause.name.text);
1882
+ const named = clause?.namedBindings;
1883
+ if (named === undefined) continue;
1884
+ if (ts.isNamespaceImport(named)) {
1885
+ bindings.add(named.name.text);
1886
+ continue;
1887
+ }
1888
+ for (const element of named.elements) bindings.add(element.name.text);
1889
+ }
1890
+ return bindings;
1891
+ }
1892
+
1893
+ function collectStaticOperationRegistryEntries(
1894
+ source: TS.SourceFile,
1895
+ operationBindings: ReadonlySet<string>,
1896
+ ): StaticOperationRegistryEntry[] {
1897
+ const entries: StaticOperationRegistryEntry[] = [];
1898
+ for (const statement of source.statements) {
1899
+ if (!ts.isVariableStatement(statement)) continue;
1900
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
1901
+ for (const declaration of statement.declarationList.declarations) {
1902
+ if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue;
1903
+ const object = unwrapExpression(declaration.initializer);
1904
+ if (object === undefined || !ts.isObjectLiteralExpression(object)) continue;
1905
+ for (const property of object.properties) {
1906
+ if (ts.isSpreadAssignment(property)) continue;
1907
+ const name = property.name;
1908
+ if (name === undefined || (!ts.isIdentifier(name) && !ts.isStringLiteral(name))) continue;
1909
+ const value = propertyValue(property);
1910
+ const binding = unwrapExpression(value);
1911
+ if (binding === undefined || !ts.isIdentifier(binding)) continue;
1912
+ if (!operationBindings.has(binding.text)) continue;
1913
+ entries.push({ operationId: name.text, binding: binding.text });
1914
+ }
1915
+ }
1916
+ }
1917
+ return entries;
1918
+ }
1919
+
1610
1920
  function collectImports(
1611
1921
  source: TS.SourceFile,
1612
1922
  containingPath: string,