codemod iterations (#2386)

This commit is contained in:
Konstantin Konstantinov 2026-06-29 20:16:37 +03:00 • committed by GitHub
commit 36055d5bbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 800 additions and 48 deletions

View file

@ -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();
}

View file

@ -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();
}