codemod iterations (#2386)
This commit is contained in:
parent
49aeb8eb38
commit
36055d5bbd
2 changed files with 800 additions and 48 deletions
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue