@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.
- package/CHANGELOG.md +8 -0
- package/dist/cli/migrate-operation-declaration.js +282 -14
- package/package.json +1 -1
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-hoisted-array-spread.ts.txt +15 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-imported-array-spread.ts.txt +9 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-mixed-array-spread.ts.txt +24 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-canonical-en.json +1 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-canonical-ja.json +1 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-canonical-ko.json +1 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-binding-ambiguous.ts.txt +12 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-imported-other.ts.txt +7 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-imported.ts.txt +5 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-key-ambiguous.ts.txt +19 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-shorthand.ts.txt +9 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-typed.ts.txt +11 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-unrelated.ts.txt +13 -0
- 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
|
|
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
|
-
|
|
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 (
|
|
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 <
|
|
413
|
-
const element =
|
|
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
|
-
|
|
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
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
@@ -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
|
+
});
|
package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-mixed-array-spread.ts.txt
ADDED
|
@@ -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":"마지막"}
|
package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-binding-ambiguous.ts.txt
ADDED
|
@@ -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
|
+
};
|
package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-key-ambiguous.ts.txt
ADDED
|
@@ -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,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
|
|
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
|
-
|
|
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 (
|
|
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 <
|
|
713
|
-
const element =
|
|
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
|
-
|
|
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
|
|
1583
|
-
|
|
1584
|
-
|
|
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,
|