feat(codemod): manifest handling overhaul and fixes from migrating sample dependents (#2393)
This commit is contained in:
parent
6cc7b1cb66
commit
e4e8b22dff
18 changed files with 4888 additions and 448 deletions
|
|
@ -8,6 +8,7 @@ import { Command } from 'commander';
|
|||
|
||||
import { listMigrations } from './migrations/index';
|
||||
import { run } from './runner';
|
||||
import type { PackageJsonChange } from './types';
|
||||
import { DiagnosticLevel } from './types';
|
||||
import { detectFormatter } from './utils/detectFormatter';
|
||||
import { CODEMOD_ERROR_PREFIX, formatDiagnostic } from './utils/diagnostics';
|
||||
|
|
@ -15,10 +16,32 @@ import { CODEMOD_ERROR_PREFIX, formatDiagnostic } from './utils/diagnostics';
|
|||
const require = createRequire(import.meta.url);
|
||||
const { version } = require('../package.json') as { version: string };
|
||||
|
||||
const SOURCE_FILE_RE = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
||||
|
||||
function quoteArg(arg: string): string {
|
||||
return /\s/.test(arg) ? `"${arg}"` : arg;
|
||||
}
|
||||
|
||||
function hasManifestEdits(pc: PackageJsonChange): boolean {
|
||||
return pc.added.length > 0 || pc.removed.length > 0;
|
||||
}
|
||||
|
||||
function printManifestChange(pc: PackageJsonChange): void {
|
||||
console.log(` ${path.relative(process.cwd(), pc.packageJsonPath) || pc.packageJsonPath}`);
|
||||
if (pc.removed.length > 0) {
|
||||
console.log(` Removed: ${pc.removed.join(', ')}`);
|
||||
}
|
||||
if (pc.added.length > 0) {
|
||||
console.log(` Added: ${pc.added.join(', ')}`);
|
||||
}
|
||||
for (const n of pc.notes ?? []) {
|
||||
console.log(` Note: ${n}`);
|
||||
}
|
||||
for (const w of pc.warnings ?? []) {
|
||||
console.log(` Warning: ${w}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The codemod transforms the AST but does not reformat — wrapped schemas and
|
||||
* generated string literals can violate a repo's lint/formatting rules. Point
|
||||
|
|
@ -71,8 +94,15 @@ for (const [name, migration] of listMigrations()) {
|
|||
|
||||
const resolvedDir = path.resolve(targetDir);
|
||||
|
||||
if (!existsSync(resolvedDir) || !statSync(resolvedDir).isDirectory()) {
|
||||
console.error(`\nError: "${resolvedDir}" is not a valid directory.\n`);
|
||||
const targetExists = existsSync(resolvedDir);
|
||||
const targetIsFile = targetExists && statSync(resolvedDir).isFile();
|
||||
if (!targetExists || (!statSync(resolvedDir).isDirectory() && !(targetIsFile && SOURCE_FILE_RE.test(resolvedDir)))) {
|
||||
console.error(`\nError: "${resolvedDir}" is not a directory or a TypeScript/JavaScript source file.\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (targetIsFile && /\.d\.(?:ts|mts|cts)$/.test(resolvedDir)) {
|
||||
console.error(`\nError: "${resolvedDir}" is a declaration file — declaration files are not migration targets.\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
|
@ -96,18 +126,44 @@ for (const [name, migration] of listMigrations()) {
|
|||
});
|
||||
|
||||
if (result.filesChanged === 0 && result.diagnostics.length === 0 && !result.packageJsonChanges) {
|
||||
console.log('No changes needed — code already migrated or no SDK imports found.\n');
|
||||
if (result.mcpImportFiles > 0 && result.v1ImportFiles === 0) {
|
||||
console.log(`No changes needed — ${result.mcpImportFiles} file(s) already import the v2 packages.\n`);
|
||||
} else if (result.v1ImportFiles > 0) {
|
||||
console.log(
|
||||
`No changes applied — ${result.v1ImportFiles} file(s) still import the v1 SDK ` +
|
||||
`(check which transforms were selected).\n`
|
||||
);
|
||||
} else {
|
||||
console.log(`No MCP SDK imports found under ${resolvedDir} — nothing to migrate.\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetIsFile && result.packageJsonChanges) {
|
||||
console.log(
|
||||
'Single-file run: manifest changes are reported for the owning package but not applied — ' +
|
||||
'run the codemod at the package root to apply them.\n'
|
||||
);
|
||||
}
|
||||
|
||||
if (result.filesChanged > 0) {
|
||||
console.log(`Changes: ${result.totalChanges} across ${result.filesChanged} file(s)\n`);
|
||||
}
|
||||
|
||||
if (opts['verbose']) {
|
||||
console.log('Files modified:');
|
||||
for (const fr of result.fileResults) {
|
||||
console.log(` ${fr.filePath} (${fr.changes} change(s))`);
|
||||
const modified = result.fileResults.filter(fr => fr.changes > 0);
|
||||
const diagnosticsOnly = result.fileResults.filter(fr => fr.changes === 0 && fr.diagnostics.length > 0);
|
||||
if (modified.length > 0) {
|
||||
console.log('Files modified:');
|
||||
for (const fr of modified) {
|
||||
console.log(` ${fr.filePath} (${fr.changes} change(s))`);
|
||||
}
|
||||
}
|
||||
if (diagnosticsOnly.length > 0) {
|
||||
console.log('Files with diagnostics only (not modified):');
|
||||
for (const fr of diagnosticsOnly) {
|
||||
console.log(` ${fr.filePath} (${fr.diagnostics.length} diagnostic(s))`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
|
@ -149,18 +205,36 @@ for (const [name, migration] of listMigrations()) {
|
|||
console.log('');
|
||||
}
|
||||
|
||||
if (result.packageJsonChanges) {
|
||||
const pc = result.packageJsonChanges;
|
||||
if (opts['dryRun']) {
|
||||
console.log('package.json changes (dry run — not applied):');
|
||||
} else {
|
||||
console.log('package.json updated:');
|
||||
const manifestChanges = result.packageJsonChanges ?? [];
|
||||
// In single-file mode nothing is written, so even the nearest manifest's
|
||||
// change set is presented as a report.
|
||||
const appliedManifests = manifestChanges.filter(pc => pc.applied && hasManifestEdits(pc) && !targetIsFile);
|
||||
const reportedManifests = manifestChanges.filter(pc => (!pc.applied || targetIsFile) && hasManifestEdits(pc));
|
||||
const warningOnlyManifests = manifestChanges.filter(pc => !hasManifestEdits(pc));
|
||||
if (manifestChanges.length > 0) {
|
||||
if (appliedManifests.length > 0) {
|
||||
if (opts['dryRun']) {
|
||||
console.log('package.json changes (dry run — not applied):');
|
||||
} else {
|
||||
console.log('package.json updated:');
|
||||
}
|
||||
for (const pc of appliedManifests) {
|
||||
printManifestChange(pc);
|
||||
}
|
||||
}
|
||||
if (pc.removed.length > 0) {
|
||||
console.log(` Removed: ${pc.removed.join(', ')}`);
|
||||
if (reportedManifests.length > 0) {
|
||||
console.log(
|
||||
'Manifests that declare @modelcontextprotocol/sdk but are not modified by the codemod — apply the listed changes yourself:'
|
||||
);
|
||||
for (const pc of reportedManifests) {
|
||||
printManifestChange(pc);
|
||||
}
|
||||
}
|
||||
if (pc.added.length > 0) {
|
||||
console.log(` Added: ${pc.added.join(', ')}`);
|
||||
if (warningOnlyManifests.length > 0) {
|
||||
console.log('Manifest warnings:');
|
||||
for (const pc of warningOnlyManifests) {
|
||||
printManifestChange(pc);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
|
@ -177,7 +251,11 @@ for (const [name, migration] of listMigrations()) {
|
|||
} else {
|
||||
const changedFiles = result.fileResults.filter(fr => fr.changes > 0).map(fr => fr.filePath);
|
||||
printFormatGuidance(resolvedDir, changedFiles);
|
||||
if (result.packageJsonChanges) {
|
||||
if (reportedManifests.length > 0) {
|
||||
console.log(
|
||||
'Apply the manifest changes listed above, then run your package manager to install the new packages.\n'
|
||||
);
|
||||
} else if (appliedManifests.length > 0) {
|
||||
console.log('Run your package manager to install the new packages.\n');
|
||||
}
|
||||
console.log('Migration complete. Review the changes and run your build/tests.\n');
|
||||
|
|
|
|||
|
|
@ -16,7 +16,13 @@ export interface ImportMapping {
|
|||
*/
|
||||
schemaSymbolTarget?: string;
|
||||
removalMessage?: string;
|
||||
/** No entries currently set this; scaffolding for when a v1 symbol has no v2 equivalent yet. */
|
||||
/**
|
||||
* Symbols from this module that have no v2 export anywhere. They are dropped from
|
||||
* the rewritten import and the call site gets an action-required marker carrying
|
||||
* the message, instead of an import of a member the target package does not have.
|
||||
*/
|
||||
removedSymbols?: Record<string, string>;
|
||||
/** Marks a module-level removal as a known v2 gap (downgrades the removal diagnostic to the v2-gap category). For per-symbol removals use `removedSymbols`. */
|
||||
isV2Gap?: boolean;
|
||||
/** Emitted as an info diagnostic after a successful move, suggesting eventual migration to v2 equivalents. */
|
||||
migrationHint?: string;
|
||||
|
|
@ -27,6 +33,18 @@ export interface ImportMapping {
|
|||
subpathSuffix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource-server auth helpers whose maintained v2 home is `@modelcontextprotocol/express`;
|
||||
* the server-legacy/auth copy they route to by default is a frozen v1 snapshot, so import
|
||||
* and re-export sites get a marker prompting a deliberate re-point.
|
||||
*/
|
||||
export const RS_ONLY_AUTH_SYMBOLS: ReadonlySet<string> = new Set([
|
||||
'requireBearerAuth',
|
||||
'mcpAuthMetadataRouter',
|
||||
'getOAuthProtectedResourceMetadataUrl',
|
||||
'OAuthTokenVerifier'
|
||||
]);
|
||||
|
||||
export const IMPORT_MAP: Record<string, ImportMapping> = {
|
||||
'@modelcontextprotocol/sdk/client/index.js': {
|
||||
target: '@modelcontextprotocol/client',
|
||||
|
|
@ -114,9 +132,11 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
|
|||
},
|
||||
|
||||
'@modelcontextprotocol/sdk/server/auth/types.js': {
|
||||
target: '@modelcontextprotocol/server-legacy/auth',
|
||||
status: 'moved',
|
||||
migrationHint: 'Legacy auth types. AuthInfo is also re-exported by @modelcontextprotocol/server.'
|
||||
// The module's only export (AuthInfo) is re-exported by both leaf packages, so
|
||||
// routing by context avoids pulling the deprecated legacy package into projects
|
||||
// that use no authorization-server helpers.
|
||||
target: 'RESOLVE_BY_CONTEXT',
|
||||
status: 'moved'
|
||||
},
|
||||
'@modelcontextprotocol/sdk/server/auth/provider.js': {
|
||||
target: '@modelcontextprotocol/server-legacy/auth',
|
||||
|
|
@ -149,7 +169,17 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
|
|||
},
|
||||
'@modelcontextprotocol/sdk/shared/protocol.js': {
|
||||
target: 'RESOLVE_BY_CONTEXT',
|
||||
status: 'moved'
|
||||
status: 'moved',
|
||||
removedSymbols: {
|
||||
Protocol:
|
||||
'The Protocol base class is not exported by the v2 packages. To observe or handle inbound requests ' +
|
||||
'that have no registered handler, use client.fallbackRequestHandler / server.fallbackRequestHandler; ' +
|
||||
'build custom behavior on Client or Server instead of subclassing Protocol. ' +
|
||||
'See the migration guide: Behavioral changes > Client connection & dispatch.',
|
||||
mergeCapabilities:
|
||||
'mergeCapabilities() is not exported by the v2 packages. Pass the complete capabilities object to the ' +
|
||||
'Client/Server constructor, or merge capability objects with a plain object spread.'
|
||||
}
|
||||
},
|
||||
'@modelcontextprotocol/sdk/shared/transport.js': {
|
||||
target: 'RESOLVE_BY_CONTEXT',
|
||||
|
|
|
|||
|
|
@ -37,3 +37,13 @@ export function symbolTargetOverride(name: string, mapping: ImportMapping): stri
|
|||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guidance for a symbol from this module that has no v2 export anywhere (it must be
|
||||
* dropped/flagged rather than routed), or `undefined` when the symbol routes normally.
|
||||
* Shared by the static-import, re-export, mock, and dynamic-import rewrites so all
|
||||
* four treat such symbols the same way.
|
||||
*/
|
||||
export function removedSymbolGuidance(name: string, mapping: ImportMapping): string | undefined {
|
||||
return mapping.removedSymbols?.[name];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
import type { CallExpression, Expression, PropertyAccessExpression, SourceFile } from 'ts-morph';
|
||||
import { SyntaxKind } from 'ts-morph';
|
||||
|
||||
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types';
|
||||
import { actionRequired, info, warning } from '../../../utils/diagnostics';
|
||||
import { isAnyMcpSpecifier } from '../../../utils/importUtils';
|
||||
|
||||
/**
|
||||
* v2 resolves completion metadata on the schema found AFTER unwrapping an outer
|
||||
* optional wrapper, so the v1 idiom `completable(z.string().optional(), cb)` registers
|
||||
* the metadata one level too deep: completions come back empty and, when no argument
|
||||
* carries metadata in the v2 position, the server does not advertise the completions
|
||||
* capability. The working v2 spelling inverts the nesting:
|
||||
* `completable(z.string(), cb).optional()`.
|
||||
*
|
||||
* Only `optional` wrappers matter — the v2 lookup unwraps exactly one outer optional
|
||||
* layer and nothing else, so `.default()` / `.nullable()` / `.catch()` arguments keep
|
||||
* working as-is and are left alone. `.nullish()` is the one non-`optional` tail whose
|
||||
* outer layer IS an optional wrapper, so it gets a marker with the concrete rewrite.
|
||||
*/
|
||||
|
||||
type ArgVerdict =
|
||||
| { kind: 'invert'; innerSchemaText: string }
|
||||
| { kind: 'nullish' }
|
||||
| { kind: 'buried-optional' }
|
||||
| { kind: 'plain-call' }
|
||||
| { kind: 'opaque' };
|
||||
|
||||
function unparenthesize(expr: Expression): Expression {
|
||||
let current = expr;
|
||||
while (true) {
|
||||
const paren = current.asKind(SyntaxKind.ParenthesizedExpression);
|
||||
if (!paren) return current;
|
||||
current = paren.getExpression();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the first argument of a completable() call.
|
||||
*
|
||||
* - `invert`: outermost wrapper is `optional` and the rewrite is mechanical.
|
||||
* - `nullish`: outermost wrapper is `.nullish()` (its outer layer is an optional
|
||||
* wrapper) — marker with the concrete manual rewrite.
|
||||
* - `buried-optional`: an `optional`/`nullish` link sits deeper in the method chain
|
||||
* (e.g. `.optional().describe(...)`) — marker; the safe spot for the wrapper
|
||||
* depends on the chain.
|
||||
* - `plain-call`: a call chain with no optional wrapper anywhere — leave alone.
|
||||
* - `opaque`: not a call (identifier, property access, …) — the schema may be
|
||||
* optional-wrapped where it is defined; one nudge per file.
|
||||
*/
|
||||
function classifyArg(arg: Expression): ArgVerdict {
|
||||
const outer = unparenthesize(arg).asKind(SyntaxKind.CallExpression);
|
||||
if (!outer) return { kind: 'opaque' };
|
||||
|
||||
const callee = outer.getExpression().asKind(SyntaxKind.PropertyAccessExpression);
|
||||
if (callee) {
|
||||
const name = callee.getName();
|
||||
const args = outer.getArguments();
|
||||
if (name === 'optional') {
|
||||
if (args.length === 0) return { kind: 'invert', innerSchemaText: callee.getExpression().getText() };
|
||||
if (args.length === 1) return { kind: 'invert', innerSchemaText: args[0]!.getText() };
|
||||
return { kind: 'buried-optional' };
|
||||
}
|
||||
if (name === 'nullish') return { kind: 'nullish' };
|
||||
}
|
||||
|
||||
// Walk the rest of the chain (and factory-call arguments) looking for a buried
|
||||
// optional/nullish link, e.g. `z.string().optional().describe('x')` or
|
||||
// `z.optional(z.string()).meta({...})`.
|
||||
let current: Expression | undefined = unparenthesize(arg);
|
||||
while (current) {
|
||||
const call: CallExpression | undefined = current.asKind(SyntaxKind.CallExpression);
|
||||
if (!call) break;
|
||||
const pa: PropertyAccessExpression | undefined = call.getExpression().asKind(SyntaxKind.PropertyAccessExpression);
|
||||
if (!pa) break;
|
||||
const name = pa.getName();
|
||||
if (name === 'optional' || name === 'nullish') return { kind: 'buried-optional' };
|
||||
current = pa.getExpression();
|
||||
}
|
||||
return { kind: 'plain-call' };
|
||||
}
|
||||
|
||||
export const completableNestingTransform: Transform = {
|
||||
name: 'Completable optional-nesting inversion',
|
||||
id: 'completable-nesting',
|
||||
apply(sourceFile: SourceFile, _context: TransformContext): TransformResult {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
let changesCount = 0;
|
||||
const filePath = sourceFile.getFilePath();
|
||||
|
||||
// Local bindings of `completable` from an MCP package (named import, possibly
|
||||
// aliased) and MCP namespace bindings (`ns.completable(...)`). importPaths runs
|
||||
// earlier, so the specifier is normally the v2 package already; the v1 specifier
|
||||
// is accepted too so the transform also works in isolation.
|
||||
const localNames = new Set<string>();
|
||||
const namespaceNames = new Set<string>();
|
||||
for (const imp of sourceFile.getImportDeclarations()) {
|
||||
if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue;
|
||||
for (const ni of imp.getNamedImports()) {
|
||||
if (ni.getName() === 'completable') localNames.add(ni.getAliasNode()?.getText() ?? 'completable');
|
||||
}
|
||||
const ns = imp.getNamespaceImport();
|
||||
if (ns) namespaceNames.add(ns.getText());
|
||||
}
|
||||
if (localNames.size === 0 && namespaceNames.size === 0) {
|
||||
return { changesCount: 0, diagnostics: [] };
|
||||
}
|
||||
|
||||
const completableCalls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).filter((call: CallExpression) => {
|
||||
const expr = call.getExpression();
|
||||
if (expr.getKind() === SyntaxKind.Identifier) return localNames.has(expr.getText());
|
||||
const pa = expr.asKind(SyntaxKind.PropertyAccessExpression);
|
||||
return pa !== undefined && pa.getName() === 'completable' && namespaceNames.has(pa.getExpression().getText());
|
||||
});
|
||||
|
||||
let opaqueArgNoted = false;
|
||||
// Reverse document order so each replacement leaves the still-unprocessed
|
||||
// (earlier) nodes' positions intact.
|
||||
for (const call of completableCalls.toReversed()) {
|
||||
const args = call.getArguments();
|
||||
if (args.length === 0) continue;
|
||||
const verdict = classifyArg(args[0]! as Expression);
|
||||
switch (verdict.kind) {
|
||||
case 'invert': {
|
||||
const line = call.getStartLineNumber();
|
||||
const calleeText = call.getExpression().getText();
|
||||
const restArgs = args.slice(1).map(a => a.getText());
|
||||
call.replaceWithText(`${calleeText}(${[verdict.innerSchemaText, ...restArgs].join(', ')}).optional()`);
|
||||
changesCount++;
|
||||
diagnostics.push(
|
||||
info(
|
||||
filePath,
|
||||
line,
|
||||
`completable() with an optional schema argument: moved .optional() outside the call — v2 resolves ` +
|
||||
`completion metadata after unwrapping an outer optional wrapper, so completable(schema, cb).optional() ` +
|
||||
`keeps both optionality and completions.`
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'nullish': {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
filePath,
|
||||
call,
|
||||
`completable() first argument ends in .nullish(), whose outer layer is an optional wrapper — v2 resolves ` +
|
||||
`completion metadata after unwrapping it, so completions would come back empty. Rewrite as ` +
|
||||
`completable(schema.nullable(), cb).optional().`
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'buried-optional': {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
filePath,
|
||||
call,
|
||||
`completable() first argument contains an optional wrapper inside its method chain — v2 resolves ` +
|
||||
`completion metadata after unwrapping an outer optional wrapper, so the v1 nesting returns empty ` +
|
||||
`completion lists. Move the optional wrapping to the completable(...) result ` +
|
||||
`(completable(schema, cb).optional()) and rebuild the rest of the chain around it.`
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'opaque': {
|
||||
// The schema may be optional-wrapped where it is defined. One nudge per
|
||||
// file, no marker (plain schemas via identifiers are common and fine).
|
||||
if (!opaqueArgNoted) {
|
||||
opaqueArgNoted = true;
|
||||
diagnostics.push({
|
||||
...warning(
|
||||
filePath,
|
||||
call.getStartLineNumber(),
|
||||
`completable() receives a schema by reference — verify the referenced schema is not wrapped in ` +
|
||||
`.optional() inside the call. v2 resolves completion metadata after unwrapping an outer optional ` +
|
||||
`wrapper, so optionality belongs on the completable(...) result: completable(schema, cb).optional().`
|
||||
),
|
||||
advisoryOnly: true
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'plain-call': {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { changesCount, diagnostics };
|
||||
}
|
||||
};
|
||||
|
|
@ -7,6 +7,8 @@ import { actionRequired, info } from '../../../utils/diagnostics';
|
|||
import { hasMcpImports } from '../../../utils/importUtils';
|
||||
import { CONTEXT_PROPERTY_MAP, CTX_PARAM_NAME, EXTRA_PARAM_NAME } from '../mappings/contextPropertyMap';
|
||||
|
||||
const CONTEXT_LIKE_KEYS = new Set(CONTEXT_PROPERTY_MAP.map(mapping => mapping.from.slice(1)));
|
||||
|
||||
const HANDLER_METHODS = new Set(['setRequestHandler', 'setNotificationHandler']);
|
||||
|
||||
const REGISTER_METHODS = new Set(['registerTool', 'registerPrompt', 'registerResource', 'tool', 'prompt', 'resource']);
|
||||
|
|
@ -29,9 +31,25 @@ function processCallback(
|
|||
const params = callbackNode.getParameters();
|
||||
if (params.length < 2) return -1;
|
||||
|
||||
const extraParam = params[1]!;
|
||||
// The context argument is always trailing — registerTool/registerPrompt callbacks
|
||||
// are (args, extra) but a registerResource template read callback is
|
||||
// (uri, variables, extra) — so find the parameter named `extra` among the
|
||||
// non-first positions instead of assuming index 1. A destructured trailing
|
||||
// parameter is selected too, so it reaches the destructuring diagnostic below.
|
||||
const trailing = params.at(-1)!;
|
||||
const extraParam =
|
||||
params.slice(1).find(par => !Node.isObjectBindingPattern(par.getNameNode()) && par.getName() === EXTRA_PARAM_NAME) ??
|
||||
(Node.isObjectBindingPattern(trailing.getNameNode()) ? trailing : undefined);
|
||||
if (extraParam === undefined) return -1;
|
||||
const paramNameNode = extraParam.getNameNode();
|
||||
if (Node.isObjectBindingPattern(paramNameNode)) {
|
||||
// A destructured trailing parameter is only the context when its keys look
|
||||
// like context members — a registerResource template read callback's
|
||||
// `(uri, { owner, repo })` destructures the URI variables, not the context.
|
||||
// Match the PROPERTY names (a renamed binding `{ signal: abort }` still
|
||||
// destructures the context's `signal`).
|
||||
const propertyNames = paramNameNode.getElements().map(el => el.getPropertyNameNode()?.getText() ?? el.getName());
|
||||
if (!propertyNames.some(name => CONTEXT_LIKE_KEYS.has(name))) return -1;
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
|
|
@ -112,7 +130,9 @@ function processCallback(
|
|||
const propName = '.' + parent.getName();
|
||||
const mapping = sortedMappings.find(m => m.from === propName);
|
||||
if (mapping) {
|
||||
replacements.push({ node: parent, newText: CTX_PARAM_NAME + mapping.to });
|
||||
// Preserve optional chaining: `extra?.signal` stays defensive.
|
||||
const joiner = parent.hasQuestionDotToken() ? '?' + mapping.to : mapping.to;
|
||||
replacements.push({ node: parent, newText: CTX_PARAM_NAME + joiner });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
|
@ -155,6 +175,33 @@ function processCallback(
|
|||
);
|
||||
}
|
||||
|
||||
// A context object forwarded wholesale to a helper carries the v1 property shape
|
||||
// into code this transform never sees — note each callee once.
|
||||
const forwardedBody = callbackNode.getBody();
|
||||
if (forwardedBody) {
|
||||
const notedCallees = new Set<string>();
|
||||
forwardedBody.forEachDescendant(node => {
|
||||
if (!Node.isCallExpression(node)) return;
|
||||
const hasBareCtx = node
|
||||
.getArguments()
|
||||
.some(arg => Node.isIdentifier(arg) && arg.getText() === CTX_PARAM_NAME && !isKeyPositionIdentifier(arg));
|
||||
if (!hasBareCtx) return;
|
||||
const calleeText = node.getExpression().getText();
|
||||
if (notedCallees.has(calleeText)) return;
|
||||
notedCallees.add(calleeText);
|
||||
diagnostics.push({
|
||||
...actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
node,
|
||||
`The context object is forwarded to ${calleeText}(…) — its property shape changed in v2 ` +
|
||||
`(e.g. extra.signal is now ctx.mcpReq.signal, extra.sendRequest is ctx.mcpReq.send). Update the ` +
|
||||
`helper's parameter type and property accesses.`
|
||||
),
|
||||
advisoryOnly: true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Warn on destructuring of ctx in body (after text replacement)
|
||||
const freshBody = callbackNode.getBody();
|
||||
if (freshBody) {
|
||||
|
|
@ -254,6 +301,207 @@ export const contextTypesTransform: Transform = {
|
|||
}
|
||||
}
|
||||
|
||||
changesCount += processFallbackHandlerAssignments(sourceFile, diagnostics);
|
||||
changesCount += remapAnnotatedContextParams(sourceFile, diagnostics);
|
||||
|
||||
return { changesCount, diagnostics };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* `server.fallbackRequestHandler = async (request, extra) => { … }` — the assigned
|
||||
* function is a handler callback in everything but registration shape.
|
||||
*/
|
||||
function processFallbackHandlerAssignments(sourceFile: SourceFile, diagnostics: Diagnostic[]): number {
|
||||
let changes = 0;
|
||||
for (const bin of sourceFile.getDescendantsOfKind(SyntaxKind.BinaryExpression)) {
|
||||
if (bin.wasForgotten()) continue;
|
||||
if (bin.getOperatorToken().getKind() !== SyntaxKind.EqualsToken) continue;
|
||||
const left = bin.getLeft();
|
||||
if (!Node.isPropertyAccessExpression(left)) continue;
|
||||
const name = left.getName();
|
||||
if (name !== 'fallbackRequestHandler' && name !== 'fallbackNotificationHandler') continue;
|
||||
const rhs = bin.getRight();
|
||||
if (!Node.isArrowFunction(rhs) && !Node.isFunctionExpression(rhs)) continue;
|
||||
const result = processCallback(rhs, sourceFile, diagnostics, name, bin.getStartLineNumber());
|
||||
if (result > 0) changes += result;
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
const CONTEXT_TYPE_NAMES = new Set(['RequestHandlerExtra', 'ServerContext', 'ClientContext']);
|
||||
/**
|
||||
* Split a type's text on top-level `|` only (angle brackets tracked). Returns
|
||||
* undefined for shapes that can never be a bare context reference: intersections,
|
||||
* object literals, and unbalanced text.
|
||||
*/
|
||||
function splitTopLevelUnion(typeText: string): string[] | undefined {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let current = '';
|
||||
for (const ch of typeText) {
|
||||
if (ch === '<') depth++;
|
||||
else if (ch === '>') depth--;
|
||||
if (depth === 0 && (ch === '&' || ch === '{')) return undefined;
|
||||
if (ch === '|' && depth === 0) {
|
||||
parts.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
parts.push(current.trim());
|
||||
return depth === 0 ? parts.filter(part => part !== '') : undefined;
|
||||
}
|
||||
|
||||
const CONTEXT_TYPE_RE = /\b(?:RequestHandlerExtra|ServerContext|ClientContext)\b/;
|
||||
|
||||
/** True when `name` is rebound by a function parameter between `node` and `scopeRoot`. */
|
||||
function isRebornWithin(node: Node, scopeRoot: Node, name: string): boolean {
|
||||
let current = node.getParent();
|
||||
while (current !== undefined && current !== scopeRoot) {
|
||||
if (
|
||||
(Node.isArrowFunction(current) ||
|
||||
Node.isFunctionExpression(current) ||
|
||||
Node.isFunctionDeclaration(current) ||
|
||||
Node.isMethodDeclaration(current)) &&
|
||||
current.getParameters().some(param => param.getName() === name)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Functions and methods whose parameter is ANNOTATED as a context type (directly,
|
||||
* `| undefined`-widened, or via same-file aliases resolved to fixpoint) carry the v1
|
||||
* property shape in their bodies even when the call-site scan never reaches them —
|
||||
* private handler methods, alias-typed callbacks. The accesses remap in place; the
|
||||
* parameter keeps its name. Annotations that mention a context type in a shape the
|
||||
* remap cannot prove (unions with other types, containers) get an advisory instead
|
||||
* of silence.
|
||||
*/
|
||||
function remapAnnotatedContextParams(sourceFile: SourceFile, diagnostics: Diagnostic[]): number {
|
||||
// An alias only joins the set when its right-hand side IS a context type (a bare,
|
||||
// possibly `| undefined`-widened reference to one, or to another accepted alias).
|
||||
// A wrapper that merely MENTIONS a context type (`{ mcp: ServerContext; signal: … }`)
|
||||
// has its own property shape — remapping accesses on it would corrupt them — so it
|
||||
// falls through to the advisory path below.
|
||||
const aliasNames = new Set<string>();
|
||||
const isDirectContextReference = (typeText: string): boolean => {
|
||||
const parts = splitTopLevelUnion(typeText);
|
||||
if (parts === undefined) return false;
|
||||
const names = parts.filter(part => part !== 'undefined' && part !== 'null');
|
||||
if (names.length !== 1) return false;
|
||||
const base = names[0]!.replace(/<[\s\S]*$/, '').trim();
|
||||
return /^[A-Za-z_$][\w$]*$/.test(base) && (CONTEXT_TYPE_NAMES.has(base) || aliasNames.has(base));
|
||||
};
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const alias of sourceFile.getTypeAliases()) {
|
||||
if (aliasNames.has(alias.getName())) continue;
|
||||
if (isDirectContextReference(alias.getTypeNode()?.getText() ?? '')) {
|
||||
aliasNames.add(alias.getName());
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aliases that MENTION a context type without being one (wrappers, intersections,
|
||||
// containers) cannot be remapped, but parameters typed with them still carry v1
|
||||
// context members somewhere inside — they get the advisory, never the rewrite.
|
||||
const aliasMentions = new Set<string>(aliasNames);
|
||||
grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const alias of sourceFile.getTypeAliases()) {
|
||||
if (aliasMentions.has(alias.getName())) continue;
|
||||
const text = alias.getTypeNode()?.getText() ?? '';
|
||||
const mentions =
|
||||
CONTEXT_TYPE_RE.test(text) || [...aliasMentions].some(known => new RegExp(String.raw`\b${known}\b`).test(text));
|
||||
if (mentions) {
|
||||
aliasMentions.add(alias.getName());
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const matchesContextType = (annotation: string): boolean => {
|
||||
// Strip generic arguments and nullable widening; the base must BE the type.
|
||||
const base = annotation
|
||||
.replace(/<[\s\S]*$/, '')
|
||||
.replaceAll(/\s*\|\s*(undefined|null)\s*$/g, '')
|
||||
.trim();
|
||||
return CONTEXT_TYPE_NAMES.has(base) || aliasNames.has(base);
|
||||
};
|
||||
const mentionsContextType = (annotation: string): boolean =>
|
||||
CONTEXT_TYPE_RE.test(annotation) || [...aliasMentions].some(known => new RegExp(String.raw`\b${known}\b`).test(annotation));
|
||||
|
||||
const sortedMappings = [...CONTEXT_PROPERTY_MAP]
|
||||
.filter(mapping => mapping.from !== mapping.to)
|
||||
.toSorted((a, b) => b.from.length - a.from.length);
|
||||
|
||||
let changes = 0;
|
||||
sourceFile.forEachDescendant(node => {
|
||||
if (
|
||||
!Node.isArrowFunction(node) &&
|
||||
!Node.isFunctionExpression(node) &&
|
||||
!Node.isFunctionDeclaration(node) &&
|
||||
!Node.isMethodDeclaration(node)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for (const param of node.getParameters()) {
|
||||
const annotation = param.getTypeNode()?.getText();
|
||||
if (annotation === undefined) continue;
|
||||
const nameNode = param.getNameNode();
|
||||
if (Node.isObjectBindingPattern(nameNode)) continue; // destructured: handled by the marker path
|
||||
if (!matchesContextType(annotation)) {
|
||||
if (mentionsContextType(annotation)) {
|
||||
diagnostics.push({
|
||||
...actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
param,
|
||||
`Parameter annotation '${annotation}' mentions a context type in a shape the codemod cannot ` +
|
||||
`remap — review the body's property accesses (e.g. .signal is now .mcpReq.signal).`
|
||||
),
|
||||
advisoryOnly: true
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const paramName = param.getName();
|
||||
const body = node.getBody();
|
||||
if (body === undefined) continue;
|
||||
|
||||
const replacements: { target: import('ts-morph').PropertyAccessExpression; newText: string }[] = [];
|
||||
body.forEachDescendant(descendant => {
|
||||
if (!Node.isPropertyAccessExpression(descendant)) return;
|
||||
const expr = descendant.getExpression();
|
||||
if (!Node.isIdentifier(expr) || expr.getText() !== paramName) return;
|
||||
if (isRebornWithin(descendant, node, paramName)) return;
|
||||
const mapping = sortedMappings.find(entry => entry.from === '.' + descendant.getName());
|
||||
if (mapping === undefined) return;
|
||||
// Preserve optional chaining: `extra?.signal` stays defensive.
|
||||
const joiner = descendant.hasQuestionDotToken() ? '?' + mapping.to : mapping.to;
|
||||
replacements.push({ target: descendant, newText: paramName + joiner });
|
||||
});
|
||||
if (replacements.length === 0) continue;
|
||||
for (const { target, newText } of replacements.toSorted((a, b) => b.target.getStart() - a.target.getStart())) {
|
||||
target.replaceWithText(newText);
|
||||
changes++;
|
||||
}
|
||||
diagnostics.push(
|
||||
info(
|
||||
sourceFile.getFilePath(),
|
||||
param.getStartLineNumber(),
|
||||
`Remapped v1 context property accesses on '${paramName}' (annotated ${annotation}) to the v2 shape.`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
return changes;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { SourceFile } from 'ts-morph';
|
||||
import { Node, SyntaxKind } from 'ts-morph';
|
||||
import { Node, SyntaxKind, VariableDeclarationKind } from 'ts-morph';
|
||||
|
||||
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types';
|
||||
import { actionRequired } from '../../../utils/diagnostics';
|
||||
|
|
@ -29,6 +29,35 @@ export const handlerRegistrationTransform: Transform = {
|
|||
if (!Node.isPropertyAccessExpression(expr)) continue;
|
||||
|
||||
const methodName = expr.getName();
|
||||
if (methodName === 'removeRequestHandler' || methodName === 'removeNotificationHandler') {
|
||||
const arg0 = call.getArguments()[0];
|
||||
if (
|
||||
arg0 === undefined ||
|
||||
Node.isStringLiteral(arg0) ||
|
||||
Node.isNoSubstitutionTemplateLiteral(arg0) ||
|
||||
Node.isTemplateExpression(arg0)
|
||||
)
|
||||
continue;
|
||||
const shapeMatch = arg0.getText().match(/^([A-Za-z_$][\w$]*)\.shape\.method\.value$/);
|
||||
const shapeBase = shapeMatch ? (resolveOriginalImportName(sourceFile, shapeMatch[1]!) ?? shapeMatch[1]!) : undefined;
|
||||
const shapeMethod = shapeBase === undefined ? undefined : ALL_SCHEMA_TO_METHOD[shapeBase];
|
||||
if (shapeMatch && shapeMethod !== undefined && isImportedFromMcp(sourceFile, shapeMatch[1]!)) {
|
||||
arg0.replaceWithText(`'${shapeMethod}'`);
|
||||
changesCount++;
|
||||
removeUnusedImport(sourceFile, shapeMatch[1]!, true);
|
||||
} else {
|
||||
diagnostics.push({
|
||||
...actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
call,
|
||||
`${methodName} takes the method string in v2 — replace the schema-derived argument with the ` +
|
||||
`literal method name (no change needed if this already passes a string).`
|
||||
),
|
||||
advisoryOnly: true
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (methodName !== 'setRequestHandler' && methodName !== 'setNotificationHandler') {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -37,7 +66,35 @@ export const handlerRegistrationTransform: Transform = {
|
|||
if (args.length < 2) continue;
|
||||
|
||||
const firstArg = args[0]!;
|
||||
if (!Node.isIdentifier(firstArg)) continue;
|
||||
if (!Node.isIdentifier(firstArg)) {
|
||||
// A string first argument is already the v2 form; any other expression
|
||||
// (inline schema object, property access, `X.shape.method.value`) is a
|
||||
// v1 registration the rename path cannot resolve — mark it instead of
|
||||
// skipping silently.
|
||||
if (Node.isStringLiteral(firstArg) || Node.isNoSubstitutionTemplateLiteral(firstArg) || Node.isTemplateExpression(firstArg))
|
||||
continue;
|
||||
const shapeMatch = firstArg.getText().match(/^([A-Za-z_$][\w$]*)\.shape\.method\.value$/);
|
||||
const shapeBase = shapeMatch ? (resolveOriginalImportName(sourceFile, shapeMatch[1]!) ?? shapeMatch[1]!) : undefined;
|
||||
const shapeMethod = shapeBase === undefined ? undefined : ALL_SCHEMA_TO_METHOD[shapeBase];
|
||||
if (shapeMatch && shapeMethod !== undefined && isImportedFromMcp(sourceFile, shapeMatch[1]!)) {
|
||||
firstArg.replaceWithText(`'${shapeMethod}'`);
|
||||
changesCount++;
|
||||
removeUnusedImport(sourceFile, shapeMatch[1]!, true);
|
||||
} else {
|
||||
diagnostics.push({
|
||||
...actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
call,
|
||||
`${methodName} with a non-string first argument: v2 takes a method string — use the typed ` +
|
||||
`two-argument spec form, or the three-argument custom form ` +
|
||||
`(${methodName}('method/name', { params, result? }, handler)). No change is needed if the ` +
|
||||
`argument already holds a method string.`
|
||||
),
|
||||
advisoryOnly: true
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const schemaName = firstArg.getText();
|
||||
const originalName = resolveOriginalImportName(sourceFile, schemaName) ?? schemaName;
|
||||
|
|
@ -56,7 +113,44 @@ export const handlerRegistrationTransform: Transform = {
|
|||
continue;
|
||||
}
|
||||
|
||||
const methodString = ALL_SCHEMA_TO_METHOD[originalName];
|
||||
let methodString = ALL_SCHEMA_TO_METHOD[originalName];
|
||||
if (methodString === undefined) {
|
||||
// `const S = ListToolsRequestSchema; setRequestHandler(S, …)` — resolve
|
||||
// one same-file variable hop before declaring the schema custom. Only an
|
||||
// unambiguous binding qualifies: exactly one const declaration of the
|
||||
// name anywhere in the file, so shadowed or reassigned locals keep the
|
||||
// custom-handler marker instead of being silently rewritten.
|
||||
const declsOfName = sourceFile
|
||||
.getDescendantsOfKind(SyntaxKind.VariableDeclaration)
|
||||
.filter(decl => decl.getName() === schemaName);
|
||||
const localDecl =
|
||||
declsOfName.length === 1 &&
|
||||
declsOfName[0]!.getVariableStatement()?.getDeclarationKind() === VariableDeclarationKind.Const
|
||||
? declsOfName[0]
|
||||
: undefined;
|
||||
const localInit = localDecl?.getInitializer();
|
||||
if (localInit !== undefined && Node.isIdentifier(localInit)) {
|
||||
const initName = localInit.getText();
|
||||
const initOriginal = resolveOriginalImportName(sourceFile, initName) ?? initName;
|
||||
if (REMOVED_TASK_SCHEMAS.has(initOriginal) && isImportedFromMcp(sourceFile, initName)) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
call,
|
||||
`Task handler registration: ${methodName}(${schemaName}, ...). ` +
|
||||
`The experimental tasks feature was removed in v2 (SEP-2663); the tasks/* method strings ` +
|
||||
`are not part of the typed RequestMethod surface. Remove this registration. ` +
|
||||
`See docs/migration/upgrade-to-v2.md#experimental-tasks-interception-removed.`
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const viaLocal = ALL_SCHEMA_TO_METHOD[initOriginal];
|
||||
if (viaLocal !== undefined && isImportedFromMcp(sourceFile, initName)) {
|
||||
methodString = viaLocal;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!methodString) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
|
|
@ -70,7 +164,8 @@ export const handlerRegistrationTransform: Transform = {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (!isImportedFromMcp(sourceFile, schemaName)) continue;
|
||||
const viaLocalVariable = ALL_SCHEMA_TO_METHOD[originalName] === undefined;
|
||||
if (!viaLocalVariable && !isImportedFromMcp(sourceFile, schemaName)) continue;
|
||||
|
||||
firstArg.replaceWithText(`'${methodString}'`);
|
||||
changesCount++;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import type { SourceFile } from 'ts-morph';
|
||||
import type { Node, SourceFile } from 'ts-morph';
|
||||
import { SyntaxKind } from 'ts-morph';
|
||||
|
||||
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types';
|
||||
import { renameAllReferences } from '../../../utils/astUtils';
|
||||
import { findFirstIdentifierOutsideImports, renameAllReferences } from '../../../utils/astUtils';
|
||||
import { actionRequired, info, v2Gap, warning } from '../../../utils/diagnostics';
|
||||
import type { NamedImportSpec } from '../../../utils/importUtils';
|
||||
import { addOrMergeImport, getSdkExports, getSdkImports, isTypeOnlyImport } from '../../../utils/importUtils';
|
||||
import { resolveTypesPackage } from '../../../utils/projectAnalyzer';
|
||||
import { AUTH_SCHEMA_NAMES_NO_V2_PUBLIC_EXPORT } from '../mappings/authSchemaNames';
|
||||
import { isAuthImport, lookupImportMapping } from '../mappings/importMap';
|
||||
import { isAuthImport, lookupImportMapping, RS_ONLY_AUTH_SYMBOLS } from '../mappings/importMap';
|
||||
import { isSharedSchemaConst, resolveRenamedName, symbolTargetOverride } from '../mappings/schemaRouting';
|
||||
import { SIMPLE_RENAMES } from '../mappings/symbolMap';
|
||||
|
||||
|
|
@ -102,6 +102,48 @@ export const importPathsTransform: Transform = {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Resource-server helpers have a maintained v2 home in @modelcontextprotocol/express;
|
||||
// the server-legacy/auth copy they are routed to is a frozen v1 snapshot. The re-point
|
||||
// is a judgment call (the express middleware answers verifier throws of the v1 error
|
||||
// classes with a generic 500, so verifiers must move to the v2 OAuthError with it) —
|
||||
// mark the import so the call sites get looked at instead of quietly staying on legacy.
|
||||
if (mapping.target === '@modelcontextprotocol/server-legacy/auth') {
|
||||
const matched = namedImports.filter(n => RS_ONLY_AUTH_SYMBOLS.has(n.getName()));
|
||||
const valueMatchedSpecifiers = matched.filter(n => !typeOnly && !n.isTypeOnly());
|
||||
const valueMatched = valueMatchedSpecifiers.map(n => n.getName());
|
||||
const typeMatched = matched.filter(n => typeOnly || n.isTypeOnly()).map(n => n.getName());
|
||||
if (valueMatched.length > 0) {
|
||||
// The marker must outlive this pass: the import declaration it
|
||||
// describes is removed when the path is rerouted, so anchor at the
|
||||
// first use of one of the helpers (the import line otherwise).
|
||||
const usageAnchor =
|
||||
valueMatchedSpecifiers
|
||||
.map(n => findFirstIdentifierOutsideImports(sourceFile, n.getAliasNode()?.getText() ?? n.getName()))
|
||||
.find(node => node !== undefined) ?? imp;
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
filePath,
|
||||
usageAnchor,
|
||||
`${valueMatched.join(', ')}: resource-server auth helpers routed to the frozen ` +
|
||||
`@modelcontextprotocol/server-legacy/auth copy. The maintained v2 home is ` +
|
||||
`@modelcontextprotocol/express — when re-pointing, verifiers must throw the v2 OAuthError ` +
|
||||
`(the express middleware does not recognize the legacy error classes). ` +
|
||||
`See the migration guide's server auth split section.`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (typeMatched.length > 0) {
|
||||
diagnostics.push(
|
||||
info(
|
||||
filePath,
|
||||
line,
|
||||
`${typeMatched.join(', ')}: type-only import routed to @modelcontextprotocol/server-legacy/auth; ` +
|
||||
`the maintained v2 type lives in @modelcontextprotocol/express — re-point when convenient.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mapping.status === 'removed') {
|
||||
imp.remove();
|
||||
changesCount++;
|
||||
|
|
@ -122,7 +164,11 @@ export const importPathsTransform: Transform = {
|
|||
const needsContext =
|
||||
namespaceImport != null ||
|
||||
defaultImport != null ||
|
||||
namedImports.some(n => symbolTargetOverride(n.getName(), mapping!) === undefined);
|
||||
namedImports.some(
|
||||
n =>
|
||||
mapping!.removedSymbols?.[n.getName()] === undefined &&
|
||||
symbolTargetOverride(n.getName(), mapping!) === undefined
|
||||
);
|
||||
if (needsContext) {
|
||||
const base = resolveTypesPackage(context, hasClientImport, hasServerImport, { filePath, line, diagnostics });
|
||||
targetPackage = mapping.subpathSuffix ? `${base}${mapping.subpathSuffix}` : base;
|
||||
|
|
@ -179,6 +225,36 @@ export const importPathsTransform: Transform = {
|
|||
);
|
||||
}
|
||||
}
|
||||
// Qualified accesses to symbols with no v2 export (`ns.Protocol`) can't be fixed by
|
||||
// moving the namespace binding — flag each accessed one. Expression positions are
|
||||
// PropertyAccessExpressions; type positions (`let p: ns.Protocol`) are QualifiedNames.
|
||||
if (mapping.removedSymbols) {
|
||||
const nsName = namespaceImport.getText();
|
||||
const accessedRemoved = new Map<string, Node>();
|
||||
for (const pa of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
|
||||
const memberName = pa.getName();
|
||||
if (
|
||||
pa.getExpression().getText() === nsName &&
|
||||
mapping.removedSymbols[memberName] !== undefined &&
|
||||
!accessedRemoved.has(memberName)
|
||||
) {
|
||||
accessedRemoved.set(memberName, pa);
|
||||
}
|
||||
}
|
||||
for (const qn of sourceFile.getDescendantsOfKind(SyntaxKind.QualifiedName)) {
|
||||
const memberName = qn.getRight().getText();
|
||||
if (
|
||||
qn.getLeft().getText() === nsName &&
|
||||
mapping.removedSymbols[memberName] !== undefined &&
|
||||
!accessedRemoved.has(memberName)
|
||||
) {
|
||||
accessedRemoved.set(memberName, qn);
|
||||
}
|
||||
}
|
||||
for (const [name, node] of accessedRemoved) {
|
||||
diagnostics.push(actionRequired(filePath, node, mapping.removedSymbols[name]!));
|
||||
}
|
||||
}
|
||||
usedPackages.add(effectiveTarget);
|
||||
imp.setModuleSpecifier(effectiveTarget);
|
||||
if (mapping.renamedSymbols) {
|
||||
|
|
@ -203,6 +279,18 @@ export const importPathsTransform: Transform = {
|
|||
|
||||
for (const n of namedImports) {
|
||||
const name = n.getName();
|
||||
const removalGuidance = mapping.removedSymbols?.[name];
|
||||
if (removalGuidance !== undefined) {
|
||||
// No v2 package exports this symbol — dropping it (with a marker) beats
|
||||
// emitting an import of a member the target package does not have. Anchor
|
||||
// the marker to a usage site: it survives the import rewrites, so the
|
||||
// runner resolves a live line, and it is where the user must act anyway.
|
||||
const usageName = n.getAliasNode()?.getText() ?? name;
|
||||
diagnostics.push(
|
||||
actionRequired(filePath, findFirstIdentifierOutsideImports(sourceFile, usageName) ?? imp, removalGuidance)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const alias = n.getAliasNode()?.getText();
|
||||
const resolvedName = mapping.renamedSymbols?.[name] ?? name;
|
||||
const specifierTypeOnly = typeOnly || n.isTypeOnly();
|
||||
|
|
@ -332,6 +420,17 @@ function rewriteExportDeclarations(
|
|||
}
|
||||
}
|
||||
|
||||
// A star re-export of a module with removed symbols silently drops them from the
|
||||
// barrel (the new target never exported them) — flag each, mirroring the
|
||||
// schema-constant star-export diagnostic below.
|
||||
if (mapping.removedSymbols && exp.getNamedExports().length === 0) {
|
||||
for (const [name, guidance] of Object.entries(mapping.removedSymbols)) {
|
||||
diagnostics.push(
|
||||
actionRequired(filePath, exp, `Star re-export of ${specifier} will no longer provide ${name}. ${guidance}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mapping.symbolTargetOverrides || mapping.schemaSymbolTarget) {
|
||||
const namedExports = exp.getNamedExports();
|
||||
// A star re-export (`export * from …`, including `export * as ns from …`) has no named
|
||||
|
|
@ -375,7 +474,21 @@ function rewriteExportDeclarations(
|
|||
if (!spec.getAliasNode()) spec.setAlias(name);
|
||||
spec.setName(newName);
|
||||
}
|
||||
if (REEXPORT_WARNINGS[name]) {
|
||||
const removalGuidance = mapping.removedSymbols?.[name];
|
||||
if (removalGuidance !== undefined) {
|
||||
diagnostics.push(
|
||||
actionRequired(filePath, exp, `Re-exported ${name} has no v2 export — remove this re-export. ${removalGuidance}`)
|
||||
);
|
||||
} else if (RS_ONLY_AUTH_SYMBOLS.has(name) && targetPackage === '@modelcontextprotocol/server-legacy/auth') {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
filePath,
|
||||
exp,
|
||||
`Re-exported ${name} now points at the frozen @modelcontextprotocol/server-legacy/auth copy; the ` +
|
||||
`maintained v2 home is @modelcontextprotocol/express. Re-point this barrel entry deliberately.`
|
||||
)
|
||||
);
|
||||
} else if (REEXPORT_WARNINGS[name]) {
|
||||
diagnostics.push(actionRequired(filePath, exp, REEXPORT_WARNINGS[name]!));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Transform } from '../../../types';
|
||||
import { completableNestingTransform } from './completableNesting';
|
||||
import { contextTypesTransform } from './contextTypes';
|
||||
import { handlerRegistrationTransform } from './handlerRegistration';
|
||||
import { importPathsTransform } from './importPaths';
|
||||
|
|
@ -29,7 +30,10 @@ import { symbolRenamesTransform } from './symbolRenames';
|
|||
// 5. handlerRegistration and schemaParamRemoval are independent of each
|
||||
// other but both depend on importPaths having run.
|
||||
//
|
||||
// 6. mockPaths runs last: handles test mocks and dynamic imports,
|
||||
// 6. completableNesting runs after importPaths (it matches the rewritten
|
||||
// completable import) and is independent of the rest.
|
||||
//
|
||||
// 7. mockPaths runs last: handles test mocks and dynamic imports,
|
||||
// independent of the other transforms.
|
||||
export const v1ToV2Transforms: Transform[] = [
|
||||
importPathsTransform,
|
||||
|
|
@ -39,5 +43,6 @@ export const v1ToV2Transforms: Transform[] = [
|
|||
handlerRegistrationTransform,
|
||||
schemaParamRemovalTransform,
|
||||
contextTypesTransform,
|
||||
completableNestingTransform,
|
||||
mockPathsTransform
|
||||
];
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Node, SyntaxKind } from 'ts-morph';
|
|||
|
||||
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types';
|
||||
import { actionRequired, info } from '../../../utils/diagnostics';
|
||||
import { isOriginalNameImportedFromMcp, resolveLocalImportName } from '../../../utils/importUtils';
|
||||
import { hasMcpImports, isOriginalNameImportedFromMcp, resolveLocalImportName } from '../../../utils/importUtils';
|
||||
|
||||
export const mcpServerApiTransform: Transform = {
|
||||
name: 'McpServer API migration',
|
||||
|
|
@ -12,9 +12,15 @@ export const mcpServerApiTransform: Transform = {
|
|||
const diagnostics: Diagnostic[] = [];
|
||||
let changesCount = 0;
|
||||
|
||||
if (!isOriginalNameImportedFromMcp(sourceFile, 'McpServer')) {
|
||||
const hasServerImport = isOriginalNameImportedFromMcp(sourceFile, 'McpServer');
|
||||
if (!hasServerImport && !hasMcpImports(sourceFile)) {
|
||||
return { changesCount: 0, diagnostics: [] };
|
||||
}
|
||||
// Without a direct McpServer import (harness objects exposing an `mcp` field,
|
||||
// wrapped servers), legacy-name calls still migrate when their shape provably
|
||||
// matches the v1 signature; non-matching calls stay silent rather than
|
||||
// collecting hard markers on receivers whose type the codemod cannot see.
|
||||
const provableShapesOnly = !hasServerImport;
|
||||
|
||||
const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
|
||||
|
||||
|
|
@ -30,102 +36,114 @@ export const mcpServerApiTransform: Transform = {
|
|||
if (!Node.isPropertyAccessExpression(expr)) continue;
|
||||
const methodName = expr.getName();
|
||||
|
||||
const legacyPrequalified =
|
||||
!provableShapesOnly ||
|
||||
(call.getArguments().length >= 2 &&
|
||||
isStringArg(call.getArguments()[0]!) &&
|
||||
receiverLooksLikeMcpServer(expr.getExpression()));
|
||||
// The same receiver evidence gates the modern register* names: a file
|
||||
// with only an MCP type import can carry an unrelated
|
||||
// `registry.registerTool('x', { inputSchema }, cb)` whose schema must
|
||||
// not be wrapped.
|
||||
const registerQualified = !provableShapesOnly || receiverLooksLikeMcpServer(expr.getExpression());
|
||||
switch (methodName) {
|
||||
case 'tool': {
|
||||
toolCalls.push(call);
|
||||
if (legacyPrequalified) toolCalls.push(call);
|
||||
break;
|
||||
}
|
||||
case 'prompt': {
|
||||
promptCalls.push(call);
|
||||
if (legacyPrequalified) promptCalls.push(call);
|
||||
break;
|
||||
}
|
||||
case 'resource': {
|
||||
resourceCalls.push(call);
|
||||
if (legacyPrequalified) resourceCalls.push(call);
|
||||
break;
|
||||
}
|
||||
case 'registerTool': {
|
||||
registerToolCalls.push(call);
|
||||
if (registerQualified) registerToolCalls.push(call);
|
||||
break;
|
||||
}
|
||||
case 'registerPrompt': {
|
||||
registerPromptCalls.push(call);
|
||||
if (registerQualified) registerPromptCalls.push(call);
|
||||
break;
|
||||
}
|
||||
case 'registerResource': {
|
||||
registerResourceCalls.push(call);
|
||||
if (registerQualified) registerResourceCalls.push(call);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const call of toolCalls) {
|
||||
const result = migrateToolCall(call, sourceFile, diagnostics);
|
||||
if (result) {
|
||||
changesCount++;
|
||||
} else {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
call,
|
||||
'Could not automatically migrate .tool() call. Manual migration required.'
|
||||
)
|
||||
);
|
||||
}
|
||||
// ONE pass in reverse document order across every category: a registration
|
||||
// nested inside another handler's body must migrate before the enclosing
|
||||
// call's rewrite replaces the surrounding text and forgets the inner node —
|
||||
// regardless of which category either call belongs to.
|
||||
interface PendingCall {
|
||||
call: CallExpression;
|
||||
kind: 'tool' | 'prompt' | 'resource' | 'registerTool' | 'registerPrompt' | 'registerResource';
|
||||
}
|
||||
const orderedCalls: PendingCall[] = [
|
||||
...toolCalls.map(call => ({ call, kind: 'tool' as const })),
|
||||
...promptCalls.map(call => ({ call, kind: 'prompt' as const })),
|
||||
...resourceCalls.map(call => ({ call, kind: 'resource' as const })),
|
||||
...registerToolCalls.map(call => ({ call, kind: 'registerTool' as const })),
|
||||
...registerPromptCalls.map(call => ({ call, kind: 'registerPrompt' as const })),
|
||||
...registerResourceCalls.map(call => ({ call, kind: 'registerResource' as const }))
|
||||
].toSorted((a, b) => b.call.getPos() - a.call.getPos());
|
||||
|
||||
for (const call of promptCalls) {
|
||||
const result = migratePromptCall(call, sourceFile, diagnostics);
|
||||
if (result) {
|
||||
changesCount++;
|
||||
} else {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
call,
|
||||
'Could not automatically migrate .prompt() call. Manual migration required.'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
const legacyFailure = (call: CallExpression, method: string): void => {
|
||||
// In fallback mode (no direct McpServer import) the receiver's type is
|
||||
// unknown — failures stay silent rather than marking non-MCP code.
|
||||
if (provableShapesOnly) return;
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
call,
|
||||
`Could not automatically migrate .${method}() call. Manual migration required.`
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
for (const call of resourceCalls) {
|
||||
const result = migrateResourceCall(call, sourceFile);
|
||||
if (result) {
|
||||
changesCount++;
|
||||
} else {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
call,
|
||||
'Could not automatically migrate .resource() call. Manual migration required.'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const call of registerToolCalls) {
|
||||
if (wrapSchemaInConfig(call, 'inputSchema', sourceFile, diagnostics)) {
|
||||
changesCount++;
|
||||
}
|
||||
if (wrapSchemaInConfig(call, 'outputSchema', sourceFile, diagnostics)) {
|
||||
changesCount++;
|
||||
}
|
||||
}
|
||||
|
||||
for (const call of registerPromptCalls) {
|
||||
if (wrapSchemaInConfig(call, 'argsSchema', sourceFile, diagnostics)) {
|
||||
changesCount++;
|
||||
}
|
||||
}
|
||||
|
||||
for (const call of registerResourceCalls) {
|
||||
if (wrapSchemaInConfig(call, 'uriSchema', sourceFile, diagnostics)) {
|
||||
changesCount++;
|
||||
for (const { call, kind } of orderedCalls) {
|
||||
if (call.wasForgotten()) continue;
|
||||
switch (kind) {
|
||||
case 'tool': {
|
||||
if (migrateToolCall(call, sourceFile, diagnostics)) changesCount++;
|
||||
else legacyFailure(call, 'tool');
|
||||
break;
|
||||
}
|
||||
case 'prompt': {
|
||||
if (migratePromptCall(call, sourceFile, diagnostics)) changesCount++;
|
||||
else legacyFailure(call, 'prompt');
|
||||
break;
|
||||
}
|
||||
case 'resource': {
|
||||
if (migrateResourceCall(call, sourceFile)) changesCount++;
|
||||
else legacyFailure(call, 'resource');
|
||||
break;
|
||||
}
|
||||
case 'registerTool': {
|
||||
if (wrapSchemaInConfig(call, 'inputSchema', sourceFile, diagnostics)) changesCount++;
|
||||
if (!call.wasForgotten() && wrapSchemaInConfig(call, 'outputSchema', sourceFile, diagnostics)) changesCount++;
|
||||
break;
|
||||
}
|
||||
case 'registerPrompt': {
|
||||
if (wrapSchemaInConfig(call, 'argsSchema', sourceFile, diagnostics)) changesCount++;
|
||||
break;
|
||||
}
|
||||
case 'registerResource': {
|
||||
if (wrapSchemaInConfig(call, 'uriSchema', sourceFile, diagnostics)) changesCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flagRemovedTaskOptions(sourceFile, diagnostics);
|
||||
|
||||
ensureZodImportForWraps(sourceFile, diagnostics);
|
||||
|
||||
noteMockShapeAssertions(sourceFile, diagnostics);
|
||||
|
||||
return { changesCount, diagnostics };
|
||||
}
|
||||
};
|
||||
|
|
@ -141,6 +159,8 @@ function isZodObjectCall(node: Node): boolean {
|
|||
return expr.getName() === 'object' && expr.getExpression().getText() === 'z';
|
||||
}
|
||||
|
||||
const WRAP_NOTE = 'wrapped with z.object().';
|
||||
|
||||
function wrapWithZObject(schemaText: string): string {
|
||||
return `z.object(${schemaText})`;
|
||||
}
|
||||
|
|
@ -155,21 +175,17 @@ function maybeWrapSchema(node: Node): string {
|
|||
|
||||
function emitWrapDiagnostic(node: Node, sourceFile: SourceFile, call: CallExpression, diagnostics: Diagnostic[]): void {
|
||||
if (Node.isObjectLiteralExpression(node)) {
|
||||
diagnostics.push(
|
||||
info(
|
||||
sourceFile.getFilePath(),
|
||||
call.getStartLineNumber(),
|
||||
'Raw object literal wrapped with z.object(). Verify that zod (z) is imported in this file.'
|
||||
)
|
||||
);
|
||||
diagnostics.push(info(sourceFile.getFilePath(), call.getStartLineNumber(), `Raw object literal ${WRAP_NOTE}`));
|
||||
} else if (!isZodObjectCall(node)) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
diagnostics.push({
|
||||
...actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
call,
|
||||
'Schema argument is not an object literal — verify it is a z.object() schema. V2 requires a Zod schema, not a raw object.'
|
||||
)
|
||||
);
|
||||
'Could not verify the schema argument is a schema object. Raw shapes are deprecated in v2 — ' +
|
||||
'pass a Standard Schema object (e.g. z.object({ … })); no change is needed if it already is one.'
|
||||
),
|
||||
advisoryOnly: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,13 +212,15 @@ function wrapSchemaInConfig(call: CallExpression, schemaPropertyName: string, so
|
|||
if (!schemaProp) return false;
|
||||
|
||||
if (Node.isShorthandPropertyAssignment(schemaProp)) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
diagnostics.push({
|
||||
...actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
call,
|
||||
`Shorthand \`{ ${schemaPropertyName} }\` in config: verify the value is a z.object() schema, not a raw object. V2 requires a Zod schema.`
|
||||
)
|
||||
);
|
||||
`Shorthand \`{ ${schemaPropertyName} }\` in config: could not verify the value is a schema object. Raw shapes ` +
|
||||
`are deprecated in v2 — pass a Standard Schema object (e.g. z.object({ … })).`
|
||||
),
|
||||
advisoryOnly: true
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -215,23 +233,21 @@ function wrapSchemaInConfig(call: CallExpression, schemaPropertyName: string, so
|
|||
const wrapped = wrapWithZObject(initializer.getText());
|
||||
initializer.replaceWithText(wrapped);
|
||||
diagnostics.push(
|
||||
info(
|
||||
sourceFile.getFilePath(),
|
||||
call.getStartLineNumber(),
|
||||
`Raw object literal in ${schemaPropertyName} wrapped with z.object(). Verify that zod (z) is imported in this file.`
|
||||
)
|
||||
info(sourceFile.getFilePath(), call.getStartLineNumber(), `Raw object literal in ${schemaPropertyName} ${WRAP_NOTE}`)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isZodObjectCall(initializer)) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
diagnostics.push({
|
||||
...actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
call,
|
||||
`\`${schemaPropertyName}\` value is not an object literal — verify it is a z.object() schema. V2 requires a Zod schema, not a raw object.`
|
||||
)
|
||||
);
|
||||
`Could not verify \`${schemaPropertyName}\` is a schema object. Raw shapes are deprecated in v2 — ` +
|
||||
`pass a Standard Schema object (e.g. z.object({ … })); no change is needed if it already is one.`
|
||||
),
|
||||
advisoryOnly: true
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -452,3 +468,123 @@ function flagRemovedTaskOptions(sourceFile: SourceFile, diagnostics: Diagnostic[
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapping a raw shape introduces a `z.object(...)` reference. When the file has no
|
||||
* `z` binding, add `import { z } from 'zod'` so the rewrite does not leave a dangling
|
||||
* identifier; the package must also declare zod (the manifest summary warns when its
|
||||
* declared range cannot satisfy v2's >=4.2 floor).
|
||||
*/
|
||||
function ensureZodImportForWraps(sourceFile: SourceFile, diagnostics: Diagnostic[]): void {
|
||||
const wrapped = diagnostics.some(d => d.message.includes(WRAP_NOTE));
|
||||
if (!wrapped) return;
|
||||
// A value import named `z` (type-only imports are erased and cannot back a
|
||||
// runtime z.object() call).
|
||||
const zValueImport = sourceFile.getImportDeclarations().some(decl => {
|
||||
if (decl.isTypeOnly()) return false;
|
||||
if (decl.getNamespaceImport()?.getText() === 'z') return true;
|
||||
if (decl.getDefaultImport()?.getText() === 'z') return true;
|
||||
return decl.getNamedImports().some(ni => !ni.isTypeOnly() && (ni.getAliasNode()?.getText() ?? ni.getName()) === 'z');
|
||||
});
|
||||
if (zValueImport) return;
|
||||
// `z` bound some other way (a variable, or a destructured require) — adding an
|
||||
// import would redeclare it, and the existing binding may not be zod at all.
|
||||
const zOtherBinding =
|
||||
sourceFile.getVariableDeclaration('z') !== undefined ||
|
||||
sourceFile.getDescendantsOfKind(SyntaxKind.BindingElement).some(be => be.getName() === 'z');
|
||||
if (zOtherBinding) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
sourceFile.getImportDeclarations()[0] ?? sourceFile,
|
||||
`Raw shapes were ${WRAP_NOTE.slice(0, -1)}, but \`z\` in this file is not a value import from zod — ` +
|
||||
'wire the wrapped schemas to your zod instance manually (zod >=4.2.0 satisfies v2).'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
sourceFile.addImportDeclaration({ moduleSpecifier: 'zod', namedImports: ['z'] });
|
||||
diagnostics.push({
|
||||
...info(
|
||||
sourceFile.getFilePath(),
|
||||
1,
|
||||
"Added `import { z } from 'zod'` for the wrapped raw shapes. The owning package must declare zod " +
|
||||
'(>=4.2.0 satisfies v2) — the manifest summary adds or warns accordingly.'
|
||||
),
|
||||
tag: 'zod-injected'
|
||||
});
|
||||
}
|
||||
|
||||
const SCHEMA_CONFIG_KEYS = new Set(['inputSchema', 'outputSchema', 'argsSchema', 'uriSchema']);
|
||||
|
||||
/**
|
||||
* `expect.objectContaining({ inputSchema: { … } })`-style call-shape assertions pin
|
||||
* the v1 registration config; after migration the schemas are wrapped (z.object) and
|
||||
* the literal shape no longer matches. The codemod cannot rewrite the assertion —
|
||||
* note it.
|
||||
*/
|
||||
function noteMockShapeAssertions(sourceFile: SourceFile, diagnostics: Diagnostic[]): void {
|
||||
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
||||
if (call.wasForgotten()) continue;
|
||||
const callee = call.getExpression();
|
||||
if (!Node.isPropertyAccessExpression(callee) || callee.getName() !== 'objectContaining') continue;
|
||||
const arg = call.getArguments()[0];
|
||||
if (arg === undefined || !Node.isObjectLiteralExpression(arg)) continue;
|
||||
const schemaProp = arg.getProperties().find(prop => {
|
||||
const name = Node.isPropertyAssignment(prop) || Node.isShorthandPropertyAssignment(prop) ? prop.getName() : undefined;
|
||||
return name !== undefined && SCHEMA_CONFIG_KEYS.has(name);
|
||||
});
|
||||
if (schemaProp === undefined) continue;
|
||||
diagnostics.push({
|
||||
...actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
call,
|
||||
`Call-shape assertion pins a registration config schema — v2 configs carry wrapped schema objects ` +
|
||||
`(z.object({ … })), so a raw-shape literal no longer matches. Assert with the wrapped schema or ` +
|
||||
`expect.any(Object).`
|
||||
),
|
||||
advisoryOnly: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Without a direct `McpServer` import the receiver's type is unknown, so the only
|
||||
* safe rewrites are calls whose receiver itself is named like an MCP server
|
||||
* (`server.tool(…)`, `harness.mcp.tool(…)`, `this.mockServer.prompt(…)`). The check
|
||||
* is strictly on the TERMINAL name of the receiver chain: a file that merely imports
|
||||
* an MCP type can contain shape-identical non-MCP calls — `cli.prompt('q', cb)`,
|
||||
* `app.resource('users', '/u', handler)`, and members hanging off a server such as
|
||||
* `this.server.cli.prompt(…)` — which must not be touched.
|
||||
*/
|
||||
function receiverLooksLikeMcpServer(receiver: Node): boolean {
|
||||
const unwrapped = unwrapReceiver(receiver);
|
||||
if (Node.isIdentifier(unwrapped)) return nameHasMcpServerWord(unwrapped.getText());
|
||||
if (Node.isPropertyAccessExpression(unwrapped)) return nameHasMcpServerWord(unwrapped.getName());
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Strip wrappers that do not change which object the method is called on. */
|
||||
function unwrapReceiver(node: Node): Node {
|
||||
let current = node;
|
||||
for (;;) {
|
||||
if (
|
||||
Node.isParenthesizedExpression(current) ||
|
||||
Node.isNonNullExpression(current) ||
|
||||
Node.isAsExpression(current) ||
|
||||
Node.isSatisfiesExpression(current)
|
||||
) {
|
||||
current = current.getExpression();
|
||||
continue;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when one of the identifier's camelCase / snake_case words is `mcp` or `server`. */
|
||||
function nameHasMcpServerWord(name: string): boolean {
|
||||
return name
|
||||
.split(/[^a-zA-Z0-9]+|(?=[A-Z])/)
|
||||
.filter(Boolean)
|
||||
.some(word => /^(mcp|server)$/i.test(word));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { isSdkSpecifier } from '../../../utils/importUtils';
|
|||
import { resolveTypesPackage } from '../../../utils/projectAnalyzer';
|
||||
import type { ImportMapping } from '../mappings/importMap';
|
||||
import { isAuthImport, lookupImportMapping } from '../mappings/importMap';
|
||||
import { isSharedSchemaConst, resolveRenamedName, symbolTargetOverride } from '../mappings/schemaRouting';
|
||||
import { isSharedSchemaConst, removedSymbolGuidance, resolveRenamedName, symbolTargetOverride } from '../mappings/schemaRouting';
|
||||
import { SIMPLE_RENAMES } from '../mappings/symbolMap';
|
||||
|
||||
/**
|
||||
|
|
@ -28,7 +28,7 @@ function routeSymbols(symbols: string[], mapping: ImportMapping): { target?: str
|
|||
return { mixed: false };
|
||||
}
|
||||
|
||||
const MOCK_METHODS = new Set([
|
||||
export const MOCK_METHODS: ReadonlySet<string> = new Set([
|
||||
'mock',
|
||||
'doMock',
|
||||
'unmock',
|
||||
|
|
@ -39,7 +39,7 @@ const MOCK_METHODS = new Set([
|
|||
'requireMock',
|
||||
'createMockFromModule'
|
||||
]);
|
||||
const MOCK_CALLERS = new Set(['vi', 'jest']);
|
||||
export const MOCK_CALLERS: ReadonlySet<string> = new Set(['vi', 'jest']);
|
||||
|
||||
export const mockPathsTransform: Transform = {
|
||||
name: 'Mock and dynamic import path rewrites',
|
||||
|
|
@ -152,6 +152,20 @@ function rewriteMockCall(
|
|||
return 0;
|
||||
}
|
||||
|
||||
const removedHits = factorySymbols.filter(sym => removedSymbolGuidance(sym, resolved.mapping) !== undefined);
|
||||
if (removedHits.length > 0) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
call,
|
||||
`Mock factory from ${specifier} provides ${removedHits.join(', ')}, which no v2 package exports — rewriting the ` +
|
||||
`specifier would mock a module that never had the member. Restructure the test instead. ` +
|
||||
removedHits.map(sym => removedSymbolGuidance(sym, resolved.mapping)!).join(' ')
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
let changes = 0;
|
||||
|
||||
let effectiveTarget = resolved.target;
|
||||
|
|
@ -398,6 +412,20 @@ function rewriteDynamicImports(
|
|||
return;
|
||||
}
|
||||
|
||||
const removedHits = destructuredKeys.filter(sym => removedSymbolGuidance(sym, resolved.mapping) !== undefined);
|
||||
if (removedHits.length > 0) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
node,
|
||||
`Dynamic import of ${specifier} destructures ${removedHits.join(', ')}, which no v2 package exports — the ` +
|
||||
`binding would be undefined at runtime. ` +
|
||||
removedHits.map(sym => removedSymbolGuidance(sym, resolved.mapping)!).join(' ')
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let effectiveTarget = resolved.target;
|
||||
const allRenames: Record<string, string> = { ...SIMPLE_RENAMES, ...resolved.mapping.renamedSymbols };
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,39 @@ import { Node, SyntaxKind } from 'ts-morph';
|
|||
|
||||
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types';
|
||||
import { renameAllReferences } from '../../../utils/astUtils';
|
||||
import { warning } from '../../../utils/diagnostics';
|
||||
import { addOrMergeImport, isAnyMcpSpecifier } from '../../../utils/importUtils';
|
||||
import { actionRequired, info, warning } from '../../../utils/diagnostics';
|
||||
import { addOrMergeImport, hasMcpImports, isAnyMcpSpecifier } from '../../../utils/importUtils';
|
||||
|
||||
/**
|
||||
* v2 `finishAuth(code, iss?)` verifies the callback's `iss` when the authorization
|
||||
* server advertises `authorization_response_iss_parameter_supported` — and the v2
|
||||
* server-legacy router advertises it by default. A bare `finishAuth(code)` stays
|
||||
* type-correct (the parameter is optional) but the verification then has no input,
|
||||
* so single-argument call sites in files this run changes get a run-log note
|
||||
* pointing at the guide (not a marker: the one-argument URLSearchParams form is the
|
||||
* blessed v2 spelling and is statically indistinguishable from the code-string
|
||||
* form, so the note is advisory-only and stays quiet on already-migrated trees).
|
||||
*/
|
||||
function handleFinishAuthAdvisory(sourceFile: SourceFile, diagnostics: Diagnostic[]): void {
|
||||
if (!hasMcpImports(sourceFile)) return;
|
||||
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
||||
const expr = call.getExpression();
|
||||
if (!Node.isPropertyAccessExpression(expr) || expr.getName() !== 'finishAuth') continue;
|
||||
if (call.getArguments().length !== 1) continue;
|
||||
diagnostics.push({
|
||||
...warning(
|
||||
sourceFile.getFilePath(),
|
||||
call.getStartLineNumber(),
|
||||
'finishAuth with one argument: if this passes the authorization code string, v2 also accepts ' +
|
||||
'finishAuth(new URL(callbackUrl).searchParams) — preferred, since it carries the iss the callback check ' +
|
||||
'reads when the authorization server advertises authorization_response_iss_parameter_supported ' +
|
||||
'(the v2 server-legacy router advertises it by default). A call already passing URLSearchParams needs ' +
|
||||
"no change. See the migration guide's authorization-server mix-up defense section."
|
||||
),
|
||||
advisoryOnly: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const REMOVED_ZOD_HELPERS: Record<string, string> = {
|
||||
schemaToJson:
|
||||
|
|
@ -29,6 +60,7 @@ export const removedApisTransform: Transform = {
|
|||
changesCount += handleRemovedZodHelpers(sourceFile, diagnostics);
|
||||
changesCount += handleIsomorphicHeaders(sourceFile, diagnostics);
|
||||
changesCount += handleStreamableHTTPError(sourceFile, diagnostics);
|
||||
handleFinishAuthAdvisory(sourceFile, diagnostics);
|
||||
|
||||
return { changesCount, diagnostics };
|
||||
}
|
||||
|
|
@ -145,9 +177,9 @@ function handleStreamableHTTPError(sourceFile: SourceFile, diagnostics: Diagnost
|
|||
if (!Node.isIdentifier(expr) || expr.getText() !== localName) continue;
|
||||
hasConstructorCalls = true;
|
||||
diagnostics.push(
|
||||
warning(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
node.getStartLineNumber(),
|
||||
node,
|
||||
'new StreamableHTTPError(statusCode, statusText, body?) → new SdkHttpError(code, message, data). ' +
|
||||
'Constructor arguments differ — manual review required. Map the HTTP status to a SdkErrorCode enum value ' +
|
||||
'and pass the HTTP status via the data argument, e.g. { status, statusText }.'
|
||||
|
|
@ -158,6 +190,8 @@ function handleStreamableHTTPError(sourceFile: SourceFile, diagnostics: Diagnost
|
|||
renameAllReferences(sourceFile, localName, 'SdkHttpError');
|
||||
changesCount++;
|
||||
|
||||
changesCount += rewriteGuardedStatusReads(sourceFile, diagnostics);
|
||||
|
||||
foundImport.remove();
|
||||
if (foundImportDecl.getNamedImports().length === 0 && !foundImportDecl.getDefaultImport() && !foundImportDecl.getNamespaceImport()) {
|
||||
foundImportDecl.remove();
|
||||
|
|
@ -193,3 +227,110 @@ function resolveTargetModule(sourceFile: SourceFile, originalModule: string): st
|
|||
if (originalModule.includes('/client')) return '@modelcontextprotocol/client';
|
||||
return '@modelcontextprotocol/server';
|
||||
}
|
||||
|
||||
/**
|
||||
* Climb only through positive conjunctions (`&&`) and parentheses: under `||` or `!`
|
||||
* the other operand evaluates exactly when the instanceof check FAILED, so it is not
|
||||
* a guarded scope.
|
||||
*/
|
||||
function conjunctionRootOf(node: Node): Node {
|
||||
let top: Node = node;
|
||||
let parent = top.getParent();
|
||||
while (parent !== undefined) {
|
||||
if (Node.isParenthesizedExpression(parent)) {
|
||||
top = parent;
|
||||
} else if (Node.isBinaryExpression(parent) && parent.getOperatorToken().getKind() === SyntaxKind.AmpersandAmpersandToken) {
|
||||
top = parent;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
parent = top.getParent();
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
/** True when `name` is rebound by a function parameter between `node` and `scopeRoot`. */
|
||||
function isShadowedWithin(node: Node, scopeRoot: Node, name: string): boolean {
|
||||
let current = node.getParent();
|
||||
while (current !== undefined && current !== scopeRoot) {
|
||||
if (
|
||||
(Node.isArrowFunction(current) ||
|
||||
Node.isFunctionExpression(current) ||
|
||||
Node.isFunctionDeclaration(current) ||
|
||||
Node.isMethodDeclaration(current)) &&
|
||||
current.getParameters().some(param => param.getName() === name)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True when the scope contains any assignment whose left side is `subject`. */
|
||||
function subjectReassignedWithin(scope: Node, subject: string): boolean {
|
||||
return scope.getDescendantsOfKind(SyntaxKind.BinaryExpression).some(bin => {
|
||||
const op = bin.getOperatorToken().getKind();
|
||||
return (
|
||||
(op === SyntaxKind.EqualsToken || op === SyntaxKind.QuestionQuestionEqualsToken || op === SyntaxKind.BarBarEqualsToken) &&
|
||||
bin.getLeft().getText() === subject
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 carries the HTTP status on `.status`; `.code` is an `SdkErrorCode` string. A
|
||||
* `.code` read on a value the surrounding check proves is an `SdkHttpError` —
|
||||
* positive-conjunction siblings of the check, or reads inside the positively guarded
|
||||
* if-block — rewrites mechanically. Unprovable reads keep the existing file-level
|
||||
* warning.
|
||||
*/
|
||||
function rewriteGuardedStatusReads(sourceFile: SourceFile, diagnostics: Diagnostic[]): number {
|
||||
let changes = 0;
|
||||
for (const guard of sourceFile.getDescendantsOfKind(SyntaxKind.BinaryExpression)) {
|
||||
if (guard.wasForgotten()) continue;
|
||||
if (guard.getOperatorToken().getKind() !== SyntaxKind.InstanceOfKeyword) continue;
|
||||
const right = guard.getRight();
|
||||
if (!Node.isIdentifier(right) || right.getText() !== 'SdkHttpError') continue;
|
||||
const subject = guard.getLeft().getText();
|
||||
|
||||
const conjunctionRoot = conjunctionRootOf(guard);
|
||||
const scopes: Node[] = [conjunctionRoot];
|
||||
// The then-block is guarded only when the check reaches the if condition
|
||||
// through positive conjunctions alone (no `!`, no `||` on the path).
|
||||
const ifStmt = guard.getFirstAncestorByKind(SyntaxKind.IfStatement);
|
||||
if (ifStmt !== undefined && conjunctionRoot === ifStmt.getExpression()) {
|
||||
const thenBlock = ifStmt.getThenStatement();
|
||||
if (!subjectReassignedWithin(thenBlock, subject)) scopes.push(thenBlock);
|
||||
}
|
||||
|
||||
let rewrote = false;
|
||||
for (const scope of scopes) {
|
||||
const reads = scope
|
||||
.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
|
||||
.filter(
|
||||
pa =>
|
||||
!pa.wasForgotten() &&
|
||||
pa.getName() === 'code' &&
|
||||
pa.getExpression().getText() === subject &&
|
||||
!isShadowedWithin(pa, scope, subject)
|
||||
);
|
||||
for (const pa of reads.toReversed()) {
|
||||
pa.getNameNode().replaceWithText('status');
|
||||
changes++;
|
||||
rewrote = true;
|
||||
}
|
||||
}
|
||||
if (rewrote) {
|
||||
diagnostics.push(
|
||||
info(
|
||||
sourceFile.getFilePath(),
|
||||
guard.getStartLineNumber(),
|
||||
`Rewrote ${subject}.code to ${subject}.status under the instanceof SdkHttpError check — v2 carries ` +
|
||||
`the HTTP status on .status (.code is an SdkErrorCode string).`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,10 +69,32 @@ export const schemaParamRemovalTransform: Transform = {
|
|||
// argument too many (TS2554). Drop it only when a third argument follows — a 2-arg
|
||||
// `callTool(params, undefined)` already type-checks, since `undefined` is a valid options arg.
|
||||
if (secondArg.getText() === 'undefined') {
|
||||
if (fileHasMcpImports && args.length >= 3) {
|
||||
call.removeArgument(1);
|
||||
changesCount++;
|
||||
if (!fileHasMcpImports || args.length < 3) continue;
|
||||
const firstArg = args[0]!;
|
||||
if (methodName === 'request') {
|
||||
// Drop only when the method is provably a literal spec method (the same
|
||||
// proof the schema-identifier path requires). When the skip leaves an
|
||||
// explicit undefined behind, the result is a LOUD compile error the user
|
||||
// resolves with the call's intent in view — strictly better than silently
|
||||
// changing semantics. The proof also keeps the rewrite off non-SDK
|
||||
// receivers that happen to be named `request` with a bare-string first
|
||||
// argument, where deleting the middle argument corrupts the call.
|
||||
const literal = literalMethodOf(firstArg);
|
||||
if (literal === undefined || !SPEC_REQUEST_METHODS.has(literal)) continue;
|
||||
} else if (
|
||||
Node.isStringLiteral(firstArg) ||
|
||||
Node.isNoSubstitutionTemplateLiteral(firstArg) ||
|
||||
Node.isTemplateExpression(firstArg) ||
|
||||
Node.isNumericLiteral(firstArg) ||
|
||||
firstArg.getKind() === SyntaxKind.TrueKeyword ||
|
||||
firstArg.getKind() === SyntaxKind.FalseKeyword
|
||||
) {
|
||||
// v1 callTool() takes a params OBJECT first — a primitive first argument
|
||||
// means a non-SDK receiver sharing the method name.
|
||||
continue;
|
||||
}
|
||||
call.removeArgument(1);
|
||||
changesCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import type { SourceFile } from 'ts-morph';
|
||||
import { Node } from 'ts-morph';
|
||||
import { Node, SyntaxKind } from 'ts-morph';
|
||||
|
||||
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types';
|
||||
import { renameAllReferences } from '../../../utils/astUtils';
|
||||
import { info, warning } from '../../../utils/diagnostics';
|
||||
import { addOrMergeImport, isAnyMcpSpecifier, removeUnusedImport } from '../../../utils/importUtils';
|
||||
import { findFirstIdentifierOutsideImports, renameAllReferences } from '../../../utils/astUtils';
|
||||
import { actionRequired, info, warning } from '../../../utils/diagnostics';
|
||||
import { addOrMergeImport, isAnyMcpSpecifier, isV2Specifier, removeUnusedImport } from '../../../utils/importUtils';
|
||||
import { resolveTypesPackage } from '../../../utils/projectAnalyzer';
|
||||
import { ERROR_CODE_SDK_MEMBERS, SIMPLE_RENAMES } from '../mappings/symbolMap';
|
||||
|
||||
|
|
@ -36,6 +36,7 @@ export const symbolRenamesTransform: Transform = {
|
|||
}
|
||||
}
|
||||
|
||||
changesCount += renameDynamicImportBindings(sourceFile);
|
||||
changesCount += handleErrorCodeSplit(sourceFile, diagnostics);
|
||||
changesCount += handleRequestHandlerExtra(sourceFile, context, diagnostics);
|
||||
changesCount += handleSchemaInput(sourceFile, context, diagnostics);
|
||||
|
|
@ -67,6 +68,18 @@ function handleErrorCodeSplit(sourceFile: SourceFile, diagnostics: Diagnostic[])
|
|||
|
||||
let needsProtocolErrorCode = false;
|
||||
let needsSdkErrorCode = false;
|
||||
let needsSdkError = false;
|
||||
|
||||
// First pass: rewrite the enum sides and remember which boolean expressions hold
|
||||
// SDK-member comparisons, protocol-member comparisons, or both. The instanceof
|
||||
// pairing decision needs the WHOLE expression's membership, so it runs after.
|
||||
interface BooleanExprInfo {
|
||||
hasSdkMember: boolean;
|
||||
hasProtocolMember: boolean;
|
||||
}
|
||||
const booleanExprs = new Map<Node, BooleanExprInfo>();
|
||||
const ctorExprs = new Map<import('ts-morph').NewExpression, BooleanExprInfo>();
|
||||
const matcherSubjects = new Map<Node, Map<string, BooleanExprInfo>>();
|
||||
|
||||
sourceFile.forEachDescendant(node => {
|
||||
if (!Node.isPropertyAccessExpression(node)) return;
|
||||
|
|
@ -74,17 +87,212 @@ function handleErrorCodeSplit(sourceFile: SourceFile, diagnostics: Diagnostic[])
|
|||
if (!Node.isIdentifier(expr) || expr.getText() !== errorCodeLocalName) return;
|
||||
|
||||
const member = node.getName();
|
||||
if (ERROR_CODE_SDK_MEMBERS.has(member)) {
|
||||
needsSdkErrorCode = true;
|
||||
node.getExpression().replaceWithText('SdkErrorCode');
|
||||
} else {
|
||||
needsProtocolErrorCode = true;
|
||||
node.getExpression().replaceWithText('ProtocolErrorCode');
|
||||
}
|
||||
const isSdkMember = ERROR_CODE_SDK_MEMBERS.has(member);
|
||||
node.getExpression().replaceWithText(isSdkMember ? 'SdkErrorCode' : 'ProtocolErrorCode');
|
||||
if (isSdkMember) needsSdkErrorCode = true;
|
||||
else needsProtocolErrorCode = true;
|
||||
changesCount++;
|
||||
|
||||
if (isInComparisonContext(node)) {
|
||||
const root = booleanExpressionRoot(node);
|
||||
const entry = booleanExprs.get(root) ?? { hasSdkMember: false, hasProtocolMember: false };
|
||||
if (isSdkMember) entry.hasSdkMember = true;
|
||||
else entry.hasProtocolMember = true;
|
||||
booleanExprs.set(root, entry);
|
||||
}
|
||||
|
||||
// Constructor pairing: `new ProtocolError(ErrorCode.RequestTimeout, …)` — the
|
||||
// SDK-routed codes ride on SdkError, so the class must move with the member.
|
||||
// Only the FIRST argument (the code) participates, and per-constructor
|
||||
// membership is tracked so a ternary mixing both enums gets a marker rather
|
||||
// than a wrong class.
|
||||
{
|
||||
const newExpr = node.getFirstAncestorByKind(SyntaxKind.NewExpression);
|
||||
const classExpr = newExpr?.getExpression();
|
||||
const firstArg = newExpr?.getArguments()[0];
|
||||
if (
|
||||
newExpr !== undefined &&
|
||||
classExpr !== undefined &&
|
||||
firstArg !== undefined &&
|
||||
Node.isIdentifier(classExpr) &&
|
||||
(classExpr.getText() === 'ProtocolError' || classExpr.getText() === 'McpError') &&
|
||||
(node === firstArg || node.getAncestors().includes(firstArg))
|
||||
) {
|
||||
const entry = ctorExprs.get(newExpr) ?? { hasSdkMember: false, hasProtocolMember: false };
|
||||
if (isSdkMember) entry.hasSdkMember = true;
|
||||
else entry.hasProtocolMember = true;
|
||||
ctorExprs.set(newExpr, entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Matcher pairing: `expect(err.code).toBe(ErrorCode.X)` assertions correlate —
|
||||
// by asserted subject within the enclosing block — with
|
||||
// `expect(err).toBeInstanceOf(…)` class assertions handled below.
|
||||
const subject = matcherSubjectOf(node);
|
||||
if (subject !== undefined) {
|
||||
const block: Node = node.getFirstAncestorByKind(SyntaxKind.Block) ?? sourceFile;
|
||||
let bySubject = matcherSubjects.get(block);
|
||||
if (!bySubject) {
|
||||
bySubject = new Map();
|
||||
matcherSubjects.set(block, bySubject);
|
||||
}
|
||||
const entry = bySubject.get(subject) ?? { hasSdkMember: false, hasProtocolMember: false };
|
||||
if (isSdkMember) entry.hasSdkMember = true;
|
||||
else entry.hasProtocolMember = true;
|
||||
bySubject.set(subject, entry);
|
||||
}
|
||||
});
|
||||
|
||||
if (changesCount > 0) {
|
||||
for (const [newExpr, entry] of ctorExprs) {
|
||||
if (!entry.hasSdkMember) continue;
|
||||
if (entry.hasProtocolMember) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
newExpr,
|
||||
`This constructor's code argument mixes SdkErrorCode and ProtocolErrorCode members, but v2 raises ` +
|
||||
`them on different error classes (SdkError and ProtocolError). Split the construction per class.`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
newExpr.getExpression().replaceWithText('SdkError');
|
||||
needsSdkError = true;
|
||||
}
|
||||
}
|
||||
|
||||
// `toBeInstanceOf(ProtocolError)` assertions whose subject is also asserted to
|
||||
// carry an SDK-routed code in the same block follow the same rule as instanceof
|
||||
// guards: all-SDK rewrites the class, mixed gets a marker.
|
||||
{
|
||||
let unpairedMatcherSites = 0;
|
||||
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
||||
const callee = call.getExpression();
|
||||
if (!Node.isPropertyAccessExpression(callee) || callee.getName() !== 'toBeInstanceOf') continue;
|
||||
const classArg = call.getArguments()[0];
|
||||
if (classArg === undefined || !Node.isIdentifier(classArg)) continue;
|
||||
const className = classArg.getText();
|
||||
if (className !== 'ProtocolError' && className !== 'McpError') continue;
|
||||
const subject = instanceofSubjectOf(callee.getExpression());
|
||||
const block: Node = call.getFirstAncestorByKind(SyntaxKind.Block) ?? sourceFile;
|
||||
const entry = subject === undefined ? undefined : matcherSubjects.get(block)?.get(subject);
|
||||
if (!entry || !entry.hasSdkMember) {
|
||||
if (needsSdkErrorCode) unpairedMatcherSites++;
|
||||
continue;
|
||||
}
|
||||
if (entry.hasProtocolMember) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
call,
|
||||
`This subject is asserted with both SdkErrorCode and ProtocolErrorCode members, but v2 raises them ` +
|
||||
`on different error classes — one toBeInstanceOf cannot cover both. Split the assertions per class.`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
classArg.replaceWithText('SdkError');
|
||||
needsSdkError = true;
|
||||
if (subject !== undefined) {
|
||||
const fnAncestor = call.getFirstAncestor(
|
||||
a =>
|
||||
Node.isArrowFunction(a) ||
|
||||
Node.isFunctionExpression(a) ||
|
||||
Node.isFunctionDeclaration(a) ||
|
||||
Node.isMethodDeclaration(a)
|
||||
);
|
||||
const fnBody =
|
||||
fnAncestor !== undefined &&
|
||||
(Node.isArrowFunction(fnAncestor) ||
|
||||
Node.isFunctionExpression(fnAncestor) ||
|
||||
Node.isFunctionDeclaration(fnAncestor) ||
|
||||
Node.isMethodDeclaration(fnAncestor))
|
||||
? fnAncestor.getBody()
|
||||
: undefined;
|
||||
repointSubjectCasts(fnBody ?? block, subject);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Codes the codemod routed to SdkErrorCode pair with SdkError; class matchers it
|
||||
// could not correlate (cast/aliased subjects, toMatchObject shapes) may still
|
||||
// name the wrong class — surface a run-log note, not a marker.
|
||||
if (unpairedMatcherSites > 0 && needsSdkErrorCode) {
|
||||
diagnostics.push(
|
||||
warning(
|
||||
sourceFile.getFilePath(),
|
||||
1,
|
||||
`This file routes ErrorCode members to SdkErrorCode and also asserts error classes with ` +
|
||||
`toBeInstanceOf — review those assertions: SDK-routed codes are raised on SdkError, not ProtocolError.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: v2 raises the SDK codes on SdkError, not ProtocolError, so a
|
||||
// guard of `instanceof ProtocolError` paired with an SDK-code comparison never
|
||||
// matches. When the expression compares ONLY SDK codes, the guard rewrites
|
||||
// mechanically; when it mixes SDK and protocol codes, one guard cannot cover
|
||||
// both classes — mark it. Expressions with no instanceof guard are left alone
|
||||
// (the enclosing guard, if any, may be elsewhere and already correct).
|
||||
for (const [root, entry] of booleanExprs) {
|
||||
if (!entry.hasSdkMember) continue;
|
||||
for (const guard of instanceofGuards(root)) {
|
||||
if (entry.hasProtocolMember) {
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
guard,
|
||||
`This check compares both SdkErrorCode and ProtocolErrorCode members, but v2 raises them on ` +
|
||||
`different error classes (SdkError and ProtocolError) — one instanceof guard cannot cover both. ` +
|
||||
`Split the check per error class.`
|
||||
)
|
||||
);
|
||||
} else if (guardPinnedToSdkCode(guard)) {
|
||||
guard.getRight().replaceWithText('SdkError');
|
||||
needsSdkError = true;
|
||||
} else {
|
||||
// `e instanceof X || e.code === SdkErrorCode.Y`, `e instanceof X &&
|
||||
// e.code !== …`, a comparison on another subject: v1's class carried
|
||||
// BOTH code families, so no single v2 class reproduces this check.
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
guard,
|
||||
`In v1 this class carried both the protocol codes and the SDK-local codes; in v2 they split into ` +
|
||||
`ProtocolError and SdkError. The codes compared here route to SdkErrorCode, but this check's shape ` +
|
||||
`does not pin the guarded value to one class — match SdkError for SDK-raised codes, ProtocolError ` +
|
||||
`for wire errors, or both.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changesCount === 0) {
|
||||
const decl = errorCodeImport.getImportDeclaration();
|
||||
// Only a v2 specifier warrants the drop: none of the v2 packages export
|
||||
// `ErrorCode`, so leaving the named import behind fails at module link time.
|
||||
// A still-v1 specifier (isolated `--transforms symbols` run) is valid as-is —
|
||||
// the imports transform handles it when it runs.
|
||||
if (!isV2Specifier(decl.getModuleSpecifierValue())) return 0;
|
||||
// Drop it and mark the first remaining use (re-export-only files are handled
|
||||
// by the export rewrite's own warning).
|
||||
const usage = findFirstIdentifierOutsideImports(sourceFile, errorCodeLocalName);
|
||||
diagnostics.push(
|
||||
actionRequired(
|
||||
sourceFile.getFilePath(),
|
||||
usage ?? decl,
|
||||
`${errorCodeLocalName} is not exported by the v2 packages — it split into ProtocolErrorCode (protocol codes) ` +
|
||||
`and SdkErrorCode (local SDK codes). No direct member access could be rewritten here; replace the remaining ` +
|
||||
`uses with the appropriate enum.`
|
||||
)
|
||||
);
|
||||
errorCodeImport.remove();
|
||||
if (decl.getNamedImports().length === 0 && !decl.getDefaultImport() && !decl.getNamespaceImport()) {
|
||||
decl.remove();
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
{
|
||||
const errorCodeImportDecl = errorCodeImport.getImportDeclaration();
|
||||
// Capture target module before removing the import, so we don't lose the original
|
||||
// module specifier when ErrorCode was the only named import in the declaration.
|
||||
|
|
@ -112,6 +320,7 @@ function handleErrorCodeSplit(sourceFile: SourceFile, diagnostics: Diagnostic[])
|
|||
const newImports: string[] = [];
|
||||
if (needsProtocolErrorCode) newImports.push('ProtocolErrorCode');
|
||||
if (needsSdkErrorCode) newImports.push('SdkErrorCode');
|
||||
if (needsSdkError) newImports.push('SdkError');
|
||||
|
||||
if (newImports.length > 0) {
|
||||
const existingImp = sourceFile
|
||||
|
|
@ -140,9 +349,195 @@ function handleErrorCodeSplit(sourceFile: SourceFile, diagnostics: Diagnostic[])
|
|||
);
|
||||
}
|
||||
|
||||
if (needsSdkError) {
|
||||
// Class rewrites can leave the original error-class import referenced nowhere.
|
||||
removeUnusedImport(sourceFile, 'ProtocolError', true);
|
||||
removeUnusedImport(sourceFile, 'McpError', true);
|
||||
}
|
||||
|
||||
return changesCount;
|
||||
}
|
||||
|
||||
const MATCHER_EQUALITY_NAMES = new Set(['toBe', 'toEqual', 'toStrictEqual']);
|
||||
|
||||
/** Strip parentheses and `as`/`satisfies` casts. */
|
||||
function unwrapCasts(node: Node): Node {
|
||||
let current = node;
|
||||
while (Node.isParenthesizedExpression(current) || Node.isAsExpression(current) || Node.isSatisfiesExpression(current)) {
|
||||
current = current.getExpression();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* For `expect(x.code).toBe(<member>)`-style assertions where `memberNode` is the
|
||||
* matcher argument, the asserted subject (`x`) — undefined when the member is not in
|
||||
* that shape. Only a `.code` / `['code']` property read pairs (that is the premise of
|
||||
* the class/code correlation); casts around the base are stripped so
|
||||
* `expect((err as any).code)` pairs with `expect(err)`.
|
||||
*/
|
||||
function matcherSubjectOf(memberNode: Node): string | undefined {
|
||||
const parent = memberNode.getParent();
|
||||
if (parent === undefined || !Node.isCallExpression(parent)) return undefined;
|
||||
if (!parent.getArguments().includes(memberNode)) return undefined;
|
||||
const callee = parent.getExpression();
|
||||
if (!Node.isPropertyAccessExpression(callee) || !MATCHER_EQUALITY_NAMES.has(callee.getName())) return undefined;
|
||||
const expectArg = expectArgumentOf(callee.getExpression());
|
||||
if (expectArg === undefined) return undefined;
|
||||
const arg = unwrapCasts(expectArg);
|
||||
if (Node.isPropertyAccessExpression(arg) && arg.getName() === 'code') {
|
||||
return unwrapCasts(arg.getExpression()).getText();
|
||||
}
|
||||
if (Node.isElementAccessExpression(arg)) {
|
||||
const key = arg.getArgumentExpression();
|
||||
if (key !== undefined && Node.isStringLiteral(key) && key.getLiteralValue() === 'code') {
|
||||
return unwrapCasts(arg.getExpression()).getText();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subject a `toBeInstanceOf` assertion is about: the full (cast-stripped) text of
|
||||
* the expect argument — `expect(err.cause)` asserts `err.cause`, not `err`.
|
||||
*/
|
||||
function instanceofSubjectOf(expr: Node): string | undefined {
|
||||
const arg = expectArgumentOf(expr);
|
||||
if (arg === undefined) return undefined;
|
||||
return unwrapCasts(arg).getText();
|
||||
}
|
||||
|
||||
/** Walk an expect-chain receiver (`expect(x)`, `expect(x).rejects`, …) to the expect call's first argument. */
|
||||
function expectArgumentOf(expr: Node): Node | undefined {
|
||||
let current: Node | undefined = expr;
|
||||
while (current !== undefined) {
|
||||
if (Node.isCallExpression(current)) {
|
||||
const callee = current.getExpression();
|
||||
if (Node.isIdentifier(callee) && callee.getText() === 'expect') {
|
||||
return current.getArguments()[0];
|
||||
}
|
||||
current = callee;
|
||||
continue;
|
||||
}
|
||||
if (Node.isPropertyAccessExpression(current)) {
|
||||
current = current.getExpression();
|
||||
continue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const EQUALITY_OPERATORS = new Set([
|
||||
SyntaxKind.EqualsEqualsToken,
|
||||
SyntaxKind.EqualsEqualsEqualsToken,
|
||||
SyntaxKind.ExclamationEqualsToken,
|
||||
SyntaxKind.ExclamationEqualsEqualsToken
|
||||
]);
|
||||
|
||||
/** True when the member access is one side of an equality comparison or a switch case expression. */
|
||||
function isInComparisonContext(node: Node): boolean {
|
||||
const parent = node.getParent();
|
||||
if (parent !== undefined && Node.isBinaryExpression(parent) && EQUALITY_OPERATORS.has(parent.getOperatorToken().getKind())) {
|
||||
return true;
|
||||
}
|
||||
return parent !== undefined && Node.isCaseClause(parent);
|
||||
}
|
||||
|
||||
const LOGICAL_OPERATORS = new Set([SyntaxKind.AmpersandAmpersandToken, SyntaxKind.BarBarToken]);
|
||||
|
||||
/**
|
||||
* The outermost expression reachable from `node` through logical operators (&&/||),
|
||||
* parentheses, and `!` only — assignments, comma sequences, and arithmetic stop the
|
||||
* climb so a guard stored or reused elsewhere is never claimed.
|
||||
*/
|
||||
function booleanExpressionRoot(node: Node): Node {
|
||||
let top: Node = node;
|
||||
let parent = top.getParent();
|
||||
while (parent !== undefined) {
|
||||
if (Node.isParenthesizedExpression(parent) || Node.isPrefixUnaryExpression(parent)) {
|
||||
top = parent;
|
||||
} else if (Node.isBinaryExpression(parent)) {
|
||||
const op = parent.getOperatorToken().getKind();
|
||||
if (!LOGICAL_OPERATORS.has(op) && !EQUALITY_OPERATORS.has(op)) break;
|
||||
top = parent;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
parent = top.getParent();
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the class an `instanceof` guard names is pinned by a conjoined SDK-routed
|
||||
* code comparison ON THE SAME SUBJECT: `e instanceof X && e.code ===
|
||||
* SdkErrorCode.RequestTimeout`, including a negated guard (`!(e instanceof X) && …`
|
||||
* excludes exactly the SDK-raised codes, which ride on SdkError in v2) and a
|
||||
* disjunction of same-subject SDK codes (`e instanceof X && (e.code === A || e.code
|
||||
* === B)`). A code reached only through `||` with the guard, a negated comparison
|
||||
* (`!==`), another subject's code, or a nested function proves nothing about this
|
||||
* guard's class.
|
||||
*/
|
||||
function guardPinnedToSdkCode(guard: import('ts-morph').BinaryExpression): boolean {
|
||||
const subject = unwrapCasts(guard.getLeft()).getText();
|
||||
|
||||
// Conjunction scope of the guard: parentheses, negation of the guard itself, `&&`.
|
||||
let top: Node = guard;
|
||||
let parent = top.getParent();
|
||||
while (parent !== undefined) {
|
||||
const isAnd = Node.isBinaryExpression(parent) && parent.getOperatorToken().getKind() === SyntaxKind.AmpersandAmpersandToken;
|
||||
if (!Node.isParenthesizedExpression(parent) && !Node.isPrefixUnaryExpression(parent) && !isAnd) break;
|
||||
top = parent;
|
||||
parent = top.getParent();
|
||||
}
|
||||
return pinsSubjectToSdkCode(top, subject, guard);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boolean-shape evaluation of "every way this expression is satisfied constrains
|
||||
* `subject` to an SDK-routed code": an `&&` pins if either conjunct pins, an `||`
|
||||
* pins only if every branch pins, a leaf pins only as a positive same-subject
|
||||
* `SdkErrorCode` equality. The guard itself and anything else (calls, negations,
|
||||
* other subjects) never pin.
|
||||
*/
|
||||
function pinsSubjectToSdkCode(node: Node, subject: string, guard: Node): boolean {
|
||||
let expr: Node = node;
|
||||
while (Node.isParenthesizedExpression(expr)) expr = expr.getExpression();
|
||||
if (expr === guard) return false;
|
||||
if (!Node.isBinaryExpression(expr)) return false;
|
||||
const op = expr.getOperatorToken().getKind();
|
||||
if (op === SyntaxKind.AmpersandAmpersandToken) {
|
||||
return pinsSubjectToSdkCode(expr.getLeft(), subject, guard) || pinsSubjectToSdkCode(expr.getRight(), subject, guard);
|
||||
}
|
||||
if (op === SyntaxKind.BarBarToken) {
|
||||
return pinsSubjectToSdkCode(expr.getLeft(), subject, guard) && pinsSubjectToSdkCode(expr.getRight(), subject, guard);
|
||||
}
|
||||
if (op !== SyntaxKind.EqualsEqualsEqualsToken && op !== SyntaxKind.EqualsEqualsToken) return false;
|
||||
const sides = [expr.getLeft(), expr.getRight()];
|
||||
const memberSide = sides.find(side => Node.isPropertyAccessExpression(side) && side.getExpression().getText() === 'SdkErrorCode');
|
||||
if (memberSide === undefined) return false;
|
||||
const valueSide = unwrapCasts(sides.find(side => side !== memberSide)!);
|
||||
if (!Node.isPropertyAccessExpression(valueSide) && !Node.isElementAccessExpression(valueSide)) return false;
|
||||
return unwrapCasts(valueSide.getExpression()).getText() === subject;
|
||||
}
|
||||
|
||||
/** `instanceof ProtocolError`/`instanceof McpError` checks within the expression. */
|
||||
function instanceofGuards(root: Node): import('ts-morph').BinaryExpression[] {
|
||||
const guards: import('ts-morph').BinaryExpression[] = [];
|
||||
const visit = (candidate: Node): void => {
|
||||
if (Node.isBinaryExpression(candidate) && candidate.getOperatorToken().getKind() === SyntaxKind.InstanceOfKeyword) {
|
||||
const right = candidate.getRight();
|
||||
if (Node.isIdentifier(right) && (right.getText() === 'ProtocolError' || right.getText() === 'McpError')) {
|
||||
guards.push(candidate);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
root.forEachDescendant(visit);
|
||||
return guards;
|
||||
}
|
||||
|
||||
function handleRequestHandlerExtra(sourceFile: SourceFile, context: TransformContext, diagnostics: Diagnostic[]): number {
|
||||
let changesCount = 0;
|
||||
|
||||
|
|
@ -354,3 +749,72 @@ function handleSchemaInput(sourceFile: SourceFile, context: TransformContext, di
|
|||
|
||||
return changesCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the pairing moves a subject's asserted class to SdkError, a
|
||||
* `const err = (await p) as ProtocolError` binding for the same subject becomes a
|
||||
* type lie (.code typed as a number while the runtime value is an SdkErrorCode
|
||||
* string) — re-point the cast with the assertion.
|
||||
*/
|
||||
function repointSubjectCasts(scope: Node, subject: string): void {
|
||||
const repointIn = (expr: Node): void => {
|
||||
const casts = Node.isAsExpression(expr) ? [expr] : expr.getDescendantsOfKind(SyntaxKind.AsExpression);
|
||||
for (const cast of casts) {
|
||||
if (cast.wasForgotten()) continue;
|
||||
const typeNode = cast.getTypeNode();
|
||||
const typeText = typeNode?.getText();
|
||||
if (typeNode !== undefined && (typeText === 'ProtocolError' || typeText === 'McpError')) {
|
||||
typeNode.replaceWithText('SdkError');
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const decl of scope.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
||||
if (decl.wasForgotten() || decl.getName() !== subject) continue;
|
||||
const initializer = decl.getInitializer();
|
||||
if (initializer !== undefined) repointIn(initializer);
|
||||
}
|
||||
// `err = e as McpError` inside a catch block is the dominant test shape — the
|
||||
// cast lives in an assignment, not a declaration.
|
||||
for (const bin of scope.getDescendantsOfKind(SyntaxKind.BinaryExpression)) {
|
||||
if (bin.wasForgotten()) continue;
|
||||
if (bin.getOperatorToken().getKind() !== SyntaxKind.EqualsToken) continue;
|
||||
if (bin.getLeft().getText() !== subject) continue;
|
||||
repointIn(bin.getRight());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `const { McpError } = await import('@modelcontextprotocol/…')` — the static-import
|
||||
* rename pass never sees these bindings. Shorthand elements rename binding and
|
||||
* references; aliased elements (`{ McpError: ME }`) re-point only the property name.
|
||||
*/
|
||||
function renameDynamicImportBindings(sourceFile: SourceFile): number {
|
||||
let changes = 0;
|
||||
for (const decl of sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
||||
if (decl.wasForgotten()) continue;
|
||||
const nameNode = decl.getNameNode();
|
||||
if (!Node.isObjectBindingPattern(nameNode)) continue;
|
||||
let initializer = decl.getInitializer();
|
||||
if (initializer !== undefined && Node.isAwaitExpression(initializer)) initializer = initializer.getExpression();
|
||||
if (initializer === undefined || !Node.isCallExpression(initializer)) continue;
|
||||
if (initializer.getExpression().getKind() !== SyntaxKind.ImportKeyword) continue;
|
||||
const spec = initializer.getArguments()[0]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
|
||||
if (spec === undefined || !isAnyMcpSpecifier(spec)) continue;
|
||||
for (const element of nameNode.getElements()) {
|
||||
const propertyNode = element.getPropertyNameNode();
|
||||
const importedName = propertyNode?.getText() ?? element.getName();
|
||||
const newName = SIMPLE_RENAMES[importedName];
|
||||
if (newName === undefined) continue;
|
||||
if (propertyNode === undefined) {
|
||||
// Text-based like every other rename here — the language-service rename
|
||||
// resolves to the installed v1 typings and can split the shorthand into
|
||||
// a dead-property alias or touch node_modules.
|
||||
renameAllReferences(sourceFile, importedName, newName);
|
||||
} else {
|
||||
propertyNode.replaceWithText(newName);
|
||||
}
|
||||
changes++;
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import fg from 'fast-glob';
|
||||
import type { Node } from 'ts-morph';
|
||||
import { Project, SyntaxKind } from 'ts-morph';
|
||||
|
||||
import type { Diagnostic, FileResult, Migration, RunnerOptions, RunnerResult } from './types';
|
||||
import { MOCK_CALLERS, MOCK_METHODS } from './migrations/v1-to-v2/transforms/mockPaths';
|
||||
import type { Diagnostic, FileResult, Migration, PackageJsonChange, RunnerOptions, RunnerResult } from './types';
|
||||
import { CODEMOD_ERROR_PREFIX, error } from './utils/diagnostics';
|
||||
import { updatePackageJson } from './utils/packageJsonUpdater';
|
||||
import { isSdkSpecifier, isV2Specifier } from './utils/importUtils';
|
||||
import { discoverManifests, ownerManifest, updatePackageJson } from './utils/packageJsonUpdater';
|
||||
import { analyzeProject } from './utils/projectAnalyzer';
|
||||
|
||||
const LITERAL_NODE_KINDS = new Set([
|
||||
|
|
@ -75,10 +81,6 @@ function insertDiagnosticComments(project: Project, fileResults: FileResult[]):
|
|||
return insertedCount;
|
||||
}
|
||||
|
||||
function escapeGlobPath(p: string): string {
|
||||
return p.replaceAll(/[[\]{}()*?!@#]/g, String.raw`\$&`);
|
||||
}
|
||||
|
||||
export function run(migration: Migration, options: RunnerOptions): RunnerResult {
|
||||
const context = analyzeProject(options.targetDir);
|
||||
|
||||
|
|
@ -104,7 +106,30 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
|
|||
}
|
||||
});
|
||||
|
||||
const globPattern = `${escapeGlobPath(options.targetDir)}/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`;
|
||||
// A file target scopes the run to that one source file; project context and the
|
||||
// nearest-manifest rule still derive from its directory.
|
||||
const targetIsFile = (() => {
|
||||
try {
|
||||
return statSync(options.targetDir).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const targetBase = fg.convertPathToPattern(path.resolve(targetIsFile ? path.dirname(options.targetDir) : options.targetDir));
|
||||
const globPattern = `${targetBase}/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`;
|
||||
// The positive pattern is absolute, so fast-glob compares ignore patterns against
|
||||
// absolute paths — a bare-relative --ignore would silently match nothing. Rebase
|
||||
// relative user patterns onto the target directory.
|
||||
const userIgnores = (options.ignore ?? []).map(pattern => {
|
||||
// User ignores are PATTERNS, not paths: convertPathToPattern would escape
|
||||
// their glob metacharacters (`**` → `\*\*`) and they would match nothing.
|
||||
// fast-glob needs forward slashes, so Windows separators are normalized.
|
||||
if (pattern.startsWith('**') || path.isAbsolute(pattern)) {
|
||||
return path.sep === '\\' ? pattern.replaceAll('\\', '/') : pattern;
|
||||
}
|
||||
return `${targetBase}/${pattern}`;
|
||||
});
|
||||
const ignorePatterns = [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
|
|
@ -117,14 +142,24 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
|
|||
'**/*.d.ts',
|
||||
'**/*.d.mts',
|
||||
'**/*.d.cts',
|
||||
...(options.ignore ?? [])
|
||||
...userIgnores
|
||||
];
|
||||
|
||||
const allPatterns = [globPattern];
|
||||
for (const ignore of ignorePatterns) {
|
||||
allPatterns.push(`!${ignore}`);
|
||||
// Collect files with fast-glob directly instead of ts-morph's glob handling:
|
||||
// symbolic links are never followed (pnpm node_modules layouts contain symlink
|
||||
// cycles that ELOOP a following walker) and ignore patterns — including the
|
||||
// user's --ignore — apply during directory descent, not as a post-filter.
|
||||
const files = targetIsFile
|
||||
? [path.resolve(options.targetDir)]
|
||||
: fg.sync(globPattern, {
|
||||
ignore: ignorePatterns,
|
||||
followSymbolicLinks: false,
|
||||
suppressErrors: true,
|
||||
absolute: true
|
||||
});
|
||||
for (const filePath of files) {
|
||||
project.addSourceFileAtPathIfExists(filePath);
|
||||
}
|
||||
project.addSourceFilesAtPaths(allPatterns);
|
||||
|
||||
const sourceFiles = project.getSourceFiles().filter(sf => {
|
||||
const fp = sf.getFilePath();
|
||||
|
|
@ -134,7 +169,6 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
|
|||
});
|
||||
const fileResults: FileResult[] = [];
|
||||
const allDiagnostics: Diagnostic[] = [];
|
||||
const allUsedPackages = new Set<string>();
|
||||
const shebangs = new Map<string, string>();
|
||||
let totalChanges = 0;
|
||||
let filesChanged = 0;
|
||||
|
|
@ -154,33 +188,11 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
|
|||
shebangs.set(sourceFile.getFilePath(), shebangMatch[0]);
|
||||
}
|
||||
|
||||
const fileClaimedPackages = new Set<string>();
|
||||
try {
|
||||
for (const transform of enabledTransforms) {
|
||||
const result = transform.apply(sourceFile, context);
|
||||
fileChanges += result.changesCount;
|
||||
fileDiagnostics.push(...result.diagnostics);
|
||||
if (result.usedPackages) {
|
||||
for (const pkg of result.usedPackages) {
|
||||
fileClaimedPackages.add(pkg);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A transform records a package as "used" when it routes a binding there — but importPaths does
|
||||
// so the moment it rewrites an import, and later transforms (handlerRegistration,
|
||||
// schemaParamRemoval) routinely rewrite the schema usage away and delete that very import.
|
||||
// Honouring a claim whose import did not survive would add an unused dependency to package.json,
|
||||
// so a claim counts only when the FINAL file still references the specifier. Every claim
|
||||
// originates from a string literal the transform wrote (an import/export module specifier, or a
|
||||
// vi.mock()/dynamic import() argument), so a surviving string-literal match is the ground truth.
|
||||
const survivingSpecifiers = new Set<string>();
|
||||
for (const literal of sourceFile.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
|
||||
survivingSpecifiers.add(literal.getLiteralValue());
|
||||
}
|
||||
for (const pkg of fileClaimedPackages) {
|
||||
if (survivingSpecifiers.has(pkg)) {
|
||||
allUsedPackages.add(pkg);
|
||||
}
|
||||
}
|
||||
} catch (error_) {
|
||||
const filePath = sourceFile.getFilePath();
|
||||
|
|
@ -188,7 +200,14 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
|
|||
fileDiagnostics.push(error(filePath, 1, `Transform failed: ${error_ instanceof Error ? error_.message : String(error_)}`));
|
||||
sourceFile.replaceWithText(originalText);
|
||||
fileChanges = 0;
|
||||
fileClaimedPackages.clear();
|
||||
}
|
||||
|
||||
// Heuristic advisories only flush for files a transform actually changed —
|
||||
// a re-run over a migrated tree stays quiet.
|
||||
if (fileChanges === 0) {
|
||||
const kept = fileDiagnostics.filter(d => !d.advisoryOnly);
|
||||
fileDiagnostics.length = 0;
|
||||
fileDiagnostics.push(...kept);
|
||||
}
|
||||
|
||||
for (const d of fileDiagnostics) {
|
||||
|
|
@ -196,7 +215,10 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
|
|||
try {
|
||||
d.line = d.resolveCurrentLine();
|
||||
} catch {
|
||||
// Node was removed by a later transform; keep snapshot line
|
||||
// The anchor node was removed by a later rewrite — the snapshot
|
||||
// line is stale against the final text, so keep the console
|
||||
// diagnostic but never insert a comment at the wrong place.
|
||||
delete d.insertComment;
|
||||
}
|
||||
delete d.resolveCurrentLine;
|
||||
}
|
||||
|
|
@ -216,10 +238,68 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
|
|||
}
|
||||
}
|
||||
|
||||
// The v2 dependency set for each manifest is derived from the POST-transform
|
||||
// import state of the files it owns (longest-prefix match against the
|
||||
// discovered manifests). Reading the final state — rather than what this run
|
||||
// rewrote — means an already-migrated package whose v1 dependency is being
|
||||
// removed still gets the v2 packages its imports need.
|
||||
const hasImportsTransform = enabledTransforms.some(t => t.id === 'imports');
|
||||
const packageJsonChanges = hasImportsTransform
|
||||
? updatePackageJson(options.targetDir, allUsedPackages, options.dryRun ?? false)
|
||||
: undefined;
|
||||
let packageJsonChanges: PackageJsonChange[] | undefined;
|
||||
if (hasImportsTransform) {
|
||||
const manifests = discoverManifests(options.targetDir);
|
||||
const usedByManifest = new Map<string, Set<string>>();
|
||||
for (const sourceFile of sourceFiles) {
|
||||
const owner = ownerManifest(sourceFile.getFilePath(), manifests);
|
||||
if (!owner) continue;
|
||||
let used = usedByManifest.get(owner.path);
|
||||
if (!used) {
|
||||
used = new Set();
|
||||
usedByManifest.set(owner.path, used);
|
||||
}
|
||||
for (const decl of sourceFile.getImportDeclarations()) {
|
||||
const spec = decl.getModuleSpecifierValue();
|
||||
if (spec.startsWith('@modelcontextprotocol/')) used.add(spec);
|
||||
}
|
||||
for (const decl of sourceFile.getExportDeclarations()) {
|
||||
const spec = decl.getModuleSpecifierValue();
|
||||
if (spec !== undefined && spec.startsWith('@modelcontextprotocol/')) used.add(spec);
|
||||
}
|
||||
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
||||
const exprText = call.getExpression().getText();
|
||||
const dot = exprText.indexOf('.');
|
||||
const isMockCall = dot !== -1 && MOCK_CALLERS.has(exprText.slice(0, dot)) && MOCK_METHODS.has(exprText.slice(dot + 1));
|
||||
const isModuleRef = call.getExpression().getKind() === SyntaxKind.ImportKeyword || exprText === 'require' || isMockCall;
|
||||
if (!isModuleRef) continue;
|
||||
const spec = call.getArguments()[0]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
|
||||
if (spec !== undefined && spec.startsWith('@modelcontextprotocol/')) used.add(spec);
|
||||
}
|
||||
}
|
||||
// A single-file run sees one file's imports — applying a manifest edit from that
|
||||
// view could remove the v1 dependency the rest of the package still needs, so
|
||||
// manifests are report-only in file mode.
|
||||
const zodInjectedByManifest = new Map<string, string[]>();
|
||||
for (const fileResult of fileResults) {
|
||||
if (!fileResult.diagnostics.some(d => d.tag === 'zod-injected')) continue;
|
||||
const owner = ownerManifest(fileResult.filePath, manifests);
|
||||
if (!owner) continue;
|
||||
let files = zodInjectedByManifest.get(owner.path);
|
||||
if (!files) {
|
||||
files = [];
|
||||
zodInjectedByManifest.set(owner.path, files);
|
||||
}
|
||||
files.push(fileResult.filePath);
|
||||
}
|
||||
|
||||
const changes = updatePackageJson(manifests, usedByManifest, (options.dryRun ?? false) || targetIsFile, zodInjectedByManifest);
|
||||
packageJsonChanges = changes.length > 0 ? changes : undefined;
|
||||
}
|
||||
|
||||
const mcpImportFiles = sourceFiles.filter(sf =>
|
||||
sf.getImportDeclarations().some(decl => isV2Specifier(decl.getModuleSpecifierValue()))
|
||||
).length;
|
||||
const v1ImportFiles = sourceFiles.filter(sf =>
|
||||
sf.getImportDeclarations().some(decl => isSdkSpecifier(decl.getModuleSpecifierValue()))
|
||||
).length;
|
||||
|
||||
// Per-file mutations are atomic: if any transform fails, the file is rolled back to its
|
||||
// original state and an error diagnostic is emitted.
|
||||
|
|
@ -253,6 +333,8 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
|
|||
diagnostics: allDiagnostics,
|
||||
fileResults,
|
||||
packageJsonChanges,
|
||||
mcpImportFiles,
|
||||
v1ImportFiles,
|
||||
commentCount
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ export interface Diagnostic {
|
|||
line: number;
|
||||
message: string;
|
||||
category?: 'v2-gap';
|
||||
/** Heuristic "verify this" advisories: dropped by the runner for files no transform changed, so re-runs over migrated trees stay quiet. */
|
||||
advisoryOnly?: boolean;
|
||||
/** Machine-readable marker for cross-stage plumbing (e.g. the runner feeding manifest edits). */
|
||||
tag?: 'zod-injected';
|
||||
insertComment?: boolean;
|
||||
resolveCurrentLine?: () => number;
|
||||
}
|
||||
|
|
@ -56,6 +60,17 @@ export interface PackageJsonChange {
|
|||
added: string[];
|
||||
removed: string[];
|
||||
packageJsonPath: string;
|
||||
/**
|
||||
* True for the manifest the codemod writes (the nearest one walking up from the
|
||||
* target directory) — or, under dry-run, the one it would write. False entries
|
||||
* describe edits to other manifests — typically workspace members — that are
|
||||
* reported for the user to apply themselves; those are never written.
|
||||
*/
|
||||
applied: boolean;
|
||||
/** Context on how the change set was computed (e.g. hoisted member usage credited to this manifest). */
|
||||
notes?: string[];
|
||||
/** Manifest-level findings that need the user's attention (e.g. an incompatible zod range). */
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface RunnerResult {
|
||||
|
|
@ -63,6 +78,10 @@ export interface RunnerResult {
|
|||
totalChanges: number;
|
||||
diagnostics: Diagnostic[];
|
||||
fileResults: FileResult[];
|
||||
packageJsonChanges?: PackageJsonChange;
|
||||
packageJsonChanges?: PackageJsonChange[];
|
||||
commentCount: number;
|
||||
/** Source files importing a v2 `@modelcontextprotocol/*` package after the run. */
|
||||
mcpImportFiles: number;
|
||||
/** Source files still importing the v1 `@modelcontextprotocol/sdk` package after the run (possible under `--transforms` subsets). */
|
||||
v1ImportFiles: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { SourceFile } from 'ts-morph';
|
||||
import { Node } from 'ts-morph';
|
||||
import { Node, SyntaxKind } from 'ts-morph';
|
||||
|
||||
export function isKeyPositionIdentifier(node: import('ts-morph').Node): boolean {
|
||||
const parent = node.getParent();
|
||||
|
|
@ -38,3 +38,13 @@ export function renameAllReferences(sourceFile: SourceFile, oldName: string, new
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** First identifier named `name` that is not part of an import declaration. */
|
||||
export function findFirstIdentifierOutsideImports(sourceFile: SourceFile, name: string): Node | undefined {
|
||||
for (const id of sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
||||
if (id.getText() !== name) continue;
|
||||
if (id.getFirstAncestorByKind(SyntaxKind.ImportDeclaration)) continue;
|
||||
return id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,13 @@ export function addOrMergeImport(
|
|||
}
|
||||
}
|
||||
|
||||
/** True when the specifier resolves to one of the published v2 packages (root or subpath). */
|
||||
export function isV2Specifier(specifier: string): boolean {
|
||||
if (V2_PACKAGES.has(specifier)) return true;
|
||||
const secondSlash = specifier.indexOf('/', specifier.indexOf('/') + 1);
|
||||
return secondSlash !== -1 && V2_PACKAGES.has(specifier.slice(0, secondSlash));
|
||||
}
|
||||
|
||||
export function isAnyMcpSpecifier(specifier: string): boolean {
|
||||
if (isSdkSpecifier(specifier)) return true;
|
||||
if (V2_PACKAGES.has(specifier)) return true;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import fg from 'fast-glob';
|
||||
|
||||
import { V2_PACKAGE_VERSIONS } from '../generated/versions';
|
||||
import type { PackageJsonChange } from '../types';
|
||||
|
|
@ -7,72 +10,422 @@ import { findPackageJson } from './projectAnalyzer';
|
|||
const V1_PACKAGE = '@modelcontextprotocol/sdk';
|
||||
const PRIVATE_PACKAGES = new Set(['@modelcontextprotocol/core-internal']);
|
||||
|
||||
function normalizeToRoot(pkg: string): string {
|
||||
type ZodSegmentVerdict = 'ok' | 'v3' | 'v4pre42';
|
||||
|
||||
/**
|
||||
* Classify one `||` alternative of a zod range by the highest version it can resolve
|
||||
* to. 'ok' = can reach >=4.2 (satisfies v2's floor); 'v3' = resolves into 3.x;
|
||||
* 'v4pre42' = resolves into 4.0/4.1 (typings predate `~standard.jsonSchema`, so
|
||||
* registration calls fail type-checking).
|
||||
*/
|
||||
function classifyZodSegment(segment: string): ZodSegmentVerdict {
|
||||
const seg = segment
|
||||
.replace(/^(?:workspace:|npm:)/, '')
|
||||
.replace(/^[=\s]+/, '')
|
||||
.trim();
|
||||
if (seg === '' || seg === '*' || seg === 'x' || seg === 'latest') return 'ok';
|
||||
|
||||
const classifyUpper = (major: number, minor: number | undefined, inclusive: boolean): ZodSegmentVerdict => {
|
||||
if (major > 4) return 'ok';
|
||||
if (major < 4) return 'v3';
|
||||
// major === 4: an upper bound below 4.2 caps resolution at 4.0/4.1; `<4` and
|
||||
// `<4.0` cap it in 3.x. A bare inclusive major (`<=4`, `… - 4`) is an
|
||||
// X-range upper bound — it admits every 4.x release and satisfies the floor.
|
||||
if (minor === undefined) return inclusive ? 'ok' : 'v3';
|
||||
if (minor === 0 && inclusive === false) return 'v3';
|
||||
if (minor < 2) return 'v4pre42';
|
||||
if (minor === 2 && inclusive === false) return 'v4pre42';
|
||||
return 'ok';
|
||||
};
|
||||
|
||||
// Hyphen range `A - B`: resolution maxes at B (inclusive).
|
||||
const hyphen = seg.match(/^\S+\s+-\s+v?(\d+)(?:\.(\d+))?/);
|
||||
if (hyphen) {
|
||||
return classifyUpper(Number(hyphen[1]), hyphen[2] === undefined ? undefined : Number(hyphen[2]), true);
|
||||
}
|
||||
|
||||
// Comparator sets: an upper `<`/`<=` bound caps resolution; a floor with no upper
|
||||
// bound resolves to the latest release.
|
||||
const upper = seg.match(/<(=?)\s*v?(\d+)(?:\.(\d+))?/);
|
||||
if (upper) {
|
||||
return classifyUpper(Number(upper[2]), upper[3] === undefined ? undefined : Number(upper[3]), upper[1] === '=');
|
||||
}
|
||||
if (seg.startsWith('>')) return 'ok';
|
||||
|
||||
// Caret: `^3.x` cannot reach 4; `^4.x` allows everything below 5.
|
||||
const caret = seg.match(/^\^\s*v?(\d+)/);
|
||||
if (caret) return Number(caret[1]) < 4 ? 'v3' : 'ok';
|
||||
|
||||
// Tilde and bare/exact versions: `~4.1`/`4.1.x`/`=4.0.2` stay below 4.2; a bare
|
||||
// major (`4`, `~4`, `4.x`) allows the whole major line.
|
||||
const plain = seg.match(/^~?\s*v?(\d+)(?:\.(x|\*|\d+))?/);
|
||||
if (plain) {
|
||||
const major = Number(plain[1]);
|
||||
if (major < 4) return 'v3';
|
||||
if (major > 4) return 'ok';
|
||||
const minorRaw = plain[2];
|
||||
if (minorRaw === undefined || minorRaw === 'x' || minorRaw === '*') return 'ok';
|
||||
return Number(minorRaw) < 2 ? 'v4pre42' : 'ok';
|
||||
}
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
export interface ManifestInfo {
|
||||
/** Directory containing the manifest. */
|
||||
dir: string;
|
||||
/** Absolute path of the package.json. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function normalizeToRoot(pkg: string): string {
|
||||
const secondSlash = pkg.indexOf('/', pkg.indexOf('/') + 1);
|
||||
if (secondSlash === -1) return pkg;
|
||||
return pkg.slice(0, secondSlash);
|
||||
}
|
||||
|
||||
/** ts-morph standardizes file paths to forward slashes on every platform; manifests must compare the same way. */
|
||||
function toPosix(p: string): string {
|
||||
return p.replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
function detectIndent(text: string): string {
|
||||
const match = text.match(/\n([ \t]+)/);
|
||||
return match ? match[1]! : ' ';
|
||||
}
|
||||
|
||||
export function updatePackageJson(targetDir: string, usedPackages: Set<string>, dryRun: boolean): PackageJsonChange | undefined {
|
||||
const pkgJsonPath = findPackageJson(targetDir);
|
||||
if (!pkgJsonPath) return undefined;
|
||||
|
||||
let raw: string;
|
||||
let pkgJson: Record<string, unknown>;
|
||||
function readJson(p: string): { raw: string; json: Record<string, unknown> } | undefined {
|
||||
try {
|
||||
raw = readFileSync(pkgJsonPath, 'utf8');
|
||||
pkgJson = JSON.parse(raw) as Record<string, unknown>;
|
||||
const raw = readFileSync(p, 'utf8');
|
||||
return { raw, json: JSON.parse(raw) as Record<string, unknown> };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const deps = pkgJson.dependencies as Record<string, string> | undefined;
|
||||
const devDeps = pkgJson.devDependencies as Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
const inDeps = deps !== undefined && V1_PACKAGE in deps;
|
||||
const inDevDeps = devDeps !== undefined && V1_PACKAGE in devDeps;
|
||||
if (!inDeps && !inDevDeps) return undefined;
|
||||
|
||||
const packagesToAdd = [...new Set([...usedPackages].map(pkg => normalizeToRoot(pkg)))].filter(
|
||||
pkg => !PRIVATE_PACKAGES.has(pkg) && pkg in V2_PACKAGE_VERSIONS
|
||||
);
|
||||
|
||||
// Determine which section to add v2 packages to.
|
||||
// If v1 SDK was in both, prefer dependencies.
|
||||
const targetSection = inDeps ? 'dependencies' : 'devDependencies';
|
||||
|
||||
const added: string[] = [];
|
||||
for (const pkg of packagesToAdd) {
|
||||
const alreadyInDeps = deps !== undefined && pkg in deps;
|
||||
const alreadyInDevDeps = devDeps !== undefined && pkg in devDeps;
|
||||
if (alreadyInDeps || alreadyInDevDeps) continue;
|
||||
|
||||
if (!pkgJson[targetSection]) {
|
||||
pkgJson[targetSection] = {};
|
||||
/** Parse the `packages:` list out of a pnpm-workspace.yaml without a YAML dependency. */
|
||||
function pnpmWorkspaceGlobs(rootDir: string): string[] {
|
||||
const p = path.join(rootDir, 'pnpm-workspace.yaml');
|
||||
if (!existsSync(p)) return [];
|
||||
const globs: string[] = [];
|
||||
let inPackages = false;
|
||||
for (const line of readFileSync(p, 'utf8').split('\n')) {
|
||||
if (/^packages\s*:/.test(line)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages) {
|
||||
const item = line.match(/^\s+-\s*['"]?([^'"#]+?)['"]?\s*(?:#.*)?$/);
|
||||
if (item) {
|
||||
globs.push(item[1]!);
|
||||
continue;
|
||||
}
|
||||
if (/^\S/.test(line)) inPackages = false; // next top-level key
|
||||
}
|
||||
(pkgJson[targetSection] as Record<string, string>)[pkg] = V2_PACKAGE_VERSIONS[pkg]!;
|
||||
added.push(pkg);
|
||||
}
|
||||
return globs;
|
||||
}
|
||||
|
||||
if (inDeps) delete deps![V1_PACKAGE];
|
||||
if (inDevDeps) delete devDeps![V1_PACKAGE];
|
||||
const removed = [V1_PACKAGE];
|
||||
|
||||
if (!dryRun) {
|
||||
const indent = detectIndent(raw);
|
||||
const trailingNewline = raw.endsWith('\n');
|
||||
let output = JSON.stringify(pkgJson, null, indent);
|
||||
if (trailingNewline) output += '\n';
|
||||
writeFileSync(pkgJsonPath, output);
|
||||
/** Workspace member globs from the root manifest's `workspaces` field (npm/yarn/bun shape). */
|
||||
function npmWorkspaceGlobs(rootJson: Record<string, unknown>): string[] {
|
||||
const ws = rootJson.workspaces;
|
||||
if (Array.isArray(ws)) return ws.filter((g): g is string => typeof g === 'string');
|
||||
if (ws && typeof ws === 'object' && Array.isArray((ws as { packages?: unknown }).packages)) {
|
||||
return (ws as { packages: unknown[] }).packages.filter((g): g is string => typeof g === 'string');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The manifests a migration run may need to update: the nearest package.json walking
|
||||
* up from the target directory, plus every workspace-member manifest it declares
|
||||
* (npm/yarn/bun `workspaces` and pnpm-workspace.yaml), so monorepo members do not
|
||||
* keep a stale v1 dependency the root swap never sees.
|
||||
*/
|
||||
export function discoverManifests(targetDir: string): ManifestInfo[] {
|
||||
const rootManifest = findPackageJson(targetDir);
|
||||
if (!rootManifest) return [];
|
||||
const rootDir = path.dirname(rootManifest);
|
||||
const manifests: ManifestInfo[] = [{ dir: toPosix(rootDir), path: rootManifest }];
|
||||
|
||||
const rootJson = readJson(rootManifest)?.json ?? {};
|
||||
const memberGlobs = [...npmWorkspaceGlobs(rootJson), ...pnpmWorkspaceGlobs(rootDir)];
|
||||
if (memberGlobs.length === 0) return manifests;
|
||||
|
||||
const memberDirs = fg.sync(memberGlobs, {
|
||||
cwd: rootDir,
|
||||
onlyDirectories: true,
|
||||
followSymbolicLinks: false,
|
||||
suppressErrors: true,
|
||||
ignore: ['**/node_modules/**'],
|
||||
absolute: true
|
||||
});
|
||||
// Workspace members live under the root that declares them; a glob that
|
||||
// resolves elsewhere (an absolute pattern, a parent reference) is outside this
|
||||
// run's scope and is not reported.
|
||||
const resolvedRoot = path.resolve(rootDir);
|
||||
for (const dir of memberDirs) {
|
||||
if (!path.resolve(dir).startsWith(resolvedRoot + path.sep)) continue;
|
||||
const manifest = path.join(dir, 'package.json');
|
||||
if (existsSync(manifest) && manifest !== rootManifest) {
|
||||
manifests.push({ dir: toPosix(dir), path: manifest });
|
||||
}
|
||||
}
|
||||
return manifests;
|
||||
}
|
||||
|
||||
/** Longest-prefix owner of a file among the discovered manifest directories. */
|
||||
export function ownerManifest(filePath: string, manifests: readonly ManifestInfo[]): ManifestInfo | undefined {
|
||||
const posixFile = toPosix(filePath);
|
||||
let best: ManifestInfo | undefined;
|
||||
for (const m of manifests) {
|
||||
const dir = toPosix(m.dir);
|
||||
const prefix = dir.endsWith('/') ? dir : dir + '/';
|
||||
if (posixFile.startsWith(prefix) && (!best || dir.length > (best ? toPosix(best.dir).length : 0))) {
|
||||
best = m;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function zodWarning(...depSections: (Record<string, string> | undefined)[]): string | undefined {
|
||||
const range = depSections.find(section => section?.zod !== undefined)?.zod;
|
||||
if (range === undefined) return undefined;
|
||||
// Every `||` alternative must fall short of the floor before we warn — `^3.25 || ^4.5` resolves fine.
|
||||
const verdicts = range.split('||').map(seg => classifyZodSegment(seg));
|
||||
if (verdicts.length === 0 || verdicts.includes('ok')) return undefined;
|
||||
const floor = `zod range '${range}' cannot satisfy v2's floor: zod >=4.2.0 is required. `;
|
||||
if (verdicts.every(v => v === 'v4pre42')) {
|
||||
return (
|
||||
floor +
|
||||
`This range resolves to zod 4.0-4.1, which predates ~standard.jsonSchema: registerTool/registerPrompt ` +
|
||||
`calls fail type-checking (TS2769: no overload matches), and plain-JavaScript projects run through a ` +
|
||||
`bundled fallback that drops .describe() field descriptions.`
|
||||
);
|
||||
}
|
||||
return (
|
||||
floor +
|
||||
`An older range installs cleanly and then, depending on the zod entry point your code imports, ` +
|
||||
`fails type-checking (zod/v4 subpath) or only fails at runtime ` +
|
||||
`(main-entry imports: the server starts normally and the first tools/list reports the failure).`
|
||||
);
|
||||
}
|
||||
|
||||
interface ParsedManifest {
|
||||
raw: string;
|
||||
json: Record<string, unknown>;
|
||||
deps?: Record<string, string>;
|
||||
devDeps?: Record<string, string>;
|
||||
peerDeps?: Record<string, string>;
|
||||
optionalDeps?: Record<string, string>;
|
||||
declaresV1: boolean;
|
||||
}
|
||||
|
||||
function parseManifest(manifestPath: string): ParsedManifest | undefined {
|
||||
const parsed = readJson(manifestPath);
|
||||
if (!parsed) return undefined;
|
||||
const deps = parsed.json.dependencies as Record<string, string> | undefined;
|
||||
const devDeps = parsed.json.devDependencies as Record<string, string> | undefined;
|
||||
const peerDeps = parsed.json.peerDependencies as Record<string, string> | undefined;
|
||||
const optionalDeps = parsed.json.optionalDependencies as Record<string, string> | undefined;
|
||||
return {
|
||||
added: added.toSorted(),
|
||||
removed,
|
||||
packageJsonPath: pkgJsonPath
|
||||
raw: parsed.raw,
|
||||
json: parsed.json,
|
||||
deps,
|
||||
devDeps,
|
||||
peerDeps,
|
||||
optionalDeps,
|
||||
declaresV1: (deps !== undefined && V1_PACKAGE in deps) || (devDeps !== undefined && V1_PACKAGE in devDeps)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the v1 SDK dependency for the v2 packages in the **nearest** manifest (the
|
||||
* first entry of `manifests`), and report — without modifying — the same edit for
|
||||
* every other manifest that declares the v1 SDK, so workspace members never receive
|
||||
* writes the user did not point the codemod at.
|
||||
*
|
||||
* The v2 additions come from the **post-transform** import state of the files each
|
||||
* manifest owns (`usedByManifest`), not from what was rewritten in this run — so a
|
||||
* partially or fully pre-migrated package still gets the v2 packages its imports
|
||||
* need when its v1 dependency is removed.
|
||||
*/
|
||||
const ZOD_RANGE = '^4.2.0';
|
||||
const TEST_PATH_RE = /(^|\/)(test|tests|__tests__)\/|\.(test|spec)\.[cm]?[jt]sx?$/;
|
||||
|
||||
function writeManifest(manifestPath: string, raw: string, pkgJson: Record<string, unknown>): void {
|
||||
const indent = detectIndent(raw);
|
||||
let output = JSON.stringify(pkgJson, null, indent);
|
||||
if (raw.endsWith('\n')) output += '\n';
|
||||
writeFileSync(manifestPath, output);
|
||||
}
|
||||
|
||||
export function updatePackageJson(
|
||||
manifests: readonly ManifestInfo[],
|
||||
usedByManifest: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
dryRun: boolean,
|
||||
zodInjectedByManifest: ReadonlyMap<string, readonly string[]> = new Map()
|
||||
): PackageJsonChange[] {
|
||||
const changes: PackageJsonChange[] = [];
|
||||
const nearestPath = manifests[0]?.path;
|
||||
|
||||
// Each manifest is read and parsed exactly once; every later step (v1 checks,
|
||||
// roll-up, edits) works from this shared view.
|
||||
const parsedByPath = new Map<string, ParsedManifest>();
|
||||
for (const m of manifests) {
|
||||
const parsed = parseManifest(m.path);
|
||||
if (parsed) parsedByPath.set(m.path, parsed);
|
||||
}
|
||||
|
||||
// Hoisted-dependency roll-up: a workspace member without its own v1 dependency
|
||||
// relies on an ancestor manifest (usually the root) for SDK resolution, so its
|
||||
// usage must count toward the nearest ancestor that DOES declare the v1 SDK —
|
||||
// otherwise a hoisted monorepo would get the v1 dependency removed from the
|
||||
// root with none of the v2 replacements added.
|
||||
const effectiveUsed = new Map<string, Set<string>>();
|
||||
const hoistNotes = new Map<string, string[]>();
|
||||
for (const m of manifests) {
|
||||
effectiveUsed.set(m.path, new Set(usedByManifest.get(m.path)));
|
||||
}
|
||||
const v1Ancestors = manifests.filter(a => parsedByPath.get(a.path)?.declaresV1 === true);
|
||||
|
||||
// zod-injected files follow the same hoisting rule as usage: a member without its
|
||||
// own v1 dependency takes part through its nearest v1-declaring ancestor, so its
|
||||
// injections must surface there or the add is silently lost.
|
||||
const effectiveZodInjected = new Map<string, string[]>();
|
||||
for (const [manifestPath, files] of zodInjectedByManifest) {
|
||||
const declaresV1Here = parsedByPath.get(manifestPath)?.declaresV1 === true;
|
||||
const home = declaresV1Here ? manifestPath : (ownerManifest(manifestPath, v1Ancestors)?.path ?? manifestPath);
|
||||
let bucket = effectiveZodInjected.get(home);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
effectiveZodInjected.set(home, bucket);
|
||||
}
|
||||
bucket.push(...files);
|
||||
}
|
||||
for (const m of manifests) {
|
||||
if (parsedByPath.get(m.path)?.declaresV1 === true) continue;
|
||||
const used = effectiveUsed.get(m.path);
|
||||
if (!used || used.size === 0) continue;
|
||||
// A member only participates when its imports map to publishable v2 packages.
|
||||
const contributes = [...used]
|
||||
.map(pkg => normalizeToRoot(pkg))
|
||||
.some(pkg => !PRIVATE_PACKAGES.has(pkg) && pkg in V2_PACKAGE_VERSIONS);
|
||||
if (!contributes) continue;
|
||||
const ancestor = ownerManifest(m.path, v1Ancestors);
|
||||
if (ancestor) {
|
||||
const target = effectiveUsed.get(ancestor.path);
|
||||
for (const pkg of used) target?.add(pkg);
|
||||
let notes = hoistNotes.get(ancestor.path);
|
||||
if (!notes) {
|
||||
notes = [];
|
||||
hoistNotes.set(ancestor.path, notes);
|
||||
}
|
||||
notes.push(
|
||||
`workspace member ${toPosix(path.relative(ancestor.dir, m.dir)) || '.'} has no own ${V1_PACKAGE} dependency ` +
|
||||
`and resolves the SDK through this manifest; its imports were included when computing the v2 dependency set`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const manifest of manifests) {
|
||||
const parsed = parsedByPath.get(manifest.path);
|
||||
if (!parsed) continue;
|
||||
const { raw, json: pkgJson, deps, devDeps, peerDeps, optionalDeps } = parsed;
|
||||
|
||||
const inDeps = deps !== undefined && V1_PACKAGE in deps;
|
||||
const inDevDeps = devDeps !== undefined && V1_PACKAGE in devDeps;
|
||||
const warning = zodWarning(deps, devDeps, peerDeps, optionalDeps);
|
||||
|
||||
const declaresAnyV2 = Object.keys({ ...deps, ...devDeps }).some(dep => dep in V2_PACKAGE_VERSIONS);
|
||||
const usesV2 = (effectiveUsed.get(manifest.path)?.size ?? 0) > 0;
|
||||
const isNearest = manifest.path === nearestPath;
|
||||
|
||||
// The file-level `import { z } from 'zod'` injection promises a manifest edit;
|
||||
// it must land even when this manifest never declared the v1 SDK (a member
|
||||
// already on v2, or a sub-tree target whose v1 dependency lives outside it).
|
||||
const injectedFiles = effectiveZodInjected.get(manifest.path);
|
||||
// A peer- or optional-declared zod is still a declaration: adding a direct
|
||||
// dependency on top of it would duplicate (and possibly conflict with) the
|
||||
// package's own contract.
|
||||
const declaresZod = [deps, devDeps, peerDeps, optionalDeps].some(section => section !== undefined && 'zod' in section);
|
||||
const needsInjectedZod = injectedFiles !== undefined && injectedFiles.length > 0 && !declaresZod;
|
||||
const addInjectedZod = (targetSection: 'dependencies' | 'devDependencies'): 'dependencies' | 'devDependencies' => {
|
||||
// Classify against the path RELATIVE to the package — a test/ segment in
|
||||
// some ancestor directory (CI checkout dirs) must not demote the dep.
|
||||
const testOnly = injectedFiles!.every(file => TEST_PATH_RE.test(toPosix(path.relative(manifest.dir, file))));
|
||||
const section = testOnly ? 'devDependencies' : targetSection;
|
||||
if (!pkgJson[section]) {
|
||||
pkgJson[section] = {};
|
||||
}
|
||||
(pkgJson[section] as Record<string, string>)['zod'] = ZOD_RANGE;
|
||||
return section;
|
||||
};
|
||||
|
||||
if (!inDeps && !inDevDeps) {
|
||||
if (needsInjectedZod) {
|
||||
addInjectedZod('dependencies');
|
||||
if (isNearest && !dryRun) {
|
||||
writeManifest(manifest.path, raw, pkgJson);
|
||||
}
|
||||
changes.push({ added: ['zod'], removed: [], packageJsonPath: manifest.path, applied: isNearest });
|
||||
continue;
|
||||
}
|
||||
// The zod note only matters for manifests that take part in the migration:
|
||||
// they declare a v2 package, or their files import one (hoisted members).
|
||||
if (warning && (declaresAnyV2 || usesV2)) {
|
||||
changes.push({ added: [], removed: [], packageJsonPath: manifest.path, applied: false, warnings: [warning] });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const used = effectiveUsed.get(manifest.path) ?? new Set<string>();
|
||||
const packagesToAdd = [...new Set([...used].map(pkg => normalizeToRoot(pkg)))].filter(
|
||||
pkg => !PRIVATE_PACKAGES.has(pkg) && pkg in V2_PACKAGE_VERSIONS
|
||||
);
|
||||
|
||||
// If v1 SDK was in both sections, prefer dependencies.
|
||||
const targetSection = inDeps ? 'dependencies' : 'devDependencies';
|
||||
|
||||
const added: string[] = [];
|
||||
for (const pkg of packagesToAdd) {
|
||||
const alreadyInDeps = deps !== undefined && pkg in deps;
|
||||
const alreadyInDevDeps = devDeps !== undefined && pkg in devDeps;
|
||||
if (alreadyInDeps || alreadyInDevDeps) continue;
|
||||
|
||||
if (!pkgJson[targetSection]) {
|
||||
pkgJson[targetSection] = {};
|
||||
}
|
||||
(pkgJson[targetSection] as Record<string, string>)[pkg] = V2_PACKAGE_VERSIONS[pkg]!;
|
||||
added.push(pkg);
|
||||
}
|
||||
|
||||
// Wrapped raw shapes had `import { z } from 'zod'` injected — under strict
|
||||
// node_modules layouts the owning package must declare zod itself, so a
|
||||
// zod-less manifest gets it added (devDependencies when only tests import it).
|
||||
if (needsInjectedZod) {
|
||||
addInjectedZod(targetSection);
|
||||
added.push('zod');
|
||||
}
|
||||
|
||||
if (inDeps) delete deps![V1_PACKAGE];
|
||||
if (inDevDeps) delete devDeps![V1_PACKAGE];
|
||||
|
||||
// Only the nearest manifest is written; the others are reported so the user
|
||||
// applies (or deliberately skips) each workspace-member edit themselves.
|
||||
if (isNearest && !dryRun) {
|
||||
writeManifest(manifest.path, raw, pkgJson);
|
||||
}
|
||||
|
||||
const notes = hoistNotes.get(manifest.path);
|
||||
changes.push({
|
||||
added: added.toSorted(),
|
||||
removed: [V1_PACKAGE],
|
||||
packageJsonPath: manifest.path,
|
||||
applied: isNearest,
|
||||
...(notes !== undefined && { notes }),
|
||||
...(warning !== undefined && (added.length > 0 || declaresAnyV2 || usesV2) && { warnings: [warning] })
|
||||
});
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue