diff --git a/.changeset/codemod-preserve-shebang.md b/.changeset/codemod-preserve-shebang.md new file mode 100644 index 0000000000..4ce0d6b343 --- /dev/null +++ b/.changeset/codemod-preserve-shebang.md @@ -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. diff --git a/packages/codemod/batch-test/README.md b/packages/codemod/batch-test/README.md index 190d36d44b..490c4a9058 100644 --- a/packages/codemod/batch-test/README.md +++ b/packages/codemod/batch-test/README.md @@ -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 ` | npm version/tag/range | `latest` | Only with `--codemod=published`. | +| `--sdk-version ` | 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//`, where `` 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 -- `/report.json` — per-repo detail: baseline vs post-codemod check results, codemod diagnostics, change counts +- `results//summary.json` — overview across all repos: which passed, which failed, error counts, plus the run `config` and the resolved per-package `sdkVersions` +- `results///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//report.json` for new findings +3. Check `results///report.json` for new findings For monorepos, list each package that uses `@modelcontextprotocol/sdk` as a separate entry in `packages`. diff --git a/packages/codemod/batch-test/analyze-prompt.md b/packages/codemod/batch-test/analyze-prompt.md index 81f206bc1d..f7e74dd185 100644 --- a/packages/codemod/batch-test/analyze-prompt.md +++ b/packages/codemod/batch-test/analyze-prompt.md @@ -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//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///report.json` (the same `` 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//...`) to understand what the codemod produced and what it should have produced. diff --git a/packages/codemod/src/bin/batchTest.ts b/packages/codemod/src/bin/batchTest.ts index f935e90ca5..8c71bf7164 100644 --- a/packages/codemod/src/bin/batchTest.ts +++ b/packages/codemod/src/bin/batchTest.ts @@ -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; postCodemod: Record; @@ -60,10 +63,22 @@ interface SummaryEntry { codemodDiagnostics: Record; } +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; // 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 = { '@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 = { @@ -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: across 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: `__`. The codemod +// segment is `codemod-local` (local source) or `codemod-`; the SDK segment is `sdk-local` (local), +// `sdk-` 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 ()` / +// `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 { mkdirSync(TARBALL_DIR, { recursive: true }); @@ -233,6 +361,64 @@ function rewriteToLocalTarballs(pkgJsonPath: string, tarballs: Record@` 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): number { + const raw = readFileSync(pkgJsonPath, 'utf8'); + const pkgJson = JSON.parse(raw) as Record; + let rewrites = 0; + + for (const section of ['dependencies', 'devDependencies']) { + const deps = pkgJson[section] as Record | 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, type: string): string | null | undefined { if (type in checks) return checks[type] ?? null; return undefined; @@ -253,10 +439,30 @@ function hasNewError(baseline: Record, post: Record 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 = {}; + 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 = {}; + 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 = { ...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 + // /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(); +} diff --git a/packages/codemod/src/runner.ts b/packages/codemod/src/runner.ts index 1554429fae..5398dcc04f 100644 --- a/packages/codemod/src/runner.ts +++ b/packages/codemod/src/runner.ts @@ -135,6 +135,7 @@ export function run(migration: Migration, options: RunnerOptions): RunnerResult const fileResults: FileResult[] = []; const allDiagnostics: Diagnostic[] = []; const allUsedPackages = new Set(); + const shebangs = new Map(); 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(); 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(); } diff --git a/packages/codemod/test/batchTest.test.ts b/packages/codemod/test/batchTest.test.ts new file mode 100644 index 0000000000..41ea0e9c61 --- /dev/null +++ b/packages/codemod/test/batchTest.test.ts @@ -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 { + 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(); + }); +}); diff --git a/packages/codemod/test/commentInsertion.test.ts b/packages/codemod/test/commentInsertion.test.ts index 8862303633..8a71cd00a6 100644 --- a/packages/codemod/test/commentInsertion.test.ts +++ b/packages/codemod/test/commentInsertion.test.ts @@ -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 diff --git a/packages/codemod/test/integration.test.ts b/packages/codemod/test/integration.test.ts index 557b533173..6c1cd0634b 100644 --- a/packages/codemod/test/integration.test.ts +++ b/packages/codemod/test/integration.test.ts @@ -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 = [ diff --git a/packages/codemod/typedoc.json b/packages/codemod/typedoc.json index a9fd090d0f..14e6814c1b 100644 --- a/packages/codemod/typedoc.json +++ b/packages/codemod/typedoc.json @@ -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