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
9 changed files with 800 additions and 48 deletions

View file

@ -0,0 +1,5 @@
---
'@modelcontextprotocol/codemod': patch
---
Preserve a leading `#!` shebang (and the blank lines after it) when migrating a file. Some transforms drop the shebang because it is leading trivia of the first import they rewrite; the codemod now captures it before transforms and restores it before saving, so CLI packages whose `bin` points at the migrated entry keep working.

View file

@ -9,8 +9,8 @@ For each repo in `repos.json`, the batch test:
1. Clones the repo (or resets an existing clone)
2. Installs dependencies
3. Runs baseline checks (typecheck, build, test, lint) to confirm the repo is healthy
4. Runs the codemod using the programmatic API
5. Packs local SDK packages as tarballs and rewrites `package.json` deps to use them (so the test runs against the current SDK branch, not published npm versions)
4. Runs the codemod — in-process via the programmatic API (`--codemod=local`, the default), or by shelling out to the published CLI via `npx` (`--codemod=published`)
5. Packs local SDK packages as tarballs and rewrites `package.json` deps to use them, so the test runs against the current SDK branch — only when `--sdk=local` (the default); with `--sdk=published` the v2 deps are installed from npm instead (see [Modes](#modes))
6. Re-installs dependencies
7. Re-runs the same checks
8. Writes structured JSON reports
@ -30,12 +30,48 @@ pnpm --filter @modelcontextprotocol/codemod batch-test
pnpm --filter @modelcontextprotocol/codemod batch-test:clean
```
## Modes
Each run independently chooses where the **SDK packages** and the **codemod** come from:
| Flag | Values | Default | Effect |
| -------------------------- | ---------------------- | --------- | ------------------------------------------------------------------------------------------------------------------- |
| `--sdk` | `local` \| `published` | `local` | `local`: pack local packages and rewrite deps to `file:` tarballs. `published`: install the v2 deps from npm. |
| `--codemod` | `local` \| `published` | `local` | `local`: run the working-copy codemod in-process. `published`: `npx` the published CLI. |
| `--codemod-version <spec>` | npm version/tag/range | `latest` | Only with `--codemod=published`. |
| `--sdk-version <spec>` | npm version/tag/range | _(unset)_ | Only with `--sdk=published`. Pins each v2 dep to its own resolved version. Unset = use whatever the codemod writes. |
Both `--flag value` and `--flag=value` forms work.
```bash
# default (today's behavior)
pnpm --filter @modelcontextprotocol/codemod batch-test
# both from npm @latest
pnpm --filter @modelcontextprotocol/codemod batch-test -- --sdk=published --codemod=published
```
Published specs are resolved to concrete versions via `npm view` at startup. A failure resolving the codemod version or the representative SDK label aborts the run; a per-package `--sdk-version` miss only warns and leaves that package on the codemod-written range. Each
`@modelcontextprotocol/*` package is resolved **independently** — they are not released in lockstep. Results are written to a per-run directory keyed on the resolved versions; the SDK segment is the resolved `@modelcontextprotocol/server` version used as a representative label,
while the full per-package set is recorded in `summary.json` → `sdkVersions`:
```
results/codemod-local__sdk-local/ # default (--sdk=local --codemod=local)
results/codemod-2.0.0-alpha.2__sdk-2.0.0-alpha.2/ # --codemod=published --sdk=published --sdk-version=2.0.0-alpha.2
results/codemod-2.0.0-alpha.2__sdk-from-codemod/ # --codemod=published --sdk=published (no --sdk-version)
```
The segment `sdk-from-codemod` appears when `--sdk=published` with no `--sdk-version` and `--codemod=published` (the SDK version is baked into the published codemod and only known after install; the installed versions are recorded in `summary.json` → `sdkVersions`).
**Limitation:** in published-codemod mode the CLI emits text, not structured diagnostics, so `codemod.diagnostics` is empty and the raw CLI output is captured under `codemod.cli` instead. Diagnostics categorization applies only to local-codemod runs.
## Output
Results are written to `batch-test/results/`:
Results are written to a per-run directory keyed on the resolved versions, `batch-test/results/<config>/`, where `<config>` is the `codemod-…__sdk-…` leaf (distinct `--codemod-version`/`--sdk-version` values produce distinct directories even within the same `--sdk`/`--codemod`
mode; see [Modes](#modes)):
- `summary.json` — overview across all repos: which passed, which failed, error counts
- `<repo-slug>/report.json` — per-repo detail: baseline vs post-codemod check results, codemod diagnostics, change counts
- `results/<config>/summary.json` — overview across all repos: which passed, which failed, error counts, plus the run `config` and the resolved per-package `sdkVersions`
- `results/<config>/<repo-slug>/report.json` — per-repo detail: baseline vs post-codemod check results, codemod diagnostics, change counts
## Repo manifest (`repos.json`)
@ -86,7 +122,7 @@ When `checks` is omitted, the runner auto-detects commands from the package's `p
1. Edit `repos.json` and add an entry
2. Run `pnpm --filter @modelcontextprotocol/codemod batch-test`
3. Check `results/<repo-slug>/report.json` for new findings
3. Check `results/<config>/<repo-slug>/report.json` for new findings
For monorepos, list each package that uses `@modelcontextprotocol/sdk` as a separate entry in `packages`.

View file

@ -32,11 +32,13 @@ The goal is to find issues in the codemod itself — incorrect transforms, missi
pnpm --filter @modelcontextprotocol/codemod batch-test
```
3. Read `packages/codemod/batch-test/results/summary.json` for the overview. Note which repos have `postCodemodClean: false` and which check types have new errors.
3. Read `packages/codemod/batch-test/results/*/summary.json` for the overview (there may be several config directories — one per `--sdk`/`--codemod` mode; read the run you're analyzing). Note which repos have `postCodemodClean: false` and which check types have new errors.
4. For each repo with new errors, read its `packages/codemod/batch-test/results/<repo-slug>/report.json`. Compare `baseline` vs `postCodemod` for each check — only errors that appear in `postCodemod` but not in `baseline` are codemod-introduced.
4. For each repo with new errors, read its `packages/codemod/batch-test/results/<config>/<repo-slug>/report.json` (the same `<config>` directory as the summary). Compare `baseline` vs `postCodemod` for each check — only errors that appear in `postCodemod` but not in `baseline`
are codemod-introduced.
5. Also review the `codemod.diagnostics` array in each report — these are warnings the codemod itself emitted about patterns it couldn't fully handle.
5. Also review the `codemod.diagnostics` array in each report — these are warnings the codemod itself emitted about patterns it couldn't fully handle. This applies only to local-codemod runs; when `config.codemodSource` is `published`, `codemod.diagnostics` is empty and
`codemod.cli` holds the raw CLI output instead.
6. For each codemod-introduced error, look at the actual source file in the cloned repo (`packages/codemod/batch-test/repos/<repo-slug>/...`) to understand what the codemod produced and what it should have produced.

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

View file

@ -0,0 +1,236 @@
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { parseArgs, parseCodemodCliOutput, runCodemod } from '../src/bin/batchTest';
import { computeResultsDirName, type ResolvedConfig } from '../src/bin/batchTest';
import { parseNpmViewVersion, rewriteToPublishedVersion, cleanSubprocessEnv } from '../src/bin/batchTest';
import { getMigration } from '../src/migrations/index';
afterEach(() => {
vi.restoreAllMocks();
});
describe('parseArgs', () => {
it('defaults to local/local with latest codemod version and unset sdk version', () => {
const opts = parseArgs([]);
expect(opts.sdk).toBe('local');
expect(opts.codemod).toBe('local');
expect(opts.codemodVersion).toBe('latest');
expect(opts.sdkVersion).toBeUndefined();
});
it('accepts both space and equals forms for --sdk/--codemod', () => {
expect(parseArgs(['--sdk', 'published', '--codemod', 'published']).sdk).toBe('published');
expect(parseArgs(['--sdk=published', '--codemod=published']).codemod).toBe('published');
});
it('parses version overrides', () => {
const opts = parseArgs([
'--codemod=published',
'--codemod-version=2.0.0-alpha.2',
'--sdk=published',
'--sdk-version=2.0.0-alpha.1'
]);
expect(opts.codemodVersion).toBe('2.0.0-alpha.2');
expect(opts.sdkVersion).toBe('2.0.0-alpha.1');
});
it('strips a leading -- separator', () => {
expect(parseArgs(['--', '--sdk=published']).sdk).toBe('published');
});
it('throws on an invalid --sdk value', () => {
expect(() => parseArgs(['--sdk=bogus'])).toThrow(/Invalid --sdk/);
});
it('throws on an unknown flag', () => {
expect(() => parseArgs(['--nope'])).toThrow(/Unknown flag/);
});
it('warns (does not throw) when a version override is given for a local source', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const opts = parseArgs(['--codemod-version=2.0.0-alpha.2', '--sdk-version=2.0.0-alpha.1']);
expect(opts.codemod).toBe('local');
expect(opts.sdk).toBe('local');
expect(warn).toHaveBeenCalledTimes(2);
});
});
describe('parseCodemodCliOutput', () => {
it('parses the Changes line (first number = totalChanges, second = filesChanged)', () => {
expect(parseCodemodCliOutput('Migrating...\nChanges: 42 across 7 file(s)\n')).toEqual({
totalChanges: 42,
filesChanged: 7
});
});
it('returns zeros for the no-changes line', () => {
expect(parseCodemodCliOutput('No changes needed — code already migrated or no SDK imports found.\n')).toEqual({
totalChanges: 0,
filesChanged: 0
});
});
it('returns zeros when neither summary line is present (diagnostics-only run)', () => {
expect(parseCodemodCliOutput('Errors (2):\n src/a.ts:1 something\n')).toEqual({
totalChanges: 0,
filesChanged: 0
});
});
});
function cfg(p: Partial<ResolvedConfig>): ResolvedConfig {
return {
codemodSource: 'local',
sdkSource: 'local',
codemodVersionSpec: 'latest',
codemodVersionResolved: null,
sdkVersionSpec: null,
sdkVersionResolved: null,
resultsDir: '',
...p
};
}
describe('computeResultsDirName', () => {
it('default local/local', () => {
expect(computeResultsDirName(cfg({}))).toBe('codemod-local__sdk-local');
});
it('local codemod, published sdk (resolved for naming)', () => {
expect(computeResultsDirName(cfg({ sdkSource: 'published', sdkVersionResolved: '2.0.0-alpha.1' }))).toBe(
'codemod-local__sdk-2.0.0-alpha.1'
);
});
it('published/published with both resolved', () => {
expect(
computeResultsDirName(
cfg({
codemodSource: 'published',
codemodVersionResolved: '2.0.0-alpha.2',
sdkSource: 'published',
sdkVersionResolved: '2.0.0-alpha.2'
})
)
).toBe('codemod-2.0.0-alpha.2__sdk-2.0.0-alpha.2');
});
it('published codemod, published sdk with unknown version → sdk-from-codemod', () => {
expect(
computeResultsDirName(cfg({ codemodSource: 'published', codemodVersionResolved: '2.0.0-alpha.2', sdkSource: 'published' }))
).toBe('codemod-2.0.0-alpha.2__sdk-from-codemod');
});
it('published codemod, local sdk', () => {
expect(computeResultsDirName(cfg({ codemodSource: 'published', codemodVersionResolved: '2.0.0-alpha.2' }))).toBe(
'codemod-2.0.0-alpha.2__sdk-local'
);
});
});
describe('parseNpmViewVersion', () => {
it('uses a JSON string directly', () => {
expect(parseNpmViewVersion('"2.0.0-alpha.2"')).toBe('2.0.0-alpha.2');
});
it('takes the last (most recently published) entry of a JSON array', () => {
expect(parseNpmViewVersion('["2.0.0-alpha.1","2.0.0-alpha.2"]')).toBe('2.0.0-alpha.2');
});
it('throws on an empty array', () => {
expect(() => parseNpmViewVersion('[]')).toThrow();
});
});
describe('rewriteToPublishedVersion', () => {
let dir: string;
afterEach(() => {
if (dir) rmSync(dir, { recursive: true, force: true });
});
it('rewrites only v2 deps to their per-package resolved versions, preserving formatting', () => {
dir = mkdtempSync(path.join(tmpdir(), 'mcp-batch-rewrite-'));
const pkgPath = path.join(dir, 'package.json');
const original =
'{\n' +
' "name": "demo",\n' +
' "dependencies": {\n' +
' "@modelcontextprotocol/server": "^2.0.0-alpha.0",\n' +
' "@modelcontextprotocol/core": "^2.0.0-alpha.0",\n' +
' "zod": "^3.0.0"\n' +
' },\n' +
' "devDependencies": {\n' +
' "@modelcontextprotocol/client": "^2.0.0-alpha.0"\n' +
' }\n' +
'}\n';
writeFileSync(pkgPath, original);
const count = rewriteToPublishedVersion(pkgPath, {
'@modelcontextprotocol/server': '2.0.0-alpha.3',
'@modelcontextprotocol/core': '2.0.0-alpha.1',
'@modelcontextprotocol/client': '2.0.0-alpha.3'
});
expect(count).toBe(3);
const result = JSON.parse(readFileSync(pkgPath, 'utf8'));
expect(result.dependencies['@modelcontextprotocol/server']).toBe('2.0.0-alpha.3');
expect(result.dependencies['@modelcontextprotocol/core']).toBe('2.0.0-alpha.1'); // independent version
expect(result.devDependencies['@modelcontextprotocol/client']).toBe('2.0.0-alpha.3');
expect(result.dependencies['zod']).toBe('^3.0.0'); // untouched
const raw = readFileSync(pkgPath, 'utf8');
expect(raw.endsWith('\n')).toBe(true); // trailing newline preserved
expect(raw).toContain(' "name"'); // 2-space indent preserved
});
it('returns 0 and leaves the file unwritten when no v2 deps are present', () => {
dir = mkdtempSync(path.join(tmpdir(), 'mcp-batch-rewrite-'));
const pkgPath = path.join(dir, 'package.json');
writeFileSync(pkgPath, '{\n "dependencies": { "zod": "^3.0.0" }\n}\n');
expect(rewriteToPublishedVersion(pkgPath, { '@modelcontextprotocol/server': '2.0.0-alpha.3' })).toBe(0);
});
});
describe('runCodemod (local)', () => {
let dir: string;
afterEach(() => {
if (dir) rmSync(dir, { recursive: true, force: true });
});
it('runs the in-process codemod and returns structured fields with a diagnostics array', () => {
dir = mkdtempSync(path.join(tmpdir(), 'mcp-batch-runcodemod-'));
// Minimal v1 source so the imports transform has something to do.
writeFileSync(path.join(dir, 'package.json'), '{\n "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0" }\n}\n');
writeFileSync(
path.join(dir, 'index.ts'),
"import { Client } from '@modelcontextprotocol/sdk/client/index.js';\nexport const c = Client;\n"
);
const migration = getMigration('v1-to-v2')!;
const outcome = runCodemod('local', { migration, sourceDir: dir, codemodVersion: 'unused' });
expect(Array.isArray(outcome.diagnostics)).toBe(true);
expect(typeof outcome.filesChanged).toBe('number');
expect(typeof outcome.totalChanges).toBe('number');
// The fixture has a real v1 import, so the in-process migration must actually change at least one file.
expect(outcome.filesChanged).toBeGreaterThanOrEqual(1);
expect(outcome.cli).toBeUndefined(); // local mode has no CLI capture
});
});
describe('cleanSubprocessEnv', () => {
it('strips npm_* and pnpm_* vars (which break npx under pnpm) but preserves everything else', () => {
const result = cleanSubprocessEnv({
PATH: '/usr/bin',
HOME: '/home/x',
npm_config_frozen_lockfile: 'true',
npm_config_registry: 'https://registry.example',
npm_execpath: '/path/to/pnpm.cjs',
PNPM_HOME: '/pnpm',
CI: 'false'
});
// preserved
expect(result.PATH).toBe('/usr/bin');
expect(result.HOME).toBe('/home/x');
expect(result.CI).toBe('false');
// stripped — these are what confuse the npx subprocess into "command not found" (exit 127)
expect(result.npm_config_frozen_lockfile).toBeUndefined();
expect(result.npm_config_registry).toBeUndefined();
expect(result.npm_execpath).toBeUndefined();
expect(result.PNPM_HOME).toBeUndefined();
});
});

View file

@ -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

View file

@ -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 = [

View file

@ -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