From 66d437ba1809032765dd0cd0ce63f8ac3f2c4c18 Mon Sep 17 00:00:00 2001 From: Adrien Zheng Date: Wed, 11 Feb 2026 11:47:02 -0500 Subject: [PATCH 1/5] infra --- packages/migrator/.gitignore | 14 + packages/migrator/.npmignore | 12 + packages/migrator/README.md | 176 +++++ packages/migrator/babel.config.cjs | 22 + packages/migrator/docs/CLI_REFERENCE.md | 558 ++++++++++++++++ packages/migrator/docs/CONFIGURATION.md | 614 ++++++++++++++++++ packages/migrator/docs/HISTORY.md | 432 ++++++++++++ packages/migrator/docs/QUICK_START.md | 371 +++++++++++ packages/migrator/docs/UTILITIES.md | 520 +++++++++++++++ packages/migrator/docs/WRITING_TRANSFORMS.md | 431 ++++++++++++ packages/migrator/package.json | 47 ++ packages/migrator/project.json | 38 ++ packages/migrator/src/cli-args.ts | 82 +++ packages/migrator/src/cli.ts | 455 +++++++++++++ packages/migrator/src/index.ts | 9 + packages/migrator/src/runner.ts | 124 ++++ packages/migrator/src/types.ts | 118 ++++ packages/migrator/src/utils/config-loader.ts | 155 +++++ packages/migrator/src/utils/constants.ts | 6 + packages/migrator/src/utils/index.ts | 9 + packages/migrator/src/utils/insert-todo.ts | 135 ++++ packages/migrator/src/utils/logger.ts | 200 ++++++ .../migrator/src/utils/migration-history.ts | 204 ++++++ packages/migrator/src/v8-to-v9/config.json | 65 ++ packages/migrator/src/v8-to-v9/index.ts | 35 + .../migrator/src/v8-to-v9/transforms/.gitkeep | 2 + .../v8-to-v9/transforms/example-transform.ts | 90 +++ packages/migrator/tsconfig.build.json | 19 + packages/migrator/tsconfig.json | 12 + yarn.lock | 273 +++++++- 30 files changed, 5205 insertions(+), 23 deletions(-) create mode 100644 packages/migrator/.gitignore create mode 100644 packages/migrator/.npmignore create mode 100644 packages/migrator/README.md create mode 100644 packages/migrator/babel.config.cjs create mode 100644 packages/migrator/docs/CLI_REFERENCE.md create mode 100644 packages/migrator/docs/CONFIGURATION.md create mode 100644 packages/migrator/docs/HISTORY.md create mode 100644 packages/migrator/docs/QUICK_START.md create mode 100644 packages/migrator/docs/UTILITIES.md create mode 100644 packages/migrator/docs/WRITING_TRANSFORMS.md create mode 100644 packages/migrator/package.json create mode 100644 packages/migrator/project.json create mode 100644 packages/migrator/src/cli-args.ts create mode 100644 packages/migrator/src/cli.ts create mode 100644 packages/migrator/src/index.ts create mode 100644 packages/migrator/src/runner.ts create mode 100644 packages/migrator/src/types.ts create mode 100644 packages/migrator/src/utils/config-loader.ts create mode 100644 packages/migrator/src/utils/constants.ts create mode 100644 packages/migrator/src/utils/index.ts create mode 100644 packages/migrator/src/utils/insert-todo.ts create mode 100644 packages/migrator/src/utils/logger.ts create mode 100644 packages/migrator/src/utils/migration-history.ts create mode 100644 packages/migrator/src/v8-to-v9/config.json create mode 100644 packages/migrator/src/v8-to-v9/index.ts create mode 100644 packages/migrator/src/v8-to-v9/transforms/.gitkeep create mode 100644 packages/migrator/src/v8-to-v9/transforms/example-transform.ts create mode 100644 packages/migrator/tsconfig.build.json create mode 100644 packages/migrator/tsconfig.json diff --git a/packages/migrator/.gitignore b/packages/migrator/.gitignore new file mode 100644 index 000000000..42ff955ac --- /dev/null +++ b/packages/migrator/.gitignore @@ -0,0 +1,14 @@ +# Build outputs +esm/ +dts/ +*.tsbuildinfo + +# Node modules +node_modules/ + +# Migration history files (user-specific) +.cds-migration-history.json + +# Logs +*.log +migration.log diff --git a/packages/migrator/.npmignore b/packages/migrator/.npmignore new file mode 100644 index 000000000..a0af7f8f7 --- /dev/null +++ b/packages/migrator/.npmignore @@ -0,0 +1,12 @@ +src/ +tsconfig.json +tsconfig.build.json +babel.config.cjs +project.json +__tests__/ +__stories__/ +__mocks__/ +__fixtures__/ +*.test.* +*.spec.* +*.stories.* diff --git a/packages/migrator/README.md b/packages/migrator/README.md new file mode 100644 index 000000000..bf24ea244 --- /dev/null +++ b/packages/migrator/README.md @@ -0,0 +1,176 @@ +# @coinbase/cds-migrator + +Code migration tools for the Coinbase Design System. + +## Quick Start + +```bash +# Interactive mode (recommended) +npx @coinbase/cds-migrator + +# Non-interactive mode +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all --dry-run +``` + +**β†’ See [Quick Start Guide](./docs/QUICK_START.md) for complete walkthrough.** + +## Features + +- 🎯 **Granular Selection** - Migrate everything or choose specific components/hooks/utilities +- πŸ“ **History Tracking** - Automatically prevents duplicate migrations +- πŸ” **Dry-Run Mode** - Preview all changes before applying +- πŸ“Š **Comprehensive Logging** - Track successes, warnings, and manual TODOs +- πŸ’¬ **TODO Comments** - Automatically marks code that needs manual review +- 🌳 **Configuration-Based** - Three-level structure (categories β†’ variables β†’ transforms) + +## Supported Migrations + +| Version | Description | Status | +| ----------- | ---------------------------------------------------- | ------------ | +| **v8 β†’ v9** | Component API updates, hook changes, utility updates | βœ… Available | + +## Documentation + +### πŸ“˜ For Users + +Start here if you're using the migrator to upgrade your codebase: + +1. **[Quick Start Guide](./docs/QUICK_START.md)** ⭐ + - Get up and running in 5 minutes + - Your first migration walkthrough + - Common workflows and troubleshooting + +2. **[CLI Reference](./docs/CLI_REFERENCE.md)** ⭐ + - Interactive and non-interactive modes + - Selection options and "All" shortcuts + - CLI flags reference + - CI/CD integration examples + +3. **[Migration History](./docs/HISTORY.md)** + - How duplicate prevention works + - Understanding the history file + - Clearing history + - Team collaboration guidelines + +### πŸ”§ For Contributors + +Read these if you're adding new migrations or transforms: + +4. **[Configuration Guide](./docs/CONFIGURATION.md)** ⭐ + - Three-level structure explained + - Adding new migration versions + - Adding transforms to existing migrations + - Configuration best practices + +5. **[Writing Transforms](./docs/WRITING_TRANSFORMS.md)** ⭐ + - Creating jscodeshift codemods + - Common transformation patterns + - Testing transforms + - Best practices and anti-patterns + +6. **[Utilities API](./docs/UTILITIES.md)** + - Complete API reference + - TODO insertion functions + - Logging utilities + - Type definitions + +## Example Session + +``` +πŸš€ CDS Migrator + +? Which major version migration do you need? + ❯ v8 to v9 - Migrate from CDS v8 to v9 + +? Enter the path to your codebase: ./src + +? What would you like to migrate? + ❯ Everything (all changes) + By category (components, hooks, etc.) + By item (specific component/hook/utility) + By specific transform + +Migration Plan: +================ +πŸ“¦ components: Component API changes + └─ Button (@coinbase/cds-web) + β€’ Rename 'variant' prop to 'appearance' + +Total transforms: 1 + +? Run in dry-run mode? Yes + + β†’ Running transform: components.Button.button-variant-to-appearance + βœ“ Transform completed + +βœ… Migration completed successfully! +πŸ“ Migration log written to: migration.log +``` + +## Project Structure + +``` +packages/migrator/ +β”œβ”€β”€ docs/ # Documentation +β”‚ β”œβ”€β”€ QUICK_START.md # Getting started guide +β”‚ β”œβ”€β”€ CLI_REFERENCE.md # Interactive & non-interactive modes +β”‚ β”œβ”€β”€ CONFIGURATION.md # Config structure and setup +β”‚ β”œβ”€β”€ WRITING_TRANSFORMS.md # Transform development +β”‚ β”œβ”€β”€ UTILITIES.md # Utils API reference +β”‚ └── HISTORY.md # History tracking guide +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ cli.ts # Interactive CLI +β”‚ β”œβ”€β”€ runner.ts # Migration executor +β”‚ β”œβ”€β”€ types.ts # TypeScript types +β”‚ β”œβ”€β”€ utils/ # Shared utilities +β”‚ └── v8-to-v9/ # Version-specific migrations +β”‚ β”œβ”€β”€ config.json # Migration configuration +β”‚ └── transforms/ # jscodeshift codemods +└── package.json +``` + +## Development + +### Build & Test + +```bash +# Build the package +yarn nx run migrator:build + +# Type check +yarn nx run migrator:typecheck + +# Lint +yarn nx run migrator:lint + +# Test the CLI +cd packages/migrator +node esm/cli.js +``` + +### Adding New Migrations + +See [docs/CONFIGURATION.md](./docs/CONFIGURATION.md) for complete instructions. + +Quick steps: + +1. Create `src/v9-to-v10/` directory +2. Add `config.json` with three-level structure +3. Create transforms in `transforms/` directory +4. Update `SUPPORTED_MIGRATIONS` in `src/cli.ts` + +## Contributing + +When creating transforms: + +- βœ… Write idempotent transforms (safe to run multiple times) +- βœ… Use the logging utilities to track changes +- βœ… Add TODO comments for complex cases +- βœ… Test in dry-run mode first +- βœ… Update the config.json + +See [docs/WRITING_TRANSFORMS.md](./docs/WRITING_TRANSFORMS.md) for detailed guide. + +## License + +See LICENSE in the root of the repository. diff --git a/packages/migrator/babel.config.cjs b/packages/migrator/babel.config.cjs new file mode 100644 index 000000000..bd2fee9a4 --- /dev/null +++ b/packages/migrator/babel.config.cjs @@ -0,0 +1,22 @@ +// @ts-check +const isTestEnv = process.env.NODE_ENV === 'test'; + +/** @type {import('@babel/core').TransformOptions} */ +module.exports = { + presets: [ + ['@babel/preset-env', { modules: isTestEnv ? 'commonjs' : false }], + ['@babel/preset-react', { runtime: 'automatic' }], + '@babel/preset-typescript', + ], + ignore: isTestEnv + ? [] + : [ + '**/__stories__/**', + '**/__tests__/**', + '**/__mocks__/**', + '**/__fixtures__/**', + '**/*.stories.*', + '**/*.test.*', + '**/*.spec.*', + ], +}; diff --git a/packages/migrator/docs/CLI_REFERENCE.md b/packages/migrator/docs/CLI_REFERENCE.md new file mode 100644 index 000000000..9bd94b58c --- /dev/null +++ b/packages/migrator/docs/CLI_REFERENCE.md @@ -0,0 +1,558 @@ +# CLI Reference + +Complete guide for using the CDS Migrator in both interactive and non-interactive modes. + +## Quick Reference + +### Interactive Mode + +```bash +npx @coinbase/cds-migrator +# Follow prompts to select what to migrate +``` + +### Non-Interactive Mode (CLI Flags) + +```bash +# Migrate everything +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all + +# Migrate specific category +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --category components + +# Migrate specific items +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --item components.Button + +# Clear history +npx @coinbase/cds-migrator --clear-history -p ./src +``` + +--- + +## Part 1: Interactive Mode + +### Selection Hierarchy + +The migrator offers four levels of selection granularity: + +``` +1. Everything β†’ Migrate all categories, items, and transforms + ↓ +2. By Category β†’ Select one or more categories (or all) + ↓ +3. By Item β†’ Select specific components/hooks/utilities (or all) + ↓ +4. By Transform β†’ Select individual transforms (or all) +``` + +### Selection Flow + +``` +? Which version migration? β†’ v8 to v9 +? Enter path β†’ ./src +[Shows history if exists] +? What would you like to migrate? + ❯ Everything (all changes) + By category (components, hooks, etc.) + By item (specific component/hook/utility) + By specific transform +``` + +### 1. Everything + +Fastest path - migrate all changes at once. + +**When to use:** + +- First-time migration +- Small codebase +- Want everything done quickly + +``` +? What would you like to migrate? + ❯ Everything (all changes) + +βœ“ Runs all transforms immediately +``` + +### 2. By Category + +Select categories of changes with "All" option. + +**When to use:** + +- Focus on one type (components, hooks, utilities) +- Phased migration strategy + +``` +? Select categories to migrate: + β—― πŸ”˜ All categories ← Migrates everything + β—― components - Component API changes + β—― hooks - Hook API changes + β—― utilities - Utility function changes +``` + +### 3. By Item + +Select specific components/hooks/utilities with "All" option. + +**When to use:** + +- Test one component at a time +- Different PRs for different components + +``` +? Select items to migrate: + β—― πŸ”˜ All items ← Migrates everything + β—― components.Button - Button changes (@coinbase/cds-web) + β—― components.Input - Input changes (@coinbase/cds-web) + β—― hooks.useTheme - useTheme changes (@coinbase/cds-common) +``` + +### 4. By Transform + +Select individual transforms with "All" option. + +**When to use:** + +- Maximum control +- Debugging issues +- Review each change carefully + +``` +? Select transforms to run: + β—― πŸ”˜ All transforms ← Migrates everything + β—― components.Button.button-variant-to-appearance - Rename 'variant' prop + β—― components.Input.input-size-values - Update size values + β—― hooks.useTheme.use-theme-return-type - Update return type +``` + +### Multiple Paths to "Migrate Everything" + +All of these produce the same result: + +| Path | When to Use | +| -------------------------------- | -------------------------------- | +| Everything (all changes) | Fastest - just go! | +| By category β†’ πŸ”˜ All categories | Want to see categories first | +| By item β†’ πŸ”˜ All items | Want to see all items first | +| By transform β†’ πŸ”˜ All transforms | Want to review each change first | + +**Choose based on how much you want to explore before committing!** + +### Decision Tree + +``` +Do you want to migrate everything? +β”‚ +β”œβ”€ YES, fast β†’ "Everything (all changes)" +β”œβ”€ YES, see categories first β†’ "By category" β†’ "πŸ”˜ All categories" +β”œβ”€ YES, see items first β†’ "By item" β†’ "πŸ”˜ All items" +β”œβ”€ YES, review changes first β†’ "By transform" β†’ "πŸ”˜ All transforms" +β”‚ +└─ NO, be selective + β”œβ”€ By category β†’ Select specific ones + β”œβ”€ By item β†’ Select specific ones + └─ By transform β†’ Select specific ones +``` + +--- + +## Part 2: Non-Interactive Mode (CLI Flags) + +Use CLI flags to run without prompts - perfect for CI/CD and automation. + +### Required Flags + +For non-interactive mode, you must provide: + +- `-m, --migration ` - Migration version (e.g., v8-to-v9) +- `-p, --path ` - Target path (default: ./src) +- One selection: `--all`, `--category`, `--item`, or `--transform` + +### Selection Flags + +#### `--all` + +Migrate everything. + +```bash +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all +``` + +#### `--category ` + +Migrate specific categories (can specify multiple). + +```bash +# Single category +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --category components + +# Multiple categories +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --category components hooks +``` + +#### `--item ` + +Migrate specific items (format: `category.item`). + +```bash +# Single item +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --item components.Button + +# Multiple items +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --item components.Button components.Input hooks.useTheme +``` + +#### `--transform ` + +Migrate specific transforms (format: `category.item.transform-name`). + +```bash +# Single transform +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --transform components.Button.button-variant-to-appearance + +# Multiple transforms +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src \ + --transform components.Button.button-variant-to-appearance \ + --transform components.Input.input-size-values +``` + +### Mode Flags + +#### `-d, --dry-run` + +Preview changes without modifying files. + +```bash +# Dry-run (safe to test) +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all --dry-run + +# Apply changes (no flag) +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all +``` + +#### `--skip-confirmation` + +Skip confirmation prompts (useful for automation). + +```bash +# For CI/CD +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all --skip-confirmation +``` + +**⚠️ Use with caution** - bypasses all safety prompts! + +### Utility Flags + +#### `--clear-history` + +Clear migration history for a path. + +```bash +# With confirmation +npx @coinbase/cds-migrator --clear-history -p ./src + +# Skip confirmation +npx @coinbase/cds-migrator --clear-history -p ./src --skip-confirmation +``` + +**Note:** Only requires `--path`, no migration version needed. + +#### `-h, --help` + +Show help message with all flags. + +```bash +npx @coinbase/cds-migrator --help +``` + +#### `-V, --version` + +Show package version. + +```bash +npx @coinbase/cds-migrator --version +``` + +### Flags Summary Table + +| Flag | Short | Description | Example | +| --------------------- | ----- | ------------------- | ---------------------------------------------- | +| `--migration` | `-m` | Migration version | `-m v8-to-v9` | +| `--path` | `-p` | Target path | `-p ./src` | +| `--dry-run` | `-d` | Preview mode | `-d` | +| `--skip-confirmation` | | Skip prompts | `--skip-confirmation` | +| `--clear-history` | | Clear history | `--clear-history` | +| `--all` | | Migrate everything | `--all` | +| `--category` | | Specific categories | `--category components` | +| `--item` | | Specific items | `--item components.Button` | +| `--transform` | | Specific transforms | `--transform components.Button.button-variant` | +| `--help` | `-h` | Show help | `-h` | +| `--version` | `-V` | Show version | `-V` | + +--- + +## Part 3: Common Workflows + +### Local Development + +```bash +# Interactive (recommended) +npx @coinbase/cds-migrator + +# Quick dry-run +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all --dry-run +``` + +### CI/CD Pipeline + +```bash +#!/bin/bash +# migrate.sh + +# Preview first +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all --dry-run --skip-confirmation + +# Apply if preview succeeded +if [ $? -eq 0 ]; then + npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all --skip-confirmation +fi +``` + +### GitHub Actions + +```yaml +name: CDS Migration +on: workflow_dispatch + +jobs: + migrate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Run migration + run: | + npx @coinbase/cds-migrator \ + --migration v8-to-v9 \ + --path ./src \ + --all \ + --skip-confirmation + + - name: Upload log + uses: actions/upload-artifact@v3 + with: + name: migration-log + path: migration.log +``` + +### Incremental Migration + +```bash +# Day 1: Components +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --category components + +# Day 2: Hooks +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --category hooks + +# Day 3: Everything else +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all +``` + +History tracking ensures no duplicates across runs. + +### Package.json Scripts + +```json +{ + "scripts": { + "migrate:dry": "cds-migrate -m v8-to-v9 -p ./src --all --dry-run", + "migrate:apply": "cds-migrate -m v8-to-v9 -p ./src --all --skip-confirmation", + "migrate:components": "cds-migrate -m v8-to-v9 -p ./src --category components", + "migrate:reset": "cds-migrate --clear-history -p ./src --skip-confirmation" + } +} +``` + +--- + +## Part 4: Interactive vs Non-Interactive + +### When to Use Interactive Mode + +βœ… **Use interactive when:** + +- First-time migration +- Want to explore options +- Not sure what to migrate +- Manual review needed + +```bash +npx @coinbase/cds-migrator +# Prompts guide you through +``` + +### When to Use Non-Interactive Mode + +βœ… **Use non-interactive when:** + +- CI/CD pipelines +- Automated scripts +- You know exactly what to migrate +- Repeated operations + +```bash +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all --skip-confirmation +``` + +### Switching Between Modes + +The tool automatically detects which mode based on provided flags: + +**Triggers Interactive:** + +```bash +npx @coinbase/cds-migrator # No flags +npx @coinbase/cds-migrator -m v8-to-v9 # Missing selection +npx @coinbase/cds-migrator -p ./src --all # Missing version +``` + +**Triggers Non-Interactive:** + +```bash +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all # All required flags +``` + +--- + +## Part 5: Examples by Use Case + +### Complete Migration + +```bash +# Step 1: Preview everything +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all --dry-run + +# Step 2: Review migration.log +cat migration.log + +# Step 3: Apply changes +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all +``` + +### Selective Migration + +```bash +# Just Button component +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --item components.Button + +# Just one transform +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src \ + --transform components.Button.button-variant-to-appearance +``` + +### Testing During Development + +```bash +# Test on sample directory +npx @coinbase/cds-migrator -m v8-to-v9 -p ./test-samples --all + +# Clear and re-test +npx @coinbase/cds-migrator --clear-history -p ./test-samples --skip-confirmation +npx @coinbase/cds-migrator -m v8-to-v9 -p ./test-samples --all +``` + +### Automated Daily Migration + +```bash +#!/bin/bash +# daily-migrate.sh + +DATE=$(date +%Y-%m-%d) +LOG_DIR="./migration-logs" + +mkdir -p "$LOG_DIR" + +npx @coinbase/cds-migrator \ + -m v8-to-v9 \ + -p ./src \ + --all \ + --skip-confirmation \ + && mv migration.log "$LOG_DIR/migration-$DATE.log" +``` + +--- + +## Troubleshooting + +### "Error: Must specify --all, --category, --item, or --transform" + +You need to specify what to migrate: + +```bash +# ❌ Missing selection +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src + +# βœ… With selection +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all +``` + +### "Warning: Some transforms have already been run" + +Options: + +1. Use `--skip-confirmation` (automatically skips duplicates) +2. Use `--clear-history` first to reset +3. Switch to interactive mode to choose options + +```bash +# Skip duplicate warning +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all --skip-confirmation +``` + +### Invalid Migration Version + +Check available versions: + +```bash +npx @coinbase/cds-migrator --help +``` + +Currently available: `v8-to-v9` + +--- + +## Exit Codes + +| Code | Meaning | +| ---- | -------------------------------------------------------------- | +| `0` | Success | +| `1` | Error (migration failed, validation failed, or user cancelled) | + +**Example:** + +```bash +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all --skip-confirmation +if [ $? -eq 0 ]; then + echo "βœ… Migration succeeded" +else + echo "❌ Migration failed" + exit 1 +fi +``` + +--- + +## See Also + +- [Quick Start](./QUICK_START.md) - Your first migration +- [Configuration](./CONFIGURATION.md) - Understanding the structure +- [History](./HISTORY.md) - How tracking works +- [Writing Transforms](./WRITING_TRANSFORMS.md) - Creating codemods +- [Utilities API](./UTILITIES.md) - Helper functions diff --git a/packages/migrator/docs/CONFIGURATION.md b/packages/migrator/docs/CONFIGURATION.md new file mode 100644 index 000000000..615af4d13 --- /dev/null +++ b/packages/migrator/docs/CONFIGURATION.md @@ -0,0 +1,614 @@ +# Migration Configuration Guide + +How to structure and configure migrations using the three-level hierarchy. + +## Overview + +Each major version migration (e.g., v8-to-v9) has a `config.json` file that defines: + +1. **Categories** - High-level groupings (components, hooks, utilities) +2. **Variables** - Specific exports (Button, useTheme, formatCurrency) +3. **Transforms** - Individual codemods to run + +This structure allows users to migrate everything at once or pick exactly what they need. + +## Configuration Structure + +``` +Categories (components, hooks, utilities) + └─ Variables (Button, Input, useTheme, etc.) + └─ Transforms (specific codemods) +``` + +### Complete Example + +```json +{ + "version": "v8-to-v9", + "description": "Migration from CDS v8 to v9", + "categories": { + "components": { + "description": "Component API changes", + "variables": { + "Button": { + "description": "Button component prop changes", + "package": "@coinbase/cds-web", + "transforms": [ + { + "name": "button-variant-to-appearance", + "description": "Rename 'variant' prop to 'appearance'", + "file": "transforms/button-variant-to-appearance.ts" + }, + { + "name": "button-remove-deprecated-sizes", + "description": "Remove deprecated size values", + "file": "transforms/button-remove-deprecated-sizes.ts" + } + ] + }, + "Input": { + "description": "Input component prop changes", + "package": "@coinbase/cds-web", + "transforms": [ + { + "name": "input-size-values", + "description": "Update size prop values", + "file": "transforms/input-size-values.ts" + } + ] + } + } + }, + "hooks": { + "description": "Hook API changes", + "variables": { + "useTheme": { + "description": "useTheme hook signature changes", + "package": "@coinbase/cds-common", + "transforms": [ + { + "name": "use-theme-return-type", + "description": "Update destructured return values", + "file": "transforms/use-theme-return-type.ts" + } + ] + } + } + } + } +} +``` + +## Level Definitions + +### Level 1: Categories + +Organize changes by type: + +| Category | Description | Examples | +| ------------ | ------------------------ | ------------------------------ | +| `components` | React component changes | Button, Input, Modal | +| `hooks` | Hook API changes | useTheme, useMediaQuery | +| `utilities` | Utility function changes | formatCurrency, parseDate | +| `types` | TypeScript type changes | Props interfaces, type aliases | +| `constants` | Constant value changes | Theme tokens, breakpoints | +| `styles` | Style/CSS changes | Class names, CSS variables | + +**Category Schema:** + +```typescript +{ + description: string; // Human-readable explanation + variables: { // Map of exported symbols + [name: string]: MigrationVariable; + } +} +``` + +### Level 2: Variables + +Specific exports from CDS packages: + +**Variable Schema:** + +```typescript +{ + description: string; // What changed about this export + package: string; // Which package exports it + transforms: Transform[]; // Array of codemods +} +``` + +**Examples:** + +- `Button` from `@coinbase/cds-web` +- `useTheme` from `@coinbase/cds-common` +- `formatCurrency` from `@coinbase/cds-utils` + +**Naming conventions:** + +- Use the actual export name (PascalCase for components/types, camelCase for functions/hooks) +- Match what users import: `import { Button } from '@coinbase/cds-web'` + +### Level 3: Transforms + +Individual jscodeshift codemods: + +**Transform Schema:** + +```typescript +{ + name: string; // Unique identifier (kebab-case) + description: string; // What this transform does + file: string; // Path relative to version directory + extensions?: string; // Optional: "tsx,ts,jsx,js" (default) +} +``` + +**Naming conventions:** + +- Use kebab-case: `button-variant-to-appearance` +- Be descriptive: `{component}-{what-it-does}` +- Examples: `input-size-values`, `use-theme-return-type` + +## Adding a New Migration Version + +### 1. Create Directory Structure + +```bash +mkdir -p packages/migrator/src/v9-to-v10/transforms +``` + +### 2. Create config.json + +```json +{ + "version": "v9-to-v10", + "description": "Migration from CDS v9 to v10", + "categories": { + "components": { + "description": "Component API changes", + "variables": {} + } + } +} +``` + +### 3. Create index.ts + +```typescript +/** + * v9 to v10 Migration + */ + +export const description = ` +CDS v9 to v10 Migration +====================== + +Breaking changes in v10: +- [List breaking changes here] + +Please review the changes carefully before committing. +`; +``` + +### 4. Update CLI + +In `src/cli.ts`, add to `SUPPORTED_MIGRATIONS`: + +```typescript +const SUPPORTED_MIGRATIONS = [ + { + name: 'v8 to v9', + value: 'v8-to-v9', + description: 'Migrate from CDS v8 to v9', + }, + { + name: 'v9 to v10', // Add this + value: 'v9-to-v10', + description: 'Migrate from CDS v9 to v10', + }, +] as const; +``` + +## Adding Transforms to Existing Migration + +### 1. Create the Transform File + +```typescript +// src/v8-to-v9/transforms/button-variant-to-appearance.ts +import type { API, FileInfo } from 'jscodeshift'; +import { getLogger } from '../../utils'; + +export default function transformer(file: FileInfo, api: API) { + const j = api.jscodeshift; + const root = j(file.source); + const logger = getLogger(); + + let hasChanges = false; + + // Transform logic here + root + .find(j.JSXAttribute, { name: { name: 'variant' } }) + .filter((path) => { + const jsxElement = path.parent.value; + return ( + j.JSXIdentifier.check(jsxElement.openingElement.name) && + jsxElement.openingElement.name.name === 'Button' + ); + }) + .forEach((path) => { + if (j.JSXIdentifier.check(path.value.name)) { + path.value.name.name = 'appearance'; + hasChanges = true; + logger?.success('Renamed variant to appearance', file.path, path.value.loc?.start.line); + } + }); + + return hasChanges ? root.toSource() : null; +} +``` + +### 2. Add to config.json + +```json +{ + "categories": { + "components": { + "variables": { + "Button": { + "description": "Button component prop changes", + "package": "@coinbase/cds-web", + "transforms": [ + { + "name": "button-variant-to-appearance", + "description": "Rename 'variant' prop to 'appearance'", + "file": "transforms/button-variant-to-appearance.ts" + } + ] + } + } + } + } +} +``` + +### 3. Test + +```bash +# Build +yarn nx run migrator:build + +# Test in dry-run +cd packages/migrator +node esm/cli.js +# Select: By specific transform β†’ components.Button.button-variant-to-appearance +``` + +## Configuration Best Practices + +### 1. Granular Transforms + +One transform should do one thing: + +βœ… **Good:** + +```json +{ + "transforms": [ + { + "name": "button-variant-to-appearance", + "description": "Rename 'variant' prop to 'appearance'" + }, + { + "name": "button-remove-deprecated-sizes", + "description": "Remove deprecated size values" + } + ] +} +``` + +❌ **Bad:** + +```json +{ + "transforms": [ + { + "name": "button-all-changes", + "description": "Update all Button props" + } + ] +} +``` + +**Why:** Granular transforms give users more control and make debugging easier. + +### 2. Clear Descriptions + +Descriptions should explain what changes, not implementation details: + +βœ… **Good:** + +```json +"description": "Rename 'variant' prop to 'appearance'" +``` + +❌ **Bad:** + +```json +"description": "Run jscodeshift transform to update JSX attributes" +``` + +### 3. Logical Grouping + +Group related transforms under the same variable: + +βœ… **Good:** + +```json +"Button": { + "transforms": [ + { "name": "button-variant-to-appearance" }, + { "name": "button-size-values" }, + { "name": "button-remove-deprecated-props" } + ] +} +``` + +❌ **Bad:** + +```json +"Button": { + "transforms": [{ "name": "button-variant-to-appearance" }] +}, +"ButtonSize": { + "transforms": [{ "name": "button-size-values" }] +} +``` + +### 4. Package Attribution + +Always specify which package exports the variable: + +```json +{ + "Button": { + "package": "@coinbase/cds-web", // ← Required! + "transforms": [...] + } +} +``` + +This helps users understand where imports come from. + +### 5. Meaningful Categories + +Use categories that match how developers think: + +βœ… **Good Categories:** + +- `components` - React components +- `hooks` - React hooks +- `utilities` - Helper functions +- `types` - TypeScript types + +❌ **Avoid:** + +- `breaking-changes` - Too vague +- `misc` - Not organized +- `v9-updates` - Redundant with version + +## Migration Execution Flow + +Understanding how the config is used: + +``` +1. User selects migration version (v8-to-v9) + ↓ +2. Load config.json from src/v8-to-v9/ + ↓ +3. User chooses scope (all, category, variable, or transform) + ↓ +4. Build list of transforms based on selection + ↓ +5. Show migration plan + ↓ +6. Execute transforms in order + ↓ +7. Record in migration history +``` + +## Example Migration Plans + +### Selecting "Everything" + +``` +Migration Plan: +================ + +πŸ“¦ components: Component API changes + └─ Button (@coinbase/cds-web) + β€’ Rename 'variant' prop to 'appearance' + β€’ Remove deprecated size values + └─ Input (@coinbase/cds-web) + β€’ Update size prop values + +πŸ“¦ hooks: Hook API changes + └─ useTheme (@coinbase/cds-common) + β€’ Update destructured return values + +Total transforms: 4 +``` + +### Selecting "By Variable" + +``` +? Select items to migrate: + β—‰ components.Button + β—― components.Input + β—‰ hooks.useTheme + +Migration Plan: +================ + +πŸ“¦ components: Component API changes + └─ Button (@coinbase/cds-web) + β€’ Rename 'variant' prop to 'appearance' + β€’ Remove deprecated size values + +πŸ“¦ hooks: Hook API changes + └─ useTheme (@coinbase/cds-common) + β€’ Update destructured return values + +Total transforms: 3 +``` + +## Advanced Configuration + +### Custom File Extensions + +For transforms that should only run on specific file types: + +```json +{ + "name": "style-object-to-stylesheet", + "description": "Convert inline styles to StyleSheet", + "file": "transforms/style-object-to-stylesheet.ts", + "extensions": "tsx,jsx" // Only run on TSX/JSX files +} +``` + +### Transform Ordering + +Transforms run in the order they appear in the config: + +```json +{ + "transforms": [ + { + "name": "remove-old-imports", + "description": "Remove old imports" + }, + { + "name": "add-new-imports", + "description": "Add new imports" + } + ] +} +``` + +**Important:** The order matters if transforms depend on each other! + +## Schema Reference + +### MigrationConfig + +```typescript +interface MigrationConfig { + version: string; // "v8-to-v9" + description: string; // Overall description + categories: Record; // Category map +} +``` + +### MigrationCategory + +```typescript +interface MigrationCategory { + description: string; // Category description + variables: Record; // Variable map +} +``` + +### MigrationVariable + +```typescript +interface MigrationVariable { + description: string; // What changed + package: string; // Source package + transforms: Transform[]; // List of transforms +} +``` + +### Transform + +```typescript +interface Transform { + name: string; // Unique ID (kebab-case) + description: string; // Human-readable explanation + file: string; // Path relative to version dir + extensions?: string; // Optional: file extensions +} +``` + +## Validation + +The config loader will validate: + +βœ… config.json exists +βœ… Valid JSON format +βœ… All referenced transform files exist +βœ… No duplicate transform names within a variable + +## Tips for Large Migrations + +### Split by Package + +Organize by which CDS package is affected: + +```json +{ + "categories": { + "cds-web": { + "description": "@coinbase/cds-web changes", + "variables": { + /* web components */ + } + }, + "cds-mobile": { + "description": "@coinbase/cds-mobile changes", + "variables": { + /* mobile components */ + } + }, + "cds-common": { + "description": "@coinbase/cds-common changes", + "variables": { + /* hooks, utilities */ + } + } + } +} +``` + +### Use Descriptive Transform Names + +Make it easy to understand what each transform does: + +βœ… Good: + +- `button-variant-to-appearance` +- `input-remove-error-text-prop` +- `modal-overlay-click-behavior` + +❌ Avoid: + +- `transform1` +- `fix-button` +- `update` + +### Document Breaking Changes + +Use the description field effectively: + +```json +{ + "description": "Migration from CDS v8 to v9\n\nBreaking changes:\n- Button 'variant' prop renamed to 'appearance'\n- Input 'errorText' prop removed\n- useTheme returns new structure" +} +``` + +## See Also + +- [Writing Transforms](./WRITING_TRANSFORMS.md) - Create the transform files +- [Utilities](./UTILITIES.md) - Use shared utilities in transforms +- [Quick Start](./QUICK_START.md) - Run your first migration diff --git a/packages/migrator/docs/HISTORY.md b/packages/migrator/docs/HISTORY.md new file mode 100644 index 000000000..3c4aa720e --- /dev/null +++ b/packages/migrator/docs/HISTORY.md @@ -0,0 +1,432 @@ +# Migration History Tracking + +The CDS Migrator automatically tracks which transforms have been run on each directory to prevent accidental duplicate migrations. + +## How It Works + +### Automatic Tracking + +When you run a migration (in non-dry-run mode), the migrator: + +1. **Records the transform** - Saves which transform was run, when, and for which version +2. **Creates a history file** - Stores this in `.cds-migration-history.json` in the target directory +3. **Checks before running** - Before executing any transform, checks if it's already been run + +### History File + +The `.cds-migration-history.json` file is created in your target directory: + +```json +{ + "targetPath": "./src", + "entries": [ + { + "transformId": "components.Button.button-variant-to-appearance", + "timestamp": "2026-02-11T12:00:00.000Z", + "version": "v8-to-v9", + "dryRun": false + } + ], + "lastUpdated": "2026-02-11T12:00:00.000Z" +} +``` + +**Important Notes:** + +- This file should be **committed to version control** +- It helps team members avoid re-running the same migrations +- Dry-run mode does NOT create or update the history file + +## User Experience + +### First Run + +When running a migration for the first time: + +```bash +? What would you like to migrate? + ❯ Everything (all changes) + +Migration Plan: +================ +πŸ“¦ components: Component API changes + └─ Button (@coinbase/cds-web) + β€’ Rename 'variant' prop to 'appearance' + +Total transforms: 1 + +? Enter the path to your codebase: ./src +? Run in dry-run mode? No + + β†’ Running transform: components.Button.button-variant-to-appearance + Rename 'variant' prop to 'appearance' + + βœ“ Transform completed: button-variant-to-appearance + +βœ… Migration completed successfully! +``` + +### Attempting to Re-run + +If you try to run the same migration again: + +```bash +Migration Plan: +================ +πŸ“¦ components: Component API changes + └─ Button (@coinbase/cds-web) + β€’ Rename 'variant' prop to 'appearance' + +Total transforms: 1 + +? Enter the path to your codebase: ./src +? Run in dry-run mode? No + +⚠️ Warning: Some transforms have already been run on this path: + + β€’ components.Button.button-variant-to-appearance + +πŸ“œ Migration History +================== + +v8-to-v9: + β€’ components.Button.button-variant-to-appearance (2/11/2026) + +Last updated: 2/11/2026, 12:00:00 PM + +? How would you like to proceed? + ❯ Skip already-run transforms (recommended) + Re-run all transforms (may cause issues) + Cancel migration +``` + +### Options When Duplicate Detected + +1. **Skip already-run transforms (recommended)** + - Only runs transforms that haven't been executed before + - Safest option to prevent issues + - The runner will automatically skip completed transforms + +2. **Re-run all transforms (may cause issues)** + - Forces re-execution of all selected transforms + - May cause problems if transforms aren't idempotent + - Use with caution + +3. **Cancel migration** + - Exits without making any changes + +### During Execution + +When transforms are skipped: + +```bash + β†’ Running transform: components.Input.input-size-values + Update size prop values (sm/md/lg to small/medium/large) + + βœ“ Transform completed: input-size-values + + ⊘ Skipping (already run): components.Button.button-variant-to-appearance + +⊘ Skipped 1 transform(s) that were already run. + +βœ… Migration completed successfully! +``` + +## API for Programmatic Use + +### Load History + +```typescript +import { loadMigrationHistory } from '@coinbase/cds-migrator'; + +const history = loadMigrationHistory('./src'); +if (history) { + console.log(`Last updated: ${history.lastUpdated}`); + console.log(`Total migrations: ${history.entries.length}`); +} +``` + +### Check if Transform Was Run + +```typescript +import { hasTransformBeenRun } from '@coinbase/cds-migrator'; + +const wasRun = hasTransformBeenRun('./src', 'components.Button.button-variant-to-appearance'); + +if (wasRun) { + console.log('This transform has already been applied'); +} +``` + +### Get Already-Run Transforms + +```typescript +import { getAlreadyRunTransforms } from '@coinbase/cds-migrator'; + +const transformsToRun = [ + 'components.Button.button-variant-to-appearance', + 'components.Input.input-size-values', +]; + +const alreadyRun = getAlreadyRunTransforms('./src', transformsToRun); +console.log('Already run:', alreadyRun); +``` + +### View History Summary + +```typescript +import { buildHistorySummary } from '@coinbase/cds-migrator'; + +const summary = buildHistorySummary('./src'); +console.log(summary); +// Outputs a formatted summary of all migrations +``` + +### Clear History + +**CLI (recommended):** + +```bash +# Clear history for a path +npx @coinbase/cds-migrator --clear-history -p ./src + +# Skip confirmation prompt +npx @coinbase/cds-migrator --clear-history -p ./src --skip-confirmation +``` + +**Programmatically:** + +```typescript +import { clearMigrationHistory } from '@coinbase/cds-migrator'; + +// Use with caution - this removes all migration tracking +clearMigrationHistory('./src'); +``` + +## Best Practices + +### βœ… Do + +- **Commit the history file** to version control +- **Review the history** before running migrations +- **Use dry-run mode** first to preview changes +- **Skip already-run transforms** when prompted (recommended option) + +### ❌ Don't + +- Don't delete `.cds-migration-history.json` without good reason +- Don't re-run transforms unless absolutely necessary +- Don't ignore warnings about duplicate runs +- Don't add `.cds-migration-history.json` to `.gitignore` + +## Team Collaboration + +### Scenario: Multiple Team Members + +1. **Developer A** runs migration and commits the history file: + + ```bash + git add .cds-migration-history.json + git commit -m "Run v8-to-v9 Button migration" + ``` + +2. **Developer B** pulls the changes: + + ```bash + git pull + ``` + +3. **Developer B** tries to run the same migration: + - Migrator detects it was already run + - Shows warning with history + - Recommends skipping + +### Scenario: Different Directories + +If your codebase has multiple directories with different migration states: + +``` +project/ +β”œβ”€β”€ src/ +β”‚ └── .cds-migration-history.json # Migrations complete +└── legacy/ + └── .cds-migration-history.json # Partial migrations +``` + +Each directory tracks its own history independently. + +## Troubleshooting + +### "I need to re-run a transform" + +If a transform failed or needs to be re-run: + +1. **Option 1:** Choose "Re-run all transforms" when prompted by the migrator +2. **Option 2:** Clear history and start fresh: + ```bash + npx @coinbase/cds-migrator --clear-history -p ./src + ``` +3. **Option 3:** Manually edit `.cds-migration-history.json` and remove specific entries + +### "History file is out of sync" + +If team members have conflicting history files: + +1. Review both history files +2. Merge the entries manually in JSON +3. Keep the most complete history +4. Resolve conflicts through code review + +### "Transform was run but not recorded" + +This can happen if: + +- The migration was run in dry-run mode (by design) +- The migrator crashed before saving history +- File permissions prevented writing the history file + +Solution: Manually add the entry to `.cds-migration-history.json` + +## Clearing Migration History + +### Quick Command + +```bash +# Clear history with confirmation +npx @coinbase/cds-migrator --clear-history -p ./src + +# Skip confirmation (be careful!) +npx @coinbase/cds-migrator --clear-history -p ./src --skip-confirmation +``` + +### When to Clear History + +#### Failed Migration + +If a migration failed partway through: + +```bash +# Migration failed +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all +# Error occurred, some transforms completed but others didn't + +# Clear history to start fresh +npx @coinbase/cds-migrator --clear-history -p ./src + +# Run migration again +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all +``` + +#### Testing Migrations + +During development or testing: + +```bash +# Test a migration +npx @coinbase/cds-migrator -m v8-to-v9 -p ./test-dir --all + +# Clear to test again +npx @coinbase/cds-migrator --clear-history -p ./test-dir --skip-confirmation + +# Re-run with changes +npx @coinbase/cds-migrator -m v8-to-v9 -p ./test-dir --all +``` + +#### Corrupted History File + +If the history file is corrupted or invalid: + +```bash +# Clear the corrupted history +npx @coinbase/cds-migrator --clear-history -p ./src --skip-confirmation + +# Start fresh +npx @coinbase/cds-migrator -m v8-to-v9 -p ./src --all +``` + +#### Reset After Rollback + +If you rolled back code changes via git: + +```bash +# Rolled back code +git reset --hard HEAD~1 + +# Clear history to match code state +npx @coinbase/cds-migrator --clear-history -p ./src --skip-confirmation +``` + +### What Gets Deleted + +The command removes: + +- `.cds-migration-history.json` in the specified path + +It does NOT delete: + +- Your code changes +- The `migration.log` file +- Any other files + +### Bulk Clear + +To clear history for multiple directories: + +```bash +#!/bin/bash +# clear-all-history.sh + +DIRS=("./src" "./lib" "./packages") + +for dir in "${DIRS[@]}"; do + echo "Clearing history for: $dir" + npx @coinbase/cds-migrator --clear-history -p "$dir" --skip-confirmation +done + +echo "βœ… All history cleared" +``` + +### Alternative: Manual Deletion + +You can also delete the file directly: + +```bash +# Delete history file +rm ./src/.cds-migration-history.json + +# Verify it's gone +ls -la ./src/.cds-migration-history.json +``` + +This is equivalent to `--clear-history` but skips all checks. + +### Best Practices + +**βœ… Do:** + +- Clear history after failed migrations +- Clear during testing/development +- Use `--skip-confirmation` in scripts + +**❌ Don't:** + +- Clear history just to re-run transforms (use "Re-run" option instead) +- Clear history without good reason in production +- Clear history if team members are mid-migration + +## Configuration + +### Disable History Tracking + +Currently, history tracking is always enabled for non-dry-run migrations. If you need to disable it: + +1. Use dry-run mode (doesn't record history) +2. Or delete the history file after each run (not recommended) + +### Custom History Location + +The history file is always created in the target directory. This ensures: + +- Each directory can be migrated independently +- History travels with the code +- No global state to manage diff --git a/packages/migrator/docs/QUICK_START.md b/packages/migrator/docs/QUICK_START.md new file mode 100644 index 000000000..0f3a74728 --- /dev/null +++ b/packages/migrator/docs/QUICK_START.md @@ -0,0 +1,371 @@ +# Quick Start Guide + +Get started with CDS Migrator in 5 minutes. + +## Installation + +```bash +# Option 1: Run directly with npx (recommended) +npx @coinbase/cds-migrator + +# Option 2: Install as dev dependency +yarn add -D @coinbase/cds-migrator +``` + +## Your First Migration + +### 1. Run the CLI + +```bash +cd your-project +npx @coinbase/cds-migrator +``` + +### 2. Follow the Prompts + +**Choose migration version:** + +``` +? Which major version migration do you need? + ❯ v8 to v9 - Migrate from CDS v8 to v9 +``` + +**Choose scope:** + +``` +? What would you like to migrate? + ❯ Everything (all changes) + By category (components, hooks, etc.) + By item (specific component/hook/utility) + By specific transform +``` + +**Choose path and mode:** + +``` +? Enter the path to your codebase: ./src +? Run in dry-run mode? (Y/n) Y +``` + +### 3. Review Changes + +The migrator will: + +- Show a migration plan +- Run transforms and display progress +- Create a `migration.log` file with details +- Add TODO comments where manual work is needed + +### 4. Apply Changes + +Once you're satisfied with the dry-run: + +1. Run the migrator again +2. Choose **No** for dry-run mode +3. Review and commit changes + +## Migration Modes + +### Dry-Run Mode (Recommended First) + +Preview changes without modifying files: + +- βœ… Safe to run anytime +- βœ… See what would change +- βœ… Review migration.log +- βœ… No history recorded + +```bash +? Run in dry-run mode? Yes +``` + +### Apply Mode + +Apply changes to your files: + +- ⚠️ Modifies your files +- ⚠️ Records migration history +- ⚠️ Can't easily undo (use git!) +- βœ… Prevents duplicate runs + +```bash +? Run in dry-run mode? No +⚠️ This will modify your files. Continue? (y/N) +``` + +## What Gets Created + +After running a migration: + +``` +your-project/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ .cds-migration-history.json # ← Tracks applied migrations +β”‚ └── ... (your modified files) +└── migration.log # ← Detailed log of changes +``` + +**Important:** + +- βœ… Commit `.cds-migration-history.json` to git +- ❌ Don't commit `migration.log` (review then delete) + +## Selection Strategies + +The migrator offers **four ways to select** what to migrate, with each level offering an "All" option. + +### 1. Everything - Fastest Path + +**Best for:** Initial migration, small codebase, want it done quickly + +``` +? What would you like to migrate? + ❯ Everything (all changes) +``` + +**What happens:** Runs all transforms immediately - no further selections needed. + +### 2. By Category + +**Best for:** Focus on specific types of changes, or see categories before migrating all + +``` +? What would you like to migrate? + ❯ By category (components, hooks, etc.) + +? Select categories to migrate: + β—― πŸ”˜ All categories ← Migrates everything + β—― components - Component API changes + β—― hooks - Hook API changes + β—― utilities - Utility function changes +``` + +Select "πŸ”˜ All categories" to migrate everything, or pick specific ones for phased migration. + +### 3. By Item + +**Best for:** Migrate specific components/hooks, or see full list before migrating all + +``` +? What would you like to migrate? + ❯ By item (specific component/hook/utility) + +? Select items to migrate: + β—― πŸ”˜ All items ← Migrates everything + β—― components.Button - Button component changes (@coinbase/cds-web) + β—― components.Input - Input component changes (@coinbase/cds-web) + β—― hooks.useTheme - useTheme hook changes (@coinbase/cds-common) + β—― utilities.formatCurrency - formatCurrency changes (@coinbase/cds-utils) +``` + +Select "πŸ”˜ All items" to migrate everything, or pick specific items to test one at a time. + +### 4. By Transform + +**Best for:** Maximum control, or review each change before migrating all + +``` +? What would you like to migrate? + ❯ By specific transform + +? Select transforms to run: + β—― πŸ”˜ All transforms ← Migrates everything + β—― components.Button.button-variant-to-appearance - Rename 'variant' prop + β—― components.Input.input-size-values - Update size values + β—― hooks.useTheme.use-theme-return-type - Update return type + β—― utilities.formatCurrency.format-currency-options - Update options +``` + +Select "πŸ”˜ All transforms" to migrate everything after reviewing details, or pick specific ones for surgical changes. + +## Understanding the Output + +### Console Output + +``` +πŸ”„ Running v8-to-v9 migration... + + β†’ Running transform: components.Button.button-variant-to-appearance + Rename 'variant' prop to 'appearance' + + Command: npx jscodeshift --dry --parser=tsx ... + + Processing 25 files... + Transforming src/Button.tsx... + Transforming src/App.tsx... + + βœ“ Transform completed: button-variant-to-appearance + +βœ… Migration completed successfully! +πŸ“ Migration log written to: migration.log +``` + +### Migration Log (`migration.log`) + +``` +CDS Migration Log +Generated: 2026-02-11T12:00:00.000Z +======================================== + +[2026-02-11T12:00:01.000Z] INFO: Starting v8-to-v9 migration +[2026-02-11T12:00:02.000Z] SUCCESS [src/Button.tsx:15]: Renamed prop +[2026-02-11T12:00:03.000Z] TODO [src/Complex.tsx:45]: Manual migration required + This component needs manual review + +======================================== +Migration Summary +======================================== + +Total Entries: 3 +- INFO: 1 +- SUCCESS: 1 +- TODO: 1 + +Manual Migration Required (1 items): + - src/Complex.tsx: Manual migration required +``` + +### TODO Comments in Code + +When automatic migration isn't possible: + +```tsx +// Before migration +; +} +``` + +### Run in Dry-Run + +```bash +cd packages/migrator +node esm/cli.js +# Select: By specific transform +# Select your transform +# Path: /tmp/migration-test/src +# Dry-run: Yes +``` + +### Verify Output + +Check: + +- βœ… Correct files were modified +- βœ… Changes are accurate +- βœ… TODOs added where needed +- βœ… No unintended side effects + +### Apply and Test + +```bash +# Run without dry-run +node esm/cli.js +# Dry-run: No + +# Test the migrated code +cd /tmp/migration-test +yarn install +yarn build +yarn test +``` + +## Resources + +- [jscodeshift Documentation](https://github.com/facebook/jscodeshift) +- [AST Explorer](https://astexplorer.net/) - Visualize JavaScript/TypeScript AST +- [Utilities API](./UTILITIES.md) - Available helper functions +- [Configuration Guide](./CONFIGURATION.md) - Config structure +- [CLI Reference](./CLI_REFERENCE.md) - Running the migrator + +## Common Issues + +### "Transform not found" + +Error: `ENOENT: no such file or directory` + +**Solution:** Check the `file` path in config.json is correct relative to the version directory. + +### "No changes detected" + +The transform runs but no files are modified. + +**Possible causes:** + +- Transform logic doesn't match the code pattern +- Files don't contain the target pattern +- Transform is checking the wrong node type + +**Debug with AST Explorer:** Paste your code and see the actual AST structure. + +### "Transform runs on wrong files" + +**Solution:** Specify `extensions` in config.json: + +```json +{ + "name": "mobile-specific", + "file": "transforms/mobile-specific.ts", + "extensions": "tsx,jsx" // Only JSX files +} +``` diff --git a/packages/migrator/package.json b/packages/migrator/package.json new file mode 100644 index 000000000..47335c489 --- /dev/null +++ b/packages/migrator/package.json @@ -0,0 +1,47 @@ +{ + "name": "@coinbase/cds-migrator", + "version": "1.0.0", + "description": "Coinbase Design System - Code Migration Tools", + "repository": { + "type": "git", + "url": "git@github.com:coinbase/cds.git", + "directory": "packages/migrator" + }, + "type": "module", + "main": "./esm/index.js", + "types": "./dts/index.d.ts", + "bin": { + "cds-migrate": "./esm/cli.js" + }, + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dts/index.d.ts", + "default": "./esm/index.js" + }, + "./*": { + "types": "./dts/*.d.ts", + "default": "./esm/*.js" + } + }, + "sideEffects": false, + "files": [ + "dts", + "esm", + "CHANGELOG" + ], + "dependencies": { + "commander": "^11.1.0", + "inquirer": "^9.2.12", + "jscodeshift": "^0.15.1" + }, + "devDependencies": { + "@babel/core": "^7.28.0", + "@babel/preset-env": "^7.28.0", + "@babel/preset-react": "^7.28.5", + "@babel/preset-typescript": "^7.27.1", + "@types/inquirer": "^9.0.7", + "@types/jscodeshift": "^0.11.10", + "@types/node": "^20.0.0" + } +} diff --git a/packages/migrator/project.json b/packages/migrator/project.json new file mode 100644 index 000000000..a3ac821d0 --- /dev/null +++ b/packages/migrator/project.json @@ -0,0 +1,38 @@ +{ + "name": "migrator", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/migrator/src", + "projectType": "library", + "targets": { + "build": { + "executor": "nx:run-commands", + "defaultConfiguration": "dev", + "configurations": { + "dev": { + "command": "rm -rf esm && babel ./src --out-dir esm --extensions .ts,.tsx,.js,.jsx --copy-files --no-copy-ignored" + }, + "prod": { + "commands": [ + "rm -rf esm && babel ./src --out-dir esm --extensions .ts,.tsx,.js,.jsx --copy-files --no-copy-ignored" + ], + "parallel": false + } + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "typecheck": { + "executor": "nx:run-commands", + "defaultConfiguration": "dev", + "configurations": { + "dev": { + "command": "tsc --build --pretty --verbose" + }, + "prod": { + "command": "tsc --build ./tsconfig.build.json --pretty --verbose" + } + } + } + } +} diff --git a/packages/migrator/src/cli-args.ts b/packages/migrator/src/cli-args.ts new file mode 100644 index 000000000..dbbb1deff --- /dev/null +++ b/packages/migrator/src/cli-args.ts @@ -0,0 +1,82 @@ +/** + * CLI argument parsing + */ + +import { Command } from 'commander'; +import type { MigrationSelection } from './types.js'; + +export interface CliArgs { + version?: string; + path?: string; + dryRun?: boolean; + skipConfirmation?: boolean; + clearHistory?: boolean; + all?: boolean; + category?: string[]; + item?: string[]; + transform?: string[]; +} + +export function parseCliArgs(): CliArgs { + const program = new Command(); + + program + .name('cds-migrate') + .description('CDS code migration tool') + .version('1.0.0') + .option('-m, --migration ', 'Migration version (e.g., v8-to-v9)') + .option('-p, --path ', 'Target path to migrate', './src') + .option('-d, --dry-run', 'Run in dry-run mode (preview changes)', false) + .option('--skip-confirmation', 'Skip confirmation prompts', false) + .option('--clear-history', 'Clear migration history for the specified path', false) + .option('--all', 'Migrate everything') + .option('--category ', 'Migrate specific categories') + .option('--item ', 'Migrate specific items (components/hooks/utilities)') + .option('--transform ', 'Migrate specific transforms'); + + program.parse(); + + const options = program.opts(); + + return { + version: options.migration, + path: options.path, + dryRun: options.dryRun, + skipConfirmation: options.skipConfirmation, + clearHistory: options.clearHistory, + all: options.all, + category: options.category, + item: options.item, + transform: options.transform, + }; +} + +export function buildSelectionFromArgs(args: CliArgs): MigrationSelection | null { + if (args.all) { + return { all: true }; + } + + if (args.transform && args.transform.length > 0) { + return { transforms: args.transform }; + } + + if (args.item && args.item.length > 0) { + return { items: args.item }; + } + + if (args.category && args.category.length > 0) { + return { categories: args.category }; + } + + return null; +} + +export function hasRequiredArgs(args: CliArgs): boolean { + // Version and path are required for non-interactive mode + // Plus at least one selection method + return !!( + args.version && + args.path && + (args.all || args.category || args.item || args.transform) + ); +} diff --git a/packages/migrator/src/cli.ts b/packages/migrator/src/cli.ts new file mode 100644 index 000000000..dd7d9a072 --- /dev/null +++ b/packages/migrator/src/cli.ts @@ -0,0 +1,455 @@ +#!/usr/bin/env node + +/** + * CDS Migrator CLI + * + * Interactive tool to migrate codebases between CDS major versions + * Also supports non-interactive mode via CLI flags + */ + +import inquirer from 'inquirer'; +import { runMigration } from './runner.js'; +import { + loadMigrationConfig, + buildMigrationSummary, + getSelectedTransforms, +} from './utils/config-loader.js'; +import { + loadMigrationHistory, + getAlreadyRunTransforms, + buildHistorySummary, + clearMigrationHistory, +} from './utils/migration-history.js'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import type { MigrationSelection } from './types.js'; +import { parseCliArgs, buildSelectionFromArgs, hasRequiredArgs, type CliArgs } from './cli-args.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const SUPPORTED_MIGRATIONS = [ + { + name: 'v8 to v9', + value: 'v8-to-v9', + description: 'Migrate from CDS v8 to v9', + }, +] as const; + +async function selectMigrationScope(migrationDir: string): Promise { + const config = loadMigrationConfig(migrationDir); + + const { scope } = await inquirer.prompt([ + { + type: 'list', + name: 'scope', + message: 'What would you like to migrate?', + choices: [ + { name: 'Everything (all changes)', value: 'all' }, + { name: 'By category (components, hooks, etc.)', value: 'category' }, + { name: 'By item (specific component/hook/utility)', value: 'item' }, + { name: 'By specific transform', value: 'transform' }, + ], + }, + ]); + + if (scope === 'all') { + return { all: true }; + } + + if (scope === 'category') { + const categoryChoices = [ + { + name: 'πŸ”˜ All categories', + value: '__ALL__', + checked: false, + }, + ...Object.entries(config.categories).map(([name, cat]) => ({ + name: `${name} - ${cat.description}`, + value: name, + checked: false, + })), + ]; + + const { categories } = (await inquirer.prompt([ + { + type: 'checkbox', + name: 'categories', + message: 'Select categories to migrate:', + choices: categoryChoices, + validate: (input: string[]) => { + if (input.length === 0) { + return 'Please select at least one category'; + } + return true; + }, + }, + ] as any)) as { categories: string[] }; + + // If "All" is selected, return all categories + if (categories.includes('__ALL__')) { + return { all: true }; + } + + return { categories }; + } + + if (scope === 'item') { + // Build list of all items across categories + const itemChoices: Array<{ name: string; value: string; checked?: boolean }> = [ + { + name: 'πŸ”˜ All items', + value: '__ALL__', + checked: false, + }, + ]; + + for (const [categoryName, category] of Object.entries(config.categories)) { + for (const [itemName, item] of Object.entries(category.variables)) { + itemChoices.push({ + name: `${categoryName}.${itemName} - ${item.description} (${item.package})`, + value: `${categoryName}.${itemName}`, + }); + } + } + + const { items } = (await inquirer.prompt([ + { + type: 'checkbox', + name: 'items', + message: 'Select items to migrate:', + choices: itemChoices, + validate: (input: string[]) => { + if (input.length === 0) { + return 'Please select at least one item'; + } + return true; + }, + }, + ] as any)) as { items: string[] }; + + // If "All" is selected, return all items + if (items.includes('__ALL__')) { + return { all: true }; + } + + return { items }; + } + + if (scope === 'transform') { + // Build list of all transforms + const transformChoices: Array<{ name: string; value: string; checked?: boolean }> = [ + { + name: 'πŸ”˜ All transforms', + value: '__ALL__', + checked: false, + }, + ]; + + for (const [categoryName, category] of Object.entries(config.categories)) { + for (const [variableName, variable] of Object.entries(category.variables)) { + for (const transform of variable.transforms) { + transformChoices.push({ + name: `${categoryName}.${variableName}.${transform.name} - ${transform.description}`, + value: `${categoryName}.${variableName}.${transform.name}`, + }); + } + } + } + + const { transforms } = (await inquirer.prompt([ + { + type: 'checkbox', + name: 'transforms', + message: 'Select transforms to run:', + choices: transformChoices, + validate: (input: string[]) => { + if (input.length === 0) { + return 'Please select at least one transform'; + } + return true; + }, + }, + ] as any)) as { transforms: string[] }; + + // If "All" is selected, return all transforms + if (transforms.includes('__ALL__')) { + return { all: true }; + } + + return { transforms }; + } + + return { all: true }; +} + +async function runInteractiveMode() { + // Step 1: Select migration version + const answers = await inquirer.prompt([ + { + type: 'list', + name: 'migration', + message: 'Which major version migration do you need?', + choices: SUPPORTED_MIGRATIONS.map((m) => ({ + name: `${m.name} - ${m.description}`, + value: m.value, + })), + }, + ]); + + // Step 2: Ask for target path + const pathAnswer = await inquirer.prompt([ + { + type: 'input', + name: 'path', + message: 'Enter the path to your codebase (relative or absolute):', + default: './src', + validate: (input: string) => { + if (!input || input.trim() === '') { + return 'Please provide a valid path'; + } + return true; + }, + }, + ]); + + const targetPath = pathAnswer.path; + + // Step 3: Check and display migration history + const history = loadMigrationHistory(targetPath); + if (history && history.entries.length > 0) { + const historySummary = buildHistorySummary(targetPath); + console.log(historySummary); + } + + // Step 4: Load config and let user select scope + const migrationDir = path.join(__dirname, answers.migration); + const selection = await selectMigrationScope(migrationDir); + + // Step 5: Show what will be migrated + const config = loadMigrationConfig(migrationDir); + const summary = buildMigrationSummary(config, selection); + console.log(summary); + + // Step 6: Ask for dry-run mode + const modeAnswer = await inquirer.prompt([ + { + type: 'confirm', + name: 'dryRun', + message: 'Run in dry-run mode? (preview changes without modifying files)', + default: true, + }, + ]); + + // Check for migration history and warn about duplicate runs + const selectedTransforms = getSelectedTransforms(config, selection); + const transformIds = selectedTransforms.map((t) => `${t.category}.${t.variable}.${t.name}`); + const alreadyRun = getAlreadyRunTransforms(targetPath, transformIds); + + if (alreadyRun.length > 0 && !modeAnswer.dryRun) { + console.log('\n⚠️ Warning: Some transforms have already been run on this path:\n'); + for (const transformId of alreadyRun) { + console.log(` β€’ ${transformId}`); + } + console.log(''); + + const { action } = await inquirer.prompt([ + { + type: 'list', + name: 'action', + message: 'How would you like to proceed?', + choices: [ + { + name: 'Skip already-run transforms (recommended)', + value: 'skip', + }, + { + name: 'Re-run all transforms (may cause issues)', + value: 'rerun', + }, + { + name: 'Cancel migration', + value: 'cancel', + }, + ], + default: 'skip', + }, + ]); + + if (action === 'cancel') { + console.log('\n❌ Migration cancelled.\n'); + return; + } + + if (action === 'skip') { + // Filter out already-run transforms + if (selection.transforms) { + selection.transforms = selection.transforms.filter((t) => !alreadyRun.includes(t)); + } else if (selection.items) { + // For item selection, we need to filter at the item level + // This is complex, so we'll just warn the user + console.log('\n⚠️ Note: Skipping at the item level may leave some transforms incomplete.'); + console.log('Consider using transform-level selection for more control.\n'); + } + // For category or all selection, we can't easily filter, so just warn + if (selection.all || selection.categories) { + console.log( + '\n⚠️ Note: Cannot skip at category level. Some transforms may be skipped during execution.\n', + ); + } + } + } + + console.log('\nπŸ“‹ Migration Summary:'); + console.log(` Version: ${answers.migration}`); + console.log(` Path: ${targetPath}`); + console.log(` Mode: ${modeAnswer.dryRun ? 'Dry Run' : 'Apply Changes'}\n`); + + if (!modeAnswer.dryRun) { + const { confirm } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: '⚠️ This will modify your files. Continue?', + default: false, + }, + ]); + + if (!confirm) { + console.log('\n❌ Migration cancelled.\n'); + return; + } + } + + return { + migration: answers.migration, + path: targetPath, + dryRun: modeAnswer.dryRun, + selection, + }; +} + +async function runNonInteractiveMode(args: CliArgs) { + const migration = args.version!; + const targetPath = args.path!; + const dryRun = args.dryRun || false; + const selection = buildSelectionFromArgs(args); + + if (!selection) { + console.error('Error: Must specify --all, --category, --item, or --transform'); + process.exit(1); + } + + // Validate migration version + const isValidMigration = SUPPORTED_MIGRATIONS.some((m) => m.value === migration); + if (!isValidMigration) { + console.error(`Error: Invalid migration version '${migration}'`); + console.error(`Available versions: ${SUPPORTED_MIGRATIONS.map((m) => m.value).join(', ')}`); + process.exit(1); + } + + // Load config + const migrationDir = path.join(__dirname, migration); + const config = loadMigrationConfig(migrationDir); + + // Show migration plan + const summary = buildMigrationSummary(config, selection); + console.log(summary); + + // Check for duplicates + const selectedTransforms = getSelectedTransforms(config, selection); + const transformIds = selectedTransforms.map((t) => `${t.category}.${t.variable}.${t.name}`); + const alreadyRun = getAlreadyRunTransforms(targetPath, transformIds); + + if (alreadyRun.length > 0 && !dryRun && !args.skipConfirmation) { + console.log('\n⚠️ Warning: Some transforms have already been run on this path:\n'); + for (const transformId of alreadyRun) { + console.log(` β€’ ${transformId}`); + } + console.log('\nUse --skip-confirmation to bypass this check or run with --dry-run first.\n'); + process.exit(1); + } + + console.log('\nπŸ“‹ Migration Summary:'); + console.log(` Version: ${migration}`); + console.log(` Path: ${targetPath}`); + console.log(` Mode: ${dryRun ? 'Dry Run' : 'Apply Changes'}`); + console.log(` Transforms: ${selectedTransforms.length}\n`); + + return { + migration, + path: targetPath, + dryRun, + selection, + }; +} + +async function main() { + console.log('\nπŸš€ CDS Migrator\n'); + + // Parse CLI arguments + const args = parseCliArgs(); + + // Handle clear-history command + if (args.clearHistory) { + if (!args.path) { + console.error('Error: --path is required when using --clear-history'); + process.exit(1); + } + + const history = loadMigrationHistory(args.path); + if (!history) { + console.log(`No migration history found at: ${args.path}`); + return; + } + + const { confirm } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: `⚠️ Clear migration history for ${args.path}?`, + default: false, + }, + ]); + + if (confirm) { + clearMigrationHistory(args.path); + console.log(`βœ… Migration history cleared for: ${args.path}\n`); + } else { + console.log('❌ Cancelled.\n'); + } + return; + } + + // Determine if running in interactive or non-interactive mode + const isNonInteractive = hasRequiredArgs(args); + + let migrationOptions; + + if (isNonInteractive) { + // Non-interactive mode with CLI flags + migrationOptions = await runNonInteractiveMode(args); + } else { + // Interactive mode with prompts + console.log('This tool will help you migrate your codebase between CDS major versions.\n'); + migrationOptions = await runInteractiveMode(); + } + + if (!migrationOptions) { + console.error('\n❌ Migration setup failed.\n'); + process.exit(1); + } + + try { + await runMigration(migrationOptions); + console.log('\nβœ… Migration completed successfully!\n'); + } catch (error) { + console.error('\n❌ Migration failed:', error); + process.exit(1); + } +} + +main().catch((error) => { + console.error('Unexpected error:', error); + process.exit(1); +}); diff --git a/packages/migrator/src/index.ts b/packages/migrator/src/index.ts new file mode 100644 index 000000000..815468540 --- /dev/null +++ b/packages/migrator/src/index.ts @@ -0,0 +1,9 @@ +/** + * @coinbase/cds-migrator + * + * Code migration tools for the Coinbase Design System + */ + +export * from './types.js'; +export { runMigration } from './runner.js'; +export * from './utils/index.js'; diff --git a/packages/migrator/src/runner.ts b/packages/migrator/src/runner.ts new file mode 100644 index 000000000..28b5bdba5 --- /dev/null +++ b/packages/migrator/src/runner.ts @@ -0,0 +1,124 @@ +/** + * Migration runner + * + * Coordinates the execution of version-specific migrations + */ + +import { execSync } from 'child_process'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { + createLogger, + loadMigrationConfig, + getSelectedTransforms, + hasTransformBeenRun, + recordTransformRun, +} from './utils/index.js'; +import type { MigrationSelection } from './types.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface RunMigrationOptions { + migration: string; + path: string; + dryRun: boolean; + selection?: MigrationSelection; +} + +export async function runMigration(options: RunMigrationOptions): Promise { + const { migration, path: targetPath, dryRun, selection = { all: true } } = options; + + console.log(`\nπŸ”„ Running ${migration} migration...\n`); + + // Create logger for this migration run + const logger = createLogger(process.cwd()); + logger.info(`Starting ${migration} migration`); + logger.info(`Target path: ${targetPath}`); + logger.info(`Mode: ${dryRun ? 'Dry Run' : 'Apply Changes'}`); + + // Load config and get selected transforms + const migrationDir = path.join(__dirname, migration); + const config = loadMigrationConfig(migrationDir); + const transforms = getSelectedTransforms(config, selection); + + if (transforms.length === 0) { + console.log('ℹ️ No transforms selected for this migration.'); + logger.warn('No transforms selected'); + return; + } + + logger.info(`Selected ${transforms.length} transforms to run`); + + // Run each transform using jscodeshift + let skippedCount = 0; + + for (const transform of transforms) { + const transformId = `${transform.category}.${transform.variable}.${transform.name}`; + + // Check if this transform has already been run + if (!dryRun && hasTransformBeenRun(targetPath, transformId)) { + console.log(`\n ⊘ Skipping (already run): ${transformId}`); + logger.info(`Skipped transform (already run): ${transformId}`); + skippedCount++; + continue; + } + + console.log(`\n β†’ Running transform: ${transformId}`); + console.log(` ${transform.description}`); + logger.info( + `Running transform: ${transform.name} (${transform.category}.${transform.variable})`, + ); + + const transformPath = path.join(migrationDir, transform.file); + const extensions = transform.extensions || 'tsx,ts,jsx,js'; + + try { + const jscodeshiftCmd = [ + 'npx', + 'jscodeshift', + dryRun ? '--dry' : '', + '--parser=tsx', + `--extensions=${extensions}`, + '--ignore-pattern="**/node_modules/**"', + '--ignore-pattern="**/.next/**"', + '--ignore-pattern="**/dist/**"', + '--ignore-pattern="**/build/**"', + `--transform=${transformPath}`, + targetPath, + ] + .filter(Boolean) + .join(' '); + + console.log(` Command: ${jscodeshiftCmd}\n`); + + execSync(jscodeshiftCmd, { + stdio: 'inherit', + cwd: process.cwd(), + }); + + console.log(`\n βœ“ Transform completed: ${transform.name}`); + logger.success(`Transform completed: ${transform.name}`); + + // Record this transform run in history (only for non-dry-runs) + recordTransformRun(targetPath, transformId, migration, dryRun); + } catch (error) { + console.error(`\n βœ— Transform failed: ${transform.name}`); + logger.error( + `Transform failed: ${transform.name}`, + undefined, + undefined, + error instanceof Error ? error.message : String(error), + ); + throw error; + } + } + + if (skippedCount > 0) { + console.log(`\n⊘ Skipped ${skippedCount} transform(s) that were already run.\n`); + logger.info(`Skipped ${skippedCount} transforms that were already run`); + } + + // Write summary and close logger + logger.writeSummary(); +} diff --git a/packages/migrator/src/types.ts b/packages/migrator/src/types.ts new file mode 100644 index 000000000..bcebef7d2 --- /dev/null +++ b/packages/migrator/src/types.ts @@ -0,0 +1,118 @@ +/** + * Types for CDS migration tools + */ + +export interface MigrationOptions { + /** + * Paths to files or directories to migrate + */ + paths: string[]; + /** + * Whether to perform a dry run without modifying files + */ + dryRun?: boolean; +} + +export interface Transform { + /** + * Name of the transform + */ + name: string; + /** + * Description of what the transform does + */ + description: string; + /** + * Path to the transform file (relative to migration directory) + */ + file: string; + /** + * File extensions to process (comma-separated) + * @default "tsx,ts,jsx,js" + */ + extensions?: string; +} + +export interface MigrationModule { + /** + * List of transforms to run for this migration + */ + transforms: Transform[]; + /** + * Description of the migration and breaking changes + */ + description: string; +} + +/** + * Configuration for a migration variable (component, hook, utility, etc.) + */ +export interface MigrationVariable { + /** + * Human-readable description of the changes + */ + description: string; + /** + * Package name where this variable is exported from + */ + package: string; + /** + * List of transforms to apply for this variable + */ + transforms: Transform[]; +} + +/** + * Configuration for a migration category + */ +export interface MigrationCategory { + /** + * Human-readable description of the category + */ + description: string; + /** + * Variables (components, hooks, utilities, etc.) in this category + */ + variables: Record; +} + +/** + * Main migration configuration structure + */ +export interface MigrationConfig { + /** + * Version identifier (e.g., "v8-to-v9") + */ + version: string; + /** + * Overall description of the migration + */ + description: string; + /** + * Categories of changes organized by type + */ + categories: Record; +} + +/** + * Selection for what to migrate + */ +export interface MigrationSelection { + /** + * If true, migrate everything + */ + all?: boolean; + /** + * Specific categories to migrate + */ + categories?: string[]; + /** + * Specific items to migrate (format: "category.item") + * Items are components, hooks, utilities, etc. + */ + items?: string[]; + /** + * Specific transforms to migrate (format: "category.item.transform") + */ + transforms?: string[]; +} diff --git a/packages/migrator/src/utils/config-loader.ts b/packages/migrator/src/utils/config-loader.ts new file mode 100644 index 000000000..ab00c8749 --- /dev/null +++ b/packages/migrator/src/utils/config-loader.ts @@ -0,0 +1,155 @@ +/** + * Configuration loader utilities + */ + +import fs from 'fs'; +import path from 'path'; +import type { MigrationConfig, Transform, MigrationSelection } from '../types.js'; + +/** + * Load migration configuration from config.json + */ +export function loadMigrationConfig(migrationDir: string): MigrationConfig { + const configPath = path.join(migrationDir, 'config.json'); + + if (!fs.existsSync(configPath)) { + throw new Error(`Migration config not found: ${configPath}`); + } + + const configContent = fs.readFileSync(configPath, 'utf-8'); + return JSON.parse(configContent) as MigrationConfig; +} + +/** + * Get all transforms from config based on selection + */ +export function getSelectedTransforms( + config: MigrationConfig, + selection: MigrationSelection, +): Array { + const transforms: Array = []; + + // If migrate all, collect everything + if (selection.all) { + for (const [categoryName, category] of Object.entries(config.categories)) { + for (const [variableName, variable] of Object.entries(category.variables)) { + for (const transform of variable.transforms) { + transforms.push({ + ...transform, + category: categoryName, + variable: variableName, + }); + } + } + } + return transforms; + } + + // Collect by specific transforms + if (selection.transforms && selection.transforms.length > 0) { + for (const transformPath of selection.transforms) { + const [categoryName, variableName, transformName] = transformPath.split('.'); + const transform = config.categories[categoryName]?.variables[variableName]?.transforms.find( + (t) => t.name === transformName, + ); + if (transform) { + transforms.push({ + ...transform, + category: categoryName, + variable: variableName, + }); + } + } + return transforms; + } + + // Collect by specific items + if (selection.items && selection.items.length > 0) { + for (const itemPath of selection.items) { + const [categoryName, itemName] = itemPath.split('.'); + const item = config.categories[categoryName]?.variables[itemName]; + if (item) { + for (const transform of item.transforms) { + transforms.push({ + ...transform, + category: categoryName, + variable: itemName, + }); + } + } + } + return transforms; + } + + // Collect by specific categories + if (selection.categories && selection.categories.length > 0) { + for (const categoryName of selection.categories) { + const category = config.categories[categoryName]; + if (category) { + for (const [variableName, variable] of Object.entries(category.variables)) { + for (const transform of variable.transforms) { + transforms.push({ + ...transform, + category: categoryName, + variable: variableName, + }); + } + } + } + } + return transforms; + } + + return transforms; +} + +/** + * Build a summary of what will be migrated + */ +export function buildMigrationSummary( + config: MigrationConfig, + selection: MigrationSelection, +): string { + const transforms = getSelectedTransforms(config, selection); + const byCategory: Record> = {}; + + for (const transform of transforms) { + if (!byCategory[transform.category]) { + byCategory[transform.category] = []; + } + byCategory[transform.category].push({ + variable: transform.variable, + transform: transform.name, + }); + } + + let summary = '\nMigration Plan:\n'; + summary += '================\n\n'; + + for (const [category, items] of Object.entries(byCategory)) { + const categoryInfo = config.categories[category]; + summary += `πŸ“¦ ${category}: ${categoryInfo.description}\n`; + + const byVariable: Record = {}; + for (const item of items) { + if (!byVariable[item.variable]) { + byVariable[item.variable] = []; + } + byVariable[item.variable].push(item.transform); + } + + for (const [variable, transformNames] of Object.entries(byVariable)) { + const variableInfo = categoryInfo.variables[variable]; + summary += ` └─ ${variable} (${variableInfo.package})\n`; + for (const transformName of transformNames) { + const transform = variableInfo.transforms.find((t) => t.name === transformName); + summary += ` β€’ ${transform?.description || transformName}\n`; + } + } + summary += '\n'; + } + + summary += `Total transforms: ${transforms.length}\n`; + + return summary; +} diff --git a/packages/migrator/src/utils/constants.ts b/packages/migrator/src/utils/constants.ts new file mode 100644 index 000000000..9a3ecd411 --- /dev/null +++ b/packages/migrator/src/utils/constants.ts @@ -0,0 +1,6 @@ +/** + * Constants used across migration utilities + */ + +export const TODO_PREFIX = 'TODO(cds-migration)'; +export const LOG_FILE_NAME = 'migration.log'; diff --git a/packages/migrator/src/utils/index.ts b/packages/migrator/src/utils/index.ts new file mode 100644 index 000000000..bf9a6d841 --- /dev/null +++ b/packages/migrator/src/utils/index.ts @@ -0,0 +1,9 @@ +/** + * Shared utility functions for CDS migrations + */ + +export * from './insert-todo.js'; +export * from './logger.js'; +export * from './constants.js'; +export * from './config-loader.js'; +export * from './migration-history.js'; diff --git a/packages/migrator/src/utils/insert-todo.ts b/packages/migrator/src/utils/insert-todo.ts new file mode 100644 index 000000000..aa7ffdeeb --- /dev/null +++ b/packages/migrator/src/utils/insert-todo.ts @@ -0,0 +1,135 @@ +/** + * Utilities for inserting TODO comments when automatic migration is not possible + */ + +import type { API, Collection } from 'jscodeshift'; +import { TODO_PREFIX } from './constants.js'; + +export interface InsertTodoOptions { + /** + * The message to include in the TODO comment + */ + message: string; + /** + * Additional context or instructions + */ + context?: string; + /** + * Whether to insert before or after the node + */ + position?: 'before' | 'after'; +} + +/** + * Insert a TODO comment near a JSX element + * + * @example + * // Before + * + + + ); +} diff --git a/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/basic-literal.output.tsx b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/basic-literal.output.tsx new file mode 100644 index 000000000..bed17c9c8 --- /dev/null +++ b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/basic-literal.output.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +// @ts-expect-error -- Fixture intentionally imports Button/IconButton from @coinbase/cds-web to exercise codemod import matching; typings are not validated for fixture sources. +import { Button, IconButton } from '@coinbase/cds-web'; + +export function Example() { + return ( + <> + + + + ); +} diff --git a/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/dynamic-variant.input.tsx b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/dynamic-variant.input.tsx new file mode 100644 index 000000000..909d2124c --- /dev/null +++ b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/dynamic-variant.input.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +// @ts-expect-error -- Fixture intentionally imports Button/IconButton from @coinbase/cds-web to exercise codemod import matching; typings are not validated for fixture sources. +import { Button } from '@coinbase/cds-web'; + +const variant = getVariant(); + +export function Example() { + return ; +} diff --git a/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/dynamic-variant.output.tsx b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/dynamic-variant.output.tsx new file mode 100644 index 000000000..8213ce92a --- /dev/null +++ b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/dynamic-variant.output.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +// @ts-expect-error -- Fixture intentionally imports Button/IconButton from @coinbase/cds-web to exercise codemod import matching; typings are not validated for fixture sources. +import { Button } from '@coinbase/cds-web'; + +const variant = getVariant(); + +export function Example() { + return ( + // TODO(cds-migration): Button variant requires manual migration + // Found dynamic or non-literal variant value. Verify mapping to v9 variants and update manually if needed. + + ); +} diff --git a/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/spread-props.input.tsx b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/spread-props.input.tsx new file mode 100644 index 000000000..c55ff1372 --- /dev/null +++ b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/spread-props.input.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +// @ts-expect-error -- Fixture intentionally imports Button/IconButton from @coinbase/cds-web to exercise codemod import matching; typings are not validated for fixture sources. +import { IconButton } from '@coinbase/cds-web'; + +const iconProps = { + variant: 'foregroundMuted' as const, + compact: true, +}; + +export function Example() { + return ; +} diff --git a/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/spread-props.output.tsx b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/spread-props.output.tsx new file mode 100644 index 000000000..b500561ce --- /dev/null +++ b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/spread-props.output.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +// @ts-expect-error -- Fixture intentionally imports Button/IconButton from @coinbase/cds-web to exercise codemod import matching; typings are not validated for fixture sources. +import { IconButton } from '@coinbase/cds-web'; + +const iconProps = { + variant: 'secondary' as const, + compact: true, +}; + +export function Example() { + return ( + // TODO(cds-migration): IconButton spread props need manual review + // Found spread props on IconButton. Spread values may still contain legacy variant values. Review spread source and flatten/update variant explicitly. An obvious local object-literal variant was auto-updated, but full spread safety still requires manual verification. + + ); +} diff --git a/packages/migrator/src/v8-to-v9/transforms/__tests__/button-variant-mapping.test.ts b/packages/migrator/src/v8-to-v9/transforms/__tests__/button-variant-mapping.test.ts new file mode 100644 index 000000000..81e75bfab --- /dev/null +++ b/packages/migrator/src/v8-to-v9/transforms/__tests__/button-variant-mapping.test.ts @@ -0,0 +1,141 @@ +import fs from 'fs'; +import path from 'path'; + +import { TODO_PREFIX } from '../../../utils/constants'; +import transformer from '../button-variant-mapping'; +import { runTransform } from '../test-utils'; + +function readFixtureFile(fileName: string): string { + const fixturePath = path.resolve( + process.cwd(), + 'src/v8-to-v9/transforms/__fixtures__/button-variant-mapping', + fileName, + ); + return fs.readFileSync(fixturePath, 'utf8'); +} + +function normalizeCode(code: string): string { + return code.replace(/\s+/g, '').replace(/"/g, "'"); +} + +describe('button-variant-mapping', () => { + it('maps static literal variants for Button and IconButton', () => { + const source = ` + import { Button, IconButton } from '@coinbase/cds-web'; + + export function Example() { + return ( + <> + + + + + + ); + } + `; + + const { output, entries } = runTransform(transformer, source); + + expect(output).toContain('variant="inverse"'); + expect(output).toContain('variant="secondary"'); + expect(entries.some((entry) => entry.level === 'SUCCESS')).toBe(true); + }); + + it('supports imported aliases', () => { + const source = ` + import { Button as CdsButton, IconButton as CdsIconButton } from '@coinbase/cds-web'; + + export const Example = () => ( + <> + Save + + + ); + `; + + const { output } = runTransform(transformer, source); + + expect(output).toContain(''); + expect(output).toContain(''); + }); + + it('adds TODO for dynamic variants and spread props', () => { + const source = ` + import { Button, IconButton } from '@coinbase/cds-web'; + + const variant = shouldInvert() ? 'tertiary' : getVariant(); + const iconProps = { variant: 'foregroundMuted' as const, compact: true }; + + export function Example() { + return ( + <> + + + + ); + } + `; + + const { output, entries } = runTransform(transformer, source); + + expect(output).toContain(TODO_PREFIX); + expect(output).toMatch(/variant:\s*["']secondary["']/); + expect(entries.filter((entry) => entry.level === 'TODO').length).toBeGreaterThanOrEqual(2); + }); + + it('is idempotent for TODO insertion', () => { + const source = ` + import { Button } from '@coinbase/cds-web'; + + export function Example({ variant, props }: { variant: string; props: Record }) { + return ; + } + `; + + const first = runTransform(transformer, source).output; + const second = runTransform(transformer, first).output; + + const firstTodoCount = first.split(TODO_PREFIX).length - 1; + const secondTodoCount = second.split(TODO_PREFIX).length - 1; + expect(secondTodoCount).toBe(firstTodoCount); + }); + + it('does not change non-target components', () => { + const source = ` + import { Button as NotCdsButton } from './local'; + + export const Example = () => Save; + `; + + const { output, entries } = runTransform(transformer, source); + expect(output).toBe(source); + expect(entries.length).toBe(0); + }); + + it('matches basic-literal fixture output', () => { + const input = readFixtureFile('basic-literal.input.tsx'); + const expected = readFixtureFile('basic-literal.output.tsx'); + + const { output } = runTransform(transformer, input); + expect(normalizeCode(output)).toBe(normalizeCode(expected)); + }); + + it('matches dynamic-variant fixture output', () => { + const input = readFixtureFile('dynamic-variant.input.tsx'); + const expected = readFixtureFile('dynamic-variant.output.tsx'); + + const { output, entries } = runTransform(transformer, input); + expect(normalizeCode(output)).toBe(normalizeCode(expected)); + expect(entries.some((entry) => entry.level === 'TODO')).toBe(true); + }); + + it('matches spread-props fixture output', () => { + const input = readFixtureFile('spread-props.input.tsx'); + const expected = readFixtureFile('spread-props.output.tsx'); + + const { output, entries } = runTransform(transformer, input); + expect(normalizeCode(output)).toBe(normalizeCode(expected)); + expect(entries.some((entry) => entry.level === 'TODO')).toBe(true); + }); +}); diff --git a/packages/migrator/src/v8-to-v9/transforms/button-variant-mapping.ts b/packages/migrator/src/v8-to-v9/transforms/button-variant-mapping.ts new file mode 100644 index 000000000..019acf613 --- /dev/null +++ b/packages/migrator/src/v8-to-v9/transforms/button-variant-mapping.ts @@ -0,0 +1,16 @@ +import type { API, FileInfo, Options } from 'jscodeshift'; + +import { migrateVariantProps } from './utils/variant-migration.js'; + +// eslint-disable-next-line no-restricted-exports -- jscodeshift requires default export +export default function transformer(file: FileInfo, api: API, options: Options) { + return migrateVariantProps(file, api, options, { + componentNames: ['Button', 'IconButton'], + variantMapping: { + tertiary: 'inverse', + foregroundMuted: 'secondary', + }, + transformLabel: 'button-variant-mapping', + includesTertiarySemanticWarning: true, + }); +} diff --git a/packages/migrator/src/v8-to-v9/transforms/test-utils.ts b/packages/migrator/src/v8-to-v9/transforms/test-utils.ts new file mode 100644 index 000000000..768c145c4 --- /dev/null +++ b/packages/migrator/src/v8-to-v9/transforms/test-utils.ts @@ -0,0 +1,23 @@ +import fs from 'fs'; +import type { API, FileInfo, Options } from 'jscodeshift'; +import { withParser } from 'jscodeshift'; +import os from 'os'; +import path from 'path'; + +import type { LogEntry } from '../../utils/logger.js'; +import { createLogger } from '../../utils/logger.js'; + +export function runTransform( + transform: (file: FileInfo, api: API, options: Options) => string | null, + source: string, +): { output: string; entries: LogEntry[] } { + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cds-migrator-test-')); + const logger = createLogger(outputDir); + const api = { jscodeshift: withParser('tsx') } as API; + + const result = transform({ path: '/tmp/example.tsx', source }, api, {}); + return { + output: result ?? source, + entries: logger.getEntries(), + }; +} diff --git a/packages/migrator/src/v8-to-v9/transforms/utils/variant-migration.ts b/packages/migrator/src/v8-to-v9/transforms/utils/variant-migration.ts new file mode 100644 index 000000000..84b08e1ab --- /dev/null +++ b/packages/migrator/src/v8-to-v9/transforms/utils/variant-migration.ts @@ -0,0 +1,362 @@ +import type { + API, + ASTPath, + Collection, + FileInfo, + JSXAttribute, + JSXOpeningElement, + Options, +} from 'jscodeshift'; + +import { + addTodoToAttribute, + getLogger, + hasMigrationTodo, + insertTodoComment, +} from '../../../utils/index.js'; + +type VariantMigrationOptions = { + componentNames: string[]; + variantMapping: Record; + transformLabel: string; + includesTertiarySemanticWarning?: boolean; +}; + +type ComponentImports = { + localToImported: Map; + localNames: Set; +}; + +function toSourceValue(source: string | null | undefined): string { + return typeof source === 'string' ? source : ''; +} + +function collectComponentImports( + root: Collection, + j: API['jscodeshift'], + componentNames: string[], +): ComponentImports { + const targetNames = new Set(componentNames); + const localToImported = new Map(); + const localNames = new Set(); + + root + .find(j.ImportDeclaration) + .filter((path: any) => toSourceValue(path.value.source.value).startsWith('@coinbase/cds')) + .forEach((path: any) => { + path.value.specifiers?.forEach((specifier: any) => { + if (!j.ImportSpecifier.check(specifier)) { + return; + } + + const importedName = specifier.imported.name; + if (!targetNames.has(importedName)) { + return; + } + + const localName = specifier.local?.name ?? importedName; + localToImported.set(localName, importedName); + localNames.add(localName); + }); + }); + + return { localToImported, localNames }; +} + +function getOpeningElementName( + j: API['jscodeshift'], + openingElement: JSXOpeningElement, +): string | null { + if (j.JSXIdentifier.check(openingElement.name)) { + return openingElement.name.name; + } + + return null; +} + +function getAttributeName(j: API['jscodeshift'], attribute: any): string | null { + if (!j.JSXAttribute.check(attribute) || !j.JSXIdentifier.check(attribute.name)) { + return null; + } + + return attribute.name.name; +} + +function createTodoReporter( + j: API['jscodeshift'], + file: FileInfo, + label: string, + openingPath: ASTPath, + fallbackLine?: number, +) { + const logger = getLogger(); + const openingName = getOpeningElementName(j, openingPath.value) ?? 'Component'; + + const hasExistingTodo = () => { + if (hasMigrationTodo(openingPath)) { + return true; + } + + const parentPath = openingPath.parent; + if (parentPath && hasMigrationTodo(parentPath as any)) { + return true; + } + + return false; + }; + + return (attributePath: ASTPath | null, message: string, details: string) => { + if (!hasExistingTodo()) { + if (attributePath) { + addTodoToAttribute(j, attributePath, { message, context: details }); + } else { + insertTodoComment(j, openingPath, { message, context: details }); + } + } + + logger?.todo(`[${label}] ${message}`, file.path, fallbackLine, `${openingName}: ${details}`); + }; +} + +function unwrapObjectExpression(j: API['jscodeshift'], value: any): any | null { + if (!value) { + return null; + } + + if (j.ObjectExpression.check(value)) { + return value; + } + + if ( + (j.TSAsExpression.check(value) || j.TSTypeAssertion.check(value)) && + j.ObjectExpression.check(value.expression) + ) { + return value.expression; + } + + return null; +} + +function getObjectVariantProperty(j: API['jscodeshift'], objectExpression: any): any | null { + const properties = objectExpression.properties ?? []; + for (const property of properties) { + const isProperty = j.Property.check(property); + const isObjectProperty = 'ObjectProperty' in j && (j as any).ObjectProperty.check(property); + if (!isProperty && !isObjectProperty) { + continue; + } + + if (j.Identifier.check(property.key) && property.key.name === 'variant') { + return property; + } + + if ( + (j.StringLiteral.check(property.key) || j.Literal.check(property.key)) && + property.key.value === 'variant' + ) { + return property; + } + } + + return null; +} + +function remapStringVariantValue( + j: API['jscodeshift'], + value: any, + variantMapping: Record, +): { didChange: boolean; previous?: string; next?: string } { + if (!value) { + return { didChange: false }; + } + + if ((j.TSAsExpression.check(value) || j.TSTypeAssertion.check(value)) && value.expression) { + const expressionResult = remapStringVariantValue(j, value.expression, variantMapping); + if (expressionResult.didChange) { + return expressionResult; + } + } + + if (j.StringLiteral.check(value) || j.Literal.check(value)) { + const previous = String(value.value); + const next = variantMapping[previous]; + if (next) { + value.value = next; + return { didChange: true, previous, next }; + } + return { didChange: false }; + } + + if (j.JSXExpressionContainer.check(value)) { + const expression = value.expression; + if (j.StringLiteral.check(expression) || j.Literal.check(expression)) { + const previous = String(expression.value); + const next = variantMapping[previous]; + if (next) { + expression.value = next; + return { didChange: true, previous, next }; + } + } + } + + return { didChange: false }; +} + +function expressionMentionsVariant(expression: any, variant: string): boolean { + const visited = new WeakSet(); + const stack: any[] = [expression]; + + while (stack.length > 0) { + const current = stack.pop(); + if (!current || typeof current !== 'object') { + continue; + } + if (visited.has(current)) { + continue; + } + visited.add(current); + + if ('value' in current && current.value === variant) { + return true; + } + + for (const child of Object.values(current)) { + if (child && typeof child === 'object') { + stack.push(child); + } + } + } + + return false; +} + +export function migrateVariantProps( + file: FileInfo, + api: API, + options: Options, + migrationOptions: VariantMigrationOptions, +): string | null { + const { + componentNames, + variantMapping, + transformLabel, + includesTertiarySemanticWarning = false, + } = migrationOptions; + const j = api.jscodeshift; + const root = j(file.source); + const logger = getLogger(); + + const imports = collectComponentImports(root, j, componentNames); + if (imports.localNames.size === 0) { + return null; + } + + let hasChanges = false; + + const variableDeclaratorsByName = new Map(); + root.find(j.VariableDeclarator).forEach((path: any) => { + if (j.Identifier.check(path.value.id)) { + variableDeclaratorsByName.set(path.value.id.name, path.value); + } + }); + + root + .find(j.JSXOpeningElement) + .filter((path: any) => { + const localName = getOpeningElementName(j, path.value); + return localName ? imports.localNames.has(localName) : false; + }) + .forEach((openingPath: any) => { + const localName = getOpeningElementName(j, openingPath.value); + if (!localName) { + return; + } + + const importedName = imports.localToImported.get(localName) ?? localName; + const fallbackLine = openingPath.value.loc?.start.line; + const reportTodo = createTodoReporter(j, file, transformLabel, openingPath, fallbackLine); + + openingPath.value.attributes.forEach((attribute: any) => { + if (j.JSXAttribute.check(attribute) && getAttributeName(j, attribute) === 'variant') { + const attributePath = j(openingPath) + .find(j.JSXAttribute, { name: { name: 'variant' } }) + .at(0) + .paths()[0]; + if (!attributePath) { + return; + } + + const remapResult = remapStringVariantValue(j, attribute.value, variantMapping); + if (remapResult.didChange) { + hasChanges = true; + logger?.success( + `[${transformLabel}] Updated ${importedName} variant: ${remapResult.previous} -> ${remapResult.next}`, + file.path, + attribute.loc?.start.line, + ); + return; + } + + if (!attribute.value || j.JSXExpressionContainer.check(attribute.value)) { + const expression = j.JSXExpressionContainer.check(attribute.value) + ? attribute.value.expression + : null; + const containsTertiary = expression + ? expressionMentionsVariant(expression, 'tertiary') + : false; + const tertiaryContext = + containsTertiary && includesTertiarySemanticWarning + ? ' Dynamic tertiary values need intent review: v9 tertiary no longer matches v8 tertiary visuals; use inverse if old v8 look is intended.' + : ''; + + reportTodo( + attributePath, + `${importedName} variant requires manual migration`, + `Found dynamic or non-literal variant value.${tertiaryContext} Verify mapping to v9 variants and update manually if needed.`, + ); + hasChanges = true; + } + } + + if (!j.JSXSpreadAttribute.check(attribute)) { + return; + } + + const spreadArgument = attribute.argument; + let didRewriteObjectVariant = false; + + if (j.Identifier.check(spreadArgument)) { + const declarator = variableDeclaratorsByName.get(spreadArgument.name); + const objectExpression = unwrapObjectExpression(j, declarator?.init); + if (objectExpression) { + const variantProperty = getObjectVariantProperty(j, objectExpression); + if (variantProperty) { + const remapResult = remapStringVariantValue(j, variantProperty.value, variantMapping); + if (remapResult.didChange) { + didRewriteObjectVariant = true; + hasChanges = true; + logger?.success( + `[${transformLabel}] Updated spread object variant for ${importedName}: ${remapResult.previous} -> ${remapResult.next}`, + file.path, + variantProperty.loc?.start.line ?? attribute.loc?.start.line, + ); + } + } + } + } + + reportTodo( + null, + `${importedName} spread props need manual review`, + `Found spread props on ${importedName}. Spread values may still contain legacy variant values. Review spread source and flatten/update variant explicitly.${didRewriteObjectVariant ? ' An obvious local object-literal variant was auto-updated, but full spread safety still requires manual verification.' : ''}`, + ); + hasChanges = true; + }); + }); + + if (!hasChanges) { + return null; + } + + return root.toSource(options); +} diff --git a/packages/migrator/tsconfig.json b/packages/migrator/tsconfig.json index 25b1d4a17..53e3c6f11 100644 --- a/packages/migrator/tsconfig.json +++ b/packages/migrator/tsconfig.json @@ -7,6 +7,14 @@ "include": [ "src/**/*" ], - "exclude": [], + "exclude": [ + // Keep migrator package typecheck focused on implementation source only. + // Transform fixtures intentionally contain example app code patterns that + // are validated by Jest transform tests, not by tsc project compilation. + "**/__tests__/**", + "**/__fixtures__/**", + "**/*.test.*", + "**/*.spec.*" + ], "references": [] } diff --git a/packages/migrator/tsconfig.spec.json b/packages/migrator/tsconfig.spec.json new file mode 100644 index 000000000..0a6eba130 --- /dev/null +++ b/packages/migrator/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [ + "jest", + "node" + ] + }, + "include": [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx", + "src/**/__tests__/**/*.ts", + "src/**/__tests__/**/*.tsx" + ] +} From 10dc34cad2dc02a1ddebba7955291719b6c19558 Mon Sep 17 00:00:00 2001 From: Boba Date: Fri, 27 Feb 2026 16:02:17 -0600 Subject: [PATCH 5/5] chore: update examples --- .../button-variant-mapping/basic-literal.input.tsx | 2 +- .../button-variant-mapping/basic-literal.output.tsx | 2 +- .../button-variant-mapping/dynamic-variant.input.tsx | 3 ++- .../button-variant-mapping/dynamic-variant.output.tsx | 3 ++- .../__fixtures__/button-variant-mapping/spread-props.input.tsx | 2 +- .../button-variant-mapping/spread-props.output.tsx | 2 +- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/basic-literal.input.tsx b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/basic-literal.input.tsx index 3f549d26b..08d5964f3 100644 --- a/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/basic-literal.input.tsx +++ b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/basic-literal.input.tsx @@ -1,5 +1,5 @@ import React from 'react'; -// @ts-expect-error -- Fixture intentionally imports Button/IconButton from @coinbase/cds-web to exercise codemod import matching; typings are not validated for fixture sources. +// @ts-expect-error -- Fixture import mirrors codemod target imports; fixture files are not validating package export typings. import { Button, IconButton } from '@coinbase/cds-web'; export function Example() { diff --git a/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/basic-literal.output.tsx b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/basic-literal.output.tsx index bed17c9c8..d5a77e5a7 100644 --- a/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/basic-literal.output.tsx +++ b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/basic-literal.output.tsx @@ -1,5 +1,5 @@ import React from 'react'; -// @ts-expect-error -- Fixture intentionally imports Button/IconButton from @coinbase/cds-web to exercise codemod import matching; typings are not validated for fixture sources. +// @ts-expect-error -- Fixture import mirrors codemod target imports; fixture files are not validating package export typings. import { Button, IconButton } from '@coinbase/cds-web'; export function Example() { diff --git a/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/dynamic-variant.input.tsx b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/dynamic-variant.input.tsx index 909d2124c..b20450bd2 100644 --- a/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/dynamic-variant.input.tsx +++ b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/dynamic-variant.input.tsx @@ -1,7 +1,8 @@ import React from 'react'; -// @ts-expect-error -- Fixture intentionally imports Button/IconButton from @coinbase/cds-web to exercise codemod import matching; typings are not validated for fixture sources. +// @ts-expect-error -- Fixture import mirrors codemod target imports; fixture files are not validating package export typings. import { Button } from '@coinbase/cds-web'; +// @ts-expect-error -- Fixture uses unresolved helper to represent a dynamic runtime variant source. const variant = getVariant(); export function Example() { diff --git a/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/dynamic-variant.output.tsx b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/dynamic-variant.output.tsx index 8213ce92a..0b0e9b06d 100644 --- a/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/dynamic-variant.output.tsx +++ b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/dynamic-variant.output.tsx @@ -1,7 +1,8 @@ import React from 'react'; -// @ts-expect-error -- Fixture intentionally imports Button/IconButton from @coinbase/cds-web to exercise codemod import matching; typings are not validated for fixture sources. +// @ts-expect-error -- Fixture import mirrors codemod target imports; fixture files are not validating package export typings. import { Button } from '@coinbase/cds-web'; +// @ts-expect-error -- Fixture uses unresolved helper to represent a dynamic runtime variant source. const variant = getVariant(); export function Example() { diff --git a/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/spread-props.input.tsx b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/spread-props.input.tsx index c55ff1372..d463cc5a2 100644 --- a/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/spread-props.input.tsx +++ b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/spread-props.input.tsx @@ -1,5 +1,5 @@ import React from 'react'; -// @ts-expect-error -- Fixture intentionally imports Button/IconButton from @coinbase/cds-web to exercise codemod import matching; typings are not validated for fixture sources. +// @ts-expect-error -- Fixture import mirrors codemod target imports; fixture files are not validating package export typings. import { IconButton } from '@coinbase/cds-web'; const iconProps = { diff --git a/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/spread-props.output.tsx b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/spread-props.output.tsx index b500561ce..c2b03a7ca 100644 --- a/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/spread-props.output.tsx +++ b/packages/migrator/src/v8-to-v9/transforms/__fixtures__/button-variant-mapping/spread-props.output.tsx @@ -1,5 +1,5 @@ import React from 'react'; -// @ts-expect-error -- Fixture intentionally imports Button/IconButton from @coinbase/cds-web to exercise codemod import matching; typings are not validated for fixture sources. +// @ts-expect-error -- Fixture import mirrors codemod target imports; fixture files are not validating package export typings. import { IconButton } from '@coinbase/cds-web'; const iconProps = {