codemod iterations (#2386)
This commit is contained in:
parent
49aeb8eb38
commit
36055d5bbd
9 changed files with 800 additions and 48 deletions
5
.changeset/codemod-preserve-shebang.md
Normal file
5
.changeset/codemod-preserve-shebang.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@modelcontextprotocol/codemod': patch
|
||||
---
|
||||
|
||||
Preserve a leading `#!` shebang (and the blank lines after it) when migrating a file. Some transforms drop the shebang because it is leading trivia of the first import they rewrite; the codemod now captures it before transforms and restores it before saving, so CLI packages whose `bin` points at the migrated entry keep working.
|
||||
|
|
@ -9,8 +9,8 @@ For each repo in `repos.json`, the batch test:
|
|||
1. Clones the repo (or resets an existing clone)
|
||||
2. Installs dependencies
|
||||
3. Runs baseline checks (typecheck, build, test, lint) to confirm the repo is healthy
|
||||
4. Runs the codemod using the programmatic API
|
||||
5. Packs local SDK packages as tarballs and rewrites `package.json` deps to use them (so the test runs against the current SDK branch, not published npm versions)
|
||||
4. Runs the codemod — in-process via the programmatic API (`--codemod=local`, the default), or by shelling out to the published CLI via `npx` (`--codemod=published`)
|
||||
5. Packs local SDK packages as tarballs and rewrites `package.json` deps to use them, so the test runs against the current SDK branch — only when `--sdk=local` (the default); with `--sdk=published` the v2 deps are installed from npm instead (see [Modes](#modes))
|
||||
6. Re-installs dependencies
|
||||
7. Re-runs the same checks
|
||||
8. Writes structured JSON reports
|
||||
|
|
@ -30,12 +30,48 @@ pnpm --filter @modelcontextprotocol/codemod batch-test
|
|||
pnpm --filter @modelcontextprotocol/codemod batch-test:clean
|
||||
```
|
||||
|
||||
## Modes
|
||||
|
||||
Each run independently chooses where the **SDK packages** and the **codemod** come from:
|
||||
|
||||
| Flag | Values | Default | Effect |
|
||||
| -------------------------- | ---------------------- | --------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--sdk` | `local` \| `published` | `local` | `local`: pack local packages and rewrite deps to `file:` tarballs. `published`: install the v2 deps from npm. |
|
||||
| `--codemod` | `local` \| `published` | `local` | `local`: run the working-copy codemod in-process. `published`: `npx` the published CLI. |
|
||||
| `--codemod-version <spec>` | npm version/tag/range | `latest` | Only with `--codemod=published`. |
|
||||
| `--sdk-version <spec>` | npm version/tag/range | _(unset)_ | Only with `--sdk=published`. Pins each v2 dep to its own resolved version. Unset = use whatever the codemod writes. |
|
||||
|
||||
Both `--flag value` and `--flag=value` forms work.
|
||||
|
||||
```bash
|
||||
# default (today's behavior)
|
||||
pnpm --filter @modelcontextprotocol/codemod batch-test
|
||||
|
||||
# both from npm @latest
|
||||
pnpm --filter @modelcontextprotocol/codemod batch-test -- --sdk=published --codemod=published
|
||||
```
|
||||
|
||||
Published specs are resolved to concrete versions via `npm view` at startup. A failure resolving the codemod version or the representative SDK label aborts the run; a per-package `--sdk-version` miss only warns and leaves that package on the codemod-written range. Each
|
||||
`@modelcontextprotocol/*` package is resolved **independently** — they are not released in lockstep. Results are written to a per-run directory keyed on the resolved versions; the SDK segment is the resolved `@modelcontextprotocol/server` version used as a representative label,
|
||||
while the full per-package set is recorded in `summary.json` → `sdkVersions`:
|
||||
|
||||
```
|
||||
results/codemod-local__sdk-local/ # default (--sdk=local --codemod=local)
|
||||
results/codemod-2.0.0-alpha.2__sdk-2.0.0-alpha.2/ # --codemod=published --sdk=published --sdk-version=2.0.0-alpha.2
|
||||
results/codemod-2.0.0-alpha.2__sdk-from-codemod/ # --codemod=published --sdk=published (no --sdk-version)
|
||||
```
|
||||
|
||||
The segment `sdk-from-codemod` appears when `--sdk=published` with no `--sdk-version` and `--codemod=published` (the SDK version is baked into the published codemod and only known after install; the installed versions are recorded in `summary.json` → `sdkVersions`).
|
||||
|
||||
**Limitation:** in published-codemod mode the CLI emits text, not structured diagnostics, so `codemod.diagnostics` is empty and the raw CLI output is captured under `codemod.cli` instead. Diagnostics categorization applies only to local-codemod runs.
|
||||
|
||||
## Output
|
||||
|
||||
Results are written to `batch-test/results/`:
|
||||
Results are written to a per-run directory keyed on the resolved versions, `batch-test/results/<config>/`, where `<config>` is the `codemod-…__sdk-…` leaf (distinct `--codemod-version`/`--sdk-version` values produce distinct directories even within the same `--sdk`/`--codemod`
|
||||
mode; see [Modes](#modes)):
|
||||
|
||||
- `summary.json` — overview across all repos: which passed, which failed, error counts
|
||||
- `<repo-slug>/report.json` — per-repo detail: baseline vs post-codemod check results, codemod diagnostics, change counts
|
||||
- `results/<config>/summary.json` — overview across all repos: which passed, which failed, error counts, plus the run `config` and the resolved per-package `sdkVersions`
|
||||
- `results/<config>/<repo-slug>/report.json` — per-repo detail: baseline vs post-codemod check results, codemod diagnostics, change counts
|
||||
|
||||
## Repo manifest (`repos.json`)
|
||||
|
||||
|
|
@ -86,7 +122,7 @@ When `checks` is omitted, the runner auto-detects commands from the package's `p
|
|||
|
||||
1. Edit `repos.json` and add an entry
|
||||
2. Run `pnpm --filter @modelcontextprotocol/codemod batch-test`
|
||||
3. Check `results/<repo-slug>/report.json` for new findings
|
||||
3. Check `results/<config>/<repo-slug>/report.json` for new findings
|
||||
|
||||
For monorepos, list each package that uses `@modelcontextprotocol/sdk` as a separate entry in `packages`.
|
||||
|
||||
|
|
|
|||
|
|
@ -32,11 +32,13 @@ The goal is to find issues in the codemod itself — incorrect transforms, missi
|
|||
pnpm --filter @modelcontextprotocol/codemod batch-test
|
||||
```
|
||||
|
||||
3. Read `packages/codemod/batch-test/results/summary.json` for the overview. Note which repos have `postCodemodClean: false` and which check types have new errors.
|
||||
3. Read `packages/codemod/batch-test/results/*/summary.json` for the overview (there may be several config directories — one per `--sdk`/`--codemod` mode; read the run you're analyzing). Note which repos have `postCodemodClean: false` and which check types have new errors.
|
||||
|
||||
4. For each repo with new errors, read its `packages/codemod/batch-test/results/<repo-slug>/report.json`. Compare `baseline` vs `postCodemod` for each check — only errors that appear in `postCodemod` but not in `baseline` are codemod-introduced.
|
||||
4. For each repo with new errors, read its `packages/codemod/batch-test/results/<config>/<repo-slug>/report.json` (the same `<config>` directory as the summary). Compare `baseline` vs `postCodemod` for each check — only errors that appear in `postCodemod` but not in `baseline`
|
||||
are codemod-introduced.
|
||||
|
||||
5. Also review the `codemod.diagnostics` array in each report — these are warnings the codemod itself emitted about patterns it couldn't fully handle.
|
||||
5. Also review the `codemod.diagnostics` array in each report — these are warnings the codemod itself emitted about patterns it couldn't fully handle. This applies only to local-codemod runs; when `config.codemodSource` is `published`, `codemod.diagnostics` is empty and
|
||||
`codemod.cli` holds the raw CLI output instead.
|
||||
|
||||
6. For each codemod-introduced error, look at the actual source file in the cloned repo (`packages/codemod/batch-test/repos/<repo-slug>/...`) to understand what the codemod produced and what it should have produced.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { V2_PACKAGE_VERSIONS } from '../generated/versions';
|
||||
import { getMigration } from '../migrations/index';
|
||||
import { run } from '../runner';
|
||||
import type { Diagnostic, RunnerResult } from '../types';
|
||||
import type { Diagnostic, Migration } from '../types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
|
|
@ -38,6 +40,7 @@ interface PackageReport {
|
|||
filesChanged: number;
|
||||
totalChanges: number;
|
||||
diagnostics: Diagnostic[];
|
||||
cli?: { exitCode: number; stdout: string; stderr: string };
|
||||
};
|
||||
baseline: Record<string, CheckResult>;
|
||||
postCodemod: Record<string, CheckResult>;
|
||||
|
|
@ -60,10 +63,22 @@ interface SummaryEntry {
|
|||
codemodDiagnostics: Record<string, number>;
|
||||
}
|
||||
|
||||
interface SummaryConfig {
|
||||
codemodSource: Source;
|
||||
sdkSource: Source;
|
||||
codemodVersionSpec: string;
|
||||
codemodVersionResolved: string | null;
|
||||
sdkVersionSpec: string | null;
|
||||
sdkVersionResolved: string | null;
|
||||
resultsDir: string;
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
timestamp: string;
|
||||
codemodVersion: string;
|
||||
codemodCommit: string;
|
||||
config: SummaryConfig;
|
||||
sdkVersions: Record<string, string>; // per-package installed versions, best-effort across the run (workspace versions when sdk=local)
|
||||
totalRepos: number;
|
||||
totalPackages: number;
|
||||
results: SummaryEntry[];
|
||||
|
|
@ -75,6 +90,33 @@ interface Summary {
|
|||
};
|
||||
}
|
||||
|
||||
type Source = 'local' | 'published';
|
||||
|
||||
interface BatchTestOptions {
|
||||
manifest: string;
|
||||
sdk: Source;
|
||||
codemod: Source;
|
||||
codemodVersion: string;
|
||||
sdkVersion?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedConfig {
|
||||
codemodSource: Source;
|
||||
sdkSource: Source;
|
||||
codemodVersionSpec: string; // requested (e.g. 'latest')
|
||||
codemodVersionResolved: string | null; // concrete; null when codemod=local
|
||||
sdkVersionSpec: string | null; // --sdk-version or null
|
||||
sdkVersionResolved: string | null; // representative (server) concrete; null when sdk=local OR sdk-from-codemod
|
||||
resultsDir: string; // e.g. 'results/codemod-local__sdk-local'
|
||||
}
|
||||
|
||||
interface CodemodOutcome {
|
||||
filesChanged: number;
|
||||
totalChanges: number;
|
||||
diagnostics: Diagnostic[]; // [] in published mode
|
||||
cli?: { exitCode: number; stdout: string; stderr: string }; // published mode only
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -95,6 +137,10 @@ const LOCAL_PACKAGE_DIRS: Record<string, string> = {
|
|||
'@modelcontextprotocol/node': path.join(SDK_ROOT, 'packages/middleware/node')
|
||||
};
|
||||
|
||||
// v2 packages a target repo can depend on = every locally-mapped package except the private,
|
||||
// never-published core-internal (mirrors PRIVATE_PACKAGES in utils/packageJsonUpdater.ts).
|
||||
const PUBLISHABLE_V2_PACKAGES: string[] = Object.keys(LOCAL_PACKAGE_DIRS).filter(name => name !== '@modelcontextprotocol/core-internal');
|
||||
|
||||
const TARBALL_DIR = path.join(BATCH_DIR, 'tarballs');
|
||||
|
||||
const CHECK_SCRIPT_NAMES: Record<string, string[]> = {
|
||||
|
|
@ -143,6 +189,21 @@ function detectCheckCmd(pkgDir: string, checkType: string): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
// The batch test is invoked via pnpm, which exports its own config (minimum-release-age,
|
||||
// frozen-lockfile, catalogs, …) as npm_config_*/PNPM_* env vars. Those leak into every subprocess and
|
||||
// break things — recent-version installs get blocked by minimum-release-age, and a workspace-cwd `npx`
|
||||
// mis-resolves — so strip them and let subprocesses see a clean package-manager env (npmrc files are
|
||||
// still honored, so a custom registry keeps working). Exported for testing.
|
||||
export function cleanSubprocessEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const cleaned: NodeJS.ProcessEnv = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (!/^(npm_|pnpm_)/i.test(key)) {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function shell(cmd: string, cwd?: string): { exitCode: number; stdout: string; stderr: string } {
|
||||
try {
|
||||
const stdout = execSync(cmd, {
|
||||
|
|
@ -153,7 +214,8 @@ function shell(cmd: string, cwd?: string): { exitCode: number; stdout: string; s
|
|||
// Commands are spawned without a TTY (piped stdio). Set CI so package managers run fully
|
||||
// non-interactively — without it, pnpm aborts rebuilding a clone's modules dir with
|
||||
// ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY when --ignore-workspace changes its link mode.
|
||||
env: { ...process.env, CI: 'true' }
|
||||
// The env is cleaned so the invoking pnpm's config (minimum-release-age, etc.) can't leak in.
|
||||
env: { ...cleanSubprocessEnv(process.env), CI: 'true' }
|
||||
}).toString();
|
||||
return { exitCode: 0, stdout, stderr: '' };
|
||||
} catch (error: unknown) {
|
||||
|
|
@ -189,6 +251,72 @@ function truncate(s: string, max = 50_000): string {
|
|||
return s.length > max ? s.slice(0, max) + '\n... (truncated)' : s;
|
||||
}
|
||||
|
||||
// The published codemod CLI prints `Changes: <totalChanges> across <filesChanged> file(s)` ONLY when
|
||||
// files changed (src/cli.ts:104); a no-op run prints the `No changes needed — …` line instead, and a
|
||||
// diagnostics-only run prints neither. Best-effort: match the Changes line, else report zeros.
|
||||
export function parseCodemodCliOutput(stdout: string): { filesChanged: number; totalChanges: number } {
|
||||
const m = stdout.match(/Changes:\s+(\d+)\s+across\s+(\d+)\s+file\(s\)/);
|
||||
if (m) {
|
||||
return { totalChanges: Number(m[1]), filesChanged: Number(m[2]) };
|
||||
}
|
||||
return { totalChanges: 0, filesChanged: 0 };
|
||||
}
|
||||
|
||||
// Normalizes the two codemod execution paths. local = in-process run() (structured diagnostics);
|
||||
// published = shell out to the pinned published CLI (raw stdout/stderr captured, diagnostics []).
|
||||
export function runCodemod(source: Source, args: { migration: Migration; sourceDir: string; codemodVersion: string }): CodemodOutcome {
|
||||
if (source === 'local') {
|
||||
try {
|
||||
const r = run(args.migration, { targetDir: args.sourceDir, verbose: true });
|
||||
return { filesChanged: r.filesChanged, totalChanges: r.totalChanges, diagnostics: r.diagnostics };
|
||||
} catch (error) {
|
||||
console.log(` ERROR: codemod threw: ${error}`);
|
||||
return { filesChanged: 0, totalChanges: 0, diagnostics: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// published: -p pins the exact resolved version; `mcp-codemod` is the bin, `v1-to-v2` the command.
|
||||
// A non-zero exit (the CLI flags error diagnostics) is recorded, not fatal.
|
||||
// SECURITY: see resolvePublishedVersion — interpolating codemodVersion/sourceDir here is safe ONLY
|
||||
// because both are operator-controlled (JSON.stringify does NOT stop $(…)/backticks under `sh -c`).
|
||||
const cmd = `npx -y -p @modelcontextprotocol/codemod@${args.codemodVersion} mcp-codemod v1-to-v2 ${JSON.stringify(args.sourceDir)} --verbose`;
|
||||
// npx must run OUTSIDE the SDK's pnpm workspace, or it resolves the workspace's own `mcp-codemod`
|
||||
// bin link instead of the published package and exits 127. tmpdir() is a neutral cwd; the codemod
|
||||
// target is an absolute path arg, so cwd doesn't affect what gets migrated.
|
||||
const result = shell(cmd, tmpdir());
|
||||
const counts = parseCodemodCliOutput(result.stdout);
|
||||
return {
|
||||
filesChanged: counts.filesChanged,
|
||||
totalChanges: counts.totalChanges,
|
||||
diagnostics: [],
|
||||
cli: { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }
|
||||
};
|
||||
}
|
||||
|
||||
// Maps a resolved config to its per-run results-directory leaf name: `<codemodSeg>__<sdkSeg>`. The codemod
|
||||
// segment is `codemod-local` (local source) or `codemod-<resolved>`; the SDK segment is `sdk-local` (local),
|
||||
// `sdk-<resolved>` when a representative version is known, else `sdk-from-codemod` (published, version unknown).
|
||||
export function computeResultsDirName(resolved: ResolvedConfig): string {
|
||||
const codemodSeg = resolved.codemodSource === 'local' ? 'codemod-local' : `codemod-${resolved.codemodVersionResolved}`;
|
||||
|
||||
let sdkSeg: string;
|
||||
if (resolved.sdkSource === 'local') {
|
||||
sdkSeg = 'sdk-local';
|
||||
} else if (resolved.sdkVersionResolved) {
|
||||
sdkSeg = `sdk-${resolved.sdkVersionResolved}`;
|
||||
} else {
|
||||
sdkSeg = 'sdk-from-codemod';
|
||||
}
|
||||
|
||||
return `${codemodSeg}__${sdkSeg}`;
|
||||
}
|
||||
|
||||
// Human-readable mode descriptor for the startup banner: `local`, or `published (<ver>)` /
|
||||
// `published (from-codemod)` when the concrete version is only known after install.
|
||||
function fmtMode(src: Source, ver: string | null): string {
|
||||
return src === 'local' ? 'local' : `published (${ver ?? 'from-codemod'})`;
|
||||
}
|
||||
|
||||
function packLocalPackages(): Record<string, string> {
|
||||
mkdirSync(TARBALL_DIR, { recursive: true });
|
||||
|
||||
|
|
@ -233,6 +361,64 @@ function rewriteToLocalTarballs(pkgJsonPath: string, tarballs: Record<string, st
|
|||
return rewrites;
|
||||
}
|
||||
|
||||
export function parseNpmViewVersion(jsonText: string): string {
|
||||
const parsed = JSON.parse(jsonText) as string | string[];
|
||||
if (Array.isArray(parsed)) {
|
||||
if (parsed.length === 0) throw new Error('npm view returned an empty version array');
|
||||
// `npm view <pkg>@<range>` lists matches in publish order, so this is the most recently published
|
||||
// match — not necessarily the highest semver (a backport published after a newer release sorts last).
|
||||
// Adequate here: specs are normally an exact version or a dist-tag (both resolve to a single string
|
||||
// above), and the SDK packages publish in forward order; not worth a semver dependency for a dev-only
|
||||
// label. Revisit with a semver max if ranges over out-of-order publishes become common.
|
||||
return parsed.at(-1)!;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Resolve a single package@spec to a concrete version via the registry. PM-agnostic (npm ships with
|
||||
// Node). Throws so the caller can abort at startup before any repo work begins. Consumed in main()
|
||||
// (Task 6); exported to keep it part of the module surface rather than an unused module-private fn.
|
||||
// SECURITY: interpolating pkg@spec here (like the npx/git shell-outs elsewhere in this harness) is safe ONLY
|
||||
// because every input is operator/maintainer-controlled — CLI flags, the committed repos.json, and
|
||||
// Anthropic-published npm versions. JSON.stringify quoting does NOT neutralize $(…)/backticks under `sh -c`,
|
||||
// so this harness must never be pointed at an untrusted manifest.
|
||||
export function resolvePublishedVersion(pkg: string, spec: string): string {
|
||||
const result = shell(`npm view ${JSON.stringify(`${pkg}@${spec}`)} version --json`);
|
||||
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
||||
throw new Error(`Failed to resolve ${pkg}@${spec} via npm view: ${result.stderr.trim() || 'no output'}`);
|
||||
}
|
||||
return parseNpmViewVersion(result.stdout.trim());
|
||||
}
|
||||
|
||||
// Pin each present v2 dependency to its OWN resolved version (packages are not lockstep). Mirrors
|
||||
// rewriteToLocalTarballs' formatting preservation. Returns the rewrite count.
|
||||
export function rewriteToPublishedVersion(pkgJsonPath: string, versionByPkg: Record<string, string>): number {
|
||||
const raw = readFileSync(pkgJsonPath, 'utf8');
|
||||
const pkgJson = JSON.parse(raw) as Record<string, unknown>;
|
||||
let rewrites = 0;
|
||||
|
||||
for (const section of ['dependencies', 'devDependencies']) {
|
||||
const deps = pkgJson[section] as Record<string, string> | undefined;
|
||||
if (!deps) continue;
|
||||
for (const name of PUBLISHABLE_V2_PACKAGES) {
|
||||
if (name in deps && versionByPkg[name]) {
|
||||
deps[name] = versionByPkg[name]!;
|
||||
rewrites++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rewrites > 0) {
|
||||
const indent = raw.match(/^(\s+)"/m)?.[1] ?? ' ';
|
||||
const trailingNewline = raw.endsWith('\n');
|
||||
let output = JSON.stringify(pkgJson, null, indent);
|
||||
if (trailingNewline) output += '\n';
|
||||
writeFileSync(pkgJsonPath, output);
|
||||
}
|
||||
|
||||
return rewrites;
|
||||
}
|
||||
|
||||
function getCheckOverride(checks: Record<string, string | null>, type: string): string | null | undefined {
|
||||
if (type in checks) return checks[type] ?? null;
|
||||
return undefined;
|
||||
|
|
@ -253,10 +439,30 @@ function hasNewError(baseline: Record<string, CheckResult>, post: Record<string,
|
|||
const CLONE_DIR = path.join(BATCH_DIR, 'repos');
|
||||
const OUTPUT_DIR = path.join(BATCH_DIR, 'results');
|
||||
|
||||
function parseArgs(): { manifest: string } {
|
||||
const args = process.argv.slice(2).filter(a => a !== '--');
|
||||
const opts = {
|
||||
manifest: path.join(BATCH_DIR, 'repos.json')
|
||||
function parseSource(flag: string, value: string | undefined): Source {
|
||||
if (value !== 'local' && value !== 'published') {
|
||||
throw new Error(`Invalid ${flag} value: ${value ?? '(missing)'}. Expected 'local' or 'published'.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[] = process.argv.slice(2)): BatchTestOptions {
|
||||
// Support both `--flag value` and `--flag=value`; drop a bare `--` separator.
|
||||
const args = argv
|
||||
.filter(a => a !== '--')
|
||||
.flatMap(a => {
|
||||
if (a.startsWith('--') && a.includes('=')) {
|
||||
const idx = a.indexOf('=');
|
||||
return [a.slice(0, idx), a.slice(idx + 1)];
|
||||
}
|
||||
return [a];
|
||||
});
|
||||
|
||||
const opts: BatchTestOptions = {
|
||||
manifest: path.join(BATCH_DIR, 'repos.json'),
|
||||
sdk: 'local',
|
||||
codemod: 'local',
|
||||
codemodVersion: 'latest'
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
|
|
@ -265,17 +471,47 @@ function parseArgs(): { manifest: string } {
|
|||
opts.manifest = args[++i]!;
|
||||
break;
|
||||
}
|
||||
case '--sdk': {
|
||||
opts.sdk = parseSource('--sdk', args[++i]);
|
||||
break;
|
||||
}
|
||||
case '--codemod': {
|
||||
opts.codemod = parseSource('--codemod', args[++i]);
|
||||
break;
|
||||
}
|
||||
case '--codemod-version': {
|
||||
opts.codemodVersion = args[++i]!;
|
||||
break;
|
||||
}
|
||||
case '--sdk-version': {
|
||||
opts.sdkVersion = args[++i]!;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.error(`Unknown flag: ${args[i]}`);
|
||||
process.exit(1);
|
||||
throw new Error(`Unknown flag: ${args[i]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A version override only applies to a published source; warn + ignore otherwise.
|
||||
if (opts.codemod === 'local' && args.includes('--codemod-version')) {
|
||||
console.warn('Warning: --codemod-version is ignored when --codemod=local');
|
||||
}
|
||||
if (opts.sdk === 'local' && opts.sdkVersion !== undefined) {
|
||||
console.warn('Warning: --sdk-version is ignored when --sdk=local');
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const opts = parseArgs();
|
||||
let opts: BatchTestOptions;
|
||||
try {
|
||||
opts = parseArgs();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(opts.manifest)) {
|
||||
console.error(`Error: manifest not found at ${opts.manifest}`);
|
||||
|
|
@ -302,11 +538,78 @@ function main(): void {
|
|||
console.log(`Codemod: v${codemodVersion} (${codemodCommit})`);
|
||||
console.log('');
|
||||
|
||||
console.log('--- Packing local SDK packages ---');
|
||||
const tarballs = packLocalPackages();
|
||||
console.log(` Packed ${Object.keys(tarballs).length} packages\n`);
|
||||
const SERVER_PKG = '@modelcontextprotocol/server';
|
||||
|
||||
// Resolve published versions once, up front. A codemod-version or representative-label miss aborts here;
|
||||
// a per-package --sdk-version miss is tolerated with a warning (see the resolve loop below).
|
||||
let resolved: ResolvedConfig;
|
||||
const sdkVersions: Record<string, string> = {};
|
||||
try {
|
||||
const codemodVersionResolved =
|
||||
opts.codemod === 'published' ? resolvePublishedVersion('@modelcontextprotocol/codemod', opts.codemodVersion) : null;
|
||||
|
||||
let sdkVersionResolved: string | null = null;
|
||||
if (opts.sdk === 'published') {
|
||||
if (opts.sdkVersion !== undefined) {
|
||||
// Force-pin: resolve EACH publishable package independently against the requested spec.
|
||||
// The SDK packages are NOT lockstep, so a given version may be missing for one package
|
||||
// (e.g. @modelcontextprotocol/core's latest is alpha.1 while the rest have alpha.3). Tolerate
|
||||
// a per-package miss with a warning + continue — mirroring packLocalPackages' per-pack
|
||||
// tolerance — instead of aborting the whole run. A skipped package is left out of sdkVersions,
|
||||
// so rewriteToPublishedVersion keeps whatever range the codemod wrote for it.
|
||||
for (const pkg of PUBLISHABLE_V2_PACKAGES) {
|
||||
try {
|
||||
sdkVersions[pkg] = resolvePublishedVersion(pkg, opts.sdkVersion);
|
||||
} catch {
|
||||
console.warn(`Warning: ${pkg}@${opts.sdkVersion} did not resolve; leaving its codemod-written range in place.`);
|
||||
}
|
||||
}
|
||||
sdkVersionResolved = sdkVersions[SERVER_PKG] ?? null; // representative label
|
||||
} else if (opts.codemod === 'local') {
|
||||
// Unset version + local codemod: the codemod writes its bundled ranges; resolve the server
|
||||
// range to a concrete version for the directory label only (no rewrite).
|
||||
sdkVersionResolved = resolvePublishedVersion(SERVER_PKG, V2_PACKAGE_VERSIONS[SERVER_PKG]!);
|
||||
}
|
||||
// else (unset + published codemod): version is unknown until install → sdk-from-codemod.
|
||||
}
|
||||
|
||||
resolved = {
|
||||
codemodSource: opts.codemod,
|
||||
sdkSource: opts.sdk,
|
||||
codemodVersionSpec: opts.codemodVersion,
|
||||
codemodVersionResolved,
|
||||
sdkVersionSpec: opts.sdkVersion ?? null,
|
||||
sdkVersionResolved,
|
||||
resultsDir: '' // filled next
|
||||
};
|
||||
resolved.resultsDir = `results/${computeResultsDirName(resolved)}`;
|
||||
} catch (error) {
|
||||
console.error(`Error resolving published versions: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const runOutputDir = path.join(OUTPUT_DIR, computeResultsDirName(resolved));
|
||||
mkdirSync(runOutputDir, { recursive: true });
|
||||
|
||||
console.log(
|
||||
`Codemod: ${fmtMode(resolved.codemodSource, resolved.codemodVersionResolved)} | SDK: ${fmtMode(resolved.sdkSource, resolved.sdkVersionResolved)}`
|
||||
);
|
||||
console.log(`Results: ${runOutputDir}\n`);
|
||||
|
||||
let tarballs: Record<string, string> = {};
|
||||
if (resolved.sdkSource === 'local') {
|
||||
console.log('--- Packing local SDK packages ---');
|
||||
tarballs = packLocalPackages();
|
||||
console.log(` Packed ${Object.keys(tarballs).length} packages\n`);
|
||||
} else {
|
||||
console.log('Skipping local pack (SDK source: published)\n');
|
||||
}
|
||||
|
||||
const summaryResults: SummaryEntry[] = [];
|
||||
// Actually-installed versions for summary.sdkVersions — seeded from the startup-resolved pins and refined
|
||||
// per-repo from node_modules below. Kept SEPARATE from `sdkVersions` (the immutable pinning map) so a
|
||||
// package left unpinned at startup is not retroactively pinned for later repos from an earlier install.
|
||||
const installedVersions: Record<string, string> = { ...sdkVersions };
|
||||
let totalPackages = 0;
|
||||
|
||||
for (let i = 0; i < manifest.length; i++) {
|
||||
|
|
@ -320,7 +623,10 @@ function main(): void {
|
|||
// Step 1: Clone or reset
|
||||
if (existsSync(path.join(clonePath, '.git'))) {
|
||||
console.log(' Resetting existing clone...');
|
||||
shell('git restore .', clonePath);
|
||||
// --staged --worktree reverts both the index and the working tree to HEAD: a prior run's
|
||||
// migration can end up staged (e.g. a target repo's own pre-commit / lint-staged hooks), and a
|
||||
// worktree-only `git restore .` can't undo a staged change, leaving a stale migrated clone.
|
||||
shell('git restore --staged --worktree .', clonePath);
|
||||
shell('git clean -fd', clonePath);
|
||||
} else {
|
||||
console.log(' Cloning...');
|
||||
|
|
@ -368,23 +674,27 @@ function main(): void {
|
|||
` Baseline: tc=${baseline['typecheck']!.exitCode} build=${baseline['build']!.exitCode} test=${baseline['test']!.exitCode} lint=${baseline['lint']!.exitCode}`
|
||||
);
|
||||
|
||||
// Step 5: Run codemod (programmatic API)
|
||||
// Step 5: Run codemod (local in-process API, or published CLI)
|
||||
console.log(' Running codemod...');
|
||||
let codemodResult: RunnerResult;
|
||||
try {
|
||||
codemodResult = run(migration, { targetDir: fullSourceDir, verbose: true });
|
||||
} catch (error) {
|
||||
console.log(` ERROR: codemod threw: ${error}`);
|
||||
codemodResult = { filesChanged: 0, totalChanges: 0, diagnostics: [], fileResults: [], commentCount: 0 };
|
||||
}
|
||||
const codemodOutcome = runCodemod(resolved.codemodSource, {
|
||||
migration,
|
||||
sourceDir: fullSourceDir,
|
||||
codemodVersion: resolved.codemodVersionResolved ?? ''
|
||||
});
|
||||
console.log(
|
||||
` Codemod: files=${codemodResult.filesChanged} changes=${codemodResult.totalChanges} diags=${codemodResult.diagnostics.length}`
|
||||
` Codemod: files=${codemodOutcome.filesChanged} changes=${codemodOutcome.totalChanges} diags=${codemodOutcome.diagnostics.length}` +
|
||||
(codemodOutcome.cli ? ` cliExit=${codemodOutcome.cli.exitCode}` : '')
|
||||
);
|
||||
|
||||
// Step 6: Rewrite v2 deps to local tarballs, then re-install
|
||||
const rewrites = rewriteToLocalTarballs(path.join(fullPkgDir, 'package.json'), tarballs);
|
||||
if (rewrites > 0) {
|
||||
console.log(` Rewrote ${rewrites} deps to local tarballs`);
|
||||
// Step 6: Point the clone's v2 deps at the chosen SDK source, then re-install
|
||||
if (resolved.sdkSource === 'local') {
|
||||
const rewrites = rewriteToLocalTarballs(path.join(fullPkgDir, 'package.json'), tarballs);
|
||||
if (rewrites > 0) console.log(` Rewrote ${rewrites} deps to local tarballs`);
|
||||
} else if (resolved.sdkVersionSpec === null) {
|
||||
console.log(' Leaving codemod-written dependency ranges (SDK: published, version from codemod)');
|
||||
} else {
|
||||
const rewrites = rewriteToPublishedVersion(path.join(fullPkgDir, 'package.json'), sdkVersions);
|
||||
if (rewrites > 0) console.log(` Pinned ${rewrites} deps to resolved published versions`);
|
||||
}
|
||||
console.log(' Re-installing dependencies...');
|
||||
shell(installCommand(pm), clonePath);
|
||||
|
|
@ -399,6 +709,35 @@ function main(): void {
|
|||
` Post: tc=${postCodemod['typecheck']!.exitCode} build=${postCodemod['build']!.exitCode} test=${postCodemod['test']!.exitCode} lint=${postCodemod['lint']!.exitCode}`
|
||||
);
|
||||
|
||||
// Record the actually-installed SDK versions (best-effort) so summary.sdkVersions is truthful for
|
||||
// every mode: published-pinned, sdk-from-codemod (versions only known post-install), and local (the
|
||||
// workspace versions packed into the tarballs the clone depends on). Recorded into `installedVersions`
|
||||
// and NOT back into the startup-resolved `sdkVersions` pins: writing into the pinning map would
|
||||
// retroactively pin a startup-unresolved package for every later repo to whatever the first repo
|
||||
// happened to install. Done AFTER the checks because a monorepo target's deps are installed into
|
||||
// <pkg.dir>/node_modules by the check command itself — the Step-6 reinstall runs at the clone root
|
||||
// and, under --ignore-workspace, never touches a subdirectory package. Resolve against the package's
|
||||
// node_modules first, then fall back to the clone root for hoisted single-package layouts; for a root
|
||||
// package (pkg.dir = '.') the two coincide (deduped). The summary's `config` records the SDK source,
|
||||
// so a bare version here is unambiguous.
|
||||
for (const sdkPkg of PUBLISHABLE_V2_PACKAGES) {
|
||||
const candidates = [
|
||||
...new Set([
|
||||
path.join(fullPkgDir, 'node_modules', sdkPkg, 'package.json'),
|
||||
path.join(clonePath, 'node_modules', sdkPkg, 'package.json')
|
||||
])
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const installed = JSON.parse(readFileSync(candidate, 'utf8')) as { version: string };
|
||||
installedVersions[sdkPkg] = installed.version;
|
||||
break;
|
||||
} catch {
|
||||
// not installed at this location — try the next candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate large outputs for the report
|
||||
for (const r of [...Object.values(baseline), ...Object.values(postCodemod)]) {
|
||||
r.stdout = truncate(r.stdout);
|
||||
|
|
@ -409,9 +748,18 @@ function main(): void {
|
|||
dir: pkg.dir,
|
||||
sourceDir,
|
||||
codemod: {
|
||||
filesChanged: codemodResult.filesChanged,
|
||||
totalChanges: codemodResult.totalChanges,
|
||||
diagnostics: codemodResult.diagnostics
|
||||
filesChanged: codemodOutcome.filesChanged,
|
||||
totalChanges: codemodOutcome.totalChanges,
|
||||
diagnostics: codemodOutcome.diagnostics,
|
||||
...(codemodOutcome.cli
|
||||
? {
|
||||
cli: {
|
||||
exitCode: codemodOutcome.cli.exitCode,
|
||||
stdout: truncate(codemodOutcome.cli.stdout),
|
||||
stderr: truncate(codemodOutcome.cli.stderr)
|
||||
}
|
||||
}
|
||||
: {})
|
||||
},
|
||||
baseline,
|
||||
postCodemod
|
||||
|
|
@ -430,15 +778,15 @@ function main(): void {
|
|||
lint: hasNewError(baseline, postCodemod, 'lint')
|
||||
},
|
||||
codemodDiagnostics: {
|
||||
warning: codemodResult.diagnostics.filter(d => d.level === 'warning').length,
|
||||
error: codemodResult.diagnostics.filter(d => d.level === 'error').length,
|
||||
info: codemodResult.diagnostics.filter(d => d.level === 'info').length
|
||||
warning: codemodOutcome.diagnostics.filter(d => d.level === 'warning').length,
|
||||
error: codemodOutcome.diagnostics.filter(d => d.level === 'error').length,
|
||||
info: codemodOutcome.diagnostics.filter(d => d.level === 'info').length
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Step 8: Write per-repo report
|
||||
const repoOutputDir = path.join(OUTPUT_DIR, repoSlug);
|
||||
// Step 8: Write per-repo report (nested under the per-run results dir)
|
||||
const repoOutputDir = path.join(runOutputDir, repoSlug);
|
||||
mkdirSync(repoOutputDir, { recursive: true });
|
||||
|
||||
const report: RepoReport = {
|
||||
|
|
@ -462,6 +810,16 @@ function main(): void {
|
|||
timestamp,
|
||||
codemodVersion,
|
||||
codemodCommit,
|
||||
config: {
|
||||
codemodSource: resolved.codemodSource,
|
||||
sdkSource: resolved.sdkSource,
|
||||
codemodVersionSpec: resolved.codemodVersionSpec,
|
||||
codemodVersionResolved: resolved.codemodVersionResolved,
|
||||
sdkVersionSpec: resolved.sdkVersionSpec,
|
||||
sdkVersionResolved: resolved.sdkVersionResolved,
|
||||
resultsDir: resolved.resultsDir
|
||||
},
|
||||
sdkVersions: installedVersions,
|
||||
totalRepos: manifest.length,
|
||||
totalPackages,
|
||||
results: summaryResults,
|
||||
|
|
@ -472,14 +830,16 @@ function main(): void {
|
|||
totalCodemodWarnings: totalWarnings
|
||||
}
|
||||
};
|
||||
writeFileSync(path.join(OUTPUT_DIR, 'summary.json'), JSON.stringify(summary, null, 2));
|
||||
writeFileSync(path.join(runOutputDir, 'summary.json'), JSON.stringify(summary, null, 2));
|
||||
|
||||
console.log('=== Summary ===');
|
||||
console.log(`Repos: ${manifest.length} | Packages: ${totalPackages}`);
|
||||
console.log(`Clean after codemod: ${reposClean} | With new errors: ${reposWithErrors}`);
|
||||
console.log(`New typecheck errors: ${totalNewTc} | Codemod warnings: ${totalWarnings}`);
|
||||
console.log('');
|
||||
console.log(`Results: ${OUTPUT_DIR}/summary.json`);
|
||||
console.log(`Results: ${runOutputDir}/summary.json`);
|
||||
}
|
||||
|
||||
main();
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ 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;
|
||||
|
||||
|
|
@ -143,6 +144,16 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
|
|||
const fileDiagnostics: Diagnostic[] = [];
|
||||
const originalText = sourceFile.getFullText();
|
||||
|
||||
// A leading `#!` shebang is leading trivia of the first import; some transforms drop it when
|
||||
// they rewrite that import, silently breaking CLI packages whose `bin` points at the compiled
|
||||
// entry. Capture it now and restore it after transforms, before saving. Include any blank lines
|
||||
// that followed it (also part of the same dropped trivia) so the original spacing round-trips —
|
||||
// the `\r?` keeps that working for CRLF files, where a blank line is `\r\n`.
|
||||
const shebangMatch = originalText.match(/^#![^\n]*\n(?:[ \t]*\r?\n)*/);
|
||||
if (shebangMatch) {
|
||||
shebangs.set(sourceFile.getFilePath(), shebangMatch[0]);
|
||||
}
|
||||
|
||||
const fileClaimedPackages = new Set<string>();
|
||||
try {
|
||||
for (const transform of enabledTransforms) {
|
||||
|
|
@ -215,6 +226,24 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult
|
|||
let commentCount = 0;
|
||||
if (!options.dryRun) {
|
||||
commentCount = insertDiagnosticComments(project, fileResults);
|
||||
// Restore any shebang a transform dropped. Done after comment insertion so the inserted
|
||||
// comments (positioned against the post-transform text) shift down with the code uniformly.
|
||||
for (const [filePath, shebang] of shebangs) {
|
||||
const sf = project.getSourceFile(filePath);
|
||||
if (sf && !sf.getFullText().startsWith('#!')) {
|
||||
sf.insertText(0, shebang);
|
||||
// Diagnostic lines were resolved against the post-transform, shebang-stripped text;
|
||||
// re-inserting the shebang pushes every line down by its line count, so bump the reported
|
||||
// lines to stay aligned with the saved file. (Comment insertion above already ran against
|
||||
// the stripped text, so its placement is unaffected.) These Diagnostic objects are shared
|
||||
// with the returned `diagnostics` array, so the fix reaches the CLI output and report too.
|
||||
const lineShift = (shebang.match(/\n/g) ?? []).length;
|
||||
const fileResult = fileResults.find(fr => fr.filePath === filePath);
|
||||
if (fileResult) {
|
||||
for (const d of fileResult.diagnostics) d.line += lineShift;
|
||||
}
|
||||
}
|
||||
}
|
||||
project.saveSync();
|
||||
}
|
||||
|
||||
|
|
|
|||
236
packages/codemod/test/batchTest.test.ts
Normal file
236
packages/codemod/test/batchTest.test.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
import { parseArgs, parseCodemodCliOutput, runCodemod } from '../src/bin/batchTest';
|
||||
import { computeResultsDirName, type ResolvedConfig } from '../src/bin/batchTest';
|
||||
import { parseNpmViewVersion, rewriteToPublishedVersion, cleanSubprocessEnv } from '../src/bin/batchTest';
|
||||
import { getMigration } from '../src/migrations/index';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('parseArgs', () => {
|
||||
it('defaults to local/local with latest codemod version and unset sdk version', () => {
|
||||
const opts = parseArgs([]);
|
||||
expect(opts.sdk).toBe('local');
|
||||
expect(opts.codemod).toBe('local');
|
||||
expect(opts.codemodVersion).toBe('latest');
|
||||
expect(opts.sdkVersion).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts both space and equals forms for --sdk/--codemod', () => {
|
||||
expect(parseArgs(['--sdk', 'published', '--codemod', 'published']).sdk).toBe('published');
|
||||
expect(parseArgs(['--sdk=published', '--codemod=published']).codemod).toBe('published');
|
||||
});
|
||||
|
||||
it('parses version overrides', () => {
|
||||
const opts = parseArgs([
|
||||
'--codemod=published',
|
||||
'--codemod-version=2.0.0-alpha.2',
|
||||
'--sdk=published',
|
||||
'--sdk-version=2.0.0-alpha.1'
|
||||
]);
|
||||
expect(opts.codemodVersion).toBe('2.0.0-alpha.2');
|
||||
expect(opts.sdkVersion).toBe('2.0.0-alpha.1');
|
||||
});
|
||||
|
||||
it('strips a leading -- separator', () => {
|
||||
expect(parseArgs(['--', '--sdk=published']).sdk).toBe('published');
|
||||
});
|
||||
|
||||
it('throws on an invalid --sdk value', () => {
|
||||
expect(() => parseArgs(['--sdk=bogus'])).toThrow(/Invalid --sdk/);
|
||||
});
|
||||
|
||||
it('throws on an unknown flag', () => {
|
||||
expect(() => parseArgs(['--nope'])).toThrow(/Unknown flag/);
|
||||
});
|
||||
|
||||
it('warns (does not throw) when a version override is given for a local source', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const opts = parseArgs(['--codemod-version=2.0.0-alpha.2', '--sdk-version=2.0.0-alpha.1']);
|
||||
expect(opts.codemod).toBe('local');
|
||||
expect(opts.sdk).toBe('local');
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCodemodCliOutput', () => {
|
||||
it('parses the Changes line (first number = totalChanges, second = filesChanged)', () => {
|
||||
expect(parseCodemodCliOutput('Migrating...\nChanges: 42 across 7 file(s)\n')).toEqual({
|
||||
totalChanges: 42,
|
||||
filesChanged: 7
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zeros for the no-changes line', () => {
|
||||
expect(parseCodemodCliOutput('No changes needed — code already migrated or no SDK imports found.\n')).toEqual({
|
||||
totalChanges: 0,
|
||||
filesChanged: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zeros when neither summary line is present (diagnostics-only run)', () => {
|
||||
expect(parseCodemodCliOutput('Errors (2):\n src/a.ts:1 something\n')).toEqual({
|
||||
totalChanges: 0,
|
||||
filesChanged: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function cfg(p: Partial<ResolvedConfig>): ResolvedConfig {
|
||||
return {
|
||||
codemodSource: 'local',
|
||||
sdkSource: 'local',
|
||||
codemodVersionSpec: 'latest',
|
||||
codemodVersionResolved: null,
|
||||
sdkVersionSpec: null,
|
||||
sdkVersionResolved: null,
|
||||
resultsDir: '',
|
||||
...p
|
||||
};
|
||||
}
|
||||
|
||||
describe('computeResultsDirName', () => {
|
||||
it('default local/local', () => {
|
||||
expect(computeResultsDirName(cfg({}))).toBe('codemod-local__sdk-local');
|
||||
});
|
||||
it('local codemod, published sdk (resolved for naming)', () => {
|
||||
expect(computeResultsDirName(cfg({ sdkSource: 'published', sdkVersionResolved: '2.0.0-alpha.1' }))).toBe(
|
||||
'codemod-local__sdk-2.0.0-alpha.1'
|
||||
);
|
||||
});
|
||||
it('published/published with both resolved', () => {
|
||||
expect(
|
||||
computeResultsDirName(
|
||||
cfg({
|
||||
codemodSource: 'published',
|
||||
codemodVersionResolved: '2.0.0-alpha.2',
|
||||
sdkSource: 'published',
|
||||
sdkVersionResolved: '2.0.0-alpha.2'
|
||||
})
|
||||
)
|
||||
).toBe('codemod-2.0.0-alpha.2__sdk-2.0.0-alpha.2');
|
||||
});
|
||||
it('published codemod, published sdk with unknown version → sdk-from-codemod', () => {
|
||||
expect(
|
||||
computeResultsDirName(cfg({ codemodSource: 'published', codemodVersionResolved: '2.0.0-alpha.2', sdkSource: 'published' }))
|
||||
).toBe('codemod-2.0.0-alpha.2__sdk-from-codemod');
|
||||
});
|
||||
it('published codemod, local sdk', () => {
|
||||
expect(computeResultsDirName(cfg({ codemodSource: 'published', codemodVersionResolved: '2.0.0-alpha.2' }))).toBe(
|
||||
'codemod-2.0.0-alpha.2__sdk-local'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseNpmViewVersion', () => {
|
||||
it('uses a JSON string directly', () => {
|
||||
expect(parseNpmViewVersion('"2.0.0-alpha.2"')).toBe('2.0.0-alpha.2');
|
||||
});
|
||||
it('takes the last (most recently published) entry of a JSON array', () => {
|
||||
expect(parseNpmViewVersion('["2.0.0-alpha.1","2.0.0-alpha.2"]')).toBe('2.0.0-alpha.2');
|
||||
});
|
||||
it('throws on an empty array', () => {
|
||||
expect(() => parseNpmViewVersion('[]')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteToPublishedVersion', () => {
|
||||
let dir: string;
|
||||
afterEach(() => {
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rewrites only v2 deps to their per-package resolved versions, preserving formatting', () => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'mcp-batch-rewrite-'));
|
||||
const pkgPath = path.join(dir, 'package.json');
|
||||
const original =
|
||||
'{\n' +
|
||||
' "name": "demo",\n' +
|
||||
' "dependencies": {\n' +
|
||||
' "@modelcontextprotocol/server": "^2.0.0-alpha.0",\n' +
|
||||
' "@modelcontextprotocol/core": "^2.0.0-alpha.0",\n' +
|
||||
' "zod": "^3.0.0"\n' +
|
||||
' },\n' +
|
||||
' "devDependencies": {\n' +
|
||||
' "@modelcontextprotocol/client": "^2.0.0-alpha.0"\n' +
|
||||
' }\n' +
|
||||
'}\n';
|
||||
writeFileSync(pkgPath, original);
|
||||
|
||||
const count = rewriteToPublishedVersion(pkgPath, {
|
||||
'@modelcontextprotocol/server': '2.0.0-alpha.3',
|
||||
'@modelcontextprotocol/core': '2.0.0-alpha.1',
|
||||
'@modelcontextprotocol/client': '2.0.0-alpha.3'
|
||||
});
|
||||
|
||||
expect(count).toBe(3);
|
||||
const result = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
||||
expect(result.dependencies['@modelcontextprotocol/server']).toBe('2.0.0-alpha.3');
|
||||
expect(result.dependencies['@modelcontextprotocol/core']).toBe('2.0.0-alpha.1'); // independent version
|
||||
expect(result.devDependencies['@modelcontextprotocol/client']).toBe('2.0.0-alpha.3');
|
||||
expect(result.dependencies['zod']).toBe('^3.0.0'); // untouched
|
||||
const raw = readFileSync(pkgPath, 'utf8');
|
||||
expect(raw.endsWith('\n')).toBe(true); // trailing newline preserved
|
||||
expect(raw).toContain(' "name"'); // 2-space indent preserved
|
||||
});
|
||||
|
||||
it('returns 0 and leaves the file unwritten when no v2 deps are present', () => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'mcp-batch-rewrite-'));
|
||||
const pkgPath = path.join(dir, 'package.json');
|
||||
writeFileSync(pkgPath, '{\n "dependencies": { "zod": "^3.0.0" }\n}\n');
|
||||
expect(rewriteToPublishedVersion(pkgPath, { '@modelcontextprotocol/server': '2.0.0-alpha.3' })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runCodemod (local)', () => {
|
||||
let dir: string;
|
||||
afterEach(() => {
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('runs the in-process codemod and returns structured fields with a diagnostics array', () => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'mcp-batch-runcodemod-'));
|
||||
// Minimal v1 source so the imports transform has something to do.
|
||||
writeFileSync(path.join(dir, 'package.json'), '{\n "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0" }\n}\n');
|
||||
writeFileSync(
|
||||
path.join(dir, 'index.ts'),
|
||||
"import { Client } from '@modelcontextprotocol/sdk/client/index.js';\nexport const c = Client;\n"
|
||||
);
|
||||
const migration = getMigration('v1-to-v2')!;
|
||||
const outcome = runCodemod('local', { migration, sourceDir: dir, codemodVersion: 'unused' });
|
||||
expect(Array.isArray(outcome.diagnostics)).toBe(true);
|
||||
expect(typeof outcome.filesChanged).toBe('number');
|
||||
expect(typeof outcome.totalChanges).toBe('number');
|
||||
// The fixture has a real v1 import, so the in-process migration must actually change at least one file.
|
||||
expect(outcome.filesChanged).toBeGreaterThanOrEqual(1);
|
||||
expect(outcome.cli).toBeUndefined(); // local mode has no CLI capture
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanSubprocessEnv', () => {
|
||||
it('strips npm_* and pnpm_* vars (which break npx under pnpm) but preserves everything else', () => {
|
||||
const result = cleanSubprocessEnv({
|
||||
PATH: '/usr/bin',
|
||||
HOME: '/home/x',
|
||||
npm_config_frozen_lockfile: 'true',
|
||||
npm_config_registry: 'https://registry.example',
|
||||
npm_execpath: '/path/to/pnpm.cjs',
|
||||
PNPM_HOME: '/pnpm',
|
||||
CI: 'false'
|
||||
});
|
||||
// preserved
|
||||
expect(result.PATH).toBe('/usr/bin');
|
||||
expect(result.HOME).toBe('/home/x');
|
||||
expect(result.CI).toBe('false');
|
||||
// stripped — these are what confuse the npx subprocess into "command not found" (exit 127)
|
||||
expect(result.npm_config_frozen_lockfile).toBeUndefined();
|
||||
expect(result.npm_config_registry).toBeUndefined();
|
||||
expect(result.npm_execpath).toBeUndefined();
|
||||
expect(result.PNPM_HOME).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -189,6 +189,36 @@ describe('comment insertion', () => {
|
|||
expect(nextLine).toContain('setRequestHandler');
|
||||
});
|
||||
|
||||
it('reports a diagnostic line matching the saved file when a dropped shebang is restored', () => {
|
||||
const dir = createTempDir();
|
||||
// The imports transform drops the leading `#!` shebang (it is leading trivia of the first
|
||||
// import); the runner restores it before saving. The reported diagnostic line must account for
|
||||
// the restored shebang, i.e. point at the line it actually occupies in the saved file — not the
|
||||
// shebang-stripped text it was resolved against.
|
||||
const input = [
|
||||
`#!/usr/bin/env node`,
|
||||
``,
|
||||
`import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`,
|
||||
`const server = new McpServer({ name: 'test', version: '1.0' });`,
|
||||
`server.setRequestHandler(FooSchema, async () => ({}));`,
|
||||
``
|
||||
].join('\n');
|
||||
writeFileSync(path.join(dir, 'cli.ts'), input);
|
||||
|
||||
const result = run(migration, { targetDir: dir });
|
||||
|
||||
const output = readFileSync(path.join(dir, 'cli.ts'), 'utf8');
|
||||
// Shebang survived the migration...
|
||||
expect(output.startsWith('#!/usr/bin/env node\n')).toBe(true);
|
||||
// ...and the comment-bearing diagnostic's reported line points exactly at its inserted
|
||||
// @mcp-codemod-error comment in the saved file (regression guard: without the shebang
|
||||
// adjustment the line is N=2 too high and lands on unrelated code).
|
||||
const diag = result.diagnostics.find(d => d.insertComment)!;
|
||||
expect(diag).toBeDefined();
|
||||
const outputLines = output.split('\n');
|
||||
expect(outputLines[diag.line - 1]).toContain(CODEMOD_ERROR_PREFIX);
|
||||
});
|
||||
|
||||
it('merges same-line diagnostics into a single comment', () => {
|
||||
const dir = createTempDir();
|
||||
// Two custom-schema handler registrations on the SAME physical line -> two same-line diagnostics
|
||||
|
|
|
|||
|
|
@ -88,6 +88,60 @@ describe('integration', () => {
|
|||
expect(output).not.toContain('extra');
|
||||
});
|
||||
|
||||
it('preserves a leading #! shebang on a migrated file', () => {
|
||||
// Regression: the imports transform consumed the line-1 shebang (leading trivia of the first
|
||||
// import), silently breaking CLI packages whose `bin` points at the compiled entry.
|
||||
const dir = createTempDir();
|
||||
const input = [
|
||||
`#!/usr/bin/env node`,
|
||||
``,
|
||||
`import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`,
|
||||
`const server = new McpServer({ name: 'test', version: '1.0' });`,
|
||||
``
|
||||
].join('\n');
|
||||
|
||||
writeFileSync(path.join(dir, 'cli.ts'), input);
|
||||
|
||||
const result = run(migration, { targetDir: dir });
|
||||
|
||||
expect(result.filesChanged).toBe(1);
|
||||
|
||||
const output = readFileSync(path.join(dir, 'cli.ts'), 'utf8');
|
||||
// Imports were migrated...
|
||||
expect(output).toContain('@modelcontextprotocol/server');
|
||||
expect(output).not.toContain('@modelcontextprotocol/sdk');
|
||||
// ...and the shebang on line 1 — plus the blank line that separated it from the code — must survive.
|
||||
expect(output.startsWith('#!/usr/bin/env node\n\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves a leading #! shebang and its blank line on a CRLF file', () => {
|
||||
// Same regression as the LF case, but with Windows line endings: the blank-line group in the
|
||||
// shebang capture must accept CRLF, or the blank line separating the shebang from the code is
|
||||
// dropped when the trivia is restored.
|
||||
const dir = createTempDir();
|
||||
const input = [
|
||||
`#!/usr/bin/env node`,
|
||||
``,
|
||||
`import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`,
|
||||
`const server = new McpServer({ name: 'test', version: '1.0' });`,
|
||||
``
|
||||
].join('\r\n');
|
||||
|
||||
writeFileSync(path.join(dir, 'cli.ts'), input);
|
||||
|
||||
const result = run(migration, { targetDir: dir });
|
||||
|
||||
expect(result.filesChanged).toBe(1);
|
||||
|
||||
const output = readFileSync(path.join(dir, 'cli.ts'), 'utf8');
|
||||
expect(output).toContain('@modelcontextprotocol/server');
|
||||
// The shebang plus the blank line that followed it must survive. ts-morph normalizes the line
|
||||
// endings of the region it rewrites to LF, so assert against normalized text — the point is that
|
||||
// the blank line is preserved (the old capture regex dropped it on CRLF files).
|
||||
const normalized = output.replace(/\r\n/g, '\n');
|
||||
expect(normalized.startsWith('#!/usr/bin/env node\n\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('dry-run mode does not modify files', () => {
|
||||
const dir = createTempDir();
|
||||
const input = [
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"$schema": "https://typedoc.org/schema.json",
|
||||
"entryPoints": ["src"],
|
||||
"entryPointStrategy": "expand",
|
||||
"exclude": ["**/*.test.ts", "**/__*__/**"],
|
||||
"exclude": ["**/*.test.ts", "**/__*__/**", "**/bin/**"],
|
||||
"navigation": {
|
||||
"includeGroups": true,
|
||||
"includeCategories": true
|
||||
|
|
|
|||
Loading…
Reference in a new issue