@apifuse/provider-sdk 2.2.0-beta.54 → 2.2.0-beta.55

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 (32) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/cli/migrate-operation-declaration.d.ts +5 -1
  3. package/dist/cli/migrate-operation-declaration.js +645 -46
  4. package/package.json +1 -1
  5. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-a.ts.txt +3 -0
  6. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-b.ts.txt +3 -0
  7. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-barrel.ts.txt +2 -0
  8. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-index.ts.txt +3 -0
  9. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-computed.ts.txt +6 -0
  10. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-cycle-a.ts.txt +1 -0
  11. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-cycle-b.ts.txt +1 -0
  12. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-cycle-index.ts.txt +3 -0
  13. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-barrel.ts.txt +7 -0
  14. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-catalog.ts.txt +19 -0
  15. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-docs.ts.txt +6 -0
  16. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-index.ts.txt +3 -0
  17. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-stores.ts.txt +8 -0
  18. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-indirect-config.ts.txt +5 -0
  19. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-nonstatic.ts.txt +4 -0
  20. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-package-index.ts.txt +3 -0
  21. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-index.ts.txt +3 -0
  22. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-leaf.ts.txt +10 -0
  23. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-one.ts.txt +1 -0
  24. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-two.ts.txt +1 -0
  25. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-star-package-barrel.ts.txt +2 -0
  26. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-star-package-index.ts.txt +3 -0
  27. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-star-package-leaf.ts.txt +3 -0
  28. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-duplicate-provider.ts.txt +11 -0
  29. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-id-unresolved.ts.txt +7 -0
  30. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-one-to-many.ts.txt +18 -0
  31. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-one-to-one.ts.txt +13 -0
  32. package/src/cli/migrate-operation-declaration.ts +844 -49
@@ -69,6 +69,8 @@ export type OperationDeclarationRefusalReason =
69
69
  | "codemod_syntax"
70
70
  | "missing_english_locale"
71
71
  | "operation_id_unresolved"
72
+ | "factory_operation_id_ambiguous"
73
+ | "no_operations_discovered"
72
74
  | "examples_conflict"
73
75
  | "invalid_locale_key"
74
76
  | "locale_todo_conflict";
@@ -122,6 +124,23 @@ type ResolvedMember = {
122
124
  readonly property: TS.ObjectLiteralElementLike;
123
125
  readonly initializer?: TS.Expression;
124
126
  readonly fromSpread?: boolean;
127
+ readonly source: TS.SourceFile;
128
+ };
129
+
130
+ type StaticObjectReference = {
131
+ readonly object: TS.ObjectLiteralExpression;
132
+ readonly source: TS.SourceFile;
133
+ };
134
+
135
+ type StaticObjectResolver = (
136
+ expression: TS.Expression,
137
+ source: TS.SourceFile,
138
+ ) => StaticObjectReference | undefined;
139
+
140
+ type RepositoryMigrationContext = {
141
+ readonly operationSites?: ReadonlyMap<number, string>;
142
+ readonly excludedBindings?: ReadonlySet<string>;
143
+ readonly staticObjectResolver?: StaticObjectResolver;
125
144
  };
126
145
 
127
146
  type OperationSite = {
@@ -147,6 +166,15 @@ export function migrateOperationDeclaration(
147
166
  sourceText: string,
148
167
  fileName: string,
149
168
  options: OperationDeclarationMigrationOptions = {},
169
+ ): OperationDeclarationMigration {
170
+ return migrateOperationDeclarationInternal(sourceText, fileName, options);
171
+ }
172
+
173
+ function migrateOperationDeclarationInternal(
174
+ sourceText: string,
175
+ fileName: string,
176
+ options: OperationDeclarationMigrationOptions,
177
+ context: RepositoryMigrationContext = {},
150
178
  ): OperationDeclarationMigration {
151
179
  const source = parseSource(fileName, sourceText);
152
180
  const parseError = firstSyntaxError(source);
@@ -159,7 +187,14 @@ export function migrateOperationDeclaration(
159
187
 
160
188
  const constObjects = collectModuleConstObjects(source);
161
189
  const constArrays = collectModuleConstArrays(source);
162
- const discovery = discoverOperationSites(source, fileName, constObjects, options.operationIds);
190
+ const discovery = discoverOperationSites(
191
+ source,
192
+ fileName,
193
+ constObjects,
194
+ options.operationIds,
195
+ context.operationSites,
196
+ context.excludedBindings,
197
+ );
163
198
  if (discovery.refusals.length > 0) {
164
199
  return { status: "refused", refusals: discovery.refusals };
165
200
  }
@@ -175,6 +210,7 @@ export function migrateOperationDeclaration(
175
210
  constObjects,
176
211
  constArrays,
177
212
  options.localeFiles ?? [],
213
+ context.staticObjectResolver,
178
214
  );
179
215
  if ("refusal" in plan) {
180
216
  refusals.push(plan.refusal);
@@ -241,6 +277,7 @@ function planOperationMigration(
241
277
  constObjects: ReadonlyMap<string, TS.ObjectLiteralExpression>,
242
278
  constArrays: ReadonlyMap<string, TS.ArrayLiteralExpression>,
243
279
  localeFiles: readonly string[],
280
+ staticObjectResolver?: StaticObjectResolver,
244
281
  ): PlannedOperation | { readonly refusal: OperationDeclarationRefusal } {
245
282
  if (site.object.properties.some(ts.isSpreadAssignment)) {
246
283
  return {
@@ -252,7 +289,14 @@ function planOperationMigration(
252
289
  ),
253
290
  };
254
291
  }
255
- const expanded = expandMembers(site.object, constObjects, fileName, site.operationKey);
292
+ const expanded = expandMembers(
293
+ site.object,
294
+ source,
295
+ constObjects,
296
+ fileName,
297
+ site.operationKey,
298
+ staticObjectResolver,
299
+ );
256
300
  if ("refusal" in expanded) return expanded;
257
301
 
258
302
  const top = indexMembers(expanded.members, fileName, site.operationKey);
@@ -272,8 +316,13 @@ function planOperationMigration(
272
316
  for (const containerName of ["annotations", "toolRouter", "docs"] as const) {
273
317
  const member = top.byName.get(containerName);
274
318
  if (member === undefined) continue;
275
- const object = resolveObjectInitializer(member, constObjects);
276
- if (object === undefined) {
319
+ const resolvedObject = resolveObjectInitializer(
320
+ member,
321
+ source,
322
+ constObjects,
323
+ staticObjectResolver,
324
+ );
325
+ if (resolvedObject === undefined) {
277
326
  return {
278
327
  refusal: refusal(
279
328
  fileName,
@@ -283,7 +332,14 @@ function planOperationMigration(
283
332
  ),
284
333
  };
285
334
  }
286
- const members = expandMembers(object, constObjects, fileName, site.operationKey);
335
+ const members = expandMembers(
336
+ resolvedObject.object,
337
+ resolvedObject.source,
338
+ constObjects,
339
+ fileName,
340
+ site.operationKey,
341
+ staticObjectResolver,
342
+ );
287
343
  if ("refusal" in members) return members;
288
344
  const indexed = indexMembers(members.members, fileName, site.operationKey);
289
345
  if ("refusal" in indexed) return indexed;
@@ -350,19 +406,12 @@ function planOperationMigration(
350
406
  annotations.get("timeoutMs"),
351
407
  fileName,
352
408
  site.operationKey,
353
- source,
354
409
  "execution_conflict",
355
410
  );
356
411
  if ("refusal" in timeout) return timeout;
357
412
  if (timeout.insert !== undefined) addInsertion(insertions, "annotations", timeout.insert);
358
413
 
359
- const connection = resolveConnectionMode(
360
- top.byName,
361
- toolRouter,
362
- fileName,
363
- site.operationKey,
364
- source,
365
- );
414
+ const connection = resolveConnectionMode(top.byName, toolRouter, fileName, site.operationKey);
366
415
  if ("refusal" in connection) return connection;
367
416
  if (connection.insert !== undefined) {
368
417
  addInsertion(insertions, "toolRouter", connection.insert);
@@ -374,7 +423,6 @@ function planOperationMigration(
374
423
  toolRouter.get("connectionExternalRefParam"),
375
424
  fileName,
376
425
  site.operationKey,
377
- source,
378
426
  "connection_mode_conflict",
379
427
  );
380
428
  if ("refusal" in externalRef) return externalRef;
@@ -388,7 +436,6 @@ function planOperationMigration(
388
436
  risk.value,
389
437
  fileName,
390
438
  site.operationKey,
391
- source,
392
439
  );
393
440
  if ("refusal" in approval) return approval;
394
441
  if (approval.removeTop !== undefined) removals.push(approval.removeTop);
@@ -401,7 +448,6 @@ function planOperationMigration(
401
448
  docs.get(field),
402
449
  fileName,
403
450
  site.operationKey,
404
- source,
405
451
  "locale_key_conflict",
406
452
  );
407
453
  if ("refusal" in merged) return merged;
@@ -668,7 +714,6 @@ function resolveConnectionMode(
668
714
  toolRouter: ReadonlyMap<string, ResolvedMember>,
669
715
  fileName: string,
670
716
  operationKey: string,
671
- source: TS.SourceFile,
672
717
  ): { readonly insert?: string } | { readonly refusal: OperationDeclarationRefusal } {
673
718
  const flat = top.get("connectionMode");
674
719
  const nested = toolRouter.get("connectionMode");
@@ -695,7 +740,7 @@ function resolveConnectionMode(
695
740
  }
696
741
  if (flat !== undefined) return {};
697
742
  if (nested !== undefined) {
698
- return { insert: memberText(nested, "connectionMode", source) };
743
+ return { insert: memberText(nested, "connectionMode") };
699
744
  }
700
745
 
701
746
  const required = toolRouter.get("requiresConnection");
@@ -716,7 +761,6 @@ function resolveApproval(
716
761
  riskClass: string,
717
762
  fileName: string,
718
763
  operationKey: string,
719
- source: TS.SourceFile,
720
764
  ):
721
765
  | { readonly insert?: string; readonly removeTop?: TS.ObjectLiteralElementLike }
722
766
  | { readonly refusal: OperationDeclarationRefusal } {
@@ -748,7 +792,7 @@ function resolveApproval(
748
792
  return flat === undefined ? {} : { removeTop: flat.property };
749
793
  }
750
794
  if (flat !== undefined) return {};
751
- return { insert: memberText(selected, "approval", source) };
795
+ return { insert: memberText(selected, "approval") };
752
796
  }
753
797
 
754
798
  function defaultApprovalPolicy(riskClass: string): string {
@@ -763,11 +807,10 @@ function mergeFlatAndNested(
763
807
  nested: ResolvedMember | undefined,
764
808
  fileName: string,
765
809
  operationKey: string,
766
- source: TS.SourceFile,
767
810
  reason: "locale_key_conflict" | "connection_mode_conflict" | "execution_conflict",
768
811
  ): { readonly insert?: string } | { readonly refusal: OperationDeclarationRefusal } {
769
812
  if (nested === undefined) return {};
770
- if (flat === undefined) return { insert: memberText(nested, field, source) };
813
+ if (flat === undefined) return { insert: memberText(nested, field) };
771
814
  const same = equivalentLiteral(flat.initializer, nested.initializer);
772
815
  if (same === undefined) {
773
816
  return nonLiteral(
@@ -777,6 +820,11 @@ function mergeFlatAndNested(
777
820
  );
778
821
  }
779
822
  if (!same) {
823
+ // A shared imported docs template is intentionally lower-precedence than
824
+ // an operation's explicit flat locale key. Keep direct-vs-direct
825
+ // conflicts fail-closed, but do not let a spread default replace the
826
+ // operation-specific key during flattening.
827
+ if (reason === "locale_key_conflict" && nested.fromSpread === true) return {};
780
828
  return {
781
829
  refusal: refusal(
782
830
  fileName,
@@ -936,7 +984,11 @@ function resolveOperationLocaleNamespace(
936
984
  site: OperationSite,
937
985
  ): { readonly namespace: string } | { readonly refusal: OperationDeclarationRefusal } {
938
986
  const authoredNamespaces = new Set<string>();
939
- for (const member of members) {
987
+ const directMembers = members.filter(
988
+ (member) => member !== undefined && member.fromSpread !== true,
989
+ );
990
+ const namespaceMembers = directMembers.length > 0 ? directMembers : members;
991
+ for (const member of namespaceMembers) {
940
992
  const localeKey = literalString(member?.initializer);
941
993
  if (localeKey === undefined) continue;
942
994
  const segments = localeKey.split(".");
@@ -956,6 +1008,10 @@ function resolveOperationLocaleNamespace(
956
1008
  }
957
1009
  const authored = authoredNamespaces.values().next().value;
958
1010
  if (authored !== undefined) return { namespace: authored };
1011
+ // planExamples proves the id before using this placeholder to construct a
1012
+ // locale key. Avoid validating an unproven binding such as <anonymous> as
1013
+ // though it were an authored operation id.
1014
+ if (!site.operationIdProven) return { namespace: site.operationKey };
959
1015
 
960
1016
  try {
961
1017
  return { namespace: operationIdToLocaleNamespace(site.operationKey) };
@@ -989,13 +1045,18 @@ function discoverOperationSites(
989
1045
  fileName: string,
990
1046
  constObjects: ReadonlyMap<string, TS.ObjectLiteralExpression>,
991
1047
  operationIds: ReadonlyMap<string, string> | undefined,
1048
+ operationSites: ReadonlyMap<number, string> | undefined,
1049
+ excludedBindings: ReadonlySet<string> | undefined,
992
1050
  ): {
993
1051
  readonly sites: OperationSite[];
994
1052
  readonly refusals: OperationDeclarationRefusal[];
995
1053
  } {
996
1054
  const sitesByStart = new Map<number, OperationSite>();
997
1055
  const localIds = new Map(operationIds ?? []);
1056
+ const localExcludedBindings = new Set(excludedBindings ?? []);
998
1057
  const refusals: OperationDeclarationRefusal[] = [];
1058
+ const operationFactories = collectSimpleOperationFactories(source);
1059
+ const factoryCallIds = new Map<string, Set<string>>();
999
1060
 
1000
1061
  for (const map of collectOperationsMaps(source, constObjects, fileName, refusals)) {
1001
1062
  for (const property of map.properties) {
@@ -1029,6 +1090,18 @@ function discoverOperationSites(
1029
1090
  });
1030
1091
  continue;
1031
1092
  }
1093
+ if (
1094
+ ts.isCallExpression(unwrapped) &&
1095
+ ts.isIdentifier(unwrapped.expression) &&
1096
+ operationFactories.has(unwrapped.expression.text) &&
1097
+ !localExcludedBindings.has(unwrapped.expression.text)
1098
+ ) {
1099
+ const factoryName = unwrapped.expression.text;
1100
+ const operationIdsForFactory = factoryCallIds.get(factoryName) ?? new Set<string>();
1101
+ operationIdsForFactory.add(key);
1102
+ factoryCallIds.set(factoryName, operationIdsForFactory);
1103
+ continue;
1104
+ }
1032
1105
  if (ts.isObjectLiteralExpression(unwrapped)) {
1033
1106
  sitesByStart.set(unwrapped.getStart(source), {
1034
1107
  object: unwrapped,
@@ -1038,11 +1111,41 @@ function discoverOperationSites(
1038
1111
  }
1039
1112
  }
1040
1113
  }
1114
+ for (const [factoryName, operationIdsForFactory] of factoryCallIds) {
1115
+ const ids = [...operationIdsForFactory];
1116
+ if (ids.length === 1) {
1117
+ const operationId = ids[0];
1118
+ if (operationId !== undefined) localIds.set(factoryName, operationId);
1119
+ continue;
1120
+ }
1121
+ localExcludedBindings.add(factoryName);
1122
+ refusals.push(
1123
+ refusal(
1124
+ fileName,
1125
+ factoryName,
1126
+ "factory_operation_id_ambiguous",
1127
+ `Factory ${factoryName} is registered under multiple operation ids: ${ids
1128
+ .map((id) => JSON.stringify(id))
1129
+ .join(", ")}. A shared operation body cannot own one examples locale namespace.`,
1130
+ ),
1131
+ );
1132
+ }
1041
1133
 
1042
1134
  const visit = (node: TS.Node): void => {
1135
+ if (ts.isObjectLiteralExpression(node)) {
1136
+ const indexedOperationKey = operationSites?.get(node.getStart(source));
1137
+ if (indexedOperationKey !== undefined) {
1138
+ sitesByStart.set(node.getStart(source), {
1139
+ object: node,
1140
+ operationKey: indexedOperationKey,
1141
+ operationIdProven: true,
1142
+ });
1143
+ }
1144
+ }
1043
1145
  if (ts.isCallExpression(node) && isOperationHelperCall(node)) {
1044
1146
  const argument = operationArgument(node);
1045
1147
  const bindingName = enclosingBindingName(node);
1148
+ if (bindingName !== undefined && localExcludedBindings.has(bindingName)) return;
1046
1149
  const operationKey =
1047
1150
  (bindingName === undefined ? undefined : localIds.get(bindingName)) ??
1048
1151
  bindingName ??
@@ -1086,6 +1189,35 @@ function discoverOperationSites(
1086
1189
  };
1087
1190
  }
1088
1191
 
1192
+ function collectSimpleOperationFactories(source: TS.SourceFile): ReadonlySet<string> {
1193
+ const factories = new Set<string>();
1194
+ for (const statement of source.statements) {
1195
+ if (
1196
+ !ts.isFunctionDeclaration(statement) ||
1197
+ statement.name === undefined ||
1198
+ statement.body === undefined ||
1199
+ statement.body.statements.length !== 1
1200
+ ) {
1201
+ continue;
1202
+ }
1203
+ const returned = statement.body.statements[0];
1204
+ if (!ts.isReturnStatement(returned) || returned.expression === undefined) continue;
1205
+ const expression = unwrapExpression(returned.expression);
1206
+ if (
1207
+ expression !== undefined &&
1208
+ ts.isCallExpression(expression) &&
1209
+ isOperationHelperCall(expression)
1210
+ ) {
1211
+ const argument = operationArgument(expression);
1212
+ const object = argument === undefined ? undefined : unwrapExpression(argument);
1213
+ if (object !== undefined && ts.isObjectLiteralExpression(object)) {
1214
+ factories.add(statement.name.text);
1215
+ }
1216
+ }
1217
+ }
1218
+ return factories;
1219
+ }
1220
+
1089
1221
  function collectOperationsMaps(
1090
1222
  source: TS.SourceFile,
1091
1223
  constObjects: ReadonlyMap<string, TS.ObjectLiteralExpression>,
@@ -1232,6 +1364,9 @@ function enclosingBindingName(node: TS.Node): string | undefined {
1232
1364
  if (ts.isVariableDeclaration(current) && ts.isIdentifier(current.name)) {
1233
1365
  return current.name.text;
1234
1366
  }
1367
+ if (ts.isFunctionDeclaration(current) && current.name !== undefined) {
1368
+ return current.name.text;
1369
+ }
1235
1370
  if (ts.isPropertyAssignment(current)) return staticPropertyName(current.name);
1236
1371
  if (ts.isExportAssignment(current)) return "default";
1237
1372
  current = current.parent;
@@ -1317,9 +1452,11 @@ function expandArrayElements(
1317
1452
 
1318
1453
  function expandMembers(
1319
1454
  object: TS.ObjectLiteralExpression,
1455
+ source: TS.SourceFile,
1320
1456
  constObjects: ReadonlyMap<string, TS.ObjectLiteralExpression>,
1321
1457
  fileName: string,
1322
1458
  operationKey: string,
1459
+ staticObjectResolver?: StaticObjectResolver,
1323
1460
  seen = new Set<TS.ObjectLiteralExpression>(),
1324
1461
  ): { readonly members: ResolvedMember[] } | { readonly refusal: OperationDeclarationRefusal } {
1325
1462
  if (seen.has(object)) {
@@ -1329,22 +1466,23 @@ function expandMembers(
1329
1466
  const members: ResolvedMember[] = [];
1330
1467
  for (const property of object.properties) {
1331
1468
  if (ts.isSpreadAssignment(property)) {
1332
- const expression = unwrapExpression(property.expression);
1333
- const spreadObject =
1334
- expression !== undefined && ts.isObjectLiteralExpression(expression)
1335
- ? expression
1336
- : expression !== undefined && ts.isIdentifier(expression)
1337
- ? constObjects.get(expression.text)
1338
- : undefined;
1469
+ const spreadObject = resolveStaticObjectExpression(
1470
+ property.expression,
1471
+ source,
1472
+ constObjects,
1473
+ staticObjectResolver,
1474
+ );
1339
1475
  if (spreadObject === undefined) {
1340
- members.push({ name: "<spread>", property });
1476
+ members.push({ name: "<spread>", property, source });
1341
1477
  continue;
1342
1478
  }
1343
1479
  const expanded = expandMembers(
1344
- spreadObject,
1480
+ spreadObject.object,
1481
+ spreadObject.source,
1345
1482
  constObjects,
1346
1483
  fileName,
1347
1484
  operationKey,
1485
+ staticObjectResolver,
1348
1486
  new Set(seen),
1349
1487
  );
1350
1488
  if ("refusal" in expanded) return expanded;
@@ -1356,7 +1494,7 @@ function expandMembers(
1356
1494
  return nonLiteral(fileName, operationKey, "A declaration member uses a computed name.");
1357
1495
  }
1358
1496
  const initializer = propertyValue(property);
1359
- members.push({ name, property, initializer });
1497
+ members.push({ name, property, initializer, source });
1360
1498
  }
1361
1499
  return { members };
1362
1500
  }
@@ -1405,7 +1543,7 @@ function indexMembersFromObject(
1405
1543
  if (name === undefined || initializer === undefined) {
1406
1544
  return nonLiteral(fileName, operationKey, "Example members must be literal properties.");
1407
1545
  }
1408
- members.push({ name, property, initializer });
1546
+ members.push({ name, property, initializer, source: object.getSourceFile() });
1409
1547
  }
1410
1548
  const indexed = indexMembers(members, fileName, operationKey);
1411
1549
  if ("refusal" in indexed) return indexed;
@@ -1414,12 +1552,36 @@ function indexMembersFromObject(
1414
1552
 
1415
1553
  function resolveObjectInitializer(
1416
1554
  member: ResolvedMember,
1555
+ rootSource: TS.SourceFile,
1417
1556
  constObjects: ReadonlyMap<string, TS.ObjectLiteralExpression>,
1418
- ): TS.ObjectLiteralExpression | undefined {
1419
- const initializer = unwrapExpression(member.initializer);
1420
- if (initializer === undefined) return undefined;
1421
- if (ts.isObjectLiteralExpression(initializer)) return initializer;
1422
- if (ts.isIdentifier(initializer)) return constObjects.get(initializer.text);
1557
+ staticObjectResolver?: StaticObjectResolver,
1558
+ ): StaticObjectReference | undefined {
1559
+ if (member.initializer === undefined) return undefined;
1560
+ return resolveStaticObjectExpression(
1561
+ member.initializer,
1562
+ member.source,
1563
+ constObjects,
1564
+ staticObjectResolver,
1565
+ rootSource,
1566
+ );
1567
+ }
1568
+
1569
+ function resolveStaticObjectExpression(
1570
+ expression: TS.Expression,
1571
+ source: TS.SourceFile,
1572
+ constObjects: ReadonlyMap<string, TS.ObjectLiteralExpression>,
1573
+ staticObjectResolver?: StaticObjectResolver,
1574
+ rootSource: TS.SourceFile = source,
1575
+ ): StaticObjectReference | undefined {
1576
+ const unwrapped = unwrapExpression(expression);
1577
+ if (unwrapped === undefined) return undefined;
1578
+ if (ts.isObjectLiteralExpression(unwrapped)) return { object: unwrapped, source };
1579
+ const externallyResolved = staticObjectResolver?.(unwrapped, source);
1580
+ if (externallyResolved !== undefined) return externallyResolved;
1581
+ if (source === rootSource && ts.isIdentifier(unwrapped)) {
1582
+ const object = constObjects.get(unwrapped.text);
1583
+ if (object !== undefined) return { object, source };
1584
+ }
1423
1585
  return undefined;
1424
1586
  }
1425
1587
 
@@ -1521,9 +1683,9 @@ function literalValue(expression: TS.Expression | undefined): unknown | typeof N
1521
1683
  return NOT_LITERAL;
1522
1684
  }
1523
1685
 
1524
- function memberText(member: ResolvedMember, name: string, source: TS.SourceFile): string {
1686
+ function memberText(member: ResolvedMember, name: string): string {
1525
1687
  if (member.initializer === undefined) return name;
1526
- return `${name}: ${member.initializer.getText(source)}`;
1688
+ return `${name}: ${member.initializer.getText(member.source)}`;
1527
1689
  }
1528
1690
 
1529
1691
  function addInsertion(
@@ -1738,6 +1900,10 @@ export type OperationDeclarationRepositoryResult =
1738
1900
  readonly status: "refused";
1739
1901
  readonly providerRoot: string;
1740
1902
  readonly refusals: readonly OperationDeclarationRefusal[];
1903
+ /** Operations that were independently migratable before repository-atomic refusal. */
1904
+ readonly operationCount: number;
1905
+ readonly changedFiles: readonly string[];
1906
+ readonly localeTodoCount: number;
1741
1907
  };
1742
1908
 
1743
1909
  /** Run the file transform repository-wide, committing writes only if every file is provable. */
@@ -1748,19 +1914,28 @@ export function migrateOperationDeclarationRepository(
1748
1914
  const providerRoot = resolve(providerRootInput);
1749
1915
  const sourceFiles = collectSourceFiles(providerRoot);
1750
1916
  const localeFiles = collectLocaleFiles(providerRoot);
1751
- const operationIds = buildOperationIdIndex(sourceFiles);
1917
+ const repositoryIndex = buildRepositoryOperationIndex(sourceFiles, providerRoot);
1752
1918
  const pendingWrites = new Map<string, string>();
1753
1919
  const changedFiles: string[] = [];
1754
1920
  const todos: LocaleTodo[] = [];
1755
- const refusals: OperationDeclarationRefusal[] = [];
1921
+ const refusals: OperationDeclarationRefusal[] = [...repositoryIndex.refusals];
1756
1922
  let operationCount = 0;
1757
1923
 
1758
1924
  for (const sourcePath of sourceFiles) {
1759
1925
  const relativePath = slash(relative(providerRoot, sourcePath));
1760
- const result = migrateOperationDeclaration(readFileSync(sourcePath, "utf8"), relativePath, {
1761
- operationIds: operationIds.get(sourcePath),
1762
- localeFiles,
1763
- });
1926
+ const result = migrateOperationDeclarationInternal(
1927
+ readFileSync(sourcePath, "utf8"),
1928
+ relativePath,
1929
+ {
1930
+ operationIds: repositoryIndex.operationIds.get(sourcePath),
1931
+ localeFiles,
1932
+ },
1933
+ {
1934
+ operationSites: repositoryIndex.operationSites.get(sourcePath),
1935
+ excludedBindings: repositoryIndex.excludedBindings.get(sourcePath),
1936
+ staticObjectResolver: repositoryIndex.staticObjectResolverFor(sourcePath),
1937
+ },
1938
+ );
1764
1939
  if (result.status === "refused") {
1765
1940
  refusals.push(...result.refusals);
1766
1941
  continue;
@@ -1772,11 +1947,32 @@ export function migrateOperationDeclarationRepository(
1772
1947
  todos.push(...result.localeTodos);
1773
1948
  }
1774
1949
  }
1950
+ if (repositoryIndex.declarations.length > 0 && repositoryIndex.discoveredCount === 0) {
1951
+ for (const declaration of repositoryIndex.declarations) {
1952
+ refusals.push(
1953
+ refusal(
1954
+ slash(relative(providerRoot, declaration.path)),
1955
+ "<operations>",
1956
+ "no_operations_discovered",
1957
+ `Provider construct ${declaration.construct} declares operations via unresolved initializer ${JSON.stringify(
1958
+ declaration.initializer.getText(declaration.initializer.getSourceFile()),
1959
+ )}, but repository-wide discovery found zero operation sites.`,
1960
+ ),
1961
+ );
1962
+ }
1963
+ }
1775
1964
 
1776
1965
  const todoConflict = findLocaleTodoConflict(todos);
1777
1966
  if (todoConflict !== undefined) refusals.push(todoConflict);
1778
1967
  if (refusals.length > 0) {
1779
- return { status: "refused", providerRoot, refusals };
1968
+ return {
1969
+ status: "refused",
1970
+ providerRoot,
1971
+ refusals,
1972
+ operationCount,
1973
+ changedFiles,
1974
+ localeTodoCount: todos.length,
1975
+ };
1780
1976
  }
1781
1977
  for (const [path, code] of renderLocaleCatalogWrites(providerRoot, todos)) {
1782
1978
  pendingWrites.set(path, code);
@@ -1968,6 +2164,601 @@ function collectLocaleFiles(root: string): string[] {
1968
2164
  .sort();
1969
2165
  }
1970
2166
 
2167
+ type LocatedExpression = {
2168
+ readonly path: string;
2169
+ readonly source: TS.SourceFile;
2170
+ readonly expression: TS.Expression;
2171
+ readonly bindingName?: string;
2172
+ };
2173
+
2174
+ type StaticResolution =
2175
+ | { readonly status: "resolved"; readonly value: LocatedExpression }
2176
+ | { readonly status: "missing" }
2177
+ | { readonly status: "refused"; readonly detail: string };
2178
+
2179
+ type ProviderOperationsDeclaration = {
2180
+ readonly path: string;
2181
+ readonly construct: string;
2182
+ readonly initializer: TS.Expression;
2183
+ };
2184
+
2185
+ type RepositoryOperationIndex = {
2186
+ readonly operationIds: Map<string, Map<string, string>>;
2187
+ readonly operationSites: Map<string, Map<number, string>>;
2188
+ readonly excludedBindings: Map<string, Set<string>>;
2189
+ readonly refusals: OperationDeclarationRefusal[];
2190
+ readonly declarations: ProviderOperationsDeclaration[];
2191
+ readonly discoveredCount: number;
2192
+ readonly staticObjectResolverFor: (path: string) => StaticObjectResolver;
2193
+ };
2194
+
2195
+ function buildRepositoryOperationIndex(
2196
+ sourceFiles: readonly string[],
2197
+ providerRoot: string,
2198
+ ): RepositoryOperationIndex {
2199
+ const sources = new Map<string, TS.SourceFile>();
2200
+ for (const path of sourceFiles) {
2201
+ const source = parseSource(path, readFileSync(path, "utf8"));
2202
+ if (firstSyntaxError(source) === undefined) sources.set(path, source);
2203
+ }
2204
+
2205
+ const operationIds = buildOperationIdIndex(sourceFiles);
2206
+ const operationSites = new Map<string, Map<number, string>>();
2207
+ const excludedBindings = new Map<string, Set<string>>();
2208
+ const refusals: OperationDeclarationRefusal[] = [];
2209
+ const declarations: ProviderOperationsDeclaration[] = [];
2210
+ const indexedProviderProperties = new Set<string>();
2211
+ const factoryIds = new Map<string, { path: string; name: string; ids: Set<string> }>();
2212
+ const bindingCandidates = new Map<string, { path: string; name: string; ids: Set<string> }>();
2213
+ let discoveredCount = 0;
2214
+
2215
+ const relativePath = (path: string): string => slash(relative(providerRoot, path));
2216
+ const resolutionRefusal = (path: string, operationKey: string, detail: string): void => {
2217
+ refusals.push(refusal(relativePath(path), operationKey, "non_literal", detail));
2218
+ };
2219
+
2220
+ const resolveExport = (
2221
+ path: string,
2222
+ exportedName: string,
2223
+ active: ReadonlySet<string>,
2224
+ ): StaticResolution => {
2225
+ const key = `${path}\0export\0${exportedName}`;
2226
+ if (active.has(key)) {
2227
+ return {
2228
+ status: "refused",
2229
+ detail: `Static relative export cycle while resolving ${JSON.stringify(exportedName)} from ${relativePath(path)}.`,
2230
+ };
2231
+ }
2232
+ const source = sources.get(path);
2233
+ if (source === undefined) return { status: "missing" };
2234
+ const nextActive = new Set(active);
2235
+ nextActive.add(key);
2236
+ const explicit: Array<() => StaticResolution> = [];
2237
+ const exportStars: string[] = [];
2238
+
2239
+ for (const statement of source.statements) {
2240
+ if (ts.isVariableStatement(statement) && hasExportModifier(statement)) {
2241
+ for (const declaration of statement.declarationList.declarations) {
2242
+ if (
2243
+ ts.isIdentifier(declaration.name) &&
2244
+ declaration.name.text === exportedName &&
2245
+ declaration.initializer !== undefined
2246
+ ) {
2247
+ const initializer = declaration.initializer;
2248
+ const bindingName = declaration.name.text;
2249
+ explicit.push(() => ({
2250
+ status: "resolved",
2251
+ value: {
2252
+ path,
2253
+ source,
2254
+ expression: initializer,
2255
+ bindingName,
2256
+ },
2257
+ }));
2258
+ }
2259
+ }
2260
+ }
2261
+ if (
2262
+ ts.isExportAssignment(statement) &&
2263
+ !statement.isExportEquals &&
2264
+ exportedName === "default"
2265
+ ) {
2266
+ explicit.push(() => ({
2267
+ status: "resolved",
2268
+ value: { path, source, expression: statement.expression },
2269
+ }));
2270
+ }
2271
+ if (!ts.isExportDeclaration(statement)) continue;
2272
+ const specifier =
2273
+ statement.moduleSpecifier !== undefined && ts.isStringLiteral(statement.moduleSpecifier)
2274
+ ? statement.moduleSpecifier.text
2275
+ : undefined;
2276
+ if (statement.exportClause === undefined) {
2277
+ if (specifier !== undefined) exportStars.push(specifier);
2278
+ continue;
2279
+ }
2280
+ if (!ts.isNamedExports(statement.exportClause)) continue;
2281
+ for (const element of statement.exportClause.elements) {
2282
+ if (element.name.text !== exportedName) continue;
2283
+ const localName = element.propertyName?.text ?? element.name.text;
2284
+ explicit.push(() => {
2285
+ if (specifier === undefined) {
2286
+ return resolveIdentifier(path, localName, nextActive);
2287
+ }
2288
+ const target = resolveStaticModule(path, specifier, sources);
2289
+ if (target.status !== "resolved") return target;
2290
+ return resolveExport(target.path, localName, nextActive);
2291
+ });
2292
+ }
2293
+ }
2294
+
2295
+ if (explicit.length > 1) {
2296
+ return {
2297
+ status: "refused",
2298
+ detail: `Export ${JSON.stringify(exportedName)} is ambiguous in ${relativePath(path)}.`,
2299
+ };
2300
+ }
2301
+ if (explicit.length === 1) return explicit[0]?.() ?? { status: "missing" };
2302
+
2303
+ const resolvedStars: LocatedExpression[] = [];
2304
+ let firstStarRefusal: StaticResolution | undefined;
2305
+ for (const specifier of exportStars) {
2306
+ const target = resolveStaticModule(path, specifier, sources);
2307
+ if (target.status !== "resolved") {
2308
+ if (target.status === "refused") firstStarRefusal ??= target;
2309
+ continue;
2310
+ }
2311
+ const candidate = resolveExport(target.path, exportedName, nextActive);
2312
+ if (candidate.status === "resolved") resolvedStars.push(candidate.value);
2313
+ else if (candidate.status === "refused") firstStarRefusal ??= candidate;
2314
+ }
2315
+ const origins = new Set(
2316
+ resolvedStars.map((item) => `${item.path}\0${item.expression.getStart(item.source)}`),
2317
+ );
2318
+ if (origins.size > 1) {
2319
+ return {
2320
+ status: "refused",
2321
+ detail: `Export ${JSON.stringify(exportedName)} is ambiguous across relative re-exports from ${relativePath(path)}.`,
2322
+ };
2323
+ }
2324
+ if (firstStarRefusal !== undefined) return firstStarRefusal;
2325
+ const resolved = resolvedStars[0];
2326
+ return resolved === undefined ? { status: "missing" } : { status: "resolved", value: resolved };
2327
+ };
2328
+
2329
+ const resolveIdentifier = (
2330
+ path: string,
2331
+ name: string,
2332
+ active: ReadonlySet<string>,
2333
+ ): StaticResolution => {
2334
+ const source = sources.get(path);
2335
+ if (source === undefined) return { status: "missing" };
2336
+ for (const statement of source.statements) {
2337
+ if (!ts.isVariableStatement(statement)) continue;
2338
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
2339
+ for (const declaration of statement.declarationList.declarations) {
2340
+ if (
2341
+ ts.isIdentifier(declaration.name) &&
2342
+ declaration.name.text === name &&
2343
+ declaration.initializer !== undefined
2344
+ ) {
2345
+ return {
2346
+ status: "resolved",
2347
+ value: {
2348
+ path,
2349
+ source,
2350
+ expression: declaration.initializer,
2351
+ bindingName: name,
2352
+ },
2353
+ };
2354
+ }
2355
+ }
2356
+ }
2357
+ for (const statement of source.statements) {
2358
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
2359
+ continue;
2360
+ }
2361
+ let exportedName: string | undefined;
2362
+ if (statement.importClause?.name?.text === name) exportedName = "default";
2363
+ const bindings = statement.importClause?.namedBindings;
2364
+ if (bindings !== undefined && ts.isNamedImports(bindings)) {
2365
+ for (const element of bindings.elements) {
2366
+ if (element.name.text === name) {
2367
+ exportedName = element.propertyName?.text ?? element.name.text;
2368
+ }
2369
+ }
2370
+ }
2371
+ if (exportedName === undefined) continue;
2372
+ const specifier = statement.moduleSpecifier.text;
2373
+ const target = resolveStaticModule(path, specifier, sources);
2374
+ if (target.status !== "resolved") return target;
2375
+ return resolveExport(target.path, exportedName, active);
2376
+ }
2377
+ return { status: "missing" };
2378
+ };
2379
+
2380
+ const resolveLocatedExpression = (
2381
+ located: LocatedExpression,
2382
+ active: ReadonlySet<string>,
2383
+ ): StaticResolution => {
2384
+ const expression = unwrapExpression(located.expression);
2385
+ if (expression === undefined) return { status: "missing" };
2386
+ if (!ts.isIdentifier(expression)) {
2387
+ return { status: "resolved", value: { ...located, expression } };
2388
+ }
2389
+ const key = `${located.path}\0identifier\0${expression.text}`;
2390
+ if (active.has(key)) {
2391
+ return {
2392
+ status: "refused",
2393
+ detail: `Static identifier cycle while resolving ${JSON.stringify(expression.text)} in ${relativePath(located.path)}.`,
2394
+ };
2395
+ }
2396
+ const nextActive = new Set(active);
2397
+ nextActive.add(key);
2398
+ const resolved = resolveIdentifier(located.path, expression.text, nextActive);
2399
+ return resolved.status === "resolved"
2400
+ ? resolveLocatedExpression(resolved.value, nextActive)
2401
+ : resolved;
2402
+ };
2403
+
2404
+ type RegistryEntry = { readonly operationId: string; readonly value: LocatedExpression };
2405
+ const flattenRegistry = (
2406
+ locatedInput: LocatedExpression,
2407
+ active: ReadonlySet<string>,
2408
+ ): { readonly entries: Map<string, RegistryEntry> } | { readonly detail: string } => {
2409
+ const resolved = resolveLocatedExpression(locatedInput, active);
2410
+ if (resolved.status === "refused") return { detail: resolved.detail };
2411
+ if (resolved.status === "missing") {
2412
+ return {
2413
+ detail: `Operations initializer ${JSON.stringify(locatedInput.expression.getText(locatedInput.source))} is not a static local or relative-imported object.`,
2414
+ };
2415
+ }
2416
+ const expression = unwrapExpression(resolved.value.expression);
2417
+ if (expression === undefined || !ts.isObjectLiteralExpression(expression)) {
2418
+ return {
2419
+ detail: `Operations initializer ${JSON.stringify(resolved.value.expression.getText(resolved.value.source))} is not a static object literal.`,
2420
+ };
2421
+ }
2422
+ const cycleKey = `${resolved.value.path}\0object\0${expression.getStart(resolved.value.source)}`;
2423
+ if (active.has(cycleKey)) {
2424
+ return {
2425
+ detail: `Static operations registry spread cycle reaches ${relativePath(resolved.value.path)}.`,
2426
+ };
2427
+ }
2428
+ const nextActive = new Set(active);
2429
+ nextActive.add(cycleKey);
2430
+ const entries = new Map<string, RegistryEntry>();
2431
+ for (const property of expression.properties) {
2432
+ if (ts.isSpreadAssignment(property)) {
2433
+ const spread = flattenRegistry(
2434
+ {
2435
+ path: resolved.value.path,
2436
+ source: resolved.value.source,
2437
+ expression: property.expression,
2438
+ },
2439
+ nextActive,
2440
+ );
2441
+ if ("detail" in spread) return spread;
2442
+ for (const [operationId, entry] of spread.entries) entries.set(operationId, entry);
2443
+ continue;
2444
+ }
2445
+ const operationId = staticPropertyName(property.name);
2446
+ const value = propertyValue(property);
2447
+ if (operationId === undefined) {
2448
+ return {
2449
+ detail: `Operations registry in ${relativePath(resolved.value.path)} uses a computed key.`,
2450
+ };
2451
+ }
2452
+ if (value === undefined) {
2453
+ return { detail: `Operation ${JSON.stringify(operationId)} has a non-static initializer.` };
2454
+ }
2455
+ entries.set(operationId, {
2456
+ operationId,
2457
+ value: {
2458
+ path: resolved.value.path,
2459
+ source: resolved.value.source,
2460
+ expression: value,
2461
+ },
2462
+ });
2463
+ }
2464
+ return { entries };
2465
+ };
2466
+
2467
+ const addBindingCandidate = (path: string, name: string, operationId: string): void => {
2468
+ const key = `${path}\0${name}`;
2469
+ const candidate = bindingCandidates.get(key) ?? { path, name, ids: new Set<string>() };
2470
+ candidate.ids.add(operationId);
2471
+ bindingCandidates.set(key, candidate);
2472
+ };
2473
+ const addObjectSite = (
2474
+ path: string,
2475
+ source: TS.SourceFile,
2476
+ object: TS.ObjectLiteralExpression,
2477
+ operationId: string,
2478
+ ): void => {
2479
+ const sites = operationSites.get(path) ?? new Map<number, string>();
2480
+ const start = object.getStart(source);
2481
+ const previous = sites.get(start);
2482
+ if (previous !== undefined && previous !== operationId) {
2483
+ resolutionRefusal(
2484
+ path,
2485
+ operationId,
2486
+ `One static operation object is registered under both ${JSON.stringify(previous)} and ${JSON.stringify(operationId)}.`,
2487
+ );
2488
+ return;
2489
+ }
2490
+ sites.set(start, operationId);
2491
+ operationSites.set(path, sites);
2492
+ discoveredCount += 1;
2493
+ };
2494
+
2495
+ const classifyEntry = (entry: RegistryEntry): void => {
2496
+ const resolved = resolveLocatedExpression(entry.value, new Set());
2497
+ if (resolved.status === "refused") {
2498
+ resolutionRefusal(entry.value.path, entry.operationId, resolved.detail);
2499
+ return;
2500
+ }
2501
+ if (resolved.status === "missing") {
2502
+ resolutionRefusal(
2503
+ entry.value.path,
2504
+ entry.operationId,
2505
+ `Operation initializer ${JSON.stringify(entry.value.expression.getText(entry.value.source))} is not statically resolvable.`,
2506
+ );
2507
+ return;
2508
+ }
2509
+ const expression = unwrapExpression(resolved.value.expression);
2510
+ if (expression === undefined) return;
2511
+ if (ts.isObjectLiteralExpression(expression)) {
2512
+ addObjectSite(resolved.value.path, resolved.value.source, expression, entry.operationId);
2513
+ return;
2514
+ }
2515
+ if (ts.isCallExpression(expression) && isOperationHelperCall(expression)) {
2516
+ const argument = operationArgument(expression);
2517
+ const object = argument === undefined ? undefined : unwrapExpression(argument);
2518
+ if (object === undefined || !ts.isObjectLiteralExpression(object)) {
2519
+ resolutionRefusal(
2520
+ resolved.value.path,
2521
+ entry.operationId,
2522
+ "Operation helper argument must be a static object literal.",
2523
+ );
2524
+ return;
2525
+ }
2526
+ if (resolved.value.bindingName !== undefined) {
2527
+ addBindingCandidate(resolved.value.path, resolved.value.bindingName, entry.operationId);
2528
+ }
2529
+ addObjectSite(resolved.value.path, resolved.value.source, object, entry.operationId);
2530
+ return;
2531
+ }
2532
+ if (
2533
+ ts.isCallExpression(expression) &&
2534
+ ts.isIdentifier(expression.expression) &&
2535
+ collectSimpleOperationFactories(resolved.value.source).has(expression.expression.text)
2536
+ ) {
2537
+ const name = expression.expression.text;
2538
+ const key = `${resolved.value.path}\0${name}`;
2539
+ const factory = factoryIds.get(key) ?? {
2540
+ path: resolved.value.path,
2541
+ name,
2542
+ ids: new Set<string>(),
2543
+ };
2544
+ factory.ids.add(entry.operationId);
2545
+ factoryIds.set(key, factory);
2546
+ discoveredCount += 1;
2547
+ return;
2548
+ }
2549
+ resolutionRefusal(
2550
+ resolved.value.path,
2551
+ entry.operationId,
2552
+ `Operation initializer ${JSON.stringify(expression.getText(resolved.value.source))} is not a raw object, operation helper, or simple same-file factory call.`,
2553
+ );
2554
+ };
2555
+
2556
+ const inspectProviderProperty = (
2557
+ path: string,
2558
+ source: TS.SourceFile,
2559
+ node: TS.PropertyAssignment,
2560
+ construct: string,
2561
+ ): void => {
2562
+ const key = `${path}\0${node.getStart(source)}`;
2563
+ if (indexedProviderProperties.has(key)) return;
2564
+ indexedProviderProperties.add(key);
2565
+ declarations.push({ path, construct, initializer: node.initializer });
2566
+ const flattened = flattenRegistry({ path, source, expression: node.initializer }, new Set());
2567
+ if ("detail" in flattened) {
2568
+ resolutionRefusal(path, "<operations>", flattened.detail);
2569
+ } else {
2570
+ for (const entry of flattened.entries.values()) classifyEntry(entry);
2571
+ }
2572
+ };
2573
+
2574
+ for (const [path, source] of sources) {
2575
+ const constObjects = collectModuleConstObjects(source);
2576
+ const visit = (node: TS.Node): void => {
2577
+ if (
2578
+ ts.isPropertyAssignment(node) &&
2579
+ staticPropertyName(node.name) === "operations" &&
2580
+ isProviderOperationsProperty(node, constObjects)
2581
+ ) {
2582
+ inspectProviderProperty(path, source, node, providerConstructName(node));
2583
+ }
2584
+ if (ts.isCallExpression(node)) {
2585
+ const construct = providerCallConstruct(node);
2586
+ if (construct !== undefined) {
2587
+ for (const argument of node.arguments) {
2588
+ const resolved = resolveLocatedExpression(
2589
+ { path, source, expression: argument },
2590
+ new Set(),
2591
+ );
2592
+ if (resolved.status !== "resolved") continue;
2593
+ const object = unwrapExpression(resolved.value.expression);
2594
+ if (object === undefined || !ts.isObjectLiteralExpression(object)) continue;
2595
+ for (const property of object.properties) {
2596
+ if (
2597
+ ts.isPropertyAssignment(property) &&
2598
+ staticPropertyName(property.name) === "operations"
2599
+ ) {
2600
+ inspectProviderProperty(
2601
+ resolved.value.path,
2602
+ resolved.value.source,
2603
+ property,
2604
+ construct,
2605
+ );
2606
+ }
2607
+ }
2608
+ }
2609
+ }
2610
+ }
2611
+ ts.forEachChild(node, visit);
2612
+ };
2613
+ visit(source);
2614
+ }
2615
+
2616
+ for (const candidate of bindingCandidates.values()) {
2617
+ const ids = [...candidate.ids];
2618
+ const indexed = operationIds.get(candidate.path) ?? new Map<string, string>();
2619
+ if (ids.length === 1) {
2620
+ const operationId = ids[0];
2621
+ if (operationId !== undefined) indexed.set(candidate.name, operationId);
2622
+ } else {
2623
+ indexed.delete(candidate.name);
2624
+ const excluded = excludedBindings.get(candidate.path) ?? new Set<string>();
2625
+ excluded.add(candidate.name);
2626
+ excludedBindings.set(candidate.path, excluded);
2627
+ }
2628
+ operationIds.set(candidate.path, indexed);
2629
+ }
2630
+ for (const factory of factoryIds.values()) {
2631
+ const indexed = operationIds.get(factory.path) ?? new Map<string, string>();
2632
+ const ids = [...factory.ids];
2633
+ if (ids.length === 1) {
2634
+ const operationId = ids[0];
2635
+ if (operationId !== undefined) indexed.set(factory.name, operationId);
2636
+ } else {
2637
+ indexed.delete(factory.name);
2638
+ const excluded = excludedBindings.get(factory.path) ?? new Set<string>();
2639
+ excluded.add(factory.name);
2640
+ excludedBindings.set(factory.path, excluded);
2641
+ refusals.push(
2642
+ refusal(
2643
+ relativePath(factory.path),
2644
+ factory.name,
2645
+ "factory_operation_id_ambiguous",
2646
+ `Factory ${factory.name} is registered under multiple operation ids: ${ids
2647
+ .map((id) => JSON.stringify(id))
2648
+ .join(", ")}. A shared operation body cannot own one examples locale namespace.`,
2649
+ ),
2650
+ );
2651
+ }
2652
+ operationIds.set(factory.path, indexed);
2653
+ }
2654
+
2655
+ const resolveStaticObject = (
2656
+ expressionInput: TS.Expression,
2657
+ path: string,
2658
+ active = new Set<string>(),
2659
+ ): StaticObjectReference | undefined => {
2660
+ const expression = unwrapExpression(expressionInput);
2661
+ if (expression === undefined) return undefined;
2662
+ if (ts.isObjectLiteralExpression(expression)) {
2663
+ const source = sources.get(path) ?? expression.getSourceFile();
2664
+ return { object: expression, source };
2665
+ }
2666
+ if (ts.isIdentifier(expression)) {
2667
+ const key = `${path}\0member\0${expression.text}`;
2668
+ if (active.has(key)) return undefined;
2669
+ const nextActive = new Set(active);
2670
+ nextActive.add(key);
2671
+ const resolved = resolveIdentifier(path, expression.text, nextActive);
2672
+ return resolved.status === "resolved"
2673
+ ? resolveStaticObject(resolved.value.expression, resolved.value.path, nextActive)
2674
+ : undefined;
2675
+ }
2676
+ if (ts.isPropertyAccessExpression(expression)) {
2677
+ const owner = resolveStaticObject(expression.expression, path, active);
2678
+ if (owner === undefined) return undefined;
2679
+ for (const property of owner.object.properties) {
2680
+ if (ts.isSpreadAssignment(property)) continue;
2681
+ if (staticPropertyName(property.name) !== expression.name.text) continue;
2682
+ const value = propertyValue(property);
2683
+ if (value === undefined) return undefined;
2684
+ return resolveStaticObject(value, owner.source.fileName, active);
2685
+ }
2686
+ }
2687
+ return undefined;
2688
+ };
2689
+
2690
+ return {
2691
+ operationIds,
2692
+ operationSites,
2693
+ excludedBindings,
2694
+ refusals,
2695
+ declarations,
2696
+ discoveredCount,
2697
+ staticObjectResolverFor: (currentPath) => (expression, source) =>
2698
+ resolveStaticObject(expression, sources.has(source.fileName) ? source.fileName : currentPath),
2699
+ };
2700
+ }
2701
+
2702
+ function hasExportModifier(node: TS.Node): boolean {
2703
+ return (
2704
+ ts.canHaveModifiers(node) &&
2705
+ ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ===
2706
+ true
2707
+ );
2708
+ }
2709
+
2710
+ function resolveStaticModule(
2711
+ containingPath: string,
2712
+ specifier: string,
2713
+ sources: ReadonlyMap<string, TS.SourceFile>,
2714
+ ):
2715
+ | { readonly status: "resolved"; readonly path: string }
2716
+ | Exclude<StaticResolution, { status: "resolved" }> {
2717
+ if (!specifier.startsWith(".")) {
2718
+ return {
2719
+ status: "refused",
2720
+ detail: `Static operation discovery refuses non-relative import ${JSON.stringify(specifier)}.`,
2721
+ };
2722
+ }
2723
+ const path = resolveLocalModule(containingPath, specifier, sources);
2724
+ if (path === undefined) {
2725
+ return {
2726
+ status: "refused",
2727
+ detail: `Static relative import ${JSON.stringify(specifier)} from ${containingPath} could not be resolved.`,
2728
+ };
2729
+ }
2730
+ return { status: "resolved", path };
2731
+ }
2732
+
2733
+ function providerConstructName(node: TS.PropertyAssignment): string {
2734
+ let current: TS.Node = node;
2735
+ while (current.parent !== undefined) {
2736
+ const parent = current.parent;
2737
+ if (ts.isCallExpression(parent)) {
2738
+ return providerCallConstruct(parent) ?? parent.expression.getText();
2739
+ }
2740
+ current = parent;
2741
+ }
2742
+ return "provider declaration";
2743
+ }
2744
+
2745
+ function providerCallConstruct(call: TS.CallExpression): string | undefined {
2746
+ if (
2747
+ ts.isIdentifier(call.expression) &&
2748
+ /(?:defineProvider|Provider)\b/.test(call.expression.text)
2749
+ ) {
2750
+ return call.expression.text;
2751
+ }
2752
+ if (
2753
+ ts.isCallExpression(call.expression) &&
2754
+ ts.isIdentifier(call.expression.expression) &&
2755
+ /\bdefineProvider\b/.test(call.expression.expression.text)
2756
+ ) {
2757
+ return call.expression.expression.text;
2758
+ }
2759
+ return undefined;
2760
+ }
2761
+
1971
2762
  function buildOperationIdIndex(sourceFiles: readonly string[]): Map<string, Map<string, string>> {
1972
2763
  const result = new Map<string, Map<string, string>>();
1973
2764
  const idsByPath = new Map<string, Map<string, string | null>>();
@@ -2155,7 +2946,11 @@ function resolveLocalModule(
2155
2946
  ): string | undefined {
2156
2947
  if (!specifier.startsWith(".")) return undefined;
2157
2948
  const base = resolve(dirname(containingPath), specifier);
2158
- for (const candidate of [base, `${base}.ts`, join(base, "index.ts")]) {
2949
+ const emittedExtensionSource = /\.(?:c|m)?js$/.test(base)
2950
+ ? base.replace(/\.(?:c|m)?js$/, ".ts")
2951
+ : undefined;
2952
+ for (const candidate of [base, emittedExtensionSource, `${base}.ts`, join(base, "index.ts")]) {
2953
+ if (candidate === undefined) continue;
2159
2954
  if (sources.has(candidate)) return candidate;
2160
2955
  }
2161
2956
  return undefined;