@apifuse/provider-sdk 2.2.0-beta.41 → 2.2.0-beta.42
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/bin/apifuse-migrate-shape.ts +141 -23
- package/bin/apifuse-submit-check.ts +99 -86
- package/dist/cli/commands.js +1 -1
- package/dist/cli/migrate-operation-shape.d.ts +44 -0
- package/dist/cli/migrate-operation-shape.js +113 -0
- package/dist/cli/migrate-provider-shape.js +64 -1
- package/dist/server/serve-implementation.js +40 -1
- package/package.json +3 -3
- package/src/cli/commands.ts +1 -1
- package/src/cli/migrate-operation-shape.ts +184 -0
- package/src/cli/migrate-provider-shape.ts +107 -36
- package/src/server/serve-implementation.ts +43 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const ts = await loadTypeScript();
|
|
2
|
+
async function loadTypeScript() {
|
|
3
|
+
try {
|
|
4
|
+
return await import("typescript");
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
console.error("apifuse migrate-shape requires typescript; install it in the workspace running the CLI (bun add -d typescript)");
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const OPERATION_HELPERS = new Set(["defineOperation", "defineStreamOperation"]);
|
|
12
|
+
const PROVIDER_CONTEXT_TYPE_NAME = "ProviderContext";
|
|
13
|
+
const PROVIDER_SDK_PROVIDER_SUBPATH = "@apifuse/provider-sdk/provider";
|
|
14
|
+
/**
|
|
15
|
+
* Migrate legacy `defineOperation(config)` calls in one source file to the
|
|
16
|
+
* curried `defineOperation<ProviderContext>()(config)` form.
|
|
17
|
+
*
|
|
18
|
+
* @param contextImportSpecifier module specifier the `ProviderContext` type
|
|
19
|
+
* import should come from when one has to be added — `"../index"` for
|
|
20
|
+
* operation modules, `"./index"` is never needed because index.ts declares
|
|
21
|
+
* the alias itself. Callers pass the correct relative path per file.
|
|
22
|
+
*/
|
|
23
|
+
export function migrateOperationShape(sourceText, fileName, contextImportSpecifier) {
|
|
24
|
+
const source = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
25
|
+
const parseError = firstSyntaxError(source);
|
|
26
|
+
if (parseError !== undefined) {
|
|
27
|
+
return { status: "skipped", reason: parseError };
|
|
28
|
+
}
|
|
29
|
+
// Every legacy call site: a direct call to the bare helper identifier
|
|
30
|
+
// whose single argument is the config (i.e. NOT the curried form, whose
|
|
31
|
+
// outer call has zero arguments and optional type arguments).
|
|
32
|
+
const legacyCalls = [];
|
|
33
|
+
let sawCurried = false;
|
|
34
|
+
const visit = (node) => {
|
|
35
|
+
if (ts.isCallExpression(node) &&
|
|
36
|
+
ts.isIdentifier(node.expression) &&
|
|
37
|
+
OPERATION_HELPERS.has(node.expression.text)) {
|
|
38
|
+
if (node.arguments.length === 0) {
|
|
39
|
+
sawCurried = true; // already-migrated outer call
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
legacyCalls.push(node);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
ts.forEachChild(node, visit);
|
|
46
|
+
};
|
|
47
|
+
visit(source);
|
|
48
|
+
if (legacyCalls.length === 0) {
|
|
49
|
+
return { status: "unchanged", code: sourceText };
|
|
50
|
+
}
|
|
51
|
+
// A file mixing both forms almost certainly had a partial hand-migration;
|
|
52
|
+
// refuse rather than guess which convention the author wants.
|
|
53
|
+
if (sawCurried) {
|
|
54
|
+
return {
|
|
55
|
+
status: "skipped",
|
|
56
|
+
reason: "File mixes legacy defineOperation(config) with curried defineOperation<...>()(config); migrate it by hand.",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const declaresContextAlias = source.statements.some((statement) => ts.isTypeAliasDeclaration(statement) &&
|
|
60
|
+
statement.name.text === PROVIDER_CONTEXT_TYPE_NAME);
|
|
61
|
+
const importsContext = sourceText.includes(PROVIDER_CONTEXT_TYPE_NAME);
|
|
62
|
+
const needsImport = !declaresContextAlias && !importsContext;
|
|
63
|
+
const edits = [];
|
|
64
|
+
for (const call of legacyCalls) {
|
|
65
|
+
// `defineOperation(` -> `defineOperation<ProviderContext>()(`
|
|
66
|
+
const callee = call.expression;
|
|
67
|
+
edits.push({
|
|
68
|
+
start: callee.getEnd(),
|
|
69
|
+
end: callee.getEnd(),
|
|
70
|
+
text: `<${PROVIDER_CONTEXT_TYPE_NAME}>()`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
let importAdded = false;
|
|
74
|
+
if (needsImport) {
|
|
75
|
+
const lastImport = [...source.statements]
|
|
76
|
+
.reverse()
|
|
77
|
+
.find((statement) => ts.isImportDeclaration(statement));
|
|
78
|
+
const insertAt = lastImport ? lastImport.getEnd() : 0;
|
|
79
|
+
const importText = `${lastImport ? "\n" : ""}import type { ${PROVIDER_CONTEXT_TYPE_NAME} } from "${contextImportSpecifier}";${lastImport ? "" : "\n"}`;
|
|
80
|
+
edits.push({ start: insertAt, end: insertAt, text: importText });
|
|
81
|
+
importAdded = true;
|
|
82
|
+
}
|
|
83
|
+
const ordered = edits.sort((a, b) => b.start - a.start);
|
|
84
|
+
let code = sourceText;
|
|
85
|
+
for (const edit of ordered) {
|
|
86
|
+
code = code.slice(0, edit.start) + edit.text + code.slice(edit.end);
|
|
87
|
+
}
|
|
88
|
+
const verified = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
89
|
+
const outputError = firstSyntaxError(verified);
|
|
90
|
+
if (outputError !== undefined) {
|
|
91
|
+
return {
|
|
92
|
+
status: "skipped",
|
|
93
|
+
reason: `Transform produced source that does not parse (${outputError}); refusing to emit a partial migration.`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
status: "migrated",
|
|
98
|
+
code,
|
|
99
|
+
rewrites: legacyCalls.length,
|
|
100
|
+
importAdded,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function firstSyntaxError(source) {
|
|
104
|
+
const diagnostics = source.parseDiagnostics;
|
|
105
|
+
if (diagnostics === undefined || diagnostics.length === 0)
|
|
106
|
+
return undefined;
|
|
107
|
+
const first = diagnostics[0];
|
|
108
|
+
if (first === undefined)
|
|
109
|
+
return undefined;
|
|
110
|
+
const message = ts.flattenDiagnosticMessageText(first.messageText, " ");
|
|
111
|
+
const { line } = source.getLineAndCharacterOfPosition(first.start);
|
|
112
|
+
return `${message} (line ${line + 1})`;
|
|
113
|
+
}
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
const ts = await loadTypeScript();
|
|
2
|
+
async function loadTypeScript() {
|
|
3
|
+
try {
|
|
4
|
+
return await import("typescript");
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
console.error("apifuse migrate-shape requires typescript; install it in the workspace running the CLI (bun add -d typescript)");
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
2
11
|
const DECLARATION_BUILDER_NAME = "buildProvider";
|
|
3
12
|
const PROVIDER_CONTEXT_TYPE_NAME = "ProviderContext";
|
|
4
13
|
const PROVIDER_CONTEXT_OF_TYPE_NAME = "ProviderContextOf";
|
|
@@ -397,6 +406,12 @@ function ensureProviderContextType(source, call, shape, builderName) {
|
|
|
397
406
|
const edits = [];
|
|
398
407
|
const hasContextTypeAlias = source.statements.some((statement) => ts.isTypeAliasDeclaration(statement) &&
|
|
399
408
|
statement.name.text === PROVIDER_CONTEXT_TYPE_NAME);
|
|
409
|
+
// A named import of `ProviderContext` (the deprecated SDK-root context
|
|
410
|
+
// type, imported by legacy sources) occupies the same name: adding the
|
|
411
|
+
// alias alongside it is TS2440. The import must yield to the derived
|
|
412
|
+
// alias — the whole point of the two-phase shape — so drop it and let the
|
|
413
|
+
// alias own the name.
|
|
414
|
+
const conflictingImportSpecifier = findNamedImportSpecifier(source, PROVIDER_CONTEXT_TYPE_NAME);
|
|
400
415
|
if (!hasContextTypeAlias) {
|
|
401
416
|
// After the builder statement — which is the variable statement when the
|
|
402
417
|
// declaration was bound, or the rewritten export statement otherwise.
|
|
@@ -409,6 +424,9 @@ function ensureProviderContextType(source, call, shape, builderName) {
|
|
|
409
424
|
end: insertAt,
|
|
410
425
|
text: `\n\nexport type ${PROVIDER_CONTEXT_TYPE_NAME} = ${PROVIDER_CONTEXT_OF_TYPE_NAME}<typeof ${builderName}>;`,
|
|
411
426
|
});
|
|
427
|
+
if (conflictingImportSpecifier !== undefined) {
|
|
428
|
+
edits.push(removeImportSpecifier(source, conflictingImportSpecifier));
|
|
429
|
+
}
|
|
412
430
|
}
|
|
413
431
|
}
|
|
414
432
|
if (text.includes(PROVIDER_CONTEXT_OF_TYPE_NAME)) {
|
|
@@ -454,6 +472,51 @@ function findProviderSdkImport(source) {
|
|
|
454
472
|
}
|
|
455
473
|
return undefined;
|
|
456
474
|
}
|
|
475
|
+
/** Named import specifier binding `localName` in any import declaration. */
|
|
476
|
+
function findNamedImportSpecifier(source, localName) {
|
|
477
|
+
for (const statement of source.statements) {
|
|
478
|
+
if (!ts.isImportDeclaration(statement))
|
|
479
|
+
continue;
|
|
480
|
+
const named = statement.importClause?.namedBindings;
|
|
481
|
+
if (named === undefined || !ts.isNamedImports(named))
|
|
482
|
+
continue;
|
|
483
|
+
for (const element of named.elements) {
|
|
484
|
+
if (element.name.text === localName)
|
|
485
|
+
return element;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Edit removing one specifier from its named-import list, absorbing one
|
|
492
|
+
* neighboring comma so the list stays well-formed. Callers guarantee the
|
|
493
|
+
* list has at least one other specifier (legacy sources always import
|
|
494
|
+
* defineProvider alongside the context type).
|
|
495
|
+
*/
|
|
496
|
+
function removeImportSpecifier(source, specifier) {
|
|
497
|
+
const list = specifier.parent;
|
|
498
|
+
const index = list.elements.indexOf(specifier);
|
|
499
|
+
const text = source.getFullText();
|
|
500
|
+
let start = specifier.getFullStart();
|
|
501
|
+
let end = specifier.getEnd();
|
|
502
|
+
let cursor = end;
|
|
503
|
+
while (cursor < text.length && /\s/.test(text.charAt(cursor)))
|
|
504
|
+
cursor += 1;
|
|
505
|
+
if (text.charAt(cursor) === ",") {
|
|
506
|
+
end = cursor + 1;
|
|
507
|
+
}
|
|
508
|
+
else if (index > 0) {
|
|
509
|
+
const previous = list.elements[index - 1];
|
|
510
|
+
if (previous !== undefined) {
|
|
511
|
+
let back = previous.getEnd();
|
|
512
|
+
while (back < text.length && /\s/.test(text.charAt(back)))
|
|
513
|
+
back += 1;
|
|
514
|
+
if (text.charAt(back) === ",")
|
|
515
|
+
start = back;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return { start, end, text: "" };
|
|
519
|
+
}
|
|
457
520
|
/**
|
|
458
521
|
* `buildProvider` unless the module already binds that name, in which case a
|
|
459
522
|
* numbered suffix keeps the transform from shadowing an existing binding.
|
|
@@ -9,7 +9,7 @@ import { validateFailClosedDeclaration } from "../declaration-validation.js";
|
|
|
9
9
|
import { safeProviderErrorObservability } from "../error-observability.js";
|
|
10
10
|
import { SDK_OWNED_PROVIDER_ERROR_CODES, SDK_RUNTIME_OWNED_ERROR_CODES, SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES, } from "../error-resolution.js";
|
|
11
11
|
import { AuthError, isProviderError, isSessionExpiredError, isTransportError, isValidationError, ProviderError, } from "../errors.js";
|
|
12
|
-
import { sanitizeDiagnosticText } from "../fixture-sanitization.js";
|
|
12
|
+
import { REDACTED_FIXTURE_VALUE, sanitizeDiagnosticText, } from "../fixture-sanitization.js";
|
|
13
13
|
import { loadProviderLocaleCatalogs, localizeAuthTurn, } from "../i18n/catalog.js";
|
|
14
14
|
import { categoryForStatus, sourceForCategory, isRetryableCategory, PROVIDER_OBSERVABILITY_TAXONOMY_VERSION, } from "../observability.js";
|
|
15
15
|
import { createScratchpad } from "../runtime/auth-flow.js";
|
|
@@ -979,8 +979,47 @@ function extractRequestId(raw) {
|
|
|
979
979
|
}
|
|
980
980
|
const MAX_PROVIDER_ERROR_CAUSE_FRAMES = 5;
|
|
981
981
|
const MAX_PROVIDER_ERROR_CAUSE_MESSAGE_LENGTH = 300;
|
|
982
|
+
const UNSTRUCTURED_PROVIDER_ERROR_CAUSE_MESSAGE = "[UNSTRUCTURED_UPSTREAM_TEXT]";
|
|
983
|
+
const PROVIDER_ERROR_CAUSE_RETAINED_URL_RUN = /https?:\/\/[^\s"'<>]+/giu;
|
|
984
|
+
const PROVIDER_ERROR_CAUSE_TOKEN_RUN = /\S+/gu;
|
|
985
|
+
const PROVIDER_ERROR_CAUSE_TOKEN_EDGE_PUNCTUATION = /^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu;
|
|
986
|
+
const STRUCTURALLY_SAFE_PROVIDER_ERROR_CAUSE_WORDS = new Set([
|
|
987
|
+
"completion",
|
|
988
|
+
"diagnostic",
|
|
989
|
+
"provider",
|
|
990
|
+
"rejected",
|
|
991
|
+
"returned",
|
|
992
|
+
"upstream",
|
|
993
|
+
]);
|
|
994
|
+
/**
|
|
995
|
+
* Cause frames fail closed when sanitization leaves a plausible credential-shaped free-text run.
|
|
996
|
+
* Redaction sentinels and retained URLs are ignored. Every other whitespace token (or each side
|
|
997
|
+
* of a structured key=value token) with at least eight Unicode characters must reduce to this
|
|
998
|
+
* small vocabulary drawn from SDK diagnostics; counting punctuation keeps bare passwords opaque.
|
|
999
|
+
*/
|
|
1000
|
+
function isStructurallySafeProviderErrorCauseMessage(message) {
|
|
1001
|
+
const classifiableMessage = message
|
|
1002
|
+
.replaceAll(REDACTED_FIXTURE_VALUE, " ")
|
|
1003
|
+
.replace(PROVIDER_ERROR_CAUSE_RETAINED_URL_RUN, " ");
|
|
1004
|
+
for (const match of classifiableMessage.matchAll(PROVIDER_ERROR_CAUSE_TOKEN_RUN)) {
|
|
1005
|
+
const token = match[0];
|
|
1006
|
+
for (const run of token.split("=")) {
|
|
1007
|
+
const diagnosticWord = run
|
|
1008
|
+
.replace(PROVIDER_ERROR_CAUSE_TOKEN_EDGE_PUNCTUATION, "")
|
|
1009
|
+
.toLowerCase();
|
|
1010
|
+
if (STRUCTURALLY_SAFE_PROVIDER_ERROR_CAUSE_WORDS.has(diagnosticWord))
|
|
1011
|
+
continue;
|
|
1012
|
+
if ([...run].length >= 8)
|
|
1013
|
+
return false;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
982
1018
|
function providerErrorCauseMessage(message) {
|
|
983
1019
|
const sanitized = sanitizeDiagnosticText(message);
|
|
1020
|
+
if (!isStructurallySafeProviderErrorCauseMessage(sanitized)) {
|
|
1021
|
+
return UNSTRUCTURED_PROVIDER_ERROR_CAUSE_MESSAGE;
|
|
1022
|
+
}
|
|
984
1023
|
return sanitized.length > MAX_PROVIDER_ERROR_CAUSE_MESSAGE_LENGTH
|
|
985
1024
|
? `${sanitized.slice(0, MAX_PROVIDER_ERROR_CAUSE_MESSAGE_LENGTH)}… [truncated]`
|
|
986
1025
|
: sanitized;
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "2.2.0-beta.
|
|
2
|
+
"version": "2.2.0-beta.42",
|
|
3
3
|
"name": "@apifuse/provider-sdk",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
@@ -128,7 +128,8 @@
|
|
|
128
128
|
"@microsoft/api-extractor": "^7.58.13",
|
|
129
129
|
"@types/bun": "latest",
|
|
130
130
|
"@types/node": "^25.9.3",
|
|
131
|
-
"ajv": "^8.17"
|
|
131
|
+
"ajv": "^8.17",
|
|
132
|
+
"typescript": "6.0.3"
|
|
132
133
|
},
|
|
133
134
|
"dependencies": {
|
|
134
135
|
"@clack/prompts": "^1.5.1",
|
|
@@ -145,7 +146,6 @@
|
|
|
145
146
|
"safe-regex": "^2.1",
|
|
146
147
|
"socks": "^2.8.9",
|
|
147
148
|
"tough-cookie": "^6.0.2",
|
|
148
|
-
"typescript": "6.0.3",
|
|
149
149
|
"wreq-js": "3.0.0",
|
|
150
150
|
"zod": "^4.4.3"
|
|
151
151
|
},
|
package/src/cli/commands.ts
CHANGED
|
@@ -59,7 +59,7 @@ export const COMMAND_MANIFEST: Record<
|
|
|
59
59
|
"migrate-shape": {
|
|
60
60
|
name: "migrate-shape",
|
|
61
61
|
summary:
|
|
62
|
-
"Migrate a provider
|
|
62
|
+
"Migrate a provider to the phase-separated SDK: two-phase defineProvider in index.ts and curried defineOperation across sources.",
|
|
63
63
|
usage: "apifuse migrate-shape [path] [--check] [--json]",
|
|
64
64
|
examples: ["apifuse migrate-shape .", "apifuse migrate-shape . --check"],
|
|
65
65
|
modulePath: "./apifuse-migrate-shape",
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type TS from "typescript";
|
|
2
|
+
|
|
3
|
+
const ts: typeof import("typescript") = await loadTypeScript();
|
|
4
|
+
|
|
5
|
+
async function loadTypeScript(): Promise<typeof import("typescript")> {
|
|
6
|
+
try {
|
|
7
|
+
return await import("typescript");
|
|
8
|
+
} catch {
|
|
9
|
+
console.error(
|
|
10
|
+
"apifuse migrate-shape requires typescript; install it in the workspace running the CLI (bun add -d typescript)",
|
|
11
|
+
);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Operation authoring-shape migration.
|
|
18
|
+
*
|
|
19
|
+
* `defineOperation` / `defineStreamOperation` changed alongside the provider
|
|
20
|
+
* builder in 2.2.0-beta.37: the identity helper became a curried,
|
|
21
|
+
* context-typed factory.
|
|
22
|
+
*
|
|
23
|
+
* before: defineOperation({ ...config })
|
|
24
|
+
* after: defineOperation<ProviderContext>()({ ...config })
|
|
25
|
+
*
|
|
26
|
+
* The old call still type-checks against an older pin, but under the new SDK
|
|
27
|
+
* it returns the INNER FACTORY FUNCTION with the config swallowed as an
|
|
28
|
+
* ignored argument, so every operation in the map becomes a function and the
|
|
29
|
+
* provider fails `finalizeProvider` with a misleading "declares neither
|
|
30
|
+
* healthCheck nor healthCheckUnsupported" error. Measured fleet blast radius
|
|
31
|
+
* (2026-08-29): 53 of 84 repositories, 450 legacy call sites.
|
|
32
|
+
*
|
|
33
|
+
* Same contract as the provider-shape transform: rewrite only what is fully
|
|
34
|
+
* understood, report a reasoned skip otherwise, never emit a partial file.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export type OperationShapeMigration =
|
|
38
|
+
| {
|
|
39
|
+
readonly status: "migrated";
|
|
40
|
+
readonly code: string;
|
|
41
|
+
/** Number of call sites rewritten in this file. */
|
|
42
|
+
readonly rewrites: number;
|
|
43
|
+
/** True when a `ProviderContext` type import was added. */
|
|
44
|
+
readonly importAdded: boolean;
|
|
45
|
+
}
|
|
46
|
+
| { readonly status: "unchanged"; readonly code: string }
|
|
47
|
+
| { readonly status: "skipped"; readonly reason: string };
|
|
48
|
+
|
|
49
|
+
const OPERATION_HELPERS = new Set(["defineOperation", "defineStreamOperation"]);
|
|
50
|
+
const PROVIDER_CONTEXT_TYPE_NAME = "ProviderContext";
|
|
51
|
+
const PROVIDER_SDK_PROVIDER_SUBPATH = "@apifuse/provider-sdk/provider";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Migrate legacy `defineOperation(config)` calls in one source file to the
|
|
55
|
+
* curried `defineOperation<ProviderContext>()(config)` form.
|
|
56
|
+
*
|
|
57
|
+
* @param contextImportSpecifier module specifier the `ProviderContext` type
|
|
58
|
+
* import should come from when one has to be added — `"../index"` for
|
|
59
|
+
* operation modules, `"./index"` is never needed because index.ts declares
|
|
60
|
+
* the alias itself. Callers pass the correct relative path per file.
|
|
61
|
+
*/
|
|
62
|
+
export function migrateOperationShape(
|
|
63
|
+
sourceText: string,
|
|
64
|
+
fileName: string,
|
|
65
|
+
contextImportSpecifier: string,
|
|
66
|
+
): OperationShapeMigration {
|
|
67
|
+
const source = ts.createSourceFile(
|
|
68
|
+
fileName,
|
|
69
|
+
sourceText,
|
|
70
|
+
ts.ScriptTarget.Latest,
|
|
71
|
+
true,
|
|
72
|
+
ts.ScriptKind.TS,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const parseError = firstSyntaxError(source);
|
|
76
|
+
if (parseError !== undefined) {
|
|
77
|
+
return { status: "skipped", reason: parseError };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Every legacy call site: a direct call to the bare helper identifier
|
|
81
|
+
// whose single argument is the config (i.e. NOT the curried form, whose
|
|
82
|
+
// outer call has zero arguments and optional type arguments).
|
|
83
|
+
const legacyCalls: TS.CallExpression[] = [];
|
|
84
|
+
let sawCurried = false;
|
|
85
|
+
const visit = (node: TS.Node): void => {
|
|
86
|
+
if (
|
|
87
|
+
ts.isCallExpression(node) &&
|
|
88
|
+
ts.isIdentifier(node.expression) &&
|
|
89
|
+
OPERATION_HELPERS.has(node.expression.text)
|
|
90
|
+
) {
|
|
91
|
+
if (node.arguments.length === 0) {
|
|
92
|
+
sawCurried = true; // already-migrated outer call
|
|
93
|
+
} else {
|
|
94
|
+
legacyCalls.push(node);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
ts.forEachChild(node, visit);
|
|
98
|
+
};
|
|
99
|
+
visit(source);
|
|
100
|
+
|
|
101
|
+
if (legacyCalls.length === 0) {
|
|
102
|
+
return { status: "unchanged", code: sourceText };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A file mixing both forms almost certainly had a partial hand-migration;
|
|
106
|
+
// refuse rather than guess which convention the author wants.
|
|
107
|
+
if (sawCurried) {
|
|
108
|
+
return {
|
|
109
|
+
status: "skipped",
|
|
110
|
+
reason:
|
|
111
|
+
"File mixes legacy defineOperation(config) with curried defineOperation<...>()(config); migrate it by hand.",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const declaresContextAlias = source.statements.some(
|
|
116
|
+
(statement) =>
|
|
117
|
+
ts.isTypeAliasDeclaration(statement) &&
|
|
118
|
+
statement.name.text === PROVIDER_CONTEXT_TYPE_NAME,
|
|
119
|
+
);
|
|
120
|
+
const importsContext = sourceText.includes(PROVIDER_CONTEXT_TYPE_NAME);
|
|
121
|
+
const needsImport = !declaresContextAlias && !importsContext;
|
|
122
|
+
|
|
123
|
+
const edits: { start: number; end: number; text: string }[] = [];
|
|
124
|
+
for (const call of legacyCalls) {
|
|
125
|
+
// `defineOperation(` -> `defineOperation<ProviderContext>()(`
|
|
126
|
+
const callee = call.expression;
|
|
127
|
+
edits.push({
|
|
128
|
+
start: callee.getEnd(),
|
|
129
|
+
end: callee.getEnd(),
|
|
130
|
+
text: `<${PROVIDER_CONTEXT_TYPE_NAME}>()`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let importAdded = false;
|
|
135
|
+
if (needsImport) {
|
|
136
|
+
const lastImport = [...source.statements]
|
|
137
|
+
.reverse()
|
|
138
|
+
.find((statement) => ts.isImportDeclaration(statement));
|
|
139
|
+
const insertAt = lastImport ? lastImport.getEnd() : 0;
|
|
140
|
+
const importText = `${lastImport ? "\n" : ""}import type { ${PROVIDER_CONTEXT_TYPE_NAME} } from "${contextImportSpecifier}";${lastImport ? "" : "\n"}`;
|
|
141
|
+
edits.push({ start: insertAt, end: insertAt, text: importText });
|
|
142
|
+
importAdded = true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const ordered = edits.sort((a, b) => b.start - a.start);
|
|
146
|
+
let code = sourceText;
|
|
147
|
+
for (const edit of ordered) {
|
|
148
|
+
code = code.slice(0, edit.start) + edit.text + code.slice(edit.end);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const verified = ts.createSourceFile(
|
|
152
|
+
fileName,
|
|
153
|
+
code,
|
|
154
|
+
ts.ScriptTarget.Latest,
|
|
155
|
+
true,
|
|
156
|
+
ts.ScriptKind.TS,
|
|
157
|
+
);
|
|
158
|
+
const outputError = firstSyntaxError(verified);
|
|
159
|
+
if (outputError !== undefined) {
|
|
160
|
+
return {
|
|
161
|
+
status: "skipped",
|
|
162
|
+
reason: `Transform produced source that does not parse (${outputError}); refusing to emit a partial migration.`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
status: "migrated",
|
|
168
|
+
code,
|
|
169
|
+
rewrites: legacyCalls.length,
|
|
170
|
+
importAdded,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function firstSyntaxError(source: TS.SourceFile): string | undefined {
|
|
175
|
+
const diagnostics = (
|
|
176
|
+
source as TS.SourceFile & { parseDiagnostics?: TS.DiagnosticWithLocation[] }
|
|
177
|
+
).parseDiagnostics;
|
|
178
|
+
if (diagnostics === undefined || diagnostics.length === 0) return undefined;
|
|
179
|
+
const first = diagnostics[0];
|
|
180
|
+
if (first === undefined) return undefined;
|
|
181
|
+
const message = ts.flattenDiagnosticMessageText(first.messageText, " ");
|
|
182
|
+
const { line } = source.getLineAndCharacterOfPosition(first.start);
|
|
183
|
+
return `${message} (line ${line + 1})`;
|
|
184
|
+
}
|