Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/upgrade/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Fill out the prompts and the CLI will:
- `--dir` — directory to scan (default: current working directory)
- `--glob` — glob of files for codemods (default: `**/*.(js|jsx|ts|tsx|mjs|cjs)`)
- `--ignore` — extra globs to ignore during scans (repeatable)
- `--skip-gitignore` — ignore `.gitignore` files and scan files they would normally exclude
- `--release` — target release (e.g., `core-3`); otherwise auto-selected from installed versions
- `--skip-upgrade` — skip installing/updating packages
- `--skip-codemods` — skip codemod execution
Expand Down
1 change: 1 addition & 0 deletions packages/upgrade/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"execa": "9.4.1",
"gray-matter": "^4.0.3",
"index-to-position": "^0.1.2",
"isbinaryfile": "^5.0.7",
"jscodeshift": "^17.0.0",
"marked": "^11.1.1",
"meow": "^11.0.0",
Expand Down
1 change: 1 addition & 0 deletions packages/upgrade/src/__tests__/integration/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ describe('CLI Integration', () => {
expect(result.stdout).toContain('--sdk');
expect(result.stdout).toContain('--dir');
expect(result.stdout).toContain('--dry-run');
expect(result.stdout).toContain('--skip-gitignore');
expect(result.stdout).toContain('--skip-upgrade');
expect(result.stdout).toContain('--release');
expect(result.stdout).toContain('--canary');
Expand Down
140 changes: 140 additions & 0 deletions packages/upgrade/src/__tests__/integration/runner.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import fs from 'node:fs';
import path from 'node:path';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('node:child_process', async importOriginal => {
const actual = await importOriginal();
return {
...actual,
execSync: vi.fn(actual.execSync),
};
});

import { execSync, spawnSync } from 'node:child_process';

import { loadConfig } from '../../config.js';
import { runCodemods, runScans } from '../../runner.js';
import { createTempFixture } from '../helpers/create-fixture.js';
Expand All @@ -22,6 +35,7 @@ vi.mock('../../render.js', () => ({
promptText: vi.fn((msg, defaultValue) => defaultValue),
renderCodemodResults: vi.fn(),
renderText: vi.fn(),
renderDebug: vi.fn(),
}));

describe('runCodemods', () => {
Expand Down Expand Up @@ -219,6 +233,132 @@ describe('runScans', () => {
expect(results[0].instances).toEqual([]);
});

it('scans extensionless node scripts as text files', async () => {
const config = await loadConfig('nextjs', 6);
config.changes = [
{
title: 'Test extensionless script',
matcher: /afterSignInUrl/g,
packages: ['*'],
category: 'breaking',
warning: false,
docsAnchor: 'test',
content: 'Test',
},
];

const content = '#!/usr/bin/env node\nafterSignInUrl\n';
fs.writeFileSync(path.join(fixture.path, 'run'), content, 'utf8');

const results = await runScans(config, 'nextjs', {
dir: fixture.path,
ignore: [],
});

const expected = path.relative(process.cwd(), path.join(fixture.path, 'run'));
expect(results[0].instances.some(instance => instance.file === expected)).toBe(true);
});

it('skips binary files that are not covered by ignore globs', async () => {
const config = await loadConfig('nextjs', 6);
config.changes = config.changes.filter(change => change.matcher);
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got a bit lazy while testing. If folks want I can add a concrete config for each test to be more specific?


const binaryPath = path.join(fixture.path, 'binary');
fs.writeFileSync(binaryPath, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00]));
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this would be a stronger test if I added Buffer.from('afterSignInUrl') to it.


const results = await runScans(config, 'nextjs', { dir: fixture.path, ignore: [] });

const allInstances = results.flatMap(result => result.instances);
const skipped = path.relative(process.cwd(), binaryPath);
expect(allInstances.every(instance => instance.file !== skipped)).toBe(true);
});

it('respects .gitignore files by default', async () => {
// Guard as this test assumes git is available
if (spawnSync('git', ['--version'], { stdio: 'ignore' }).status !== 0) return;

const config = await loadConfig('nextjs', 6);
config.changes = config.changes.filter(change => change.matcher);

const gitPath = fs.mkdtempSync(path.join(fixture.path, 'gitignore-'));

expect(spawnSync('git', ['init', '-q'], { cwd: gitPath, stdio: 'ignore' }).status).toBe(0);
fs.writeFileSync(path.join(gitPath, '.gitignore'), 'run\n', 'utf8');
fs.writeFileSync(path.join(gitPath, 'run'), '#!/usr/bin/env node\nafterSignInUrl\n', 'utf8');

const results = await runScans(config, 'nextjs', { dir: gitPath, ignore: [] });

const skipped = path.relative(process.cwd(), path.join(gitPath, 'run'));
expect(results.flatMap(result => result.instances).every(instance => instance.file !== skipped)).toBe(true);
});

it('can skip using .gitignore files', async () => {
// Guard as this test assumes git is available
if (spawnSync('git', ['--version'], { stdio: 'ignore' }).status !== 0) return;

const config = await loadConfig('nextjs', 6);
config.changes = config.changes.filter(change => change.matcher);

const gitPath = fs.mkdtempSync(path.join(fixture.path, 'gitignore-'));

expect(spawnSync('git', ['init', '-q'], { cwd: gitPath, stdio: 'ignore' }).status).toBe(0);
fs.writeFileSync(path.join(gitPath, '.gitignore'), 'run\n', 'utf8');
fs.writeFileSync(path.join(gitPath, 'run'), '#!/usr/bin/env node\nafterSignInUrl\n', 'utf8');

const results = await runScans(config, 'nextjs', {
dir: gitPath,
ignore: [],
skipGitignore: true,
});

const expected = path.relative(process.cwd(), path.join(gitPath, 'run'));
expect(results.flatMap(result => result.instances).some(instance => instance.file === expected)).toBe(true);
});

it('falls back to normal scanning when git is not installed', async () => {
const config = await loadConfig('nextjs', 6);
config.changes = config.changes.filter(change => change.matcher);

vi.mocked(execSync).mockImplementationOnce(() => {
const error = new Error('spawnSync git ENOENT');
error.code = 'ENOENT';
throw error;
});

fs.writeFileSync(path.join(fixture.path, '.gitignore'), 'run\n', 'utf8');
fs.writeFileSync(path.join(fixture.path, 'run'), '#!/usr/bin/env node\nafterSignInUrl\n', 'utf8');

const results = await runScans(config, 'nextjs', {
dir: fixture.path,
ignore: [],
});

const file = path.relative(process.cwd(), path.join(fixture.path, 'run'));
expect(results.flatMap(result => result.instances).some(instance => instance.file === file)).toBe(true);
});

it('falls back to normal scanning when the directory is not a git repo', async () => {
const config = await loadConfig('nextjs', 6);
config.changes = config.changes.filter(change => change.matcher);

vi.mocked(execSync).mockImplementationOnce(() => {
const error = new Error('fatal: not a git repository');
error.status = 128;
throw error;
});

fs.writeFileSync(path.join(fixture.path, '.gitignore'), 'run\n', 'utf8');
fs.writeFileSync(path.join(fixture.path, 'run'), '#!/usr/bin/env node\nafterSignInUrl\n', 'utf8');

const results = await runScans(config, 'nextjs', {
dir: fixture.path,
ignore: [],
});

const file = path.relative(process.cwd(), path.join(fixture.path, 'run'));
expect(results.flatMap(result => result.instances).some(instance => instance.file === file)).toBe(true);
});

it('includes both changes with and without matchers', async () => {
const config = await loadConfig('nextjs', 6);
// Add a change without a matcher and one with a matcher
Expand Down
3 changes: 3 additions & 0 deletions packages/upgrade/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const cli = meow(
--dir Directory to scan (defaults to current directory)
--glob Glob pattern for files to transform (defaults to **/*.{js,jsx,ts,tsx,mjs,cjs})
--ignore Directories/files to ignore (can be used multiple times)
--skip-gitignore Do not use .gitignore files to exclude scan results
--skip-upgrade Skip the upgrade step
--release Name of the release you're upgrading to (e.g. core-3)
--canary Upgrade to the latest canary version instead of the stable release
Expand All @@ -72,6 +73,7 @@ const cli = meow(
dryRun: { type: 'boolean', default: false },
glob: { type: 'string', default: '**/*.(js|jsx|ts|tsx|mjs|cjs)' },
ignore: { type: 'string', isMultiple: true },
skipGitignore: { type: 'boolean', default: false },
release: { type: 'string' },
sdk: { type: 'string' },
canary: { type: 'boolean', default: false },
Expand All @@ -90,6 +92,7 @@ async function main() {
dryRun: cli.flags.dryRun,
glob: cli.flags.glob,
ignore: cli.flags.ignore,
skipGitignore: cli.flags.skipGitignore,
release: cli.flags.release,
skipCodemods: cli.flags.skipCodemods,
skipUpgrade: cli.flags.skipUpgrade,
Expand Down
6 changes: 6 additions & 0 deletions packages/upgrade/src/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ export function renderWarning(message) {
console.log(chalk.yellow(`⚠️ ${message}`));
}

export function renderDebug(message) {
if (process.env.DEBUG) {
console.debug(chalk.dim(message));
}
}

export function renderNewline() {
console.log('');
}
Expand Down
46 changes: 38 additions & 8 deletions packages/upgrade/src/runner.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { execSync } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';

import chalk from 'chalk';
import indexToPosition from 'index-to-position';
import { glob } from 'tinyglobby';
import { isBinaryFile } from 'isbinaryfile';
import { escapePath, glob } from 'tinyglobby';

import { getCodemodConfig, runCodemod } from './codemods/index.js';
import { createSpinner, renderCodemodResults } from './render.js';
import { createSpinner, renderCodemodResults, renderDebug } from './render.js';

const GLOBBY_IGNORE = [
'node_modules/**',
Expand Down Expand Up @@ -95,15 +97,15 @@ export async function runScans(config, sdk, options) {

try {
const cwd = path.resolve(options.dir);
const files = await glob('**/*', {
cwd,
absolute: true,
ignore: [...GLOBBY_IGNORE, ...(options.ignore || [])],
});
const files = await getFilesToScan(cwd, options);

for (let idx = 0; idx < files.length; idx++) {
const file = files[idx];
spinner.update(`Scanning ${path.basename(file)} (${idx + 1}/${files.length})`);
spinner.update(`Scanning ${path.relative(cwd, file)} (${idx + 1}/${files.length})`);

if (await isBinaryFile(file)) {
continue;
}

const content = await fs.readFile(file, 'utf8');

Expand Down Expand Up @@ -156,6 +158,34 @@ function loadMatchers(config, sdk) {
});
}

async function getFilesToScan(cwd, options) {
// NOTE: tinyglobby recommends shelling out to git for .gitignore support instead of
// implementing it internally:
// - https://superchupu.dev/tinyglobby/migration#gitignore
// - https://github.com/SuperchupuDev/tinyglobby/issues/92
function gitIgnored() {
try {
return execSync('git ls-files --others --ignored --exclude-standard --directory', {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
})
.split('\n')
.filter(Boolean)
.map(file => escapePath(file));
} catch (error) {
renderDebug(`Skipping .gitignore support for scans in ${cwd}: ${error.message}`);
return [];
}
}

return glob('**/*', {
cwd,
absolute: true,
ignore: [...GLOBBY_IGNORE, ...(options.ignore || []), ...(options.skipGitignore ? [] : gitIgnored())],
});
}

function findMatches(content, matcher) {
if (Array.isArray(matcher)) {
return matcher.flatMap(m => Array.from(content.matchAll(m)));
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading