@apifuse/provider-sdk 2.2.0-beta.51 → 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 +4 -0
- package/dist/cli/migrate-operation-declaration.js +201 -4
- package/package.json +1 -1
- 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 +216 -3
package/CHANGELOG.md
CHANGED
|
@@ -1088,6 +1088,9 @@ export function migrateOperationDeclarationRepository(providerRootInput, options
|
|
|
1088
1088
|
if (refusals.length > 0) {
|
|
1089
1089
|
return { status: "refused", providerRoot, refusals };
|
|
1090
1090
|
}
|
|
1091
|
+
for (const [path, code] of renderLocaleCatalogWrites(providerRoot, todos)) {
|
|
1092
|
+
pendingWrites.set(path, code);
|
|
1093
|
+
}
|
|
1091
1094
|
if (pendingWrites.size === 0) {
|
|
1092
1095
|
return {
|
|
1093
1096
|
status: "unchanged",
|
|
@@ -1122,6 +1125,89 @@ export function renderLocaleTodoSidecar(todos) {
|
|
|
1122
1125
|
}
|
|
1123
1126
|
return `${JSON.stringify({ schemaVersion: 1, localeFiles }, null, 2)}\n`;
|
|
1124
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
|
+
}
|
|
1125
1211
|
function findLocaleTodoConflict(todos) {
|
|
1126
1212
|
const values = new Map();
|
|
1127
1213
|
for (const todo of todos) {
|
|
@@ -1170,6 +1256,8 @@ function collectLocaleFiles(root) {
|
|
|
1170
1256
|
}
|
|
1171
1257
|
function buildOperationIdIndex(sourceFiles) {
|
|
1172
1258
|
const result = new Map();
|
|
1259
|
+
const idsByPath = new Map();
|
|
1260
|
+
const ambiguousBindingsByPath = new Map();
|
|
1173
1261
|
const sources = new Map();
|
|
1174
1262
|
for (const path of sourceFiles) {
|
|
1175
1263
|
const source = parseSource(path, readFileSync(path, "utf8"));
|
|
@@ -1178,12 +1266,38 @@ function buildOperationIdIndex(sourceFiles) {
|
|
|
1178
1266
|
}
|
|
1179
1267
|
const record = (path, binding, operationId) => {
|
|
1180
1268
|
const map = result.get(path) ?? new Map();
|
|
1181
|
-
const
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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) {
|
|
1185
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
|
+
}
|
|
1186
1298
|
result.set(path, map);
|
|
1299
|
+
idsByPath.set(path, ids);
|
|
1300
|
+
ambiguousBindingsByPath.set(path, ambiguousBindings);
|
|
1187
1301
|
};
|
|
1188
1302
|
for (const [path, source] of sources) {
|
|
1189
1303
|
const imports = collectImports(source, path, sources);
|
|
@@ -1206,9 +1320,92 @@ function buildOperationIdIndex(sourceFiles) {
|
|
|
1206
1320
|
record(imported.path, imported.exportedName, operationId);
|
|
1207
1321
|
}
|
|
1208
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
|
+
}
|
|
1209
1335
|
}
|
|
1210
1336
|
return result;
|
|
1211
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
|
+
}
|
|
1212
1409
|
function collectImports(source, containingPath, sources) {
|
|
1213
1410
|
const imports = new Map();
|
|
1214
1411
|
for (const statement of source.statements) {
|
package/package.json
CHANGED
|
@@ -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
|
+
};
|
|
@@ -1576,6 +1576,9 @@ export function migrateOperationDeclarationRepository(
|
|
|
1576
1576
|
if (refusals.length > 0) {
|
|
1577
1577
|
return { status: "refused", providerRoot, refusals };
|
|
1578
1578
|
}
|
|
1579
|
+
for (const [path, code] of renderLocaleCatalogWrites(providerRoot, todos)) {
|
|
1580
|
+
pendingWrites.set(path, code);
|
|
1581
|
+
}
|
|
1579
1582
|
|
|
1580
1583
|
if (pendingWrites.size === 0) {
|
|
1581
1584
|
return {
|
|
@@ -1616,6 +1619,103 @@ export function renderLocaleTodoSidecar(todos: readonly LocaleTodo[]): string {
|
|
|
1616
1619
|
return `${JSON.stringify({ schemaVersion: 1, localeFiles }, null, 2)}\n`;
|
|
1617
1620
|
}
|
|
1618
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
|
+
|
|
1619
1719
|
function findLocaleTodoConflict(
|
|
1620
1720
|
todos: readonly LocaleTodo[],
|
|
1621
1721
|
): OperationDeclarationRefusal | undefined {
|
|
@@ -1668,6 +1768,8 @@ function collectLocaleFiles(root: string): string[] {
|
|
|
1668
1768
|
|
|
1669
1769
|
function buildOperationIdIndex(sourceFiles: readonly string[]): Map<string, Map<string, string>> {
|
|
1670
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>>();
|
|
1671
1773
|
const sources = new Map<string, TS.SourceFile>();
|
|
1672
1774
|
for (const path of sourceFiles) {
|
|
1673
1775
|
const source = parseSource(path, readFileSync(path, "utf8"));
|
|
@@ -1676,10 +1778,40 @@ function buildOperationIdIndex(sourceFiles: readonly string[]): Map<string, Map<
|
|
|
1676
1778
|
|
|
1677
1779
|
const record = (path: string, binding: string, operationId: string): void => {
|
|
1678
1780
|
const map = result.get(path) ?? new Map<string, string>();
|
|
1679
|
-
const
|
|
1680
|
-
|
|
1681
|
-
|
|
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
|
+
}
|
|
1682
1812
|
result.set(path, map);
|
|
1813
|
+
idsByPath.set(path, ids);
|
|
1814
|
+
ambiguousBindingsByPath.set(path, ambiguousBindings);
|
|
1683
1815
|
};
|
|
1684
1816
|
|
|
1685
1817
|
for (const [path, source] of sources) {
|
|
@@ -1700,10 +1832,91 @@ function buildOperationIdIndex(sourceFiles: readonly string[]): Map<string, Map<
|
|
|
1700
1832
|
else record(imported.path, imported.exportedName, operationId);
|
|
1701
1833
|
}
|
|
1702
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
|
+
}
|
|
1703
1847
|
}
|
|
1704
1848
|
return result;
|
|
1705
1849
|
}
|
|
1706
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
|
+
|
|
1707
1920
|
function collectImports(
|
|
1708
1921
|
source: TS.SourceFile,
|
|
1709
1922
|
containingPath: string,
|