@apifuse/provider-sdk 2.2.0-beta.51 → 2.2.0-beta.53
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.d.ts +1 -1
- package/dist/cli/migrate-operation-declaration.js +327 -9
- package/dist/i18n/operation-locale-namespace.d.ts +2 -0
- package/dist/i18n/operation-locale-namespace.js +7 -0
- package/package.json +1 -1
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-kebab-operation-id.ts.txt +17 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-snake-operation-id.ts.txt +21 -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/locale-existing-operation-namespace-en.json +8 -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 +424 -9
- package/src/i18n/operation-locale-namespace.ts +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @apifuse/provider-sdk Changelog
|
|
2
2
|
|
|
3
|
+
## 2.2.0-beta.53
|
|
4
|
+
|
|
5
|
+
- Release candidate for main commit 40a916bac16bb2b2bbda7fc4a96132d10ca103bf.
|
|
6
|
+
|
|
7
|
+
## 2.2.0-beta.52
|
|
8
|
+
|
|
9
|
+
- Release candidate for main commit 6e64ae0ad2e735b3de0c115f5ea29517efcf4ff2.
|
|
10
|
+
|
|
3
11
|
## 2.2.0-beta.51
|
|
4
12
|
|
|
5
13
|
- Release candidate for main commit a1269b0a7161618e5fc25e532aaa45de0efd632f.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type OperationDeclarationRefusalReason = "no_safety" | "safety_conflict" | "locale_key_conflict" | "connection_mode_conflict" | "execution_conflict" | "approval_conflict" | "non_literal" | "unsupported_member" | "factory_composed_operations" | "source_syntax" | "codemod_syntax" | "missing_english_locale" | "operation_id_unresolved" | "examples_conflict" | "locale_todo_conflict";
|
|
1
|
+
export type OperationDeclarationRefusalReason = "no_safety" | "safety_conflict" | "locale_key_conflict" | "connection_mode_conflict" | "execution_conflict" | "approval_conflict" | "non_literal" | "unsupported_member" | "factory_composed_operations" | "source_syntax" | "codemod_syntax" | "missing_english_locale" | "operation_id_unresolved" | "examples_conflict" | "invalid_locale_key" | "locale_todo_conflict";
|
|
2
2
|
export type OperationDeclarationRefusal = {
|
|
3
3
|
readonly file: string;
|
|
4
4
|
readonly operationKey: string;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, extname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { assertProviderLocaleKey } from "../i18n/keys.js";
|
|
4
|
+
import { operationIdToLocaleNamespace } from "../i18n/operation-locale-namespace.js";
|
|
3
5
|
const ts = await loadTypeScript();
|
|
4
6
|
async function loadTypeScript() {
|
|
5
7
|
try {
|
|
@@ -81,6 +83,10 @@ export function migrateOperationDeclaration(sourceText, fileName, options = {})
|
|
|
81
83
|
}
|
|
82
84
|
if (refusals.length > 0)
|
|
83
85
|
return { status: "refused", refusals };
|
|
86
|
+
const invalidLocaleKey = findInvalidLocaleTodo(todos, fileName);
|
|
87
|
+
if (invalidLocaleKey !== undefined) {
|
|
88
|
+
return { status: "refused", refusals: [invalidLocaleKey] };
|
|
89
|
+
}
|
|
84
90
|
if (edits.length === 0) {
|
|
85
91
|
return {
|
|
86
92
|
status: "unchanged",
|
|
@@ -221,7 +227,20 @@ function planOperationMigration(source, fileName, site, constObjects, constArray
|
|
|
221
227
|
if (merged.insert !== undefined)
|
|
222
228
|
addInsertion(insertions, "docs", merged.insert);
|
|
223
229
|
}
|
|
224
|
-
const
|
|
230
|
+
const title = planTitleLocale(top.byName.get("title"), top.byName.get("titleKey"), docs.get("titleKey"), fileName, site, localeFiles);
|
|
231
|
+
if ("refusal" in title)
|
|
232
|
+
return title;
|
|
233
|
+
const localeNamespace = top.byName.get("inputExamples") === undefined
|
|
234
|
+
? { namespace: site.operationKey }
|
|
235
|
+
: resolveOperationLocaleNamespace([
|
|
236
|
+
top.byName.get("titleKey"),
|
|
237
|
+
docs.get("titleKey"),
|
|
238
|
+
top.byName.get("descriptionKey"),
|
|
239
|
+
docs.get("descriptionKey"),
|
|
240
|
+
], fileName, site);
|
|
241
|
+
if ("refusal" in localeNamespace)
|
|
242
|
+
return localeNamespace;
|
|
243
|
+
const examples = planExamples(top.byName.get("inputExamples"), top.byName.get("examples"), fileName, site, source, constArrays, localeFiles, localeNamespace.namespace);
|
|
225
244
|
if ("refusal" in examples)
|
|
226
245
|
return examples;
|
|
227
246
|
const edits = [...examples.edits];
|
|
@@ -245,7 +264,66 @@ function planOperationMigration(source, fileName, site, constObjects, constArray
|
|
|
245
264
|
if (!edits.some((existing) => rangesOverlap(existing, edit)))
|
|
246
265
|
edits.push(edit);
|
|
247
266
|
}
|
|
248
|
-
return { edits, localeTodos: examples.localeTodos };
|
|
267
|
+
return { edits, localeTodos: [...title.localeTodos, ...examples.localeTodos] };
|
|
268
|
+
}
|
|
269
|
+
function planTitleLocale(title, flatTitleKey, nestedTitleKey, fileName, site, localeFiles) {
|
|
270
|
+
if (title === undefined)
|
|
271
|
+
return { localeTodos: [] };
|
|
272
|
+
const originalProse = literalString(title.initializer);
|
|
273
|
+
if (originalProse === undefined) {
|
|
274
|
+
return nonLiteral(fileName, site.operationKey, "title must be a string literal so its authored prose can be preserved in the English locale catalog.");
|
|
275
|
+
}
|
|
276
|
+
if (!site.operationIdProven) {
|
|
277
|
+
return {
|
|
278
|
+
refusal: refusal(fileName, site.operationKey, "operation_id_unresolved", "title requires an exact operation id proven from a static operations map."),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (!localeFiles.includes("locales/en.json")) {
|
|
282
|
+
return {
|
|
283
|
+
refusal: refusal(fileName, site.operationKey, "missing_english_locale", "title cannot be migrated because locales/en.json does not exist."),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
let selectedTitleKey = flatTitleKey ?? nestedTitleKey;
|
|
287
|
+
if (flatTitleKey !== undefined && nestedTitleKey !== undefined) {
|
|
288
|
+
const same = equivalentLiteral(flatTitleKey.initializer, nestedTitleKey.initializer);
|
|
289
|
+
if (same === undefined) {
|
|
290
|
+
return nonLiteral(fileName, site.operationKey, "titleKey must be literal when both top-level and nested declarations exist.");
|
|
291
|
+
}
|
|
292
|
+
if (!same) {
|
|
293
|
+
return {
|
|
294
|
+
refusal: refusal(fileName, site.operationKey, "locale_key_conflict", "Top-level titleKey conflicts with nested titleKey."),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
selectedTitleKey = flatTitleKey;
|
|
298
|
+
}
|
|
299
|
+
const explicitTitleKey = selectedTitleKey === undefined ? undefined : literalString(selectedTitleKey.initializer);
|
|
300
|
+
if (selectedTitleKey !== undefined && explicitTitleKey === undefined) {
|
|
301
|
+
return nonLiteral(fileName, site.operationKey, "titleKey must be a string literal so the title locale destination is provable.");
|
|
302
|
+
}
|
|
303
|
+
let selectedTitleLocaleKey;
|
|
304
|
+
if (explicitTitleKey !== undefined) {
|
|
305
|
+
selectedTitleLocaleKey = explicitTitleKey;
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
try {
|
|
309
|
+
selectedTitleLocaleKey = `operations.${operationIdToLocaleNamespace(site.operationKey)}.title`;
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
return {
|
|
313
|
+
refusal: invalidLocaleKeyRefusal(fileName, site.operationKey, `operations.${site.operationKey}.title`, error),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
localeTodos: [
|
|
319
|
+
{
|
|
320
|
+
localeFile: "locales/en.json",
|
|
321
|
+
operationKey: site.operationKey,
|
|
322
|
+
key: selectedTitleLocaleKey,
|
|
323
|
+
originalProse,
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
};
|
|
249
327
|
}
|
|
250
328
|
function resolveRiskClass(top, annotations, toolRouter, fileName, operationKey) {
|
|
251
329
|
const topRisk = top.get("riskClass");
|
|
@@ -381,7 +459,7 @@ function mergeFlatAndNested(field, flat, nested, fileName, operationKey, source,
|
|
|
381
459
|
}
|
|
382
460
|
return {};
|
|
383
461
|
}
|
|
384
|
-
function planExamples(inputExamples, existingExamples, fileName, site, source, constArrays, localeFiles) {
|
|
462
|
+
function planExamples(inputExamples, existingExamples, fileName, site, source, constArrays, localeFiles, localeNamespace) {
|
|
385
463
|
if (inputExamples === undefined)
|
|
386
464
|
return { edits: [], localeTodos: [] };
|
|
387
465
|
if (existingExamples !== undefined) {
|
|
@@ -440,7 +518,7 @@ function planExamples(inputExamples, existingExamples, fileName, site, source, c
|
|
|
440
518
|
if (scenarioProse === undefined) {
|
|
441
519
|
return nonLiteral(fileName, site.operationKey, `inputExamples[${index}].scenario must be a string literal.`);
|
|
442
520
|
}
|
|
443
|
-
const scenarioKey = `operations.${
|
|
521
|
+
const scenarioKey = `operations.${localeNamespace}.examples.${index}.scenario`;
|
|
444
522
|
edits.push(replaceExampleLocaleMember(scenario, "scenarioKey", scenarioKey, source));
|
|
445
523
|
for (const localeFile of localeFiles) {
|
|
446
524
|
todos.push({
|
|
@@ -456,7 +534,7 @@ function planExamples(inputExamples, existingExamples, fileName, site, source, c
|
|
|
456
534
|
if (rationaleProse === undefined) {
|
|
457
535
|
return nonLiteral(fileName, site.operationKey, `inputExamples[${index}].rationale must be a string literal.`);
|
|
458
536
|
}
|
|
459
|
-
const rationaleKey = `operations.${
|
|
537
|
+
const rationaleKey = `operations.${localeNamespace}.examples.${index}.rationale`;
|
|
460
538
|
edits.push(replaceExampleLocaleMember(rationale, "rationaleKey", rationaleKey, source));
|
|
461
539
|
for (const localeFile of localeFiles) {
|
|
462
540
|
todos.push({
|
|
@@ -470,6 +548,34 @@ function planExamples(inputExamples, existingExamples, fileName, site, source, c
|
|
|
470
548
|
}
|
|
471
549
|
return { edits, localeTodos: todos };
|
|
472
550
|
}
|
|
551
|
+
function resolveOperationLocaleNamespace(members, fileName, site) {
|
|
552
|
+
const authoredNamespaces = new Set();
|
|
553
|
+
for (const member of members) {
|
|
554
|
+
const localeKey = literalString(member?.initializer);
|
|
555
|
+
if (localeKey === undefined)
|
|
556
|
+
continue;
|
|
557
|
+
const segments = localeKey.split(".");
|
|
558
|
+
if (segments[0] === "operations" && segments[1] !== undefined) {
|
|
559
|
+
authoredNamespaces.add(segments[1]);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (authoredNamespaces.size > 1) {
|
|
563
|
+
return {
|
|
564
|
+
refusal: refusal(fileName, site.operationKey, "locale_key_conflict", `Operation titleKey and descriptionKey declarations use different locale namespaces: ${[...authoredNamespaces].join(", ")}.`),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
const authored = authoredNamespaces.values().next().value;
|
|
568
|
+
if (authored !== undefined)
|
|
569
|
+
return { namespace: authored };
|
|
570
|
+
try {
|
|
571
|
+
return { namespace: operationIdToLocaleNamespace(site.operationKey) };
|
|
572
|
+
}
|
|
573
|
+
catch (error) {
|
|
574
|
+
return {
|
|
575
|
+
refusal: invalidLocaleKeyRefusal(fileName, site.operationKey, `operations.${site.operationKey}.examples`, error),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
}
|
|
473
579
|
function replaceExampleLocaleMember(member, newName, localeKey, source) {
|
|
474
580
|
return {
|
|
475
581
|
start: member.property.getStart(source),
|
|
@@ -1049,6 +1155,21 @@ function firstSyntaxError(source) {
|
|
|
1049
1155
|
function refusal(file, operationKey, reason, detail) {
|
|
1050
1156
|
return { file, operationKey, reason, detail };
|
|
1051
1157
|
}
|
|
1158
|
+
function findInvalidLocaleTodo(todos, fileName) {
|
|
1159
|
+
for (const todo of todos) {
|
|
1160
|
+
try {
|
|
1161
|
+
assertProviderLocaleKey(todo.key);
|
|
1162
|
+
}
|
|
1163
|
+
catch (error) {
|
|
1164
|
+
return invalidLocaleKeyRefusal(fileName, todo.operationKey, todo.key, error);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return undefined;
|
|
1168
|
+
}
|
|
1169
|
+
function invalidLocaleKeyRefusal(fileName, operationKey, localeKey, error) {
|
|
1170
|
+
const validatorDetail = error instanceof Error ? error.message : String(error);
|
|
1171
|
+
return refusal(fileName, operationKey, "invalid_locale_key", `Refusing to write invalid provider locale key ${JSON.stringify(localeKey)}: ${validatorDetail}`);
|
|
1172
|
+
}
|
|
1052
1173
|
function nonLiteral(fileName, operationKey, detail) {
|
|
1053
1174
|
return {
|
|
1054
1175
|
refusal: refusal(fileName, operationKey, "non_literal", detail),
|
|
@@ -1088,6 +1209,9 @@ export function migrateOperationDeclarationRepository(providerRootInput, options
|
|
|
1088
1209
|
if (refusals.length > 0) {
|
|
1089
1210
|
return { status: "refused", providerRoot, refusals };
|
|
1090
1211
|
}
|
|
1212
|
+
for (const [path, code] of renderLocaleCatalogWrites(providerRoot, todos)) {
|
|
1213
|
+
pendingWrites.set(path, code);
|
|
1214
|
+
}
|
|
1091
1215
|
if (pendingWrites.size === 0) {
|
|
1092
1216
|
return {
|
|
1093
1217
|
status: "unchanged",
|
|
@@ -1122,6 +1246,89 @@ export function renderLocaleTodoSidecar(todos) {
|
|
|
1122
1246
|
}
|
|
1123
1247
|
return `${JSON.stringify({ schemaVersion: 1, localeFiles }, null, 2)}\n`;
|
|
1124
1248
|
}
|
|
1249
|
+
function renderLocaleCatalogWrites(providerRoot, todos) {
|
|
1250
|
+
const todosByFile = new Map();
|
|
1251
|
+
for (const todo of todos) {
|
|
1252
|
+
const fileTodos = todosByFile.get(todo.localeFile) ?? [];
|
|
1253
|
+
fileTodos.push(todo);
|
|
1254
|
+
todosByFile.set(todo.localeFile, fileTodos);
|
|
1255
|
+
}
|
|
1256
|
+
const englishPath = "locales/en.json";
|
|
1257
|
+
const englishTodos = todosByFile.get(englishPath);
|
|
1258
|
+
if (englishTodos === undefined)
|
|
1259
|
+
return new Map();
|
|
1260
|
+
const english = readLocaleCatalog(join(providerRoot, englishPath));
|
|
1261
|
+
applyLocaleTodos(english, englishTodos);
|
|
1262
|
+
const writes = new Map([
|
|
1263
|
+
[join(providerRoot, englishPath), renderCanonicalLocaleCatalog(english)],
|
|
1264
|
+
]);
|
|
1265
|
+
for (const [localeFile, fileTodos] of todosByFile) {
|
|
1266
|
+
if (localeFile === englishPath)
|
|
1267
|
+
continue;
|
|
1268
|
+
const catalog = readLocaleCatalog(join(providerRoot, localeFile));
|
|
1269
|
+
applyLocaleTodos(catalog, fileTodos);
|
|
1270
|
+
writes.set(join(providerRoot, localeFile), renderCanonicalLocaleCatalog(reorderLikeReference(english, catalog)));
|
|
1271
|
+
}
|
|
1272
|
+
return writes;
|
|
1273
|
+
}
|
|
1274
|
+
function readLocaleCatalog(path) {
|
|
1275
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
1276
|
+
if (!isRecord(value))
|
|
1277
|
+
throw new Error(`${path} must contain a JSON object.`);
|
|
1278
|
+
return value;
|
|
1279
|
+
}
|
|
1280
|
+
function applyLocaleTodos(catalog, todos) {
|
|
1281
|
+
for (const todo of todos) {
|
|
1282
|
+
const segments = todo.key.split(".");
|
|
1283
|
+
const leaf = segments.pop();
|
|
1284
|
+
if (leaf === undefined)
|
|
1285
|
+
continue;
|
|
1286
|
+
let cursor = catalog;
|
|
1287
|
+
for (const segment of segments) {
|
|
1288
|
+
const child = cursor[segment];
|
|
1289
|
+
if (isRecord(child)) {
|
|
1290
|
+
cursor = child;
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
const created = {};
|
|
1294
|
+
cursor[segment] = created;
|
|
1295
|
+
cursor = created;
|
|
1296
|
+
}
|
|
1297
|
+
cursor[leaf] = todo.originalProse;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
/** Match provider-contract's canonical locale serialization exactly. */
|
|
1301
|
+
function renderCanonicalLocaleCatalog(value) {
|
|
1302
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Put shared keys first in English order, then retain locale-only authored order.
|
|
1306
|
+
* Arrays keep their shape and use the English item at the same index as a reference.
|
|
1307
|
+
*/
|
|
1308
|
+
function reorderLikeReference(reference, value) {
|
|
1309
|
+
if (Array.isArray(value)) {
|
|
1310
|
+
const referenceArray = Array.isArray(reference) ? reference : [];
|
|
1311
|
+
return value.map((item, index) => reorderLikeReference(referenceArray[index], item));
|
|
1312
|
+
}
|
|
1313
|
+
if (!isRecord(value))
|
|
1314
|
+
return value;
|
|
1315
|
+
const referenceRecord = isRecord(reference) ? reference : {};
|
|
1316
|
+
const ordered = {};
|
|
1317
|
+
for (const key of Object.keys(referenceRecord)) {
|
|
1318
|
+
if (Object.hasOwn(value, key)) {
|
|
1319
|
+
ordered[key] = reorderLikeReference(referenceRecord[key], value[key]);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1323
|
+
if (!Object.hasOwn(ordered, key)) {
|
|
1324
|
+
ordered[key] = reorderLikeReference(Object.hasOwn(referenceRecord, key) ? referenceRecord[key] : undefined, child);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
return ordered;
|
|
1328
|
+
}
|
|
1329
|
+
function isRecord(value) {
|
|
1330
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1331
|
+
}
|
|
1125
1332
|
function findLocaleTodoConflict(todos) {
|
|
1126
1333
|
const values = new Map();
|
|
1127
1334
|
for (const todo of todos) {
|
|
@@ -1170,6 +1377,8 @@ function collectLocaleFiles(root) {
|
|
|
1170
1377
|
}
|
|
1171
1378
|
function buildOperationIdIndex(sourceFiles) {
|
|
1172
1379
|
const result = new Map();
|
|
1380
|
+
const idsByPath = new Map();
|
|
1381
|
+
const ambiguousBindingsByPath = new Map();
|
|
1173
1382
|
const sources = new Map();
|
|
1174
1383
|
for (const path of sourceFiles) {
|
|
1175
1384
|
const source = parseSource(path, readFileSync(path, "utf8"));
|
|
@@ -1178,12 +1387,38 @@ function buildOperationIdIndex(sourceFiles) {
|
|
|
1178
1387
|
}
|
|
1179
1388
|
const record = (path, binding, operationId) => {
|
|
1180
1389
|
const map = result.get(path) ?? new Map();
|
|
1181
|
-
const
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1390
|
+
const ids = idsByPath.get(path) ?? new Map();
|
|
1391
|
+
const ambiguousBindings = ambiguousBindingsByPath.get(path) ?? new Set();
|
|
1392
|
+
// An operation id is usable only when it identifies exactly one binding.
|
|
1393
|
+
// Keep an explicit null marker for an ambiguous id so a later occurrence
|
|
1394
|
+
// cannot accidentally make it usable again.
|
|
1395
|
+
const previousBinding = ids.get(operationId);
|
|
1396
|
+
if (ids.has(operationId)) {
|
|
1397
|
+
if (previousBinding !== undefined &&
|
|
1398
|
+
previousBinding !== null &&
|
|
1399
|
+
previousBinding !== binding) {
|
|
1400
|
+
map.delete(previousBinding);
|
|
1401
|
+
map.delete(binding);
|
|
1402
|
+
ambiguousBindings.add(previousBinding);
|
|
1403
|
+
ambiguousBindings.add(binding);
|
|
1404
|
+
ids.set(operationId, null);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
else {
|
|
1408
|
+
ids.set(operationId, binding);
|
|
1409
|
+
}
|
|
1410
|
+
// A binding registered under two different ids is likewise ambiguous.
|
|
1411
|
+
const previousId = map.get(binding);
|
|
1412
|
+
if (previousId !== undefined && previousId !== operationId) {
|
|
1185
1413
|
map.delete(binding);
|
|
1414
|
+
ambiguousBindings.add(binding);
|
|
1415
|
+
}
|
|
1416
|
+
else if (!ambiguousBindings.has(binding) && ids.get(operationId) === binding) {
|
|
1417
|
+
map.set(binding, operationId);
|
|
1418
|
+
}
|
|
1186
1419
|
result.set(path, map);
|
|
1420
|
+
idsByPath.set(path, ids);
|
|
1421
|
+
ambiguousBindingsByPath.set(path, ambiguousBindings);
|
|
1187
1422
|
};
|
|
1188
1423
|
for (const [path, source] of sources) {
|
|
1189
1424
|
const imports = collectImports(source, path, sources);
|
|
@@ -1206,9 +1441,92 @@ function buildOperationIdIndex(sourceFiles) {
|
|
|
1206
1441
|
record(imported.path, imported.exportedName, operationId);
|
|
1207
1442
|
}
|
|
1208
1443
|
}
|
|
1444
|
+
// Some providers keep their operation registry in a same-file const with
|
|
1445
|
+
// an arbitrary name (for example, `companionsOperations`). This scan is
|
|
1446
|
+
// deliberately ID-only: it accepts only static object members whose value
|
|
1447
|
+
// is a same-file operation binding, and never evaluates spreads, factories,
|
|
1448
|
+
// or imported values.
|
|
1449
|
+
const operationBindings = collectModuleOperationBindings(source);
|
|
1450
|
+
const importedBindings = collectImportedBindingNames(source);
|
|
1451
|
+
for (const entry of collectStaticOperationRegistryEntries(source, operationBindings)) {
|
|
1452
|
+
if (importedBindings.has(entry.binding))
|
|
1453
|
+
continue;
|
|
1454
|
+
record(path, entry.binding, entry.operationId);
|
|
1455
|
+
}
|
|
1209
1456
|
}
|
|
1210
1457
|
return result;
|
|
1211
1458
|
}
|
|
1459
|
+
function collectModuleOperationBindings(source) {
|
|
1460
|
+
const bindings = new Set();
|
|
1461
|
+
for (const statement of source.statements) {
|
|
1462
|
+
if (!ts.isVariableStatement(statement))
|
|
1463
|
+
continue;
|
|
1464
|
+
if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0)
|
|
1465
|
+
continue;
|
|
1466
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
1467
|
+
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined)
|
|
1468
|
+
continue;
|
|
1469
|
+
const initializer = unwrapExpression(declaration.initializer);
|
|
1470
|
+
if (initializer !== undefined &&
|
|
1471
|
+
ts.isCallExpression(initializer) &&
|
|
1472
|
+
isOperationHelperCall(initializer)) {
|
|
1473
|
+
bindings.add(declaration.name.text);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
return bindings;
|
|
1478
|
+
}
|
|
1479
|
+
function collectImportedBindingNames(source) {
|
|
1480
|
+
const bindings = new Set();
|
|
1481
|
+
for (const statement of source.statements) {
|
|
1482
|
+
if (!ts.isImportDeclaration(statement))
|
|
1483
|
+
continue;
|
|
1484
|
+
const clause = statement.importClause;
|
|
1485
|
+
if (clause?.name !== undefined)
|
|
1486
|
+
bindings.add(clause.name.text);
|
|
1487
|
+
const named = clause?.namedBindings;
|
|
1488
|
+
if (named === undefined)
|
|
1489
|
+
continue;
|
|
1490
|
+
if (ts.isNamespaceImport(named)) {
|
|
1491
|
+
bindings.add(named.name.text);
|
|
1492
|
+
continue;
|
|
1493
|
+
}
|
|
1494
|
+
for (const element of named.elements)
|
|
1495
|
+
bindings.add(element.name.text);
|
|
1496
|
+
}
|
|
1497
|
+
return bindings;
|
|
1498
|
+
}
|
|
1499
|
+
function collectStaticOperationRegistryEntries(source, operationBindings) {
|
|
1500
|
+
const entries = [];
|
|
1501
|
+
for (const statement of source.statements) {
|
|
1502
|
+
if (!ts.isVariableStatement(statement))
|
|
1503
|
+
continue;
|
|
1504
|
+
if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0)
|
|
1505
|
+
continue;
|
|
1506
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
1507
|
+
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined)
|
|
1508
|
+
continue;
|
|
1509
|
+
const object = unwrapExpression(declaration.initializer);
|
|
1510
|
+
if (object === undefined || !ts.isObjectLiteralExpression(object))
|
|
1511
|
+
continue;
|
|
1512
|
+
for (const property of object.properties) {
|
|
1513
|
+
if (ts.isSpreadAssignment(property))
|
|
1514
|
+
continue;
|
|
1515
|
+
const name = property.name;
|
|
1516
|
+
if (name === undefined || (!ts.isIdentifier(name) && !ts.isStringLiteral(name)))
|
|
1517
|
+
continue;
|
|
1518
|
+
const value = propertyValue(property);
|
|
1519
|
+
const binding = unwrapExpression(value);
|
|
1520
|
+
if (binding === undefined || !ts.isIdentifier(binding))
|
|
1521
|
+
continue;
|
|
1522
|
+
if (!operationBindings.has(binding.text))
|
|
1523
|
+
continue;
|
|
1524
|
+
entries.push({ operationId: name.text, binding: binding.text });
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
return entries;
|
|
1529
|
+
}
|
|
1212
1530
|
function collectImports(source, containingPath, sources) {
|
|
1213
1531
|
const imports = new Map();
|
|
1214
1532
|
for (const statement of source.statements) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { assertProviderLocaleKey } from "./keys.js";
|
|
2
|
+
/** Canonical locale-catalog namespace for a URL-safe provider operation id. */
|
|
3
|
+
export function operationIdToLocaleNamespace(operationId) {
|
|
4
|
+
const namespace = operationId.replace(/[-_]([a-z0-9])/g, (_separator, character) => character.toUpperCase());
|
|
5
|
+
assertProviderLocaleKey(`operations.${namespace}.description`);
|
|
6
|
+
return namespace;
|
|
7
|
+
}
|
package/package.json
CHANGED
package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-kebab-operation-id.ts.txt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const listHospitalsOperation = defineOperation<ProviderContext>()({
|
|
2
|
+
annotations: { readOnly: true },
|
|
3
|
+
inputExamples: [
|
|
4
|
+
{
|
|
5
|
+
scenario: "List nearby hospitals",
|
|
6
|
+
input: { latitude: 37.5665, longitude: 126.978 },
|
|
7
|
+
rationale: "Exercises a kebab-case operation id.",
|
|
8
|
+
},
|
|
9
|
+
],
|
|
10
|
+
input: InputSchema,
|
|
11
|
+
output: OutputSchema,
|
|
12
|
+
handler,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export default buildProvider({
|
|
16
|
+
operations: { "list-hospitals": listHospitalsOperation },
|
|
17
|
+
});
|
package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-snake-operation-id.ts.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const listRecentEarthquakesOperation = defineOperation<ProviderContext>()({
|
|
2
|
+
annotations: { readOnly: true },
|
|
3
|
+
docs: {
|
|
4
|
+
titleKey: "operations.listRecentEarthquakes.title",
|
|
5
|
+
descriptionKey: "operations.listRecentEarthquakes.description",
|
|
6
|
+
},
|
|
7
|
+
inputExamples: [
|
|
8
|
+
{
|
|
9
|
+
scenario: "List recent earthquakes",
|
|
10
|
+
input: { limit: 10 },
|
|
11
|
+
rationale: "Exercises a snake-case operation id.",
|
|
12
|
+
},
|
|
13
|
+
],
|
|
14
|
+
input: InputSchema,
|
|
15
|
+
output: OutputSchema,
|
|
16
|
+
handler,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export default buildProvider({
|
|
20
|
+
operations: { list_recent_earthquakes: listRecentEarthquakesOperation },
|
|
21
|
+
});
|
|
@@ -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
|
+
};
|
|
@@ -3,6 +3,9 @@ import { dirname, extname, join, relative, resolve } from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import type TS from "typescript";
|
|
5
5
|
|
|
6
|
+
import { assertProviderLocaleKey } from "../i18n/keys.js";
|
|
7
|
+
import { operationIdToLocaleNamespace } from "../i18n/operation-locale-namespace.js";
|
|
8
|
+
|
|
6
9
|
const ts: typeof import("typescript") = await loadTypeScript();
|
|
7
10
|
|
|
8
11
|
async function loadTypeScript(): Promise<typeof import("typescript")> {
|
|
@@ -67,6 +70,7 @@ export type OperationDeclarationRefusalReason =
|
|
|
67
70
|
| "missing_english_locale"
|
|
68
71
|
| "operation_id_unresolved"
|
|
69
72
|
| "examples_conflict"
|
|
73
|
+
| "invalid_locale_key"
|
|
70
74
|
| "locale_todo_conflict";
|
|
71
75
|
|
|
72
76
|
export type OperationDeclarationRefusal = {
|
|
@@ -180,6 +184,10 @@ export function migrateOperationDeclaration(
|
|
|
180
184
|
todos.push(...plan.localeTodos);
|
|
181
185
|
}
|
|
182
186
|
if (refusals.length > 0) return { status: "refused", refusals };
|
|
187
|
+
const invalidLocaleKey = findInvalidLocaleTodo(todos, fileName);
|
|
188
|
+
if (invalidLocaleKey !== undefined) {
|
|
189
|
+
return { status: "refused", refusals: [invalidLocaleKey] };
|
|
190
|
+
}
|
|
183
191
|
|
|
184
192
|
if (edits.length === 0) {
|
|
185
193
|
return {
|
|
@@ -400,6 +408,30 @@ function planOperationMigration(
|
|
|
400
408
|
if (merged.insert !== undefined) addInsertion(insertions, "docs", merged.insert);
|
|
401
409
|
}
|
|
402
410
|
|
|
411
|
+
const title = planTitleLocale(
|
|
412
|
+
top.byName.get("title"),
|
|
413
|
+
top.byName.get("titleKey"),
|
|
414
|
+
docs.get("titleKey"),
|
|
415
|
+
fileName,
|
|
416
|
+
site,
|
|
417
|
+
localeFiles,
|
|
418
|
+
);
|
|
419
|
+
if ("refusal" in title) return title;
|
|
420
|
+
const localeNamespace =
|
|
421
|
+
top.byName.get("inputExamples") === undefined
|
|
422
|
+
? { namespace: site.operationKey }
|
|
423
|
+
: resolveOperationLocaleNamespace(
|
|
424
|
+
[
|
|
425
|
+
top.byName.get("titleKey"),
|
|
426
|
+
docs.get("titleKey"),
|
|
427
|
+
top.byName.get("descriptionKey"),
|
|
428
|
+
docs.get("descriptionKey"),
|
|
429
|
+
],
|
|
430
|
+
fileName,
|
|
431
|
+
site,
|
|
432
|
+
);
|
|
433
|
+
if ("refusal" in localeNamespace) return localeNamespace;
|
|
434
|
+
|
|
403
435
|
const examples = planExamples(
|
|
404
436
|
top.byName.get("inputExamples"),
|
|
405
437
|
top.byName.get("examples"),
|
|
@@ -408,6 +440,7 @@ function planOperationMigration(
|
|
|
408
440
|
source,
|
|
409
441
|
constArrays,
|
|
410
442
|
localeFiles,
|
|
443
|
+
localeNamespace.namespace,
|
|
411
444
|
);
|
|
412
445
|
if ("refusal" in examples) return examples;
|
|
413
446
|
|
|
@@ -431,7 +464,107 @@ function planOperationMigration(
|
|
|
431
464
|
if (!edits.some((existing) => rangesOverlap(existing, edit))) edits.push(edit);
|
|
432
465
|
}
|
|
433
466
|
|
|
434
|
-
return { edits, localeTodos: examples.localeTodos };
|
|
467
|
+
return { edits, localeTodos: [...title.localeTodos, ...examples.localeTodos] };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function planTitleLocale(
|
|
471
|
+
title: ResolvedMember | undefined,
|
|
472
|
+
flatTitleKey: ResolvedMember | undefined,
|
|
473
|
+
nestedTitleKey: ResolvedMember | undefined,
|
|
474
|
+
fileName: string,
|
|
475
|
+
site: OperationSite,
|
|
476
|
+
localeFiles: readonly string[],
|
|
477
|
+
):
|
|
478
|
+
| { readonly localeTodos: readonly LocaleTodo[] }
|
|
479
|
+
| { readonly refusal: OperationDeclarationRefusal } {
|
|
480
|
+
if (title === undefined) return { localeTodos: [] };
|
|
481
|
+
const originalProse = literalString(title.initializer);
|
|
482
|
+
if (originalProse === undefined) {
|
|
483
|
+
return nonLiteral(
|
|
484
|
+
fileName,
|
|
485
|
+
site.operationKey,
|
|
486
|
+
"title must be a string literal so its authored prose can be preserved in the English locale catalog.",
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
if (!site.operationIdProven) {
|
|
490
|
+
return {
|
|
491
|
+
refusal: refusal(
|
|
492
|
+
fileName,
|
|
493
|
+
site.operationKey,
|
|
494
|
+
"operation_id_unresolved",
|
|
495
|
+
"title requires an exact operation id proven from a static operations map.",
|
|
496
|
+
),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
if (!localeFiles.includes("locales/en.json")) {
|
|
500
|
+
return {
|
|
501
|
+
refusal: refusal(
|
|
502
|
+
fileName,
|
|
503
|
+
site.operationKey,
|
|
504
|
+
"missing_english_locale",
|
|
505
|
+
"title cannot be migrated because locales/en.json does not exist.",
|
|
506
|
+
),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
let selectedTitleKey = flatTitleKey ?? nestedTitleKey;
|
|
511
|
+
if (flatTitleKey !== undefined && nestedTitleKey !== undefined) {
|
|
512
|
+
const same = equivalentLiteral(flatTitleKey.initializer, nestedTitleKey.initializer);
|
|
513
|
+
if (same === undefined) {
|
|
514
|
+
return nonLiteral(
|
|
515
|
+
fileName,
|
|
516
|
+
site.operationKey,
|
|
517
|
+
"titleKey must be literal when both top-level and nested declarations exist.",
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
if (!same) {
|
|
521
|
+
return {
|
|
522
|
+
refusal: refusal(
|
|
523
|
+
fileName,
|
|
524
|
+
site.operationKey,
|
|
525
|
+
"locale_key_conflict",
|
|
526
|
+
"Top-level titleKey conflicts with nested titleKey.",
|
|
527
|
+
),
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
selectedTitleKey = flatTitleKey;
|
|
531
|
+
}
|
|
532
|
+
const explicitTitleKey =
|
|
533
|
+
selectedTitleKey === undefined ? undefined : literalString(selectedTitleKey.initializer);
|
|
534
|
+
if (selectedTitleKey !== undefined && explicitTitleKey === undefined) {
|
|
535
|
+
return nonLiteral(
|
|
536
|
+
fileName,
|
|
537
|
+
site.operationKey,
|
|
538
|
+
"titleKey must be a string literal so the title locale destination is provable.",
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
let selectedTitleLocaleKey: string;
|
|
542
|
+
if (explicitTitleKey !== undefined) {
|
|
543
|
+
selectedTitleLocaleKey = explicitTitleKey;
|
|
544
|
+
} else {
|
|
545
|
+
try {
|
|
546
|
+
selectedTitleLocaleKey = `operations.${operationIdToLocaleNamespace(site.operationKey)}.title`;
|
|
547
|
+
} catch (error) {
|
|
548
|
+
return {
|
|
549
|
+
refusal: invalidLocaleKeyRefusal(
|
|
550
|
+
fileName,
|
|
551
|
+
site.operationKey,
|
|
552
|
+
`operations.${site.operationKey}.title`,
|
|
553
|
+
error,
|
|
554
|
+
),
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
localeTodos: [
|
|
560
|
+
{
|
|
561
|
+
localeFile: "locales/en.json",
|
|
562
|
+
operationKey: site.operationKey,
|
|
563
|
+
key: selectedTitleLocaleKey,
|
|
564
|
+
originalProse,
|
|
565
|
+
},
|
|
566
|
+
],
|
|
567
|
+
};
|
|
435
568
|
}
|
|
436
569
|
|
|
437
570
|
function resolveRiskClass(
|
|
@@ -664,6 +797,7 @@ function planExamples(
|
|
|
664
797
|
source: TS.SourceFile,
|
|
665
798
|
constArrays: ReadonlyMap<string, TS.ArrayLiteralExpression>,
|
|
666
799
|
localeFiles: readonly string[],
|
|
800
|
+
localeNamespace: string,
|
|
667
801
|
):
|
|
668
802
|
| { readonly edits: readonly TextEdit[]; readonly localeTodos: readonly LocaleTodo[] }
|
|
669
803
|
| { readonly refusal: OperationDeclarationRefusal } {
|
|
@@ -760,7 +894,7 @@ function planExamples(
|
|
|
760
894
|
`inputExamples[${index}].scenario must be a string literal.`,
|
|
761
895
|
);
|
|
762
896
|
}
|
|
763
|
-
const scenarioKey = `operations.${
|
|
897
|
+
const scenarioKey = `operations.${localeNamespace}.examples.${index}.scenario`;
|
|
764
898
|
edits.push(replaceExampleLocaleMember(scenario, "scenarioKey", scenarioKey, source));
|
|
765
899
|
for (const localeFile of localeFiles) {
|
|
766
900
|
todos.push({
|
|
@@ -781,7 +915,7 @@ function planExamples(
|
|
|
781
915
|
`inputExamples[${index}].rationale must be a string literal.`,
|
|
782
916
|
);
|
|
783
917
|
}
|
|
784
|
-
const rationaleKey = `operations.${
|
|
918
|
+
const rationaleKey = `operations.${localeNamespace}.examples.${index}.rationale`;
|
|
785
919
|
edits.push(replaceExampleLocaleMember(rationale, "rationaleKey", rationaleKey, source));
|
|
786
920
|
for (const localeFile of localeFiles) {
|
|
787
921
|
todos.push({
|
|
@@ -796,6 +930,47 @@ function planExamples(
|
|
|
796
930
|
return { edits, localeTodos: todos };
|
|
797
931
|
}
|
|
798
932
|
|
|
933
|
+
function resolveOperationLocaleNamespace(
|
|
934
|
+
members: readonly (ResolvedMember | undefined)[],
|
|
935
|
+
fileName: string,
|
|
936
|
+
site: OperationSite,
|
|
937
|
+
): { readonly namespace: string } | { readonly refusal: OperationDeclarationRefusal } {
|
|
938
|
+
const authoredNamespaces = new Set<string>();
|
|
939
|
+
for (const member of members) {
|
|
940
|
+
const localeKey = literalString(member?.initializer);
|
|
941
|
+
if (localeKey === undefined) continue;
|
|
942
|
+
const segments = localeKey.split(".");
|
|
943
|
+
if (segments[0] === "operations" && segments[1] !== undefined) {
|
|
944
|
+
authoredNamespaces.add(segments[1]);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
if (authoredNamespaces.size > 1) {
|
|
948
|
+
return {
|
|
949
|
+
refusal: refusal(
|
|
950
|
+
fileName,
|
|
951
|
+
site.operationKey,
|
|
952
|
+
"locale_key_conflict",
|
|
953
|
+
`Operation titleKey and descriptionKey declarations use different locale namespaces: ${[...authoredNamespaces].join(", ")}.`,
|
|
954
|
+
),
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
const authored = authoredNamespaces.values().next().value;
|
|
958
|
+
if (authored !== undefined) return { namespace: authored };
|
|
959
|
+
|
|
960
|
+
try {
|
|
961
|
+
return { namespace: operationIdToLocaleNamespace(site.operationKey) };
|
|
962
|
+
} catch (error) {
|
|
963
|
+
return {
|
|
964
|
+
refusal: invalidLocaleKeyRefusal(
|
|
965
|
+
fileName,
|
|
966
|
+
site.operationKey,
|
|
967
|
+
`operations.${site.operationKey}.examples`,
|
|
968
|
+
error,
|
|
969
|
+
),
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
799
974
|
function replaceExampleLocaleMember(
|
|
800
975
|
member: ResolvedMember,
|
|
801
976
|
newName: string,
|
|
@@ -984,9 +1159,7 @@ function isProviderOperationsProperty(
|
|
|
984
1159
|
// (a) The initializer (direct or via same-file const) mentions
|
|
985
1160
|
// defineOperation / defineStreamOperation — the strongest signal.
|
|
986
1161
|
const target = ts.isIdentifier(unwrapExpression(node.initializer) ?? node.initializer)
|
|
987
|
-
? constObjects.get(
|
|
988
|
-
(unwrapExpression(node.initializer) as TS.Identifier).text,
|
|
989
|
-
)
|
|
1162
|
+
? constObjects.get((unwrapExpression(node.initializer) as TS.Identifier).text)
|
|
990
1163
|
: undefined;
|
|
991
1164
|
const initializerText = (target ?? node.initializer).getText();
|
|
992
1165
|
if (/\bdefine(?:Stream)?Operation\b/.test(initializerText)) return true;
|
|
@@ -1513,6 +1686,35 @@ function refusal(
|
|
|
1513
1686
|
return { file, operationKey, reason, detail };
|
|
1514
1687
|
}
|
|
1515
1688
|
|
|
1689
|
+
function findInvalidLocaleTodo(
|
|
1690
|
+
todos: readonly LocaleTodo[],
|
|
1691
|
+
fileName: string,
|
|
1692
|
+
): OperationDeclarationRefusal | undefined {
|
|
1693
|
+
for (const todo of todos) {
|
|
1694
|
+
try {
|
|
1695
|
+
assertProviderLocaleKey(todo.key);
|
|
1696
|
+
} catch (error) {
|
|
1697
|
+
return invalidLocaleKeyRefusal(fileName, todo.operationKey, todo.key, error);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
return undefined;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function invalidLocaleKeyRefusal(
|
|
1704
|
+
fileName: string,
|
|
1705
|
+
operationKey: string,
|
|
1706
|
+
localeKey: string,
|
|
1707
|
+
error: unknown,
|
|
1708
|
+
): OperationDeclarationRefusal {
|
|
1709
|
+
const validatorDetail = error instanceof Error ? error.message : String(error);
|
|
1710
|
+
return refusal(
|
|
1711
|
+
fileName,
|
|
1712
|
+
operationKey,
|
|
1713
|
+
"invalid_locale_key",
|
|
1714
|
+
`Refusing to write invalid provider locale key ${JSON.stringify(localeKey)}: ${validatorDetail}`,
|
|
1715
|
+
);
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1516
1718
|
function nonLiteral(
|
|
1517
1719
|
fileName: string,
|
|
1518
1720
|
operationKey: string,
|
|
@@ -1576,6 +1778,9 @@ export function migrateOperationDeclarationRepository(
|
|
|
1576
1778
|
if (refusals.length > 0) {
|
|
1577
1779
|
return { status: "refused", providerRoot, refusals };
|
|
1578
1780
|
}
|
|
1781
|
+
for (const [path, code] of renderLocaleCatalogWrites(providerRoot, todos)) {
|
|
1782
|
+
pendingWrites.set(path, code);
|
|
1783
|
+
}
|
|
1579
1784
|
|
|
1580
1785
|
if (pendingWrites.size === 0) {
|
|
1581
1786
|
return {
|
|
@@ -1616,6 +1821,103 @@ export function renderLocaleTodoSidecar(todos: readonly LocaleTodo[]): string {
|
|
|
1616
1821
|
return `${JSON.stringify({ schemaVersion: 1, localeFiles }, null, 2)}\n`;
|
|
1617
1822
|
}
|
|
1618
1823
|
|
|
1824
|
+
function renderLocaleCatalogWrites(
|
|
1825
|
+
providerRoot: string,
|
|
1826
|
+
todos: readonly LocaleTodo[],
|
|
1827
|
+
): Map<string, string> {
|
|
1828
|
+
const todosByFile = new Map<string, LocaleTodo[]>();
|
|
1829
|
+
for (const todo of todos) {
|
|
1830
|
+
const fileTodos = todosByFile.get(todo.localeFile) ?? [];
|
|
1831
|
+
fileTodos.push(todo);
|
|
1832
|
+
todosByFile.set(todo.localeFile, fileTodos);
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
const englishPath = "locales/en.json";
|
|
1836
|
+
const englishTodos = todosByFile.get(englishPath);
|
|
1837
|
+
if (englishTodos === undefined) return new Map();
|
|
1838
|
+
|
|
1839
|
+
const english = readLocaleCatalog(join(providerRoot, englishPath));
|
|
1840
|
+
applyLocaleTodos(english, englishTodos);
|
|
1841
|
+
const writes = new Map<string, string>([
|
|
1842
|
+
[join(providerRoot, englishPath), renderCanonicalLocaleCatalog(english)],
|
|
1843
|
+
]);
|
|
1844
|
+
|
|
1845
|
+
for (const [localeFile, fileTodos] of todosByFile) {
|
|
1846
|
+
if (localeFile === englishPath) continue;
|
|
1847
|
+
const catalog = readLocaleCatalog(join(providerRoot, localeFile));
|
|
1848
|
+
applyLocaleTodos(catalog, fileTodos);
|
|
1849
|
+
writes.set(
|
|
1850
|
+
join(providerRoot, localeFile),
|
|
1851
|
+
renderCanonicalLocaleCatalog(reorderLikeReference(english, catalog)),
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
return writes;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function readLocaleCatalog(path: string): Record<string, unknown> {
|
|
1858
|
+
const value: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
1859
|
+
if (!isRecord(value)) throw new Error(`${path} must contain a JSON object.`);
|
|
1860
|
+
return value;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
function applyLocaleTodos(catalog: Record<string, unknown>, todos: readonly LocaleTodo[]): void {
|
|
1864
|
+
for (const todo of todos) {
|
|
1865
|
+
const segments = todo.key.split(".");
|
|
1866
|
+
const leaf = segments.pop();
|
|
1867
|
+
if (leaf === undefined) continue;
|
|
1868
|
+
let cursor = catalog;
|
|
1869
|
+
for (const segment of segments) {
|
|
1870
|
+
const child = cursor[segment];
|
|
1871
|
+
if (isRecord(child)) {
|
|
1872
|
+
cursor = child;
|
|
1873
|
+
continue;
|
|
1874
|
+
}
|
|
1875
|
+
const created: Record<string, unknown> = {};
|
|
1876
|
+
cursor[segment] = created;
|
|
1877
|
+
cursor = created;
|
|
1878
|
+
}
|
|
1879
|
+
cursor[leaf] = todo.originalProse;
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/** Match provider-contract's canonical locale serialization exactly. */
|
|
1884
|
+
function renderCanonicalLocaleCatalog(value: unknown): string {
|
|
1885
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
/**
|
|
1889
|
+
* Put shared keys first in English order, then retain locale-only authored order.
|
|
1890
|
+
* Arrays keep their shape and use the English item at the same index as a reference.
|
|
1891
|
+
*/
|
|
1892
|
+
function reorderLikeReference(reference: unknown, value: unknown): unknown {
|
|
1893
|
+
if (Array.isArray(value)) {
|
|
1894
|
+
const referenceArray = Array.isArray(reference) ? reference : [];
|
|
1895
|
+
return value.map((item, index) => reorderLikeReference(referenceArray[index], item));
|
|
1896
|
+
}
|
|
1897
|
+
if (!isRecord(value)) return value;
|
|
1898
|
+
|
|
1899
|
+
const referenceRecord = isRecord(reference) ? reference : {};
|
|
1900
|
+
const ordered: Record<string, unknown> = {};
|
|
1901
|
+
for (const key of Object.keys(referenceRecord)) {
|
|
1902
|
+
if (Object.hasOwn(value, key)) {
|
|
1903
|
+
ordered[key] = reorderLikeReference(referenceRecord[key], value[key]);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1907
|
+
if (!Object.hasOwn(ordered, key)) {
|
|
1908
|
+
ordered[key] = reorderLikeReference(
|
|
1909
|
+
Object.hasOwn(referenceRecord, key) ? referenceRecord[key] : undefined,
|
|
1910
|
+
child,
|
|
1911
|
+
);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
return ordered;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1918
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1619
1921
|
function findLocaleTodoConflict(
|
|
1620
1922
|
todos: readonly LocaleTodo[],
|
|
1621
1923
|
): OperationDeclarationRefusal | undefined {
|
|
@@ -1668,6 +1970,8 @@ function collectLocaleFiles(root: string): string[] {
|
|
|
1668
1970
|
|
|
1669
1971
|
function buildOperationIdIndex(sourceFiles: readonly string[]): Map<string, Map<string, string>> {
|
|
1670
1972
|
const result = new Map<string, Map<string, string>>();
|
|
1973
|
+
const idsByPath = new Map<string, Map<string, string | null>>();
|
|
1974
|
+
const ambiguousBindingsByPath = new Map<string, Set<string>>();
|
|
1671
1975
|
const sources = new Map<string, TS.SourceFile>();
|
|
1672
1976
|
for (const path of sourceFiles) {
|
|
1673
1977
|
const source = parseSource(path, readFileSync(path, "utf8"));
|
|
@@ -1676,10 +1980,40 @@ function buildOperationIdIndex(sourceFiles: readonly string[]): Map<string, Map<
|
|
|
1676
1980
|
|
|
1677
1981
|
const record = (path: string, binding: string, operationId: string): void => {
|
|
1678
1982
|
const map = result.get(path) ?? new Map<string, string>();
|
|
1679
|
-
const
|
|
1680
|
-
|
|
1681
|
-
|
|
1983
|
+
const ids = idsByPath.get(path) ?? new Map<string, string | null>();
|
|
1984
|
+
const ambiguousBindings = ambiguousBindingsByPath.get(path) ?? new Set<string>();
|
|
1985
|
+
|
|
1986
|
+
// An operation id is usable only when it identifies exactly one binding.
|
|
1987
|
+
// Keep an explicit null marker for an ambiguous id so a later occurrence
|
|
1988
|
+
// cannot accidentally make it usable again.
|
|
1989
|
+
const previousBinding = ids.get(operationId);
|
|
1990
|
+
if (ids.has(operationId)) {
|
|
1991
|
+
if (
|
|
1992
|
+
previousBinding !== undefined &&
|
|
1993
|
+
previousBinding !== null &&
|
|
1994
|
+
previousBinding !== binding
|
|
1995
|
+
) {
|
|
1996
|
+
map.delete(previousBinding);
|
|
1997
|
+
map.delete(binding);
|
|
1998
|
+
ambiguousBindings.add(previousBinding);
|
|
1999
|
+
ambiguousBindings.add(binding);
|
|
2000
|
+
ids.set(operationId, null);
|
|
2001
|
+
}
|
|
2002
|
+
} else {
|
|
2003
|
+
ids.set(operationId, binding);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// A binding registered under two different ids is likewise ambiguous.
|
|
2007
|
+
const previousId = map.get(binding);
|
|
2008
|
+
if (previousId !== undefined && previousId !== operationId) {
|
|
2009
|
+
map.delete(binding);
|
|
2010
|
+
ambiguousBindings.add(binding);
|
|
2011
|
+
} else if (!ambiguousBindings.has(binding) && ids.get(operationId) === binding) {
|
|
2012
|
+
map.set(binding, operationId);
|
|
2013
|
+
}
|
|
1682
2014
|
result.set(path, map);
|
|
2015
|
+
idsByPath.set(path, ids);
|
|
2016
|
+
ambiguousBindingsByPath.set(path, ambiguousBindings);
|
|
1683
2017
|
};
|
|
1684
2018
|
|
|
1685
2019
|
for (const [path, source] of sources) {
|
|
@@ -1700,10 +2034,91 @@ function buildOperationIdIndex(sourceFiles: readonly string[]): Map<string, Map<
|
|
|
1700
2034
|
else record(imported.path, imported.exportedName, operationId);
|
|
1701
2035
|
}
|
|
1702
2036
|
}
|
|
2037
|
+
|
|
2038
|
+
// Some providers keep their operation registry in a same-file const with
|
|
2039
|
+
// an arbitrary name (for example, `companionsOperations`). This scan is
|
|
2040
|
+
// deliberately ID-only: it accepts only static object members whose value
|
|
2041
|
+
// is a same-file operation binding, and never evaluates spreads, factories,
|
|
2042
|
+
// or imported values.
|
|
2043
|
+
const operationBindings = collectModuleOperationBindings(source);
|
|
2044
|
+
const importedBindings = collectImportedBindingNames(source);
|
|
2045
|
+
for (const entry of collectStaticOperationRegistryEntries(source, operationBindings)) {
|
|
2046
|
+
if (importedBindings.has(entry.binding)) continue;
|
|
2047
|
+
record(path, entry.binding, entry.operationId);
|
|
2048
|
+
}
|
|
1703
2049
|
}
|
|
1704
2050
|
return result;
|
|
1705
2051
|
}
|
|
1706
2052
|
|
|
2053
|
+
type StaticOperationRegistryEntry = {
|
|
2054
|
+
readonly operationId: string;
|
|
2055
|
+
readonly binding: string;
|
|
2056
|
+
};
|
|
2057
|
+
|
|
2058
|
+
function collectModuleOperationBindings(source: TS.SourceFile): ReadonlySet<string> {
|
|
2059
|
+
const bindings = new Set<string>();
|
|
2060
|
+
for (const statement of source.statements) {
|
|
2061
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
2062
|
+
if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
|
|
2063
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
2064
|
+
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue;
|
|
2065
|
+
const initializer = unwrapExpression(declaration.initializer);
|
|
2066
|
+
if (
|
|
2067
|
+
initializer !== undefined &&
|
|
2068
|
+
ts.isCallExpression(initializer) &&
|
|
2069
|
+
isOperationHelperCall(initializer)
|
|
2070
|
+
) {
|
|
2071
|
+
bindings.add(declaration.name.text);
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
return bindings;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
function collectImportedBindingNames(source: TS.SourceFile): ReadonlySet<string> {
|
|
2079
|
+
const bindings = new Set<string>();
|
|
2080
|
+
for (const statement of source.statements) {
|
|
2081
|
+
if (!ts.isImportDeclaration(statement)) continue;
|
|
2082
|
+
const clause = statement.importClause;
|
|
2083
|
+
if (clause?.name !== undefined) bindings.add(clause.name.text);
|
|
2084
|
+
const named = clause?.namedBindings;
|
|
2085
|
+
if (named === undefined) continue;
|
|
2086
|
+
if (ts.isNamespaceImport(named)) {
|
|
2087
|
+
bindings.add(named.name.text);
|
|
2088
|
+
continue;
|
|
2089
|
+
}
|
|
2090
|
+
for (const element of named.elements) bindings.add(element.name.text);
|
|
2091
|
+
}
|
|
2092
|
+
return bindings;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
function collectStaticOperationRegistryEntries(
|
|
2096
|
+
source: TS.SourceFile,
|
|
2097
|
+
operationBindings: ReadonlySet<string>,
|
|
2098
|
+
): StaticOperationRegistryEntry[] {
|
|
2099
|
+
const entries: StaticOperationRegistryEntry[] = [];
|
|
2100
|
+
for (const statement of source.statements) {
|
|
2101
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
2102
|
+
if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
|
|
2103
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
2104
|
+
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue;
|
|
2105
|
+
const object = unwrapExpression(declaration.initializer);
|
|
2106
|
+
if (object === undefined || !ts.isObjectLiteralExpression(object)) continue;
|
|
2107
|
+
for (const property of object.properties) {
|
|
2108
|
+
if (ts.isSpreadAssignment(property)) continue;
|
|
2109
|
+
const name = property.name;
|
|
2110
|
+
if (name === undefined || (!ts.isIdentifier(name) && !ts.isStringLiteral(name))) continue;
|
|
2111
|
+
const value = propertyValue(property);
|
|
2112
|
+
const binding = unwrapExpression(value);
|
|
2113
|
+
if (binding === undefined || !ts.isIdentifier(binding)) continue;
|
|
2114
|
+
if (!operationBindings.has(binding.text)) continue;
|
|
2115
|
+
entries.push({ operationId: name.text, binding: binding.text });
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
return entries;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
1707
2122
|
function collectImports(
|
|
1708
2123
|
source: TS.SourceFile,
|
|
1709
2124
|
containingPath: string,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { assertProviderLocaleKey } from "./keys.js";
|
|
2
|
+
|
|
3
|
+
/** Canonical locale-catalog namespace for a URL-safe provider operation id. */
|
|
4
|
+
export function operationIdToLocaleNamespace(operationId: string): string {
|
|
5
|
+
const namespace = operationId.replace(/[-_]([a-z0-9])/g, (_separator, character: string) =>
|
|
6
|
+
character.toUpperCase(),
|
|
7
|
+
);
|
|
8
|
+
assertProviderLocaleKey(`operations.${namespace}.description`);
|
|
9
|
+
return namespace;
|
|
10
|
+
}
|