From 4728ba1b290fc04126e808db2337750ff378852c Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 20 Oct 2026 09:46:24 -0400 Subject: [PATCH 01/16] feat(gamut-codemods): add the codemods CLI and an empty scope-swap preset Adds @skillsoft/gamut-codemods: the CLI (clean-tree guard, --dry, --only, --no-report, --force), the runner for ast, splice, and file migrations, the shared helpers (module-string finder, leftovers report, version-range checks, flat ESLint config detection), the preset registry, and the fixture harness. The scope-swap preset has its package map but no migrations yet, so a run only reports the lines that still name an old package. Each breaking change lands its migration in its own PR. Co-Authored-By: Claude Opus 5.5 --- .changeset/config.json | 3 + .changeset/gamut-codemods-scope-swap.md | 5 + .eslintignore | 1 + .prettierignore | 2 + packages/gamut-codemods/.gitignore | 1 + packages/gamut-codemods/README.md | 120 ++++++ packages/gamut-codemods/jest.config.ts | 19 + packages/gamut-codemods/package.json | 38 ++ packages/gamut-codemods/project.json | 46 ++ .../__testfixtures__/no-gamut.input.tsx | 4 + .../__testfixtures__/no-gamut.output.tsx | 4 + .../gamut-codemods/src/__tests__/cli.test.ts | 192 +++++++++ .../src/__tests__/fixtures.test.ts | 113 +++++ .../gamut-codemods/src/__tests__/lib.test.ts | 251 +++++++++++ .../src/__tests__/manifest.test.ts | 196 +++++++++ .../src/__tests__/module-strings.test.ts | 21 + packages/gamut-codemods/src/bin.ts | 13 + packages/gamut-codemods/src/cli.ts | 235 +++++++++++ packages/gamut-codemods/src/lib/eslint.ts | 39 ++ packages/gamut-codemods/src/lib/git.ts | 27 ++ packages/gamut-codemods/src/lib/leftovers.ts | 138 ++++++ packages/gamut-codemods/src/lib/manifest.ts | 124 ++++++ .../gamut-codemods/src/lib/module-strings.ts | 81 ++++ packages/gamut-codemods/src/lib/run-source.ts | 112 +++++ packages/gamut-codemods/src/lib/types.ts | 96 +++++ packages/gamut-codemods/src/presets/index.ts | 43 ++ .../src/presets/scope-swap/index.ts | 15 + .../src/presets/scope-swap/manifest.ts | 35 ++ packages/gamut-codemods/src/transform.ts | 77 ++++ packages/gamut-codemods/tsconfig.json | 13 + packages/gamut-codemods/tsconfig.lib.json | 13 + packages/gamut-codemods/tsconfig.spec.json | 11 + yarn.lock | 398 +++++++++++++++++- 33 files changed, 2480 insertions(+), 6 deletions(-) create mode 100644 .changeset/gamut-codemods-scope-swap.md create mode 100644 packages/gamut-codemods/.gitignore create mode 100644 packages/gamut-codemods/README.md create mode 100644 packages/gamut-codemods/jest.config.ts create mode 100644 packages/gamut-codemods/package.json create mode 100644 packages/gamut-codemods/project.json create mode 100644 packages/gamut-codemods/src/__tests__/__testfixtures__/no-gamut.input.tsx create mode 100644 packages/gamut-codemods/src/__tests__/__testfixtures__/no-gamut.output.tsx create mode 100644 packages/gamut-codemods/src/__tests__/cli.test.ts create mode 100644 packages/gamut-codemods/src/__tests__/fixtures.test.ts create mode 100644 packages/gamut-codemods/src/__tests__/lib.test.ts create mode 100644 packages/gamut-codemods/src/__tests__/manifest.test.ts create mode 100644 packages/gamut-codemods/src/__tests__/module-strings.test.ts create mode 100644 packages/gamut-codemods/src/bin.ts create mode 100644 packages/gamut-codemods/src/cli.ts create mode 100644 packages/gamut-codemods/src/lib/eslint.ts create mode 100644 packages/gamut-codemods/src/lib/git.ts create mode 100644 packages/gamut-codemods/src/lib/leftovers.ts create mode 100644 packages/gamut-codemods/src/lib/manifest.ts create mode 100644 packages/gamut-codemods/src/lib/module-strings.ts create mode 100644 packages/gamut-codemods/src/lib/run-source.ts create mode 100644 packages/gamut-codemods/src/lib/types.ts create mode 100644 packages/gamut-codemods/src/presets/index.ts create mode 100644 packages/gamut-codemods/src/presets/scope-swap/index.ts create mode 100644 packages/gamut-codemods/src/presets/scope-swap/manifest.ts create mode 100644 packages/gamut-codemods/src/transform.ts create mode 100644 packages/gamut-codemods/tsconfig.json create mode 100644 packages/gamut-codemods/tsconfig.lib.json create mode 100644 packages/gamut-codemods/tsconfig.spec.json diff --git a/.changeset/config.json b/.changeset/config.json index 67eba4b98a..60a8f22d4f 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,9 +8,12 @@ "fixed": [ [ "@skillsoft/gamut-agent-tools", + "@skillsoft/gamut-codemods", "@skillsoft/gamut-icons", + "@skillsoft/gamut-illustrations", "@skillsoft/gamut-patterns", "@skillsoft/gamut-styles", + "@skillsoft/gamut-tests", "@skillsoft/gamut", "@skillsoft/variance" ] diff --git a/.changeset/gamut-codemods-scope-swap.md b/.changeset/gamut-codemods-scope-swap.md new file mode 100644 index 0000000000..e6581a6692 --- /dev/null +++ b/.changeset/gamut-codemods-scope-swap.md @@ -0,0 +1,5 @@ +--- +'@skillsoft/gamut-codemods': minor +--- + +Add `@skillsoft/gamut-codemods`, a CLI for moving a repo from `@codecademy/gamut*` to `@skillsoft/gamut*`. Run `npx @skillsoft/gamut-codemods scope-swap .` from a clean working tree. This release adds the runner and the `scope-swap` preset with no migrations yet: a run lists every line that still names an old package and prints next steps. Pass `--dry` to preview, `--only=a,b` to pick migrations, and `--no-report` to skip the leftovers list. diff --git a/.eslintignore b/.eslintignore index 790b3f75f1..3b8050a6ca 100644 --- a/.eslintignore +++ b/.eslintignore @@ -12,3 +12,4 @@ packages/gamut-patterns/src/patterns packages/code-connect .nx packages/gamut-agent-tools/skills/*-workspace +packages/gamut-codemods/**/__testfixtures__ diff --git a/.prettierignore b/.prettierignore index 104cb1cfe7..17524c399a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,6 +7,8 @@ packages/gamut-patterns/src/patterns packages/gamut-styles/**/*.d.ts packages/styleguide/stories/Core/Atoms/Markdown/*.md packages/code-connect/**/* +# Codemod fixtures are exact expected output, formatting included. +packages/gamut-codemods/**/__testfixtures__ /.nx/cache /.nx/workspace-data diff --git a/packages/gamut-codemods/.gitignore b/packages/gamut-codemods/.gitignore new file mode 100644 index 0000000000..1521c8b765 --- /dev/null +++ b/packages/gamut-codemods/.gitignore @@ -0,0 +1 @@ +dist diff --git a/packages/gamut-codemods/README.md b/packages/gamut-codemods/README.md new file mode 100644 index 0000000000..aabf663a72 --- /dev/null +++ b/packages/gamut-codemods/README.md @@ -0,0 +1,120 @@ +# @skillsoft/gamut-codemods + +Codemods for moving a repo onto `@skillsoft/gamut` and between its releases. + +## Presets + +Each preset is one upgrade. Run them in order if you're more than one behind. + +| Preset | What it's for | +| ------------ | ------------------------------------------------------- | +| `scope-swap` | Moving from `@codecademy/gamut*` to `@skillsoft/gamut*` | + +`npx @skillsoft/gamut-codemods list` shows every preset and the migrations inside it. + +## Running scope-swap + +Start from a clean working tree. The codemod won't write into uncommitted changes (pass `--force` to override), so `git checkout .` always undoes a run. + +```sh +# Preview without writing anything +npx @skillsoft/gamut-codemods scope-swap . --dry + +# Run it +npx @skillsoft/gamut-codemods scope-swap . + +# Run only some migrations +npx @skillsoft/gamut-codemods scope-swap . --only=moved-exports,scope-rename +``` + +A run ends with three things: + +1. **Warnings**, prefixed with the migration name, e.g. `[moved-exports] src/Player.test.tsx:12 mock of '@codecademy/gamut' stubs Video/VideoProps`. Each one says what to do. +2. **Leftovers**: every line that still names an old package. These are for you to fix: jest `moduleNameMapper` regexes, tsconfig `paths`, flat ESLint configs, comments, and docs. +3. **Next steps**, built from what actually happened in the run. + +### Trying it against a preview build + +Every skillsoft-gamut pull request publishes installable previews through pkg.pr.new. To test a migration against one: + +1. Add the `@skillsoft/*` packages to your root `package.json` first, pointed at the preview URLs. The codemod keeps any `@skillsoft/*` entry that's already there. +2. Preview packages point at each other through commit-pinned URLs, so without help yarn installs a second copy of `gamut-styles`, `variance`, and so on under `@skillsoft/gamut/node_modules`. Pin every `@skillsoft/*` package you use with `resolutions`: + + ```json + "resolutions": { + "@skillsoft/gamut": "https://pkg.pr.new/@skillsoft/gamut@", + "@skillsoft/gamut-styles": "https://pkg.pr.new/@skillsoft/gamut-styles@", + "@skillsoft/variance": "https://pkg.pr.new/@skillsoft/variance@" + } + ``` + + Published releases don't need this. The packages share one version through the changesets `fixed` group, so their dependencies on each other line up. + +3. Commit, then run the codemod (or pass `--force`). + +### What scope-swap changes + +| Migration | What it does | +| --------- | ------------ | + +### Known limits + +- Every file is parsed with the `tsx` parser, so Flow-typed JS won't parse. +- The leftovers report uses regex, not an AST, so expect some false positives. + +## Contributing + +### How it's put together + +``` +src/ + presets/ + index.ts # every preset the CLI can run + / + index.ts # ordered migrations + next-steps checklist + manifest.ts # the data: what changed in this release + migrations// + index.ts # a reusable migration + __testfixtures__/ # .input., .output., optional .warnings.json + lib/ # shared types, module-string finder, leftovers, git guard + transform.ts # jscodeshift entry point + cli.ts, bin.ts +``` + +Two rules hold it together: + +- **Manifests hold the data; migrations hold the code.** Most Gamut changes need a manifest row and a fixture, not a new migration. +- **Manifests use the old package names.** In `scope-swap`, `scope-rename` runs last and swaps the scope, so nothing before it needs to know the new one. + +### Three kinds of migration + +- **`splice`** (the default choice): uses the AST to find ranges, then edits the original text by offset. Use it for anything that swaps one string for another. recast never prints the file, so nothing else in it moves. +- **`ast`**: mutates the AST, and recast prints the file. Only for structural rewrites like splitting an import. recast can reprint more than you changed: mutating one string literal in a semicolon-less file added semicolons and changed JSX text whitespace in neighbouring statements. Presets must list every `ast` migration before any `splice` one, and `validatePreset` enforces it. +- **`file`**: gets raw text for files its `match()` accepts (package.json, .eslintrc). + +### Changing a preset's manifest + +1. Add the row to `src/presets//manifest.ts`. +2. Add a fixture pair under the migration that handles it, e.g. `src/migrations/moved-exports/__testfixtures__/my-case.input.tsx` and `.output.tsx`. +3. If it should warn, add `my-case.warnings.json`: a list of substrings that must appear in the warnings. +4. `yarn nx test gamut-codemods`. + +`manifest.test.ts` type-checks every export a manifest points at against the built packages, so a row can't promise an export that doesn't exist. + +### Adding a migration + +1. Create `src/migrations//index.ts` exporting an `AstMigration`, `SpliceMigration`, or `FileMigration` (see `src/lib/types.ts`). +2. Add it to the presets that need it. In `scope-swap`, put it before `scope-rename` if it matches on old names. +3. Add fixtures. They're picked up automatically, and every source migration is also checked for idempotence. +4. If you find a new place a module name can appear, teach `lib/module-strings.ts` rather than special-casing it. +5. If it leaves follow-up work, call `note('')` and add the key to the preset's `conditionalChecklist`. + +### Adding a preset + +Create `src/presets//` with a manifest and an `index.ts`, register it in `src/presets/index.ts`, and reuse migrations from `src/migrations`. + +### recast gotchas already hit + +- Rebuild specifiers instead of mutating them. Changing `imported` on `import { List }` drops the alias, because `imported` and `local` share one source range. +- Clone nodes before moving them into a new declaration, or recast's patcher can crash. +- `insertAfter` and `replace(a, b)` copy the gap after the original node, which can leave a stray blank line. `moved-exports.postPrint` closes that specific gap. diff --git a/packages/gamut-codemods/jest.config.ts b/packages/gamut-codemods/jest.config.ts new file mode 100644 index 0000000000..dc5f1ad9f1 --- /dev/null +++ b/packages/gamut-codemods/jest.config.ts @@ -0,0 +1,19 @@ +/** @jest-config-loader ts-node */ +/* eslint-disable */ +import base from '../../jest.config.base'; + +export default base('gamut-codemods', { + testEnvironment: 'node', + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json', + }, + ], + }, + /* Fixtures are inputs, not tests, and may be deliberately odd code. */ + testPathIgnorePatterns: ['node_modules', 'dist', '__testfixtures__'], + setupFiles: [], + setupFilesAfterEnv: [], +}); diff --git a/packages/gamut-codemods/package.json b/packages/gamut-codemods/package.json new file mode 100644 index 0000000000..6acbecb748 --- /dev/null +++ b/packages/gamut-codemods/package.json @@ -0,0 +1,38 @@ +{ + "name": "@skillsoft/gamut-codemods", + "description": "Codemods for upgrading to and between @skillsoft/gamut releases", + "version": "0.0.1", + "author": "Skillsoft Engineering ", + "bin": "./dist/bin.js", + "dependencies": { + "jscodeshift": "^17.3.0" + }, + "devDependencies": { + "@skillsoft/gamut": "workspace:*", + "@types/jscodeshift": "^17.3.0" + }, + "engines": { + "node": ">=18" + }, + "files": [ + "dist" + ], + "keywords": [ + "codemod", + "gamut", + "jscodeshift", + "migration" + ], + "license": "MIT", + "main": "./dist/cli.js", + "publishConfig": { + "access": "public" + }, + "repository": "Codecademy/skillsoft-gamut.git", + "scripts": { + "build": "nx build gamut-codemods", + "verify": "tsc --noEmit -p tsconfig.lib.json && tsc --noEmit -p tsconfig.spec.json" + }, + "types": "./dist/cli.d.ts", + "type": "commonjs" +} diff --git a/packages/gamut-codemods/project.json b/packages/gamut-codemods/project.json new file mode 100644 index 0000000000..91d962dcc2 --- /dev/null +++ b/packages/gamut-codemods/project.json @@ -0,0 +1,46 @@ +{ + "name": "gamut-codemods", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/gamut-codemods/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "nx:run-commands", + "options": { + "cwd": "packages/gamut-codemods", + "commands": [ + "rm -rf ./dist", + "tsc -p tsconfig.lib.json", + "chmod +x ./dist/bin.js" + ], + "parallel": false + } + }, + "test": { + "executor": "@nx/jest:jest", + "dependsOn": ["^build"], + "outputs": ["{workspaceRoot}/coverage/packages/gamut-codemods"], + "options": { + "jestConfig": "packages/gamut-codemods/jest.config.ts" + } + }, + "verify-package": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "options": { + "cwd": "{projectRoot}", + "commands": ["publint ."], + "parallel": false + } + }, + "publish-build": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "options": { + "commands": [], + "parallel": false + } + } + } +} diff --git a/packages/gamut-codemods/src/__tests__/__testfixtures__/no-gamut.input.tsx b/packages/gamut-codemods/src/__tests__/__testfixtures__/no-gamut.input.tsx new file mode 100644 index 0000000000..0ac536f08b --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/__testfixtures__/no-gamut.input.tsx @@ -0,0 +1,4 @@ +import { render } from '@testing-library/react'; +import { Thing } from '@codecademy/other-package'; + +export const run = () => render(); diff --git a/packages/gamut-codemods/src/__tests__/__testfixtures__/no-gamut.output.tsx b/packages/gamut-codemods/src/__tests__/__testfixtures__/no-gamut.output.tsx new file mode 100644 index 0000000000..0ac536f08b --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/__testfixtures__/no-gamut.output.tsx @@ -0,0 +1,4 @@ +import { render } from '@testing-library/react'; +import { Thing } from '@codecademy/other-package'; + +export const run = () => render(); diff --git a/packages/gamut-codemods/src/__tests__/cli.test.ts b/packages/gamut-codemods/src/__tests__/cli.test.ts new file mode 100644 index 0000000000..5c18886764 --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/cli.test.ts @@ -0,0 +1,192 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import type { API, FileInfo } from 'jscodeshift'; +import jscodeshift from 'jscodeshift'; + +import { main } from '../cli'; +import transform, { type TransformOptions } from '../transform'; + +/* + Drives the real CLI. jscodeshift's Runner normally spawns workers that + load a built transform.js; here it's replaced with an in-process loop + over the same transform, so these tests run against source and count + toward coverage. The factory only builds a wrapper, so the mock reference + is resolved at call time. +*/ +const mockRun = jest.fn(); +jest.mock('jscodeshift/src/Runner', () => ({ + run: (...args: unknown[]) => mockRun(...args), +})); + +const SOURCE_EXT = /\.(m?[jt]sx?|c[jt]s)$/; + +const listSources = (target: string): string[] => + fs.statSync(target).isDirectory() + ? fs + .readdirSync(target) + .filter((name) => name !== 'node_modules') + .flatMap((name) => listSources(path.join(target, name))) + : SOURCE_EXT.test(target) + ? [target] + : []; + +const simulateRunner = async ( + _transformFile: string, + paths: string[], + options: Record +) => { + const api = { + jscodeshift: jscodeshift.withParser('tsx'), + report: (message: string) => console.log(message), + stats: () => {}, + }; + for (const file of paths.flatMap(listSources)) { + const source = fs.readFileSync(file, 'utf8'); + const out = transform( + { path: file, source } as FileInfo, + api as unknown as API, + options as TransformOptions + ); + if (out && !options.dry) fs.writeFileSync(file, out); + } + return { error: 0 }; +}; + +const project = (files: Record) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gamut-cli-test-')); + for (const [name, text] of Object.entries(files)) { + const file = path.join(dir, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); + } + return dir; +}; + +const read = (dir: string, name: string) => + fs.readFileSync(path.join(dir, name), 'utf8'); + +const POST = `import { Box, Markdown, Video } from '@codecademy/gamut'; + +export const Post = ({ text, url }: { text: string; url: string }) => ( + + + +); +`; + +let logs: string[]; +let errors: string[]; + +beforeEach(() => { + logs = []; + errors = []; + jest.spyOn(console, 'log').mockImplementation((...args) => { + logs.push(args.join(' ')); + }); + jest.spyOn(console, 'error').mockImplementation((...args) => { + errors.push(args.join(' ')); + }); + mockRun.mockReset(); + mockRun.mockImplementation(simulateRunner); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +const output = () => logs.join('\n'); + +describe('main: no-op paths', () => { + it('lists presets and their migrations', async () => { + expect(await main(['list'])).toBe(0); + expect(output()).toMatch(/scope-swap/); + }); + + it('prints usage for --help and for a missing path', async () => { + expect(await main(['--help'])).toBe(0); + expect(output()).toMatch(/Usage: gamut-codemods/); + + logs = []; + expect(await main(['scope-swap'])).toBe(1); + expect(output()).toMatch(/Usage: gamut-codemods/); + }); + + it('rejects an unknown preset', async () => { + await expect(main(['no-such-preset', '/tmp'])).rejects.toThrow( + /unknown preset 'no-such-preset'/ + ); + }); +}); + +describe('main: refusing to write', () => { + it('refuses a target outside git without --force', async () => { + const dir = project({ 'package.json': '{}' }); + expect(await main(['scope-swap', dir])).toBe(1); + expect(errors.join('\n')).toMatch(/not inside a git repository/); + expect(errors.join('\n')).toMatch(/--force/); + expect(mockRun).not.toHaveBeenCalled(); + }); + + it('refuses a dirty git tree without --force', async () => { + const dir = project({ 'src/a.ts': 'export {};\n' }); + const git = (...args: string[]) => + execFileSync('git', args, { cwd: dir, stdio: 'ignore' }); + git('init', '-q'); + expect(await main(['scope-swap', dir])).toBe(1); + expect(errors.join('\n')).toMatch(/uncommitted changes/); + }); +}); + +describe('main: full run', () => { + it('lists leftovers and prints the checklist', async () => { + const dir = project({ + 'src/Post.tsx': POST, + 'package.json': JSON.stringify({ + name: 'app', + dependencies: { '@codecademy/gamut': '^73.0.0' }, + }), + 'jest.config.js': + "module.exports = { moduleNameMapper: { '^@codecademy/gamut/(.*)$': '/x/$1' } };\n", + }); + + expect(await main(['scope-swap', dir, '--force'])).toBe(0); + + const out = output(); + expect(out).toMatch(/file\(s\) changed/); + expect(out).toMatch(/leftovers \(\d+\)/); + expect(out).toMatch(/Post\.tsx:1/); + expect(out).toMatch(/package\.json:1/); + expect(out).toMatch(/jest\.config\.js:1/); + expect(out).toMatch(/next steps/); + expect(out).toMatch(/Reinstall \(yarn\)/); + }); + + it('writes nothing under --dry and skips the checklist and leftovers', async () => { + const pkg = JSON.stringify({ + dependencies: { '@codecademy/gamut': '^73.0.0' }, + }); + const dir = project({ 'src/Post.tsx': POST, 'package.json': pkg }); + expect(await main(['scope-swap', dir, '--dry'])).toBe(0); + expect(read(dir, 'src/Post.tsx')).toBe(POST); + expect(read(dir, 'package.json')).toBe(pkg); + expect(output()).toMatch(/skipped in --dry/); + expect(output()).not.toMatch(/next steps/); + }); + + it('skips the leftovers report with --no-report', async () => { + const dir = project({ 'src/Post.tsx': POST }); + expect(await main(['scope-swap', dir, '--force', '--no-report'])).toBe(0); + expect(output()).not.toMatch(/leftovers \(/); + expect(output()).not.toMatch(/leftovers skipped/); + }); + + it('returns 1 when the source runner reports errors', async () => { + mockRun.mockResolvedValueOnce({ error: 1 }); + const dir = project({ 'src/Post.tsx': POST }); + expect(await main(['scope-swap', dir, '--force'])).toBe(1); + }); +}); diff --git a/packages/gamut-codemods/src/__tests__/fixtures.test.ts b/packages/gamut-codemods/src/__tests__/fixtures.test.ts new file mode 100644 index 0000000000..cc5f0f2d48 --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/fixtures.test.ts @@ -0,0 +1,113 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { applyTransform } from 'jscodeshift/src/testUtils'; + +import { presets } from '../presets'; +import * as transform from '../transform'; + +/* + Fixture-driven. Every `.input.` in a migration's + __testfixtures__ runs through that migration alone and must match + `.output.`. An optional `.warnings.json` lists substrings + that must each appear in some warning. `{{version}}` in an output stands + for this package's version. Adding a case means adding files, not code. +*/ + +const { version } = JSON.parse( + fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8') +) as { version: string }; + +interface Case { + name: string; + ext: string; + input: string; + output: string; + warnings: string[]; +} + +const readCases = (dir: string): Case[] => { + if (!fs.existsSync(dir)) return []; + const read = (f: string) => fs.readFileSync(path.join(dir, f), 'utf8'); + return fs + .readdirSync(dir) + .filter((f) => f.includes('.input.')) + .map((input) => { + const [name, ext] = input.split('.input.'); + const warnings = `${name}.warnings.json`; + return { + name, + ext, + input: read(input), + output: read(`${name}.output.${ext}`).replace(/{{version}}/g, version), + warnings: fs.existsSync(path.join(dir, warnings)) + ? (JSON.parse(read(warnings)) as string[]) + : [], + }; + }); +}; + +const fixtureDir = (migration: string) => + path.join(__dirname, '../migrations', migration, '__testfixtures__'); + +const expectWarnings = (actual: string[], expected: string[]) => { + for (const needle of expected) { + expect(actual.join('\n')).toContain(needle); + } +}; + +const runSource = (preset: string, source: string, only?: string) => { + const warnings: string[] = []; + const out = applyTransform( + transform, + { preset, only, onWarn: (w: string) => warnings.push(w) }, + { source, path: 'fixture.tsx' }, + { parser: 'tsx' } + ); + return { out: out || source, warnings }; +}; + +describe.each(Object.values(presets))('preset $name', (preset) => { + for (const migration of preset.migrations) { + const cases = readCases(fixtureDir(migration.name)); + if (cases.length === 0) continue; + + describe(migration.name, () => { + it.each(cases)('$name', (c) => { + if (migration.kind === 'file') { + const warnings: string[] = []; + const out = migration.run({ + source: c.input, + manifest: preset.manifest, + warn: (w) => warnings.push(w), + note: () => {}, + transformSource: (code) => runSource(preset.name, code).out, + }); + expect((out ?? c.input).trim()).toBe(c.output.trim()); + expectWarnings(warnings, c.warnings); + return; + } + + const { out, warnings } = runSource( + preset.name, + c.input, + migration.name + ); + expect(out.trim()).toBe(c.output.trim()); + expectWarnings(warnings, c.warnings); + + /* Idempotent: a second run over the output changes nothing. */ + expect( + runSource(preset.name, c.output, migration.name).out.trim() + ).toBe(c.output.trim()); + }); + }); + } + + const endToEnd = readCases(path.join(__dirname, '__testfixtures__')); + if (endToEnd.length > 0) { + it.each(endToEnd)('every migration: $name', (c) => { + expect(runSource(preset.name, c.input).out.trim()).toBe(c.output.trim()); + }); + } +}); diff --git a/packages/gamut-codemods/src/__tests__/lib.test.ts b/packages/gamut-codemods/src/__tests__/lib.test.ts new file mode 100644 index 0000000000..5c821d40e1 --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/lib.test.ts @@ -0,0 +1,251 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { parseArgs } from '../cli'; +import { findFlatEslintConfig } from '../lib/eslint'; +import { dirtyTreeReason } from '../lib/git'; +import { findLeftovers } from '../lib/leftovers'; +import { isAtLeast, isNewerThan, minVersion } from '../lib/manifest'; +import type { Migration } from '../lib/types'; +import { validatePreset } from '../presets'; +import { scopeSwap } from '../presets/scope-swap'; +import { manifest } from '../presets/scope-swap/manifest'; + +const tmp = () => + fs.mkdtempSync(path.join(os.tmpdir(), 'gamut-codemods-test-')); + +describe('validatePreset', () => { + it('accepts scope-swap', () => { + expect(() => validatePreset(scopeSwap)).not.toThrow(); + }); + + it('rejects an ast migration listed after a splice one', () => { + const migration = (name: string, kind: 'ast' | 'splice') => + ({ name, kind, description: name } as Migration); + expect(() => + validatePreset({ + ...scopeSwap, + migrations: [ + migration('first-ast', 'ast'), + migration('a-splice', 'splice'), + migration('late-ast', 'ast'), + ], + }) + ).toThrow(/late-ast/); + }); +}); + +describe('parseArgs', () => { + it('reads a preset, paths, and flags', () => { + expect( + parseArgs(['scope-swap', 'a', '--dry', '--only=scope-rename']) + ).toEqual( + expect.objectContaining({ + preset: 'scope-swap', + paths: [path.resolve('a')], + dry: true, + force: false, + only: 'scope-rename', + }) + ); + }); + + it('rejects unknown flags', () => { + expect(() => parseArgs(['scope-swap', 'a', '--drry'])).toThrow(/--drry/); + }); +}); + +describe('dirtyTreeReason', () => { + const git = (cwd: string, ...args: string[]) => + execFileSync('git', args, { cwd, stdio: 'ignore' }); + + it('refuses outside a git repo, and on uncommitted changes', () => { + const dir = tmp(); + expect(dirtyTreeReason(dir)).toMatch(/not inside a git repository/); + + git(dir, 'init', '-q'); + fs.writeFileSync(path.join(dir, 'a.ts'), ''); + expect(dirtyTreeReason(dir)).toMatch(/uncommitted changes/); + + git(dir, 'add', '-A'); + git(dir, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'x'); + expect(dirtyTreeReason(dir)).toBeNull(); + }); +}); + +describe('findLeftovers', () => { + it('finds old names but not new names that contain them', () => { + const dir = tmp(); + fs.writeFileSync( + path.join(dir, 'a.js'), + [ + "moduleNameMapper: { '^@codecademy/gamut(.*)$': 'x' }", + "'@skillsoft/eslint-plugin-gamut'", + "'@skillsoft/gamut-styles'", + ].join('\n') + ); + expect(findLeftovers([dir], manifest).map((h) => h.line)).toEqual([1]); + }); + + it('tags every old package the manifest replaces', () => { + const dir = tmp(); + const old = [ + ...Object.keys(manifest.packages), + ...Object.keys(manifest.removedPackages), + ]; + fs.writeFileSync( + path.join(dir, 'README.md'), + old.map((name) => `import x from '${name}/dist/x';`).join('\n') + ); + expect(findLeftovers([dir], manifest).map((h) => h.line)).toEqual( + old.map((_, i) => i + 1) + ); + }); + + it('flags /dist paths into published packages only', () => { + const dir = tmp(); + fs.writeFileSync( + path.join(dir, 'README.md'), + [ + "import { X } from '@codecademy/gamut-styles/dist/AssetProvider';", + "import { Y } from '@codecademy/gamut-foo/dist/bar';", + "import { Z } from '@skillsoft/gamut-styles/dist/bar';", + ].join('\n') + ); + expect(findLeftovers([dir], manifest).map((h) => h.line)).toEqual([1]); + }); + + it('reads editor config and extensionless scripts', () => { + const dir = tmp(); + const line = "require('@codecademy/gamut/package.json');"; + fs.writeFileSync( + path.join(dir, 'sync-deps'), + `#!/usr/bin/env node\n${line}` + ); + fs.writeFileSync(path.join(dir, 'LICENSE'), line); + fs.writeFileSync(path.join(dir, 'rules.mdc'), line); + fs.writeFileSync(path.join(dir, 'react.code-snippets'), line); + expect( + findLeftovers([dir], manifest) + .map((h) => path.basename(h.file)) + .sort() + ).toEqual(['react.code-snippets', 'rules.mdc', 'sync-deps']); + }); + + it('skips quoted rule keys when told the repo uses a flat config', () => { + const dir = tmp(); + fs.writeFileSync( + path.join(dir, 'eslint.config.ts'), + [ + "rules: { 'gamut/no-inline-style': 'error' },", + "import gamut from '@codecademy/eslint-plugin-gamut';", + "import gamut from 'eslint-plugin-gamut';", + ].join('\n') + ); + expect(findLeftovers([dir], manifest).map((h) => h.line)).toEqual([1, 3]); + expect( + findLeftovers([dir], manifest, { eslintRuleKeys: false }).map( + (h) => h.line + ) + ).toEqual([3]); + }); +}); + +describe('findFlatEslintConfig', () => { + it('finds a flat config inside the target', () => { + const dir = tmp(); + fs.mkdirSync(path.join(dir, 'libs', 'a'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'libs', 'a', 'eslint.config.mjs'), ''); + expect(findFlatEslintConfig([dir])).toBe( + path.join(dir, 'libs', 'a', 'eslint.config.mjs') + ); + }); + + it('finds a flat config above the target', () => { + const dir = tmp(); + fs.writeFileSync(path.join(dir, 'eslint.config.ts'), ''); + fs.mkdirSync(path.join(dir, 'src')); + fs.writeFileSync(path.join(dir, 'src', 'a.tsx'), ''); + expect(findFlatEslintConfig([path.join(dir, 'src')])).toBe( + path.join(dir, 'eslint.config.ts') + ); + expect(findFlatEslintConfig([path.join(dir, 'src', 'a.tsx')])).toBe( + path.join(dir, 'eslint.config.ts') + ); + }); + + it('returns null for a legacy .eslintrc repo', () => { + const dir = tmp(); + fs.writeFileSync(path.join(dir, '.eslintrc.json'), '{}'); + expect(findFlatEslintConfig([dir])).toBeNull(); + }); +}); + +describe('isNewerThan', () => { + it.each([ + ['74.0.0', '73.6.1', true], + ['^73.7.0', '73.6.1', true], + ['~73.6.2', '73.6.1', true], + ['npm:@codecademy/gamut@74.0.0', '73.6.1', true], + ['73.6.1', '73.6.1', false], + ['^73.0.0', '73.6.1', false], + ['74.0.0-alpha.1', '74.0.0', false], + ['*', '73.6.1', false], + ['workspace:*', '73.6.1', false], + ['https://example.com/gamut.tgz', '73.6.1', false], + ])('%s vs %s -> %s', (range, version, expected) => { + expect(isNewerThan(range, version)).toBe(expected); + }); + + it('reads the lowest version a range allows', () => { + expect(minVersion('>= 1.2.3')).toEqual([1, 2, 3]); + expect(minVersion('latest')).toBeNull(); + }); +}); + +describe('isAtLeast', () => { + it.each([ + ['74.0.0', true], + ['^74.1.0', true], + ['~75.0.0', true], + ['73.6.3', false], + ['^73.6.1', false], + ['*', false], + ])('%s >= 74.0.0 is %s', (range, expected) => { + expect(isAtLeast(range, '74.0.0')).toBe(expected); + }); +}); + +describe('findLeftovers: scoped directives under a flat config', () => { + const directive = + '// eslint-disable-next-line @skillsoft/gamut/no-inline-style'; + + it('lists them when no file registers the scoped plugin key', () => { + const dir = tmp(); + fs.writeFileSync(path.join(dir, 'eslint.config.ts'), 'plugins: { gamut }'); + fs.writeFileSync(path.join(dir, 'a.tsx'), directive); + const hits = findLeftovers([dir], manifest, { eslintRuleKeys: false }); + expect(hits).toHaveLength(1); + expect(hits[0].note).toMatch(/plugins key/); + }); + + it('leaves them alone when the plugin is registered under that key', () => { + const dir = tmp(); + fs.writeFileSync( + path.join(dir, 'eslint.config.ts'), + "plugins: { '@skillsoft/gamut': gamut }" + ); + fs.writeFileSync(path.join(dir, 'a.tsx'), directive); + expect(findLeftovers([dir], manifest, { eslintRuleKeys: false })).toEqual( + [] + ); + }); + + it('is not checked for legacy configs', () => { + const dir = tmp(); + fs.writeFileSync(path.join(dir, 'a.tsx'), directive); + expect(findLeftovers([dir], manifest)).toEqual([]); + }); +}); diff --git a/packages/gamut-codemods/src/__tests__/manifest.test.ts b/packages/gamut-codemods/src/__tests__/manifest.test.ts new file mode 100644 index 0000000000..f48b665496 --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/manifest.test.ts @@ -0,0 +1,196 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import ts from 'typescript'; + +import { renameSpecifier } from '../lib/manifest'; +import { presets } from '../presets'; +import { manifest } from '../presets/scope-swap/manifest'; + +/* + Guards against a manifest that promises something the packages don't + deliver. Runs against the built dist, so `^build` has to run first + (project.json wires that up). +*/ + +const REPO = path.join(__dirname, '../../../..'); +const readJson = (file: string) => + JSON.parse(fs.readFileSync(path.join(REPO, file), 'utf8')); + +/* + Every name a manifest claims is importable from a new entry point: + moved exports at their destination, and renamed deep-import exports at + theirs. Written as one virtual file so tsc resolves it the way a + consumer's bundler would, through the exports map. +*/ +const importsToCheck = () => { + const byModule = new Map>(); + const add = (spec: string | null, names: string[]) => { + if (!spec) return; + const set = byModule.get(spec) ?? new Set(); + names.forEach((name) => set.add(name)); + byModule.set(spec, set); + }; + for (const preset of Object.values(presets)) { + const m = preset.manifest; + for (const rule of m.movedExports) { + add(renameSpecifier(m, rule.to), rule.names); + } + for (const row of m.deepImports) { + if (!row.to) continue; + const to = renameSpecifier(m, row.to); + if (row.onlyNames) { + add( + to, + row.onlyNames.map((name) => row.renames?.[name] ?? name) + ); + } + if (row.renames) add(to, Object.values(row.renames)); + } + } + return [...byModule] + .map( + ([spec, names]) => + `import type { ${[...names].join(', ')} } from '${spec}';` + ) + .join('\n'); +}; + +/* `@codecademy/gamut/dist/Form/styles` -> packages/gamut/src/Form/styles(.ts|/index.tsx|...) */ +const sourceModuleFor = (deepPath: string) => { + const match = /^@codecademy\/([\w-]+)\/dist\/(.+)$/.exec(deepPath); + if (!match) return null; + const base = path.join(REPO, 'packages', match[1], 'src', match[2]); + return ( + ['.ts', '.tsx', '/index.ts', '/index.tsx'] + .map((ext) => base + ext) + .find((file) => fs.existsSync(file)) ?? null + ); +}; + +/* Export names of each module specifier, resolved like a bundler would. */ +const exportsOf = (specifiers: string[]) => { + const file = path.join(REPO, 'packages/gamut-codemods/__exports-check__.ts'); + const source = specifiers + .map((spec, i) => `import * as m${i} from '${spec}';`) + .join('\n'); + const host = ts.createCompilerHost({}); + const readFile = host.readFile.bind(host); + host.readFile = (f) => (f === file ? source : readFile(f)); + const fileExists = host.fileExists.bind(host); + host.fileExists = (f) => f === file || fileExists(f); + const program = ts.createProgram( + [file], + { + noEmit: true, + skipLibCheck: true, + jsx: ts.JsxEmit.ReactJSX, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + types: [], + }, + host + ); + const checker = program.getTypeChecker(); + const imports = program + .getSourceFile(file)! + .statements.filter(ts.isImportDeclaration); + return specifiers.map((_, i) => { + const symbol = checker.getSymbolAtLocation(imports[i].moduleSpecifier); + return new Set( + symbol ? checker.getExportsOfModule(symbol).map((s) => s.name) : [] + ); + }); +}; + +describe('manifests', () => { + /* + A deep-import row sends every name imported from `from` to `to`, so + everything `from` exports has to be available there: directly, via + `renames`, or be left out on purpose by listing what is public in + `onlyNames`. Otherwise the codemod writes imports that don't compile. + */ + it('only rewrite deep imports whose names all exist at the target', () => { + const rows = manifest.deepImports.filter((row) => row.to && !row.onlyNames); + const sources = rows.map((row) => sourceModuleFor(row.from)); + const targets = rows.map((row) => renameSpecifier(manifest, row.to!)!); + const found = exportsOf([ + ...sources.map((s) => s ?? 'missing'), + ...targets, + ]); + + const problems = rows.flatMap((row, i) => { + if (!sources[i]) return [`${row.from}: no source module found`]; + const available = found[rows.length + i]; + const missing = [...found[i]] + .map((name) => row.renames?.[name] ?? name) + .filter((name) => !available.has(name)); + return missing.length + ? [`${row.from} -> ${targets[i]}: missing ${missing.join(', ')}`] + : []; + }); + + expect(problems).toEqual([]); + }); + + it('only point at exports that exist in the built packages', () => { + const file = path.join( + REPO, + 'packages/gamut-codemods/__manifest-check__.ts' + ); + const host = ts.createCompilerHost({}); + const source = importsToCheck(); + const readFile = host.readFile.bind(host); + host.readFile = (f) => (f === file ? source : readFile(f)); + const fileExists = host.fileExists.bind(host); + host.fileExists = (f) => f === file || fileExists(f); + + const program = ts.createProgram( + [file], + { + noEmit: true, + skipLibCheck: true, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + types: [], + }, + host + ); + const errors = ts + .getPreEmitDiagnostics(program) + .map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n')); + + expect(errors).toEqual([]); + }); + + it('rename every package to one that exists in this repo', () => { + const published = new Set( + fs + .readdirSync(path.join(REPO, 'packages')) + .map((dir) => path.join(REPO, 'packages', dir, 'package.json')) + .filter((file) => fs.existsSync(file)) + .map((file) => JSON.parse(fs.readFileSync(file, 'utf8')).name) + ); + for (const { to } of Object.values(manifest.packages)) { + expect(published).toContain(to); + } + }); + + it('keep scope-swap target versions in step with the packages', () => { + const { fixed } = readJson('.changeset/config.json') as { + fixed: string[][]; + }; + const group = + fixed.find((g) => g.includes('@skillsoft/gamut-codemods')) ?? []; + const eslintPlugin = readJson('packages/eslint-plugin-gamut/package.json'); + + for (const [pkg, range] of Object.entries(manifest.targetVersions)) { + if (pkg === eslintPlugin.name) { + expect(range).toBe(`^${eslintPlugin.version}`); + } else { + /* Versioned by the fixed group, which is what makes GROUP_RANGE right. */ + expect(group).toContain(pkg); + } + } + }); +}); diff --git a/packages/gamut-codemods/src/__tests__/module-strings.test.ts b/packages/gamut-codemods/src/__tests__/module-strings.test.ts new file mode 100644 index 0000000000..758534a63c --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/module-strings.test.ts @@ -0,0 +1,21 @@ +import jscodeshift from 'jscodeshift'; + +import { findModuleStrings } from '../lib/module-strings'; + +/* + Calls that aren't require(), import(), or a known module API must not be + taken for module strings, even when their argument looks like a package. +*/ +describe('findModuleStrings', () => { + it('finds module strings in require and require.resolve, and skips other calls', () => { + const j = jscodeshift.withParser('tsx'); + const root = j(` + require('@codecademy/gamut/a'); + require.resolve('@codecademy/gamut/b'); + getModule()('@codecademy/gamut/c'); + (0, load)('@codecademy/gamut/d'); + `); + const values = findModuleStrings(j, root).map((node) => node.value); + expect(values).toEqual(['@codecademy/gamut/a', '@codecademy/gamut/b']); + }); +}); diff --git a/packages/gamut-codemods/src/bin.ts b/packages/gamut-codemods/src/bin.ts new file mode 100644 index 0000000000..1d6eb0af32 --- /dev/null +++ b/packages/gamut-codemods/src/bin.ts @@ -0,0 +1,13 @@ +#!/usr/bin/env node +import { main } from './cli'; + +main(process.argv.slice(2)).then( + (code) => { + process.exitCode = code; + }, + (err: unknown) => { + // eslint-disable-next-line no-console + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + } +); diff --git a/packages/gamut-codemods/src/cli.ts b/packages/gamut-codemods/src/cli.ts new file mode 100644 index 0000000000..1774af75a3 --- /dev/null +++ b/packages/gamut-codemods/src/cli.ts @@ -0,0 +1,235 @@ +/* eslint-disable no-console -- it's a CLI; stdout is the interface. */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import jscodeshift from 'jscodeshift'; + +import { findFlatEslintConfig } from './lib/eslint'; +import { dirtyTreeReason } from './lib/git'; +import { findLeftovers, walk } from './lib/leftovers'; +import { + activeSourceMigrations, + mentionsOldNames, + runSourceMigrations, +} from './lib/run-source'; +import type { FileMigration, Preset } from './lib/types'; +import { getPreset, presets } from './presets'; + +/* No published types for the Runner; it's jscodeshift's programmatic API. */ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const Runner = require('jscodeshift/src/Runner') as { + run( + transformFile: string, + paths: string[], + options: Record + ): Promise<{ error: number }>; +}; + +const USAGE = `Usage: gamut-codemods [options] + gamut-codemods list + +Options: + --dry Don't write anything; show what would change + --force Run even if the target has uncommitted changes + --only=a,b Run only these migrations + --no-report Skip the leftovers report`; + +interface Options { + preset?: string; + paths: string[]; + dry: boolean; + force: boolean; + only?: string; + /* Filled in by the CLI from each migration's skipReason, not a flag. */ + skip?: string; + report: boolean; +} + +export const parseArgs = (argv: string[]): Options | 'help' | 'list' => { + const [command, ...rest] = argv; + if (!command || command === '-h' || command === '--help') return 'help'; + if (command === 'list') return 'list'; + + const opts: Options = { + preset: command, + paths: [], + dry: false, + force: false, + report: true, + }; + for (const arg of rest) { + if (arg === '--dry') opts.dry = true; + else if (arg === '--force') opts.force = true; + else if (arg === '--no-report') opts.report = false; + else if (arg.startsWith('--only=')) opts.only = arg.slice('--only='.length); + else if (arg.startsWith('--')) throw new Error(`unknown option ${arg}`); + else opts.paths.push(path.resolve(arg)); + } + return opts; +}; + +const heading = (text: string) => console.log(`\n— ${text} —`); + +const runFileMigrations = ( + preset: Preset, + opts: Options, + note: (key: string) => void +) => { + const only = opts.only ? new Set(opts.only.split(',')) : null; + const skip = new Set(opts.skip ? opts.skip.split(',') : []); + const active = preset.migrations.filter( + (m): m is FileMigration => + m.kind === 'file' && (!only || only.has(m.name)) && !skip.has(m.name) + ); + let changed = 0; + + for (const root of opts.paths) { + const files = fs.statSync(root).isDirectory() ? walk(root) : [root]; + for (const file of files) { + for (const migration of active) { + if (!migration.match(file)) continue; + const source = fs.readFileSync(file, 'utf8'); + const warn = (msg: string) => + console.log(`[${migration.name}] ${file} ${msg}`); + const transformSource = (code: string) => + mentionsOldNames(preset.manifest, code) + ? runSourceMigrations({ + j: jscodeshift.withParser('tsx'), + source: code, + migrations: activeSourceMigrations( + preset.migrations, + opts.only, + opts.skip + ), + manifest: preset.manifest, + warnFor: (name) => (_node, message) => + console.log(`[${name}] ${file} ${message}`), + note, + }) + : code; + const next = migration.run({ + source, + manifest: preset.manifest, + warn, + note, + transformSource, + }); + if (next === null) continue; + changed += 1; + console.log(`${opts.dry ? 'would update' : 'updated'} ${file}`); + if (!opts.dry) fs.writeFileSync(file, next); + } + } + } + return changed; +}; + +const printChecklist = (preset: Preset, notes: Set) => { + heading('next steps'); + const items = [ + ...Object.entries(preset.conditionalChecklist) + .filter(([key]) => notes.has(key)) + .map(([, text]) => text), + ...preset.checklist, + ]; + items.forEach((item, i) => console.log(`${i + 1}. ${item}`)); +}; + +export const main = async (argv: string[]) => { + const opts = parseArgs(argv); + + if (opts === 'help') { + console.log(USAGE); + return 0; + } + if (opts === 'list') { + for (const preset of Object.values(presets)) { + console.log(`${preset.name} ${preset.description}`); + for (const m of preset.migrations) { + console.log(` ${m.name.padEnd(18)} ${m.description}`); + } + } + return 0; + } + + const preset = getPreset(opts.preset!); + if (opts.paths.length === 0) { + console.log(USAGE); + return 1; + } + + if (!opts.dry && !opts.force) { + const reasons = opts.paths.map(dirtyTreeReason).filter(Boolean); + if (reasons.length > 0) { + reasons.forEach((reason) => console.error(reason)); + console.error( + 'Re-run with --force to write anyway, or --dry to preview.' + ); + return 1; + } + } + + const notes = new Set(); + const skipped = preset.migrations.flatMap((m) => { + const reason = m.skipReason?.(opts.paths, preset.manifest); + if (!reason) return []; + console.log(`[${m.name}] skipped: ${reason}`); + notes.add(`skipped:${m.name}`); + return [m.name]; + }); + opts.skip = skipped.join(',') || undefined; + + const notesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gamut-codemods-')); + try { + heading('source files'); + const result = await Runner.run( + path.join(__dirname, 'transform.js'), + opts.paths, + { + preset: preset.name, + only: opts.only, + skip: opts.skip, + notesDir, + dry: opts.dry, + extensions: 'ts,tsx,js,jsx,mjs,cjs,mts,cts', + /* .d.ts stays in: Emotion theme augmentations import gamut-styles. */ + ignorePattern: ['**/node_modules/**', '**/dist/**', '**/build/**'], + parser: 'tsx', + babel: false, + verbose: 0, + } + ); + + fs.readdirSync(notesDir).forEach((key) => + notes.add(decodeURIComponent(key)) + ); + + heading('package.json and .eslintrc'); + const changed = runFileMigrations(preset, opts, (key) => notes.add(key)); + console.log(`${changed} file(s) ${opts.dry ? 'would change' : 'changed'}`); + + if (opts.report && opts.dry) { + heading( + 'leftovers skipped in --dry (nothing was written, so everything would show up)' + ); + } else if (opts.report) { + const hits = findLeftovers(opts.paths, preset.manifest, { + eslintRuleKeys: !findFlatEslintConfig(opts.paths), + }); + heading(`leftovers (${hits.length})`); + hits.forEach((hit) => + console.log( + `${hit.file}:${hit.line} ${hit.text}${ + hit.note ? ` (${hit.note})` : '' + }` + ) + ); + } + + if (!opts.dry) printChecklist(preset, notes); + return result.error > 0 ? 1 : 0; + } finally { + fs.rmSync(notesDir, { recursive: true, force: true }); + } +}; diff --git a/packages/gamut-codemods/src/lib/eslint.ts b/packages/gamut-codemods/src/lib/eslint.ts new file mode 100644 index 0000000000..c2b11adf1c --- /dev/null +++ b/packages/gamut-codemods/src/lib/eslint.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { walk } from './leftovers'; + +const FLAT_CONFIG = /(^|\/)eslint\.config\.[cm]?[jt]s$/; + +const flatConfigIn = (dir: string) => + fs + .readdirSync(dir) + .map((name) => path.join(dir, name)) + .find((file) => FLAT_CONFIG.test(file)); + +/* + The first flat ESLint config that applies to any root, or null: one + inside it, or one in a parent directory up to the git root (running on + a subfolder of a repo still uses the repo's config). In a flat config the rule prefix is + the key the plugin is registered under (`plugins: { gamut }`), not the + package name, so renaming the package doesn't change it. +*/ +export const findFlatEslintConfig = (roots: string[]) => { + for (const root of roots) { + const isDir = fs.statSync(root).isDirectory(); + for ( + let dir = isDir ? root : path.dirname(root); + dir !== path.dirname(dir); + dir = path.dirname(dir) + ) { + const found = flatConfigIn(dir); + if (found) return found; + if (fs.existsSync(path.join(dir, '.git'))) break; + } + if (!isDir) continue; + for (const file of walk(root)) { + if (FLAT_CONFIG.test(file)) return file; + } + } + return null; +}; diff --git a/packages/gamut-codemods/src/lib/git.ts b/packages/gamut-codemods/src/lib/git.ts new file mode 100644 index 0000000000..6929f0f277 --- /dev/null +++ b/packages/gamut-codemods/src/lib/git.ts @@ -0,0 +1,27 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +/* + The CLI refuses to write into a dirty tree, so `git checkout .` always + undoes a run completely and the codemod's diff never mixes with work in + progress. Returns a reason to refuse, or null if it's safe. +*/ +export const dirtyTreeReason = (target: string) => { + const cwd = fs.statSync(target).isDirectory() ? target : path.dirname(target); + let status: string; + try { + /* execFileSync with an argument array: no shell, so paths can't inject. */ + status = execFileSync('git', ['status', '--porcelain', '--', '.'], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return `${target} is not inside a git repository, so a run couldn't be undone.`; + } + if (status.trim()) { + return `${target} has uncommitted changes. Commit or stash them first, so the codemod's diff stands on its own.`; + } + return null; +}; diff --git a/packages/gamut-codemods/src/lib/leftovers.ts b/packages/gamut-codemods/src/lib/leftovers.ts new file mode 100644 index 0000000000..45220b8a60 --- /dev/null +++ b/packages/gamut-codemods/src/lib/leftovers.ts @@ -0,0 +1,138 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { Manifest } from './manifest'; + +/* + After the migrations run, anything still naming an old package is for a + person: jest moduleNameMapper regexes, tsconfig paths, flat ESLint + configs, comments, docs. This lists them as file:line. +*/ + +export const SKIP_DIRS = new Set([ + 'node_modules', + 'dist', + 'build', + 'coverage', + '.git', + '.next', + '.nx', + 'tmp', +]); +const SKIP_FILES = + /(^|\/)(yarn\.lock|package-lock\.json|pnpm-lock\.yaml|CHANGELOG\.md)$/; +/* .mdc is Cursor rules; .code-snippets is VS Code snippets (JSONC). */ +const TEXT_EXT = + /\.(m?[jt]sx?|c[jt]s|json|ya?ml|md|mdx|mdc|html|scss|css|code-snippets)$|(^|\/)\.eslintrc$/; + +/* Extensionless executables, like tools/script/sync-gamut-deps. */ +const isScript = (file: string) => { + if (path.basename(file).includes('.')) return false; + const fd = fs.openSync(file, 'r'); + try { + const head = Buffer.alloc(2); + fs.readSync(fd, head, 0, 2, 0); + return head.toString() === '#!'; + } finally { + fs.closeSync(fd); + } +}; + +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); + +/* + Old names, not already followed by a longer package name, and not the + tail of a new one (`@skillsoft/eslint-plugin-gamut` contains + `eslint-plugin-gamut`). + + A `/dist` path into one of the packages this repo publishes also counts, + even outside files the parser reads (docs, JSON, Flow). Only the packages + in manifest.packages are checked. Removed and unrelated `@codecademy` + packages aren't ours to rewrite. + + Quoted `gamut/` rule keys and `plugin:gamut/` only count when + eslintRuleKeys is set. In a flat config they're correct as written. +*/ +const oldNamesPattern = (manifest: Manifest, eslintRuleKeys: boolean) => { + const published = Object.keys(manifest.packages); + const names = [...published, ...Object.keys(manifest.removedPackages)] + .sort((a, b) => b.length - a.length) + .map(escape); + const dist = published.map(escape).join('|'); + const prefix = escape(manifest.eslintPlugin.from); + const ruleKeys = eslintRuleKeys ? `|plugin:${prefix}/|["'\`]${prefix}/` : ''; + return new RegExp( + `(? { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) yield* walk(path.join(dir, entry.name)); + } else { + yield path.join(dir, entry.name); + } + } +} + +export interface Leftover { + file: string; + line: number; + text: string; + /* Why the line is listed, when the old name alone doesn't explain it. */ + note?: string; +} + +/* + Under a flat config the directive prefix is the plugins key. Older builds + of this codemod rewrote `gamut/` directives to `@skillsoft/gamut/` anyway. + Unless some file registers the plugin under that key, those directives + name a rule that doesn't exist and stop suppressing the real one. +*/ +const scopedDirectivePattern = (manifest: Manifest) => + new RegExp( + `eslint-(?:disable|enable)\\S*\\s.*(? + new RegExp(`["'\`]${escape(manifest.eslintPlugin.to)}["'\`]\\s*:`); + +export const findLeftovers = ( + roots: string[], + manifest: Manifest, + { eslintRuleKeys = true }: { eslintRuleKeys?: boolean } = {} +) => { + const pattern = oldNamesPattern(manifest, eslintRuleKeys); + const scopedDirective = scopedDirectivePattern(manifest); + const registersKey = registersScopedKey(manifest); + const hits: Leftover[] = []; + const directives: Leftover[] = []; + let keyRegistered = false; + for (const root of roots) { + const files = fs.statSync(root).isDirectory() ? walk(root) : [root]; + for (const file of files) { + if (SKIP_FILES.test(file)) continue; + if (!TEXT_EXT.test(file) && !isScript(file)) continue; + fs.readFileSync(file, 'utf8') + .split('\n') + .forEach((text, i) => { + if (pattern.test(text)) + hits.push({ file, line: i + 1, text: text.trim() }); + if (eslintRuleKeys) return; + if (registersKey.test(text)) keyRegistered = true; + if (scopedDirective.test(text)) + directives.push({ + file, + line: i + 1, + text: text.trim(), + note: `no flat config registers the plugin as '${manifest.eslintPlugin.to}'; use its plugins key, e.g. ${manifest.eslintPlugin.from}/`, + }); + }); + } + } + return keyRegistered ? hits : [...hits, ...directives]; +}; diff --git a/packages/gamut-codemods/src/lib/manifest.ts b/packages/gamut-codemods/src/lib/manifest.ts new file mode 100644 index 0000000000..bd5976333e --- /dev/null +++ b/packages/gamut-codemods/src/lib/manifest.ts @@ -0,0 +1,124 @@ +/* + The shape of a preset's manifest: the data describing what changed. Most + contributions should only add rows to a preset's manifest.ts plus a + fixture; migrations read from here and never hardcode package or export + names. +*/ + +export interface PackageRename { + to: string; +} + +export interface RemovedPackage { + /* Old package names to depend on instead, in package.json. */ + expandTo: string[]; + /* Old package names to share as singletons in Module Federation config. */ + mfSharedAs: string[]; + note: string; +} + +export interface MovedExport { + from: string; + to: string; + names: string[]; + note: string; + /* Checklist key (see the preset) to print when this rule moves anything. */ + checklist?: string; +} + +export interface DeepImport { + from: string; + /* null when there's no public replacement; the migration warns instead. */ + to: string | null; + /* Old imported name -> new exported name. Local bindings are kept. */ + renames?: Record; + /* + When only some of the old module went public: the old names that work + from `to` (after `renames`). A declaration importing anything else is + left alone with a warning. manifest.test.ts fails if a row sends names + to `to` that it doesn't export, so partial rows must list these. + */ + onlyNames?: string[]; + note?: string; +} + +export interface Manifest { + /* Old package -> new package. */ + packages: Record; + /* Packages with no replacement under the new name. */ + removedPackages: Record; + /* Version range written to package.json, keyed by NEW package name. */ + targetVersions: Record; + /* + Newest release of each OLD package the new one is known to match, + keyed by OLD name. A consumer already on something newer may use APIs + the new package doesn't have yet; package-json warns. + */ + sourceVersions: Record; + movedExports: MovedExport[]; + deepImports: DeepImport[]; + /* ESLint plugin prefix, for rule keys, `plugin:` extends, and comments. */ + eslintPlugin: { from: string; to: string }; +} + +/* + Match a specifier against a package name, exact or with a subpath. + '@codecademy/gamut' matches '@codecademy/gamut/Video' but not + '@codecademy/gamut-styles'. Returns the subpath ('' or '/Video') or null. +*/ +export const matchPackage = (specifier: string, pkg: string) => { + if (specifier === pkg) return ''; + if (specifier.startsWith(`${pkg}/`)) return specifier.slice(pkg.length); + return null; +}; + +/* + The lowest x.y.z a package.json range allows, or null for anything that + isn't a plain version, `^`/`~`/`>=` range, or `npm:` alias (`*`, URLs, + `workspace:`). Prerelease tags are ignored. +*/ +export const minVersion = (range: string) => { + const match = + /^(?:npm:(?:@[^/]+\/)?[^@]+@)?(?:\^|~|>=)?\s*v?(\d+)\.(\d+)\.(\d+)/.exec( + range.trim() + ); + return match ? match.slice(1, 4).map(Number) : null; +}; + +const parseVersion = (version: string) => version.split('.').map(Number); + +/* True when `range` can't be satisfied by `version` or anything older. */ +export const isNewerThan = (range: string, version: string) => { + const min = minVersion(range); + if (!min) return false; + const max = parseVersion(version); + for (let i = 0; i < 3; i += 1) { + if (min[i] !== max[i]) return min[i] > max[i]; + } + return false; +}; + +/* True when every version `range` allows is `version` or newer. */ +export const isAtLeast = (range: string, version: string) => { + const min = minVersion(range); + if (!min) return false; + const floor = parseVersion(version); + for (let i = 0; i < 3; i += 1) { + if (min[i] !== floor[i]) return min[i] > floor[i]; + } + return true; +}; + +/* The new name for an old specifier, keeping any subpath. */ +export const renameSpecifier = (manifest: Manifest, value: string) => { + for (const [from, { to }] of Object.entries(manifest.packages)) { + const subpath = matchPackage(value, from); + if (subpath !== null) return to + subpath; + } + return null; +}; + +export const removedPackageFor = (manifest: Manifest, value: string) => + Object.keys(manifest.removedPackages).find( + (pkg) => matchPackage(value, pkg) !== null + ); diff --git a/packages/gamut-codemods/src/lib/module-strings.ts b/packages/gamut-codemods/src/lib/module-strings.ts new file mode 100644 index 0000000000..50b7a5f484 --- /dev/null +++ b/packages/gamut-codemods/src/lib/module-strings.ts @@ -0,0 +1,81 @@ +import type { Collection, JSCodeshift, StringLiteral } from 'jscodeshift'; + +/* + Finds every string in a file that names a module: import and export + sources, require(), dynamic import(), jest/vi mocks, `import('x').T` types, + and `declare module 'x'` augmentations. Migrations that rewrite a specifier + should go through here, so a new place a module name can appear only has + to be taught once. +*/ + +const CALLEE_OBJECTS = new Set(['jest', 'vi']); +const CALLEE_METHODS = new Set([ + 'mock', + 'doMock', + 'unmock', + 'dontMock', + 'requireActual', + 'requireMock', + 'importActual', + 'importMock', + 'setMock', +]); + +/* + The tsx parser produces StringLiteral; the babel/estree ones produce + Literal. Both carry babel's start/end offsets, which splice edits rely on. +*/ +export type StringNode = StringLiteral & { start: number; end: number }; + +export const isStringLiteral = (node: unknown): node is StringNode => { + const n = node as { type?: string; value?: unknown } | null; + return ( + !!n && + (n.type === 'StringLiteral' || + (n.type === 'Literal' && typeof n.value === 'string')) + ); +}; + +/* + Identifier-ish nodes (Identifier, JSXIdentifier, TSTypeParameter) all + carry a string `name`, but the jscodeshift types don't share one shape. +*/ +export const nameOf = (node: unknown) => { + const name = (node as { name?: unknown } | null | undefined)?.name; + return typeof name === 'string' ? name : ''; +}; + +const isModuleCall = (callee: any): boolean => { + if (callee.type === 'Import') return true; + if (callee.type === 'Identifier') return callee.name === 'require'; + if (callee.type === 'MemberExpression' && !callee.computed) { + const { object, property } = callee; + if (object.type !== 'Identifier') return false; + if (object.name === 'require') return property.name === 'resolve'; + return CALLEE_OBJECTS.has(object.name) && CALLEE_METHODS.has(property.name); + } + return false; +}; + +/* The string literal nodes that name a module, in source order. */ +export const findModuleStrings = (j: JSCodeshift, root: Collection) => { + const found: StringNode[] = []; + const push = (node: unknown) => { + if (isStringLiteral(node)) found.push(node); + }; + + root.find(j.ImportDeclaration).forEach((p) => push(p.node.source)); + root.find(j.ExportNamedDeclaration).forEach((p) => push(p.node.source)); + root.find(j.ExportAllDeclaration).forEach((p) => push(p.node.source)); + root.find(j.CallExpression).forEach((p) => { + if (isModuleCall(p.node.callee)) push(p.node.arguments[0]); + }); + root.find(j.ImportExpression).forEach((p) => push(p.node.source)); + root.find(j.TSImportType).forEach((p) => push(p.node.argument)); + root.find(j.TSModuleDeclaration).forEach((p) => push(p.node.id)); + root + .find(j.TSExternalModuleReference) + .forEach((p) => push(p.node.expression)); + + return found; +}; diff --git a/packages/gamut-codemods/src/lib/run-source.ts b/packages/gamut-codemods/src/lib/run-source.ts new file mode 100644 index 0000000000..42eb019392 --- /dev/null +++ b/packages/gamut-codemods/src/lib/run-source.ts @@ -0,0 +1,112 @@ +import type { JSCodeshift } from 'jscodeshift'; + +import type { Manifest } from './manifest'; +import type { + AstMigration, + Edit, + Locatable, + Note, + SourceMigration, + SpliceMigration, +} from './types'; + +/* + Runs source migrations over one piece of source text. Two phases: + + 1. AST migrations share one parse and mutate it. If any changed + something, recast prints the text once and `postPrint` hooks run. + 2. Splice migrations run in order, each against a fresh parse of the + current text, returning `{ start, end, text }` edits applied back to + front. The re-parse means a later splice sees an earlier one's output + (mf-shared writes @codecademy keys that scope-rename then renames). + + Shared by the jscodeshift transform (whole files) and file migrations + that embed code, like MDX's ESM blocks. +*/ + +export interface RunSourceOptions { + j: JSCodeshift; + source: string; + migrations: SourceMigration[]; + manifest: Manifest; + warnFor: (migration: string) => (node: Locatable, message: string) => void; + note: Note; +} + +const applyEdits = (source: string, edits: Edit[]) => + [...edits] + .sort((a, b) => b.start - a.start) + .reduce( + (out, { start, end, text }) => + out.slice(0, start) + text + out.slice(end), + source + ); + +export const runSourceMigrations = ({ + j, + source: original, + migrations, + manifest, + warnFor, + note, +}: RunSourceOptions) => { + let source = original; + + const astMigrations = migrations.filter( + (m): m is AstMigration => m.kind === 'ast' + ); + if (astMigrations.length > 0) { + const root = j(source); + let changed = false; + for (const m of astMigrations) { + changed = + m.run({ j, root, source, manifest, warn: warnFor(m.name), note }) || + changed; + } + if (changed) { + source = astMigrations.reduce( + (out, m) => (m.postPrint ? m.postPrint(out, { manifest }) : out), + root.toSource({ quote: 'single' }) + ); + } + } + + for (const m of migrations.filter( + (x): x is SpliceMigration => x.kind === 'splice' + )) { + const root = j(source); + const edits = m.edits({ + j, + root, + source, + manifest, + warn: warnFor(m.name), + note, + }); + if (edits.length > 0) source = applyEdits(source, edits); + } + + return source; +}; + +/* The pre-filter every caller uses so big repos don't parse every file. */ +export const mentionsOldNames = (manifest: Manifest, source: string) => + [ + ...Object.keys(manifest.packages), + ...Object.keys(manifest.removedPackages), + `${manifest.eslintPlugin.from}/`, + ].some((name) => source.includes(name)); + +/* `only` and `skip` are comma-separated migration names. */ +export const activeSourceMigrations = ( + migrations: { kind: string; name: string }[], + only?: string, + skip?: string +) => { + const names = only ? new Set(only.split(',')) : null; + const skipped = new Set(skip ? skip.split(',') : []); + return migrations.filter( + (m) => + m.kind !== 'file' && (!names || names.has(m.name)) && !skipped.has(m.name) + ) as SourceMigration[]; +}; diff --git a/packages/gamut-codemods/src/lib/types.ts b/packages/gamut-codemods/src/lib/types.ts new file mode 100644 index 0000000000..76064080ca --- /dev/null +++ b/packages/gamut-codemods/src/lib/types.ts @@ -0,0 +1,96 @@ +import type { Collection, JSCodeshift } from 'jscodeshift'; + +import type { Manifest } from './manifest'; + +/* Anything with a source location, so warnings can point at a line. */ +export type Locatable = + | { loc?: { start: { line: number } } | null } + | null + | undefined; + +export type Warn = (node: Locatable, message: string) => void; + +/* + Records that a checklist item applies to this run. Keys are defined by the + preset; the CLI prints the matching items once every file is done. +*/ +export type Note = (checklistKey: string) => void; + +export interface SourceContext { + j: JSCodeshift; + root: Collection; + source: string; + manifest: Manifest; + warn: Warn; + note: Note; +} + +export interface Edit { + start: number; + end: number; + text: string; +} + +interface MigrationBase { + name: string; + description: string; + /* + Checked once per run, before any file is touched. Return a reason to + skip this migration for the whole run (the CLI prints it and notes + `skipped:`), or null to run it. + */ + skipReason?(roots: string[], manifest: Manifest): string | null; +} + +/* + Mutates the shared AST. Use for structural rewrites only: recast reprints + whatever it thinks changed, and in semicolon-less files that has spread to + neighbouring statements. Return true if anything changed. +*/ +export interface AstMigration extends MigrationBase { + kind: 'ast'; + run(ctx: SourceContext): boolean; + /* Formatting fixes on the printed output that the AST can't express. */ + postPrint?(source: string, ctx: { manifest: Manifest }): string; +} + +/* + Uses the AST to find ranges, then edits the original text by offset. The + default for anything that swaps one string for another. +*/ +export interface SpliceMigration extends MigrationBase { + kind: 'splice'; + edits(ctx: SourceContext): Edit[]; +} + +/* Gets raw text for non-source files (package.json, .eslintrc, .mdx). */ +export interface FileMigration extends MigrationBase { + kind: 'file'; + match(path: string): boolean; + run(ctx: { + source: string; + manifest: Manifest; + warn: (message: string) => void; + note: Note; + /* + Runs the preset's source migrations (respecting --only) over a + snippet of JS/TS, for files that embed code, like MDX's ESM blocks. + */ + transformSource: (code: string) => string; + }): string | null; +} + +export type SourceMigration = AstMigration | SpliceMigration; +export type Migration = SourceMigration | FileMigration; + +export interface Preset { + name: string; + description: string; + manifest: Manifest; + /* Run in order. All `ast` migrations must come before any `splice` one. */ + migrations: Migration[]; + /* Printed at the end of every run. */ + checklist: string[]; + /* Printed only when a migration calls note(key). */ + conditionalChecklist: Record; +} diff --git a/packages/gamut-codemods/src/presets/index.ts b/packages/gamut-codemods/src/presets/index.ts new file mode 100644 index 0000000000..2ff0c9294a --- /dev/null +++ b/packages/gamut-codemods/src/presets/index.ts @@ -0,0 +1,43 @@ +import type { Preset } from '../lib/types'; +import { scopeSwap } from './scope-swap'; + +/* + Every preset the CLI can run. A preset is one upgrade: its own manifest, + an ordered list of migrations, and the checklist to print afterwards. + Later presets are named for the release they target and can reuse + migrations from src/migrations with a different manifest. +*/ +export const presets: Record = { + [scopeSwap.name]: scopeSwap, +}; + +/* + AST migrations print through recast once, then splice migrations edit that + output by offset. Interleaving them would need a print and re-parse per + switch, so presets have to list every `ast` migration before any + `splice` one. +*/ +export const validatePreset = (preset: Preset) => { + const source = preset.migrations.filter((m) => m.kind !== 'file'); + const firstSplice = source.findIndex((m) => m.kind === 'splice'); + const lateAst = + firstSplice === -1 + ? undefined + : source.slice(firstSplice).find((m) => m.kind === 'ast'); + if (lateAst) { + throw new Error( + `preset '${preset.name}': ast migration '${lateAst.name}' is listed after a splice migration. List every ast migration first.` + ); + } + return preset; +}; + +export const getPreset = (name: string) => { + const preset = presets[name]; + if (!preset) { + throw new Error( + `unknown preset '${name}'. Available: ${Object.keys(presets).join(', ')}` + ); + } + return validatePreset(preset); +}; diff --git a/packages/gamut-codemods/src/presets/scope-swap/index.ts b/packages/gamut-codemods/src/presets/scope-swap/index.ts new file mode 100644 index 0000000000..69f1682399 --- /dev/null +++ b/packages/gamut-codemods/src/presets/scope-swap/index.ts @@ -0,0 +1,15 @@ +import type { Preset } from '../../lib/types'; +import { manifest } from './manifest'; + +export const scopeSwap: Preset = { + name: 'scope-swap', + description: 'Move from @codecademy/gamut* to @skillsoft/gamut*.', + manifest, + migrations: [], + checklist: [ + 'Run your formatter and lint autofix (prettier --write, then eslint --fix). Split imports come out in recast style, `@skillsoft` sorts differently from `@codecademy` for import-order rules like simple-import-sort, and the shorter scope lets some wrapped imports fit on one line.', + 'Reinstall (yarn), then type-check and run your tests.', + 'Work through the warnings above and the leftovers list.', + ], + conditionalChecklist: {}, +}; diff --git a/packages/gamut-codemods/src/presets/scope-swap/manifest.ts b/packages/gamut-codemods/src/presets/scope-swap/manifest.ts new file mode 100644 index 0000000000..6802bda554 --- /dev/null +++ b/packages/gamut-codemods/src/presets/scope-swap/manifest.ts @@ -0,0 +1,35 @@ +import type { Manifest } from '../../lib/manifest'; + +/* + What changed between @codecademy/gamut* and @skillsoft/gamut*. Every entry + uses the OLD (@codecademy) names; scope-rename runs last and swaps the + scope, so no other migration needs to know the new one. + + manifest.test.ts type-checks every export this file points at against the + built packages, so a row can't reference an export that doesn't exist. +*/ + +export const manifest: Manifest = { + packages: { + '@codecademy/gamut': { to: '@skillsoft/gamut' }, + '@codecademy/gamut-styles': { to: '@skillsoft/gamut-styles' }, + '@codecademy/gamut-icons': { to: '@skillsoft/gamut-icons' }, + '@codecademy/gamut-illustrations': { to: '@skillsoft/gamut-illustrations' }, + '@codecademy/gamut-patterns': { to: '@skillsoft/gamut-patterns' }, + '@codecademy/gamut-tests': { to: '@skillsoft/gamut-tests' }, + '@codecademy/variance': { to: '@skillsoft/variance' }, + 'eslint-plugin-gamut': { to: '@skillsoft/eslint-plugin-gamut' }, + }, + + removedPackages: {}, + + targetVersions: {}, + + sourceVersions: {}, + + movedExports: [], + + deepImports: [], + + eslintPlugin: { from: 'gamut', to: '@skillsoft/gamut' }, +}; diff --git a/packages/gamut-codemods/src/transform.ts b/packages/gamut-codemods/src/transform.ts new file mode 100644 index 0000000000..e1e60939f0 --- /dev/null +++ b/packages/gamut-codemods/src/transform.ts @@ -0,0 +1,77 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { API, FileInfo } from 'jscodeshift'; + +import { + activeSourceMigrations, + mentionsOldNames, + runSourceMigrations, +} from './lib/run-source'; +import type { Locatable } from './lib/types'; +import { getPreset } from './presets'; + +/* + jscodeshift entry point: runs a preset's source migrations over one file + (see lib/run-source.ts for the two phases). + + Options: + preset: preset name (default: scope-swap) + only: comma-separated migration names to run (default: all) + skip: comma-separated migration names the CLI ruled out for this + run (see Migration.skipReason) + notesDir: directory the CLI reads checklist notes back from. jscodeshift + only aggregates api.stats in --dry runs, so workers leave an + empty marker file per note key instead. + onWarn: (message) => void, for tests; otherwise warnings go to api.report +*/ + +export interface TransformOptions { + preset?: string; + only?: string; + skip?: string; + notesDir?: string; + onWarn?: (message: string) => void; +} + +export default function transform( + file: FileInfo, + api: API, + options: TransformOptions = {} +) { + const preset = getPreset(options.preset ?? 'scope-swap'); + const { manifest } = preset; + if (!mentionsOldNames(manifest, file.source)) return undefined; + + const note = (key: string) => { + if (options.notesDir) { + fs.writeFileSync( + path.join(options.notesDir, encodeURIComponent(key)), + '' + ); + } + }; + const warnFor = (name: string) => (node: Locatable, message: string) => { + const line = node?.loc ? `:${node.loc.start.line}` : ''; + const text = `[${name}] ${file.path}${line} ${message}`; + if (options.onWarn) options.onWarn(text); + else api.report(text); + }; + + const source = runSourceMigrations({ + j: api.jscodeshift, + source: file.source, + migrations: activeSourceMigrations( + preset.migrations, + options.only, + options.skip + ), + manifest, + warnFor, + note, + }); + + return source === file.source ? undefined : source; +} + +export const parser = 'tsx'; diff --git a/packages/gamut-codemods/tsconfig.json b/packages/gamut-codemods/tsconfig.json new file mode 100644 index 0000000000..62ebbd9464 --- /dev/null +++ b/packages/gamut-codemods/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/gamut-codemods/tsconfig.lib.json b/packages/gamut-codemods/tsconfig.lib.json new file mode 100644 index 0000000000..8c51e7c29d --- /dev/null +++ b/packages/gamut-codemods/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "module": "CommonJS", + "outDir": "./dist", + "rootDir": "./src", + "lib": ["es2022"], + "types": ["node"] + }, + "exclude": ["jest.config.ts", "**/*.test.ts", "**/__testfixtures__/**"], + "include": ["./src/**/*.ts"] +} diff --git a/packages/gamut-codemods/tsconfig.spec.json b/packages/gamut-codemods/tsconfig.spec.json new file mode 100644 index 0000000000..38a15e287a --- /dev/null +++ b/packages/gamut-codemods/tsconfig.spec.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "./dist", + "lib": ["es2022"], + "types": ["jest", "node"] + }, + "exclude": ["**/__testfixtures__/**"], + "include": ["jest.config.ts", "**/*.test.ts", "**/*.d.ts"] +} diff --git a/yarn.lock b/yarn.lock index a9ef908690..56c2d68611 100644 --- a/yarn.lock +++ b/yarn.lock @@ -400,7 +400,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.12.3, @babel/core@npm:^7.21.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.27.4, @babel/core@npm:^7.28.0, @babel/core@npm:^7.29.0": +"@babel/core@npm:^7.12.3, @babel/core@npm:^7.21.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.24.7, @babel/core@npm:^7.27.4, @babel/core@npm:^7.28.0, @babel/core@npm:^7.29.0": version: 7.29.7 resolution: "@babel/core@npm:7.29.7" dependencies: @@ -445,6 +445,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-annotate-as-pure@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-annotate-as-pure@npm:7.29.7" + dependencies: + "@babel/types": "npm:^7.29.7" + checksum: 10c0/c56536b52d17632d89d49db2063ed6102f0e3bbadf6a0ccb74e6599d6a77173b644c7fe8c3ef17c7a162709d55b75ee5145ef6db917d16ba7f375fbffcf2e942 + languageName: node + linkType: hard + "@babel/helper-builder-binary-assignment-operator-visitor@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-builder-binary-assignment-operator-visitor@npm:7.25.9" @@ -485,6 +494,23 @@ __metadata: languageName: node linkType: hard +"@babel/helper-create-class-features-plugin@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-create-class-features-plugin@npm:7.29.7" + dependencies: + "@babel/helper-annotate-as-pure": "npm:^7.29.7" + "@babel/helper-member-expression-to-functions": "npm:^7.29.7" + "@babel/helper-optimise-call-expression": "npm:^7.29.7" + "@babel/helper-replace-supers": "npm:^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" + semver: "npm:^6.3.1" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/75f34905b5e708b473f1e9b33e07b2fcc8f4c60676df8bc74541bb91c77f387c32a948dd04d5071e469ba454d72d0a872e3ace40fbb1d1e7aaa8569efcf09ed4 + languageName: node + linkType: hard + "@babel/helper-create-regexp-features-plugin@npm:^7.18.6, @babel/helper-create-regexp-features-plugin@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-create-regexp-features-plugin@npm:7.25.9" @@ -530,6 +556,16 @@ __metadata: languageName: node linkType: hard +"@babel/helper-member-expression-to-functions@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-member-expression-to-functions@npm:7.29.7" + dependencies: + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/eef7940ce0797208854a5af1049a98fee9abbffb5c619640c69ff5a555f8e3552295bb18756490b02bc6af7df8c1babcb83f12203aac2deb9dfecfc78846e12d + languageName: node + linkType: hard + "@babel/helper-module-imports@npm:^7.16.7, @babel/helper-module-imports@npm:^7.18.6, @babel/helper-module-imports@npm:^7.25.9, @babel/helper-module-imports@npm:^7.29.7": version: 7.29.7 resolution: "@babel/helper-module-imports@npm:7.29.7" @@ -562,6 +598,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-optimise-call-expression@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-optimise-call-expression@npm:7.29.7" + dependencies: + "@babel/types": "npm:^7.29.7" + checksum: 10c0/fd0244b9bfbb487db02d59aa2703c6991d654ea5f3f39d912682842bdca2e87b5ae8643b0ce8069bf5fbee39d1aa9db7abefeb5e6ba1aa650dca12777cf5b7e2 + languageName: node + linkType: hard + "@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.18.6, @babel/helper-plugin-utils@npm:^7.22.5, @babel/helper-plugin-utils@npm:^7.25.9, @babel/helper-plugin-utils@npm:^7.27.1, @babel/helper-plugin-utils@npm:^7.29.7, @babel/helper-plugin-utils@npm:^7.8.0": version: 7.29.7 resolution: "@babel/helper-plugin-utils@npm:7.29.7" @@ -595,6 +640,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-replace-supers@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-replace-supers@npm:7.29.7" + dependencies: + "@babel/helper-member-expression-to-functions": "npm:^7.29.7" + "@babel/helper-optimise-call-expression": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/1c7ae37797f226e965ab85f6affa53d25a10c169c604a4daeb36f9df09e673471e6522f631c13761cf9fbafeca2ea14c241dea8d723a51039d561beb01d86ac4 + languageName: node + linkType: hard + "@babel/helper-simple-access@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-simple-access@npm:7.25.9" @@ -615,6 +673,16 @@ __metadata: languageName: node linkType: hard +"@babel/helper-skip-transparent-expression-wrappers@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.29.7" + dependencies: + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/8c59493621487fc491f27adfc200af82a6aca3b9a5511e4e6050f8716593b4b243472cb56c8d2016e828b7ae12d605a819205aa8600ca08ee291dcd58d65c832 + languageName: node + linkType: hard + "@babel/helper-string-parser@npm:^7.29.7": version: 7.29.7 resolution: "@babel/helper-string-parser@npm:7.29.7" @@ -804,6 +872,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-flow@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-syntax-flow@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/b2e330dd0dc535c7364937ce2b29a06065e60b1d523db75f36ab4fb89e4ba664e2230cbb1937b35712c9a0ebafc3358f323d6e6b22247d5ebad12fdd3e1f6e98 + languageName: node + linkType: hard + "@babel/plugin-syntax-import-assertions@npm:^7.26.0": version: 7.26.0 resolution: "@babel/plugin-syntax-import-assertions@npm:7.26.0" @@ -1041,6 +1120,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-class-properties@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-class-properties@npm:7.29.7" + dependencies: + "@babel/helper-create-class-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/c370700423439aa9f0c1f8c4b97f2ef7c2dc46a1b04ec3b10e83e6bae5e4e2159f56d8e4376c9d669b3cf827650cc3740170a36e3924e3e9970d27fd85f4e48a + languageName: node + linkType: hard + "@babel/plugin-transform-class-static-block@npm:^7.26.0": version: 7.26.0 resolution: "@babel/plugin-transform-class-static-block@npm:7.26.0" @@ -1161,6 +1252,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-flow-strip-types@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-flow-strip-types@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/plugin-syntax-flow": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/083e864a60927cde7bd75822bdf5c6c72de200ee955e48f6ff5923f0d2b0744ab8f1b64c45e74b88ae3796f03721489afffdb0536f25c54d0dd58508a8904f40 + languageName: node + linkType: hard + "@babel/plugin-transform-for-of@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-for-of@npm:7.25.9" @@ -1242,6 +1345,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-modules-commonjs@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-modules-commonjs@npm:7.29.7" + dependencies: + "@babel/helper-module-transforms": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/9791cb524438b2a8ba6cb8715788fa1e202fbecd4e76b3ccab0af0819fd69212b40ae30d72ac377012f7149889f7792ed8bf91e97bbe9113ca9641f8ad3bf332 + languageName: node + linkType: hard + "@babel/plugin-transform-modules-commonjs@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-modules-commonjs@npm:7.25.9" @@ -1304,6 +1419,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-nullish-coalescing-operator@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/b0c186fe38bc66830e1be76f06fabbae8a655d3896a841ba5ffa12d6c40bb9c8a6ecd38a7e2196034b1d7470653109b1ceac1ef5e46a5fdc9291d14afa56e0d0 + languageName: node + linkType: hard + "@babel/plugin-transform-nullish-coalescing-operator@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.25.9" @@ -1362,6 +1488,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-optional-chaining@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-optional-chaining@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/71feacf9a7083030f4c69bf4e91db75f2fceae28e58c58b63db040006d908e15bc1e85a464f24ad659f17702e91a64eabbff9f1dd555ba33d78bb1af8a1d697a + languageName: node + linkType: hard + "@babel/plugin-transform-optional-chaining@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-optional-chaining@npm:7.25.9" @@ -1385,6 +1523,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-private-methods@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/plugin-transform-private-methods@npm:7.29.7" + dependencies: + "@babel/helper-create-class-features-plugin": "npm:^7.29.7" + "@babel/helper-plugin-utils": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/d0dc12fa478d05e346dfb02fad2ed99b308d9a7324bada71530a62dc1ccbf07b4c2581ac677b7196b3994f191ef1922199e2c8f33957b726e2019ce07b4ced0f + languageName: node + linkType: hard + "@babel/plugin-transform-private-methods@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-private-methods@npm:7.25.9" @@ -1751,6 +1901,19 @@ __metadata: languageName: node linkType: hard +"@babel/preset-flow@npm:^7.24.7": + version: 7.29.7 + resolution: "@babel/preset-flow@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + "@babel/helper-validator-option": "npm:^7.29.7" + "@babel/plugin-transform-flow-strip-types": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/17bb0f3c320fadb631ab64951ec0f6e05a62b15418c7864c181645b3205cabd3a8541dd3f497bf06cb1d0e04a3806f6cffee7675c97a87d4aaa04961f334cdcb + languageName: node + linkType: hard + "@babel/preset-modules@npm:0.1.6-no-external-plugins": version: 0.1.6-no-external-plugins resolution: "@babel/preset-modules@npm:0.1.6-no-external-plugins" @@ -1795,6 +1958,21 @@ __metadata: languageName: node linkType: hard +"@babel/register@npm:^7.24.6": + version: 7.29.7 + resolution: "@babel/register@npm:7.29.7" + dependencies: + clone-deep: "npm:^4.0.1" + find-cache-dir: "npm:^2.0.0" + make-dir: "npm:^2.1.0" + pirates: "npm:^4.0.6" + source-map-support: "npm:^0.5.16" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/154dc72cc354a3d2b7b69d10738f02278774bbf53d3b365f7897035c9f26ad6433b24e81ad277c0fbf33139e08230e0cab3913e91d9325308af532da854765f2 + languageName: node + linkType: hard + "@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7": version: 7.28.2 resolution: "@babel/runtime@npm:7.28.2" @@ -7631,6 +7809,18 @@ __metadata: languageName: unknown linkType: soft +"@skillsoft/gamut-codemods@workspace:packages/gamut-codemods": + version: 0.0.0-use.local + resolution: "@skillsoft/gamut-codemods@workspace:packages/gamut-codemods" + dependencies: + "@skillsoft/gamut": "workspace:*" + "@types/jscodeshift": "npm:^17.3.0" + jscodeshift: "npm:^17.3.0" + bin: + gamut-codemods: ./dist/bin.js + languageName: unknown + linkType: soft + "@skillsoft/gamut-docs@workspace:packages/gamut-docs": version: 0.0.0-use.local resolution: "@skillsoft/gamut-docs@workspace:packages/gamut-docs" @@ -8962,6 +9152,16 @@ __metadata: languageName: node linkType: hard +"@types/jscodeshift@npm:^17.3.0": + version: 17.3.0 + resolution: "@types/jscodeshift@npm:17.3.0" + dependencies: + ast-types: "npm:^0.16.1" + recast: "npm:^0.23.11" + checksum: 10c0/56b889b290f12322b43d74c7434b69cef3e1ca8444e37891e08585f716b1c23f44596f650fb92220a8c6e5776d3dbc7d8ece67e950b6f9e733eee8e712128c73 + languageName: node + linkType: hard + "@types/jsdom@npm:^21.1.7": version: 21.1.7 resolution: "@types/jsdom@npm:21.1.7" @@ -11542,6 +11742,17 @@ __metadata: languageName: node linkType: hard +"clone-deep@npm:^4.0.1": + version: 4.0.1 + resolution: "clone-deep@npm:4.0.1" + dependencies: + is-plain-object: "npm:^2.0.4" + kind-of: "npm:^6.0.2" + shallow-clone: "npm:^3.0.0" + checksum: 10c0/637753615aa24adf0f2d505947a1bb75e63964309034a1cf56ba4b1f30af155201edd38d26ffe26911adaae267a3c138b344a4947d39f5fc1b6d6108125aa758 + languageName: node + linkType: hard + "clone@npm:^1.0.2": version: 1.0.4 resolution: "clone@npm:1.0.4" @@ -14115,6 +14326,17 @@ __metadata: languageName: node linkType: hard +"find-cache-dir@npm:^2.0.0": + version: 2.1.0 + resolution: "find-cache-dir@npm:2.1.0" + dependencies: + commondir: "npm:^1.0.1" + make-dir: "npm:^2.0.0" + pkg-dir: "npm:^3.0.0" + checksum: 10c0/556117fd0af14eb88fb69250f4bba9e905e7c355c6136dff0e161b9cbd1f5285f761b778565a278da73a130f42eccc723d7ad4c002ae547ed1d698d39779dabb + languageName: node + linkType: hard + "find-cache-dir@npm:^3.3.1, find-cache-dir@npm:^3.3.2": version: 3.3.2 resolution: "find-cache-dir@npm:3.3.2" @@ -14164,6 +14386,15 @@ __metadata: languageName: node linkType: hard +"find-up@npm:^3.0.0": + version: 3.0.0 + resolution: "find-up@npm:3.0.0" + dependencies: + locate-path: "npm:^3.0.0" + checksum: 10c0/2c2e7d0a26db858e2f624f39038c74739e38306dee42b45f404f770db357947be9d0d587f1cac72d20c114deb38aa57316e879eb0a78b17b46da7dab0a3bd6e3 + languageName: node + linkType: hard + "find-up@npm:^4.0.0, find-up@npm:^4.1.0": version: 4.1.0 resolution: "find-up@npm:4.1.0" @@ -14218,6 +14449,22 @@ __metadata: languageName: node linkType: hard +"flow-estree@npm:0.335.0": + version: 0.335.0 + resolution: "flow-estree@npm:0.335.0" + checksum: 10c0/e7289495d86aabc0cec83e88d04ca294ea820061dbda0b171fb112b29ccb318533e2822f1b9aa8de0a590d039d7ac013ca7dc6d4f50a367de96776976b1458a3 + languageName: node + linkType: hard + +"flow-parser@npm:0.*": + version: 0.335.0 + resolution: "flow-parser@npm:0.335.0" + dependencies: + flow-estree: "npm:0.335.0" + checksum: 10c0/79e7896a6591dac44a9a63c6fab6945bab4dd55cc665d923b596b9f72416d8fae8d25df1fb74932c96ad374e7b927044f5c8adb1f42f2658bf7668bc44f2a25c + languageName: node + linkType: hard + "focus-lock@npm:^1.3.6": version: 1.3.6 resolution: "focus-lock@npm:1.3.6" @@ -16048,6 +16295,15 @@ __metadata: languageName: node linkType: hard +"is-plain-object@npm:^2.0.4": + version: 2.0.4 + resolution: "is-plain-object@npm:2.0.4" + dependencies: + isobject: "npm:^3.0.1" + checksum: 10c0/f050fdd5203d9c81e8c4df1b3ff461c4bc64e8b5ca383bcdde46131361d0a678e80bcf00b5257646f6c636197629644d53bd8e2375aea633de09a82d57e942f4 + languageName: node + linkType: hard + "is-plain-object@npm:^5.0.0": version: 5.0.0 resolution: "is-plain-object@npm:5.0.0" @@ -16215,6 +16471,13 @@ __metadata: languageName: node linkType: hard +"isobject@npm:^3.0.1": + version: 3.0.1 + resolution: "isobject@npm:3.0.1" + checksum: 10c0/03344f5064a82f099a0cd1a8a407f4c0d20b7b8485e8e816c39f249e9416b06c322e8dec5b842b6bb8a06de0af9cb48e7bc1b5352f0fadc2f0abac033db3d4db + languageName: node + linkType: hard + "isomorphic-ws@npm:5.0.0": version: 5.0.0 resolution: "isomorphic-ws@npm:5.0.0" @@ -16854,6 +17117,39 @@ __metadata: languageName: node linkType: hard +"jscodeshift@npm:^17.3.0": + version: 17.4.0 + resolution: "jscodeshift@npm:17.4.0" + dependencies: + "@babel/core": "npm:^7.24.7" + "@babel/parser": "npm:^7.24.7" + "@babel/plugin-transform-class-properties": "npm:^7.24.7" + "@babel/plugin-transform-modules-commonjs": "npm:^7.24.7" + "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.24.7" + "@babel/plugin-transform-optional-chaining": "npm:^7.24.7" + "@babel/plugin-transform-private-methods": "npm:^7.24.7" + "@babel/preset-flow": "npm:^7.24.7" + "@babel/preset-typescript": "npm:^7.24.7" + "@babel/register": "npm:^7.24.6" + flow-parser: "npm:0.*" + graceful-fs: "npm:^4.2.4" + neo-async: "npm:^2.5.0" + picocolors: "npm:^1.0.1" + picomatch: "npm:^4.0.2" + recast: "npm:^0.23.11" + tmp: "npm:^0.2.3" + write-file-atomic: "npm:^5.0.1" + peerDependencies: + "@babel/preset-env": ^7.1.6 + peerDependenciesMeta: + "@babel/preset-env": + optional: true + bin: + jscodeshift: bin/jscodeshift.js + checksum: 10c0/ef7b71c22f211cbba8ae96ffd2d3e8af28450d8f703d32050ff9f6cb29042bc163ce7d8239798b672b543bb12203adea20fb4d2c0034acc5440d26cb17c7a42f + languageName: node + linkType: hard + "jsdom@npm:^26.1.0": version: 26.1.0 resolution: "jsdom@npm:26.1.0" @@ -17025,7 +17321,7 @@ __metadata: languageName: node linkType: hard -"kind-of@npm:^6.0.3": +"kind-of@npm:^6.0.2, kind-of@npm:^6.0.3": version: 6.0.3 resolution: "kind-of@npm:6.0.3" checksum: 10c0/61cdff9623dabf3568b6445e93e31376bee1cdb93f8ba7033d86022c2a9b1791a1d9510e026e6465ebd701a6dd2f7b0808483ad8838341ac52f003f512e0b4c4 @@ -17311,6 +17607,16 @@ __metadata: languageName: node linkType: hard +"locate-path@npm:^3.0.0": + version: 3.0.0 + resolution: "locate-path@npm:3.0.0" + dependencies: + p-locate: "npm:^3.0.0" + path-exists: "npm:^3.0.0" + checksum: 10c0/3db394b7829a7fe2f4fbdd25d3c4689b85f003c318c5da4052c7e56eed697da8f1bce5294f685c69ff76e32cba7a33629d94396976f6d05fb7f4c755c5e2ae8b + languageName: node + linkType: hard + "locate-path@npm:^5.0.0": version: 5.0.0 resolution: "locate-path@npm:5.0.0" @@ -17539,6 +17845,16 @@ __metadata: languageName: node linkType: hard +"make-dir@npm:^2.0.0, make-dir@npm:^2.1.0": + version: 2.1.0 + resolution: "make-dir@npm:2.1.0" + dependencies: + pify: "npm:^4.0.1" + semver: "npm:^5.6.0" + checksum: 10c0/ada869944d866229819735bee5548944caef560d7a8536ecbc6536edca28c72add47cc4f6fc39c54fb25d06b58da1f8994cf7d9df7dadea047064749efc085d8 + languageName: node + linkType: hard + "make-dir@npm:^3.0.0, make-dir@npm:^3.0.2": version: 3.1.0 resolution: "make-dir@npm:3.1.0" @@ -18876,7 +19192,7 @@ __metadata: languageName: node linkType: hard -"neo-async@npm:^2.6.2": +"neo-async@npm:^2.5.0, neo-async@npm:^2.6.2": version: 2.6.2 resolution: "neo-async@npm:2.6.2" checksum: 10c0/c2f5a604a54a8ec5438a342e1f356dff4bc33ccccdb6dc668d94fe8e5eccfc9d2c2eea6064b0967a767ba63b33763f51ccf2cd2441b461a7322656c1f06b3f5d @@ -19637,7 +19953,7 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^2.2.0": +"p-limit@npm:^2.0.0, p-limit@npm:^2.2.0": version: 2.3.0 resolution: "p-limit@npm:2.3.0" dependencies: @@ -19664,6 +19980,15 @@ __metadata: languageName: node linkType: hard +"p-locate@npm:^3.0.0": + version: 3.0.0 + resolution: "p-locate@npm:3.0.0" + dependencies: + p-limit: "npm:^2.0.0" + checksum: 10c0/7b7f06f718f19e989ce6280ed4396fb3c34dabdee0df948376483032f9d5ec22fdf7077ec942143a75827bb85b11da72016497fc10dac1106c837ed593969ee8 + languageName: node + linkType: hard + "p-locate@npm:^4.1.0": version: 4.1.0 resolution: "p-locate@npm:4.1.0" @@ -19893,6 +20218,13 @@ __metadata: languageName: node linkType: hard +"path-exists@npm:^3.0.0": + version: 3.0.0 + resolution: "path-exists@npm:3.0.0" + checksum: 10c0/17d6a5664bc0a11d48e2b2127d28a0e58822c6740bde30403f08013da599182289c56518bec89407e3f31d3c2b6b296a4220bc3f867f0911fee6952208b04167 + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -20011,6 +20343,13 @@ __metadata: languageName: node linkType: hard +"pify@npm:^4.0.1": + version: 4.0.1 + resolution: "pify@npm:4.0.1" + checksum: 10c0/6f9d404b0d47a965437403c9b90eca8bb2536407f03de165940e62e72c8c8b75adda5516c6b9b23675a5877cc0bcac6bdfb0ef0e39414cd2476d5495da40e7cf + languageName: node + linkType: hard + "pinkie-promise@npm:^2.0.0": version: 2.0.1 resolution: "pinkie-promise@npm:2.0.1" @@ -20027,13 +20366,22 @@ __metadata: languageName: node linkType: hard -"pirates@npm:^4.0.7": +"pirates@npm:^4.0.6, pirates@npm:^4.0.7": version: 4.0.7 resolution: "pirates@npm:4.0.7" checksum: 10c0/a51f108dd811beb779d58a76864bbd49e239fa40c7984cd11596c75a121a8cc789f1c8971d8bb15f0dbf9d48b76c05bb62fcbce840f89b688c0fa64b37e8478a languageName: node linkType: hard +"pkg-dir@npm:^3.0.0": + version: 3.0.0 + resolution: "pkg-dir@npm:3.0.0" + dependencies: + find-up: "npm:^3.0.0" + checksum: 10c0/902a3d0c1f8ac43b1795fa1ba6ffeb37dfd53c91469e969790f6ed5e29ff2bdc50b63ba6115dc056d2efb4a040aa2446d512b3804bdafdf302f734fb3ec21847 + languageName: node + linkType: hard + "pkg-dir@npm:^4.1.0, pkg-dir@npm:^4.2.0": version: 4.2.0 resolution: "pkg-dir@npm:4.2.0" @@ -21007,6 +21355,19 @@ __metadata: languageName: node linkType: hard +"recast@npm:^0.23.11": + version: 0.23.21 + resolution: "recast@npm:0.23.21" + dependencies: + ast-types: "npm:^0.16.1" + esprima: "npm:~4.0.0" + source-map: "npm:~0.6.1" + tiny-invariant: "npm:^1.3.3" + tslib: "npm:^2.0.1" + checksum: 10c0/afee4673fda5a95b0db8199a3f75c6f675269f00328a3673f241a6088c99b5a58f7082b6cff683456a182e3302bfb57313ebb1c2073228b260724b0993ff1f9a + languageName: node + linkType: hard + "recast@npm:^0.23.5": version: 0.23.9 resolution: "recast@npm:0.23.9" @@ -22075,6 +22436,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^5.6.0": + version: 5.7.2 + resolution: "semver@npm:5.7.2" + bin: + semver: bin/semver + checksum: 10c0/e4cf10f86f168db772ae95d86ba65b3fd6c5967c94d97c708ccb463b778c2ee53b914cd7167620950fc07faf5a564e6efe903836639e512a1aa15fbc9667fa25 + languageName: node + linkType: hard + "semver@npm:^6.0.0, semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" @@ -22177,6 +22547,15 @@ __metadata: languageName: node linkType: hard +"shallow-clone@npm:^3.0.0": + version: 3.0.1 + resolution: "shallow-clone@npm:3.0.1" + dependencies: + kind-of: "npm:^6.0.2" + checksum: 10c0/7bab09613a1b9f480c85a9823aebec533015579fa055ba6634aa56ba1f984380670eaf33b8217502931872aa1401c9fcadaa15f9f604d631536df475b05bcf1e + languageName: node + linkType: hard + "shallowequal@npm:^1.1.0": version: 1.1.0 resolution: "shallowequal@npm:1.1.0" @@ -22635,7 +23014,7 @@ __metadata: languageName: node linkType: hard -"source-map-support@npm:^0.5.21, source-map-support@npm:~0.5.20": +"source-map-support@npm:^0.5.16, source-map-support@npm:^0.5.21, source-map-support@npm:~0.5.20": version: 0.5.21 resolution: "source-map-support@npm:0.5.21" dependencies: @@ -23591,6 +23970,13 @@ __metadata: languageName: node linkType: hard +"tmp@npm:^0.2.3": + version: 0.2.7 + resolution: "tmp@npm:0.2.7" + checksum: 10c0/59eb55584f2f07210d3231b6a1f6b5c2b9794d8a7b509c8ee867ed2acad6d2245ee2448b7937b676ffbff3155a70077edde8a69f9d7cf0f90c86a62e8910c357 + languageName: node + linkType: hard + "tmp@npm:~0.2.1": version: 0.2.3 resolution: "tmp@npm:0.2.3" From 5a91398cb1fb72866237dfcb6e7120920f80d96b Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 20 Oct 2026 09:48:11 -0400 Subject: [PATCH 02/16] feat(gamut-codemods): rename packages and replace gamut-kit in scope-swap scope-rename swaps @codecademy/gamut* and eslint-plugin-gamut for their @skillsoft names in every module position and in comments. package-json, mdx-imports, and yarnrc cover dependencies, MDX ESM blocks, and .yarnrc.yml lists. @codecademy/gamut-kit has no replacement, so package-json and mf-shared expand it into the packages it bundled. In Module Federation config that makes Gamut a real singleton, which the checklist calls out. Co-Authored-By: Claude Opus 5.5 --- .changeset/gamut-codemods-scope-rename.md | 5 + packages/gamut-codemods/README.md | 9 +- .../__testfixtures__/end-to-end.input.tsx | 5 + .../__testfixtures__/end-to-end.output.tsx | 5 + .../gamut-codemods/src/__tests__/cli.test.ts | 31 ++++- .../gamut-codemods/src/__tests__/lib.test.ts | 7 +- .../__testfixtures__/story.input.mdx | 16 +++ .../__testfixtures__/story.output.mdx | 16 +++ .../__testfixtures__/unterminated.input.mdx | 4 + .../__testfixtures__/unterminated.output.mdx | 4 + .../src/migrations/mdx-imports/index.ts | 63 ++++++++++ .../__testfixtures__/already-shared.input.ts | 11 ++ .../__testfixtures__/already-shared.output.ts | 19 +++ .../__testfixtures__/computed-key.input.ts | 5 + .../__testfixtures__/computed-key.output.ts | 5 + .../mf-shared/__testfixtures__/kit.input.ts | 11 ++ .../mf-shared/__testfixtures__/kit.output.ts | 16 +++ .../__testfixtures__/kit.warnings.json | 1 + .../src/migrations/mf-shared/index.ts | 112 ++++++++++++++++++ .../already-renamed.input.json | 9 ++ .../already-renamed.output.json | 12 ++ .../__testfixtures__/kit.input.json | 11 ++ .../__testfixtures__/kit.output.json | 16 +++ .../__testfixtures__/kit.warnings.json | 1 + .../newer-than-source.input.json | 7 ++ .../newer-than-source.output.json | 7 ++ .../newer-than-source.warnings.json | 1 + .../__testfixtures__/peers.input.json | 7 ++ .../__testfixtures__/peers.output.json | 7 ++ .../src/migrations/package-json/index.ts | 95 +++++++++++++++ .../__testfixtures__/comments.input.tsx | 12 ++ .../__testfixtures__/comments.output.tsx | 12 ++ .../__testfixtures__/everywhere.input.tsx | 20 ++++ .../__testfixtures__/everywhere.output.tsx | 20 ++++ .../__testfixtures__/everywhere.warnings.json | 1 + .../__testfixtures__/no-semicolons.input.tsx | 13 ++ .../__testfixtures__/no-semicolons.output.tsx | 13 ++ .../__testfixtures__/require-forms.input.ts | 3 + .../__testfixtures__/require-forms.output.ts | 3 + .../src/migrations/scope-rename/index.ts | 109 +++++++++++++++++ .../yarnrc/__testfixtures__/no-glob.input.yml | 2 + .../__testfixtures__/no-glob.output.yml | 2 + .../__testfixtures__/preapproved.input.yml | 10 ++ .../__testfixtures__/preapproved.output.yml | 9 ++ .../src/migrations/yarnrc/index.ts | 45 +++++++ .../src/presets/scope-swap/index.ts | 23 +++- .../src/presets/scope-swap/manifest.ts | 68 ++++++++++- 47 files changed, 869 insertions(+), 14 deletions(-) create mode 100644 .changeset/gamut-codemods-scope-rename.md create mode 100644 packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.input.tsx create mode 100644 packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.output.tsx create mode 100644 packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.input.mdx create mode 100644 packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.output.mdx create mode 100644 packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/unterminated.input.mdx create mode 100644 packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/unterminated.output.mdx create mode 100644 packages/gamut-codemods/src/migrations/mdx-imports/index.ts create mode 100644 packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.input.ts create mode 100644 packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.output.ts create mode 100644 packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/computed-key.input.ts create mode 100644 packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/computed-key.output.ts create mode 100644 packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.input.ts create mode 100644 packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.output.ts create mode 100644 packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.warnings.json create mode 100644 packages/gamut-codemods/src/migrations/mf-shared/index.ts create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.input.json create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.output.json create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.input.json create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.output.json create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.warnings.json create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.input.json create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.output.json create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.warnings.json create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.input.json create mode 100644 packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.output.json create mode 100644 packages/gamut-codemods/src/migrations/package-json/index.ts create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/comments.input.tsx create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/comments.output.tsx create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.input.tsx create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.output.tsx create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.warnings.json create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.input.tsx create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.output.tsx create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/require-forms.input.ts create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/require-forms.output.ts create mode 100644 packages/gamut-codemods/src/migrations/scope-rename/index.ts create mode 100644 packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.input.yml create mode 100644 packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.output.yml create mode 100644 packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.input.yml create mode 100644 packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.output.yml create mode 100644 packages/gamut-codemods/src/migrations/yarnrc/index.ts diff --git a/.changeset/gamut-codemods-scope-rename.md b/.changeset/gamut-codemods-scope-rename.md new file mode 100644 index 0000000000..3a6a13605f --- /dev/null +++ b/.changeset/gamut-codemods-scope-rename.md @@ -0,0 +1,5 @@ +--- +'@skillsoft/gamut-codemods': minor +--- + +`scope-swap` now renames every `@codecademy/gamut*` package and `eslint-plugin-gamut` to `@skillsoft/*`: imports, exports, `require`, `import()`, `jest.mock`/`vi.mock`, `declare module`, import types, and package names in comments, `package.json`, `.mdx` ESM blocks, and `.yarnrc.yml` lists. `@codecademy/gamut-kit` has no `@skillsoft` replacement, so it's replaced with the packages it bundled, in `package.json` and in Module Federation `shared` config. That makes Gamut a real singleton across host and remotes, so the run ends with a reminder to deploy them together. Dependencies on a newer upstream release than `@skillsoft` has caught up to get a warning. diff --git a/packages/gamut-codemods/README.md b/packages/gamut-codemods/README.md index aabf663a72..fb4ab06c5e 100644 --- a/packages/gamut-codemods/README.md +++ b/packages/gamut-codemods/README.md @@ -54,8 +54,13 @@ Every skillsoft-gamut pull request publishes installable previews through pkg.pr ### What scope-swap changes -| Migration | What it does | -| --------- | ------------ | +| Migration | What it does | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mf-shared` | Replaces `@codecademy/gamut-kit` in Module Federation `shared` config with the packages it bundled. Gamut becomes a real singleton, so test the host and remotes together. | +| `scope-rename` | Every `@codecademy/gamut*` module string becomes `@skillsoft/gamut*`: imports, exports, `require`, `import()`, `jest.mock`/`vi.mock`, `declare module`, and `import('x').T`. Also exact package-name strings elsewhere, like `transpilePackages`, with one warning per file, and package names in comments (`/dist` paths and removed packages are left for the leftovers). | +| `package-json` | Renames dependencies and sets their versions. A new package that's already listed keeps its value (a preview URL, say), and a `*` peer range stays `*`. Replaces `@codecademy/gamut-kit` with the individual packages. | +| `mdx-imports` | Runs the migrations above over `import`/`export` statements in `.mdx` files. Fenced code blocks are left alone. | +| `yarnrc` | `.yarnrc.yml` list items, like `npmPreapprovedPackages`: renames old package names, or drops them if a glob like `'@skillsoft/*'` already covers the new one. | ### Known limits diff --git a/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.input.tsx b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.input.tsx new file mode 100644 index 0000000000..d3ece33271 --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.input.tsx @@ -0,0 +1,5 @@ +import { Box } from '@codecademy/gamut'; +import { theme } from '@codecademy/gamut-styles'; + +export const Thing = () => ; +export const shared = { '@codecademy/gamut-kit': { singleton: true } }; diff --git a/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.output.tsx b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.output.tsx new file mode 100644 index 0000000000..3c06199eab --- /dev/null +++ b/packages/gamut-codemods/src/__tests__/__testfixtures__/end-to-end.output.tsx @@ -0,0 +1,5 @@ +import { Box } from '@skillsoft/gamut'; +import { theme } from '@skillsoft/gamut-styles'; + +export const Thing = () => ; +export const shared = { '@skillsoft/gamut': { singleton: true }, '@skillsoft/gamut-icons': { singleton: true }, '@skillsoft/gamut-illustrations': { singleton: true }, '@skillsoft/gamut-patterns': { singleton: true }, '@skillsoft/gamut-styles': { singleton: true }, '@skillsoft/variance': { singleton: true } }; diff --git a/packages/gamut-codemods/src/__tests__/cli.test.ts b/packages/gamut-codemods/src/__tests__/cli.test.ts index 5c18886764..d17bc79f16 100644 --- a/packages/gamut-codemods/src/__tests__/cli.test.ts +++ b/packages/gamut-codemods/src/__tests__/cli.test.ts @@ -142,12 +142,16 @@ describe('main: refusing to write', () => { }); describe('main: full run', () => { - it('lists leftovers and prints the checklist', async () => { + it('rewrites sources, manifests, and docs, then prints the checklist', async () => { const dir = project({ 'src/Post.tsx': POST, + 'docs/story.mdx': "import { Box } from '@codecademy/gamut';\n\n# Hi\n", 'package.json': JSON.stringify({ name: 'app', - dependencies: { '@codecademy/gamut': '^73.0.0' }, + dependencies: { + '@codecademy/gamut': '^73.0.0', + '@codecademy/gamut-kit': '^73.0.0', + }, }), 'jest.config.js': "module.exports = { moduleNameMapper: { '^@codecademy/gamut/(.*)$': '/x/$1' } };\n", @@ -155,13 +159,17 @@ describe('main: full run', () => { expect(await main(['scope-swap', dir, '--force'])).toBe(0); + expect(read(dir, 'src/Post.tsx')).toContain("from '@skillsoft/gamut'"); + expect(read(dir, 'docs/story.mdx')).toContain("'@skillsoft/gamut'"); + expect(read(dir, 'package.json')).toContain('"@skillsoft/gamut"'); + expect(read(dir, 'package.json')).not.toContain('gamut-kit'); + const out = output(); expect(out).toMatch(/file\(s\) changed/); expect(out).toMatch(/leftovers \(\d+\)/); - expect(out).toMatch(/Post\.tsx:1/); - expect(out).toMatch(/package\.json:1/); expect(out).toMatch(/jest\.config\.js:1/); expect(out).toMatch(/next steps/); + expect(out).toMatch(/gamut-kit is gone/); expect(out).toMatch(/Reinstall \(yarn\)/); }); @@ -173,6 +181,7 @@ describe('main: full run', () => { expect(await main(['scope-swap', dir, '--dry'])).toBe(0); expect(read(dir, 'src/Post.tsx')).toBe(POST); expect(read(dir, 'package.json')).toBe(pkg); + expect(output()).toMatch(/would update .*package\.json/); expect(output()).toMatch(/skipped in --dry/); expect(output()).not.toMatch(/next steps/); }); @@ -184,6 +193,20 @@ describe('main: full run', () => { expect(output()).not.toMatch(/leftovers skipped/); }); + it('runs only the named migrations with --only', async () => { + const dir = project({ + 'src/Post.tsx': POST, + 'package.json': JSON.stringify({ + dependencies: { '@codecademy/gamut': '^73.0.0' }, + }), + }); + expect( + await main(['scope-swap', dir, '--force', '--only=package-json']) + ).toBe(0); + expect(read(dir, 'src/Post.tsx')).toBe(POST); + expect(read(dir, 'package.json')).toContain('"@skillsoft/gamut"'); + }); + it('returns 1 when the source runner reports errors', async () => { mockRun.mockResolvedValueOnce({ error: 1 }); const dir = project({ 'src/Post.tsx': POST }); diff --git a/packages/gamut-codemods/src/__tests__/lib.test.ts b/packages/gamut-codemods/src/__tests__/lib.test.ts index 5c821d40e1..f4d9cd1b6e 100644 --- a/packages/gamut-codemods/src/__tests__/lib.test.ts +++ b/packages/gamut-codemods/src/__tests__/lib.test.ts @@ -84,12 +84,13 @@ describe('findLeftovers', () => { "moduleNameMapper: { '^@codecademy/gamut(.*)$': 'x' }", "'@skillsoft/eslint-plugin-gamut'", "'@skillsoft/gamut-styles'", + "'@codecademy/gamut-kit'", ].join('\n') ); - expect(findLeftovers([dir], manifest).map((h) => h.line)).toEqual([1]); + expect(findLeftovers([dir], manifest).map((h) => h.line)).toEqual([1, 4]); }); - it('tags every old package the manifest replaces', () => { + it('tags every old package the manifest replaces, including gamut-kit', () => { const dir = tmp(); const old = [ ...Object.keys(manifest.packages), @@ -119,7 +120,7 @@ describe('findLeftovers', () => { it('reads editor config and extensionless scripts', () => { const dir = tmp(); - const line = "require('@codecademy/gamut/package.json');"; + const line = "require('@codecademy/gamut-kit/package.json');"; fs.writeFileSync( path.join(dir, 'sync-deps'), `#!/usr/bin/env node\n${line}` diff --git a/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.input.mdx b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.input.mdx new file mode 100644 index 0000000000..74c89ceda0 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.input.mdx @@ -0,0 +1,16 @@ +import { Canvas, Meta } from '@storybook/blocks'; +import { Box } from '@codecademy/gamut'; +import { theme } from '@codecademy/gamut-styles'; +import * as Stories from './Card.stories'; + + + +# Card + +Built on `@codecademy/gamut`'s Box. + +```tsx +import { Card } from '@codecademy/gamut'; +``` + + diff --git a/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.output.mdx b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.output.mdx new file mode 100644 index 0000000000..9631c38c47 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/story.output.mdx @@ -0,0 +1,16 @@ +import { Canvas, Meta } from '@storybook/blocks'; +import { Box } from '@skillsoft/gamut'; +import { theme } from '@skillsoft/gamut-styles'; +import * as Stories from './Card.stories'; + + + +# Card + +Built on `@codecademy/gamut`'s Box. + +```tsx +import { Card } from '@codecademy/gamut'; +``` + + diff --git a/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/unterminated.input.mdx b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/unterminated.input.mdx new file mode 100644 index 0000000000..db0db1b105 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/unterminated.input.mdx @@ -0,0 +1,4 @@ +import { + Text, + +Some prose that never closes the import. diff --git a/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/unterminated.output.mdx b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/unterminated.output.mdx new file mode 100644 index 0000000000..db0db1b105 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mdx-imports/__testfixtures__/unterminated.output.mdx @@ -0,0 +1,4 @@ +import { + Text, + +Some prose that never closes the import. diff --git a/packages/gamut-codemods/src/migrations/mdx-imports/index.ts b/packages/gamut-codemods/src/migrations/mdx-imports/index.ts new file mode 100644 index 0000000000..f98acf3d56 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mdx-imports/index.ts @@ -0,0 +1,63 @@ +import type { FileMigration } from '../../lib/types'; + +/* + MDX files (Storybook docs, mostly) import from Gamut like any module, but + jscodeshift can't parse MDX. This pulls out the top-level ESM statements + (`import ... from '...'`, `export ... from '...'`) and runs each one + through the preset's source migrations, so stories get the same scope + rename, Video split, and deep-import rewrites as .tsx files. + + Fenced code blocks are left alone. They're documentation, and a code + sample that still says @codecademy is the leftovers report's job. +*/ + +const FENCE = /^\s*(```|~~~)/; +const STATEMENT_START = /^(import\s|export\s+(\*|\{|type\s+\{))/; +const STATEMENT_END = + /(from\s*(['"])[^'"\n]*\2|^import\s*(['"])[^'"\n]*\3)\s*;?\s*$/; +/* A statement that runs longer than this isn't one we understand. */ +const MAX_STATEMENT_LINES = 50; + +export const mdxImports: FileMigration = { + name: 'mdx-imports', + kind: 'file', + description: + 'Run the source migrations over import/export statements in .mdx files.', + match: (file) => file.endsWith('.mdx'), + run({ source, transformSource }) { + const lines = source.split('\n'); + const out: string[] = []; + let inFence = false; + let changed = false; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (FENCE.test(line)) inFence = !inFence; + if (inFence || !STATEMENT_START.test(line)) { + out.push(line); + continue; + } + + let end = i; + while ( + end < lines.length - 1 && + end - i < MAX_STATEMENT_LINES && + !STATEMENT_END.test(lines.slice(i, end + 1).join('\n')) + ) { + end += 1; + } + const statement = lines.slice(i, end + 1).join('\n'); + if (!STATEMENT_END.test(statement)) { + out.push(line); + continue; + } + + const next = transformSource(statement); + if (next !== statement) changed = true; + out.push(next); + i = end; + } + + return changed ? out.join('\n') : null; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.input.ts b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.input.ts new file mode 100644 index 0000000000..04847f011a --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.input.ts @@ -0,0 +1,11 @@ +const shared = { + '@codecademy/gamut': { singleton: true, requiredVersion: '^73.0.0' }, + '@codecademy/gamut-kit': { singleton: true }, +}; +export const config = { shared }; +export const inline = { + shared: { + '@codecademy/gamut-kit': { eager: true }, + '@codecademy/gamut': { singleton: true }, + }, +}; diff --git a/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.output.ts b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.output.ts new file mode 100644 index 0000000000..e3154ac7d5 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/already-shared.output.ts @@ -0,0 +1,19 @@ +const shared = { + '@codecademy/gamut': { singleton: true, requiredVersion: '^73.0.0' }, + '@codecademy/gamut-icons': { singleton: true }, + '@codecademy/gamut-illustrations': { singleton: true }, + '@codecademy/gamut-patterns': { singleton: true }, + '@codecademy/gamut-styles': { singleton: true }, + '@codecademy/variance': { singleton: true }, +}; +export const config = { shared }; +export const inline = { + shared: { + '@codecademy/gamut-icons': { eager: true }, + '@codecademy/gamut-illustrations': { eager: true }, + '@codecademy/gamut-patterns': { eager: true }, + '@codecademy/gamut-styles': { eager: true }, + '@codecademy/variance': { eager: true }, + '@codecademy/gamut': { singleton: true }, + }, +}; diff --git a/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/computed-key.input.ts b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/computed-key.input.ts new file mode 100644 index 0000000000..0d890b8874 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/computed-key.input.ts @@ -0,0 +1,5 @@ +const key = '@codecademy/gamut'; +export const shared = { + [key]: { singleton: true }, + 123: { singleton: true }, +}; diff --git a/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/computed-key.output.ts b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/computed-key.output.ts new file mode 100644 index 0000000000..0d890b8874 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/computed-key.output.ts @@ -0,0 +1,5 @@ +const key = '@codecademy/gamut'; +export const shared = { + [key]: { singleton: true }, + 123: { singleton: true }, +}; diff --git a/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.input.ts b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.input.ts new file mode 100644 index 0000000000..caf4b09e6d --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.input.ts @@ -0,0 +1,11 @@ +export default { + plugins: [ + new ModuleFederationPlugin({ + shared: { + react: { singleton: true }, + '@codecademy/gamut-kit': { singleton: true }, + '@emotion/react': { singleton: true }, + }, + }), + ], +}; diff --git a/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.output.ts b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.output.ts new file mode 100644 index 0000000000..4c70a53c17 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.output.ts @@ -0,0 +1,16 @@ +export default { + plugins: [ + new ModuleFederationPlugin({ + shared: { + react: { singleton: true }, + '@codecademy/gamut': { singleton: true }, + '@codecademy/gamut-icons': { singleton: true }, + '@codecademy/gamut-illustrations': { singleton: true }, + '@codecademy/gamut-patterns': { singleton: true }, + '@codecademy/gamut-styles': { singleton: true }, + '@codecademy/variance': { singleton: true }, + '@emotion/react': { singleton: true }, + }, + }), + ], +}; diff --git a/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.warnings.json b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.warnings.json new file mode 100644 index 0000000000..cc4b2bc076 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/__testfixtures__/kit.warnings.json @@ -0,0 +1 @@ +["replaced '@codecademy/gamut-kit' in Module Federation shared config"] diff --git a/packages/gamut-codemods/src/migrations/mf-shared/index.ts b/packages/gamut-codemods/src/migrations/mf-shared/index.ts new file mode 100644 index 0000000000..9d3e677821 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/mf-shared/index.ts @@ -0,0 +1,112 @@ +import { isStringLiteral } from '../../lib/module-strings'; +import type { Edit, SpliceMigration } from '../../lib/types'; + +/* + Module Federation `shared` config that names a removed package: + + shared: { '@codecademy/gamut-kit': { singleton: true } } + becomes + shared: { + '@codecademy/gamut': { singleton: true }, + '@codecademy/gamut-styles': { singleton: true }, + ... + } + + Handles `const shared = { ... }` the same way. + + MF matches shared entries by import name, and code imports the individual + packages, not the kit. So the old entry never deduped anything, and after + this Gamut really is a singleton across host and remotes. That's a runtime + change, hence the warning and checklist item. scope-rename runs after this + and swaps the scope on the new keys. +*/ + +const keyName = (key: any): string | null => { + if (!key) return null; + if (key.type === 'Identifier') return key.name; + if (isStringLiteral(key)) return key.value; + return null; +}; + +/* + The object in `shared: { ... }` (inline plugin options) or + `const shared = { ... }` (declared, then passed in). +*/ +const sharedObject = (node: any) => { + if ( + (node.type === 'ObjectProperty' || node.type === 'Property') && + keyName(node.key) === 'shared' && + node.value?.type === 'ObjectExpression' + ) { + return node.value; + } + if ( + node.type === 'VariableDeclarator' && + keyName(node.id) === 'shared' && + node.init?.type === 'ObjectExpression' + ) { + return node.init; + } + return null; +}; + +export const mfShared: SpliceMigration = { + name: 'mf-shared', + kind: 'splice', + description: + 'Replace removed packages in Module Federation `shared` config with the packages they bundled.', + edits({ j, root, source, manifest, warn, note }) { + const edits: Edit[] = []; + + root + .find(j.Node) + .filter((p) => sharedObject(p.node) !== null) + .forEach((p) => { + const shared = sharedObject(p.node); + const existing = new Set( + shared.properties.map((prop: any) => keyName(prop.key)) + ); + + for (const prop of shared.properties) { + const removed = manifest.removedPackages[keyName(prop.key) ?? '']; + if (!removed) continue; + + const lineStart = source.lastIndexOf('\n', prop.start) + 1; + const before = source.slice(lineStart, prop.start); + /* One entry per line if the old one had its own line, else inline. */ + const ownLine = /^\s*$/.test(before); + const separator = ownLine ? `,\n${before}` : ', '; + const value = source.slice(prop.value.start, prop.value.end); + const additions = removed.mfSharedAs.filter( + (pkg) => !existing.has(pkg) + ); + const text = additions + .map((pkg) => `'${pkg}': ${value}`) + .join(separator); + + /* Nothing to add: drop the entry and the comma that follows it. */ + const end = + text || source[prop.end] !== ',' + ? prop.end + : prop.end + + 1 + + (source.slice(prop.end + 1).match(/^\s*/)?.[0].length ?? 0); + edits.push({ + start: text || !ownLine ? prop.start : lineStart, + end, + text, + }); + + warn( + prop, + `replaced '${keyName( + prop.key + )}' in Module Federation shared config. Gamut is now actually shared as a singleton; test the host and remotes together.` + ); + note('mf-shared'); + } + }); + + return edits; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.input.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.input.json new file mode 100644 index 0000000000..a332e52190 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.input.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "dependencies": { + "@codecademy/gamut-kit": "3.0.25", + "@skillsoft/gamut": "https://pkg.pr.new/@skillsoft/gamut@28", + "@skillsoft/gamut-icons": "https://pkg.pr.new/@skillsoft/gamut-icons@28", + "@codecademy/gamut-styles": "21.2.0" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.output.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.output.json new file mode 100644 index 0000000000..e51edd4454 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/already-renamed.output.json @@ -0,0 +1,12 @@ +{ + "name": "app", + "dependencies": { + "@skillsoft/gamut": "https://pkg.pr.new/@skillsoft/gamut@28", + "@skillsoft/gamut-icons": "https://pkg.pr.new/@skillsoft/gamut-icons@28", + "@skillsoft/gamut-illustrations": "^{{version}}", + "@skillsoft/gamut-patterns": "^{{version}}", + "@skillsoft/gamut-styles": "^{{version}}", + "@skillsoft/gamut-tests": "^{{version}}", + "@skillsoft/variance": "^{{version}}" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.input.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.input.json new file mode 100644 index 0000000000..1ad0d5bf74 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.input.json @@ -0,0 +1,11 @@ +{ + "name": "app", + "dependencies": { + "@codecademy/gamut": "73.6.1", + "@codecademy/gamut-kit": "3.0.24", + "react": "^19.0.0" + }, + "devDependencies": { + "eslint-plugin-gamut": "3.1.0" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.output.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.output.json new file mode 100644 index 0000000000..779450d73c --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.output.json @@ -0,0 +1,16 @@ +{ + "name": "app", + "dependencies": { + "@skillsoft/gamut": "^{{version}}", + "@skillsoft/gamut-icons": "^{{version}}", + "@skillsoft/gamut-illustrations": "^{{version}}", + "@skillsoft/gamut-patterns": "^{{version}}", + "@skillsoft/gamut-styles": "^{{version}}", + "@skillsoft/gamut-tests": "^{{version}}", + "@skillsoft/variance": "^{{version}}", + "react": "^19.0.0" + }, + "devDependencies": { + "@skillsoft/eslint-plugin-gamut": "^0.0.1" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.warnings.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.warnings.json new file mode 100644 index 0000000000..47a5eebf34 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/kit.warnings.json @@ -0,0 +1 @@ +["replaced '@codecademy/gamut-kit'"] diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.input.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.input.json new file mode 100644 index 0000000000..23e11d6c43 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.input.json @@ -0,0 +1,7 @@ +{ + "name": "app", + "dependencies": { + "@codecademy/gamut": "74.0.0", + "@codecademy/gamut-styles": "^21.2.0" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.output.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.output.json new file mode 100644 index 0000000000..780d3a617b --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.output.json @@ -0,0 +1,7 @@ +{ + "name": "app", + "dependencies": { + "@skillsoft/gamut": "^{{version}}", + "@skillsoft/gamut-styles": "^{{version}}" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.warnings.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.warnings.json new file mode 100644 index 0000000000..9fe9ebdcc7 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/newer-than-source.warnings.json @@ -0,0 +1 @@ +["'@codecademy/gamut@74.0.0' is newer than 73.6.1"] diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.input.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.input.json new file mode 100644 index 0000000000..27dbc10c94 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.input.json @@ -0,0 +1,7 @@ +{ + "name": "lib", + "peerDependencies": { + "@codecademy/gamut": "*", + "@codecademy/gamut-styles": "^21.0.0" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.output.json b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.output.json new file mode 100644 index 0000000000..e4ecfdfaf1 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/__testfixtures__/peers.output.json @@ -0,0 +1,7 @@ +{ + "name": "lib", + "peerDependencies": { + "@skillsoft/gamut": "*", + "@skillsoft/gamut-styles": "^{{version}}" + } +} diff --git a/packages/gamut-codemods/src/migrations/package-json/index.ts b/packages/gamut-codemods/src/migrations/package-json/index.ts new file mode 100644 index 0000000000..24c14cb656 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/package-json/index.ts @@ -0,0 +1,95 @@ +import { isNewerThan } from '../../lib/manifest'; +import type { FileMigration } from '../../lib/types'; + +/* + Renames dependencies in package.json and pins them to + manifest.targetVersions. Removed packages are replaced by the packages + they bundled. Only touched fields are re-sorted; indentation and the + trailing newline are kept. + + A new package that's already listed keeps its value. People add the new + names by hand before running this, often pointed at a preview tarball, + and overwriting that with a range that isn't published yet breaks + install. A `*` peer range stays `*`: it means "whatever the host has", + and narrowing it is a decision for the package owner. + + A range newer than manifest.sourceVersions gets a warning: the code may + already use APIs that only exist upstream, which no rename can fix. +*/ + +const FIELDS = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +] as const; + +const sortKeys = (obj: Record) => + Object.fromEntries( + Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)) + ); + +const detectIndent = (text: string) => + (text.match(/^[ \t]+(?=")/m) ?? [' '])[0]; + +export const packageJson: FileMigration = { + name: 'package-json', + kind: 'file', + description: 'Rename dependencies in package.json.', + match: (file) => /(^|\/)package\.json$/.test(file), + run({ source, manifest, warn, note }) { + const pkg = JSON.parse(source) as Record; + let changed = false; + + for (const field of FIELDS) { + const deps = pkg[field] as Record | undefined; + if (!deps) continue; + const next = { ...deps }; + let touched = false; + + for (const [removed, { expandTo, note: why }] of Object.entries( + manifest.removedPackages + )) { + if (!(removed in next)) continue; + delete next[removed]; + for (const old of expandTo) { + const renamed = manifest.packages[old]?.to; + if (!(old in next) && !(renamed && renamed in next)) next[old] = '*'; + } + warn( + `${field}: replaced '${removed}' with its individual packages. ${why}` + ); + note(`removed:${removed}`); + touched = true; + } + + for (const [from, { to }] of Object.entries(manifest.packages)) { + if (!(from in next)) continue; + const range = next[from]; + const covered = manifest.sourceVersions[from]; + if (covered && isNewerThan(range, covered)) { + warn( + `${field}: '${from}@${range}' is newer than ${covered}, the last release ${to} matches. Code written against the newer release may not type-check until ${to} catches up.` + ); + note('newer-than-source'); + } + delete next[from]; + touched = true; + if (to in next) continue; + next[to] = + field === 'peerDependencies' && range === '*' + ? '*' + : manifest.targetVersions[to]; + } + + if (touched) { + pkg[field] = sortKeys(next); + changed = true; + } + } + + if (!changed) return null; + const trailing = source.endsWith('\n') ? '\n' : ''; + return JSON.stringify(pkg, null, detectIndent(source)) + trailing; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/comments.input.tsx b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/comments.input.tsx new file mode 100644 index 0000000000..bc44b7f3c0 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/comments.input.tsx @@ -0,0 +1,12 @@ +/** + * @deprecated + * @see `Disclosure` and `List` from `@codecademy/gamut` + * @example + * import { FillButton } from '@codecademy/gamut'; + * import { theme } from '@codecademy/gamut-styles'; + */ +export const Old = () => null; + +// Built on @codecademy/gamut-kit and @codecademy/gamut/dist/Box/props, see eslint-plugin-gamut. +// Already moved: @skillsoft/gamut and @skillsoft/eslint-plugin-gamut. +export const el =
{/* from @codecademy/variance */}
; diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/comments.output.tsx b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/comments.output.tsx new file mode 100644 index 0000000000..d1f4e01a73 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/comments.output.tsx @@ -0,0 +1,12 @@ +/** + * @deprecated + * @see `Disclosure` and `List` from `@skillsoft/gamut` + * @example + * import { FillButton } from '@skillsoft/gamut'; + * import { theme } from '@skillsoft/gamut-styles'; + */ +export const Old = () => null; + +// Built on @codecademy/gamut-kit and @codecademy/gamut/dist/Box/props, see @skillsoft/eslint-plugin-gamut. +// Already moved: @skillsoft/gamut and @skillsoft/eslint-plugin-gamut. +export const el =
{/* from @skillsoft/variance */}
; diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.input.tsx b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.input.tsx new file mode 100644 index 0000000000..db77a7d96b --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.input.tsx @@ -0,0 +1,20 @@ +import { Box } from '@codecademy/gamut'; +import { theme } from '@codecademy/gamut-styles'; +import { Video } from '@codecademy/gamut/Video'; +import { setupRtl } from '@codecademy/gamut-tests'; +import type { CoreTheme } from '@codecademy/gamut-styles'; +import { Kit } from '@codecademy/gamut-kit'; +import { Other } from '@codecademy/gamut-extra-thing'; + +export * from '@codecademy/gamut-icons'; + +declare module '@codecademy/gamut-styles' { + interface Theme extends CoreTheme {} +} + +jest.mock('@codecademy/variance'); +const lazy = () => import('@codecademy/gamut-patterns'); +const req = require('@codecademy/gamut-illustrations'); +type T = import('@codecademy/gamut').BoxProps; + +export const transpilePackages = ['@codecademy/gamut']; diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.output.tsx b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.output.tsx new file mode 100644 index 0000000000..dca4d84c5d --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.output.tsx @@ -0,0 +1,20 @@ +import { Box } from '@skillsoft/gamut'; +import { theme } from '@skillsoft/gamut-styles'; +import { Video } from '@skillsoft/gamut/Video'; +import { setupRtl } from '@skillsoft/gamut-tests'; +import type { CoreTheme } from '@skillsoft/gamut-styles'; +import { Kit } from '@codecademy/gamut-kit'; +import { Other } from '@codecademy/gamut-extra-thing'; + +export * from '@skillsoft/gamut-icons'; + +declare module '@skillsoft/gamut-styles' { + interface Theme extends CoreTheme {} +} + +jest.mock('@skillsoft/variance'); +const lazy = () => import('@skillsoft/gamut-patterns'); +const req = require('@skillsoft/gamut-illustrations'); +type T = import('@skillsoft/gamut').BoxProps; + +export const transpilePackages = ['@skillsoft/gamut']; diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.warnings.json b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.warnings.json new file mode 100644 index 0000000000..158b86d60b --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/everywhere.warnings.json @@ -0,0 +1 @@ +["'@codecademy/gamut-kit' has no replacement package", "renamed 1 package-name string(s) outside imports (@codecademy/gamut)"] diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.input.tsx b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.input.tsx new file mode 100644 index 0000000000..5570545ab3 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.input.tsx @@ -0,0 +1,13 @@ +import { Box } from '@codecademy/gamut' + +jest.mock('./Card', () => { + return ({ title, description }: any) => ( +
+ {title} - {description} +
+ ) +}) + +jest.mock("@codecademy/gamut-icons", () => ({ + AiEditSparkIcon: () =>
AI Icon
, +})) diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.output.tsx b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.output.tsx new file mode 100644 index 0000000000..fac3c38590 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/no-semicolons.output.tsx @@ -0,0 +1,13 @@ +import { Box } from '@skillsoft/gamut' + +jest.mock('./Card', () => { + return ({ title, description }: any) => ( +
+ {title} - {description} +
+ ) +}) + +jest.mock("@skillsoft/gamut-icons", () => ({ + AiEditSparkIcon: () =>
AI Icon
, +})) diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/require-forms.input.ts b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/require-forms.input.ts new file mode 100644 index 0000000000..e3383b9181 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/require-forms.input.ts @@ -0,0 +1,3 @@ +import gamut = require('@codecademy/gamut'); +export const resolved = require.resolve('@codecademy/gamut/Video'); +export { gamut }; diff --git a/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/require-forms.output.ts b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/require-forms.output.ts new file mode 100644 index 0000000000..885aecb532 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/__testfixtures__/require-forms.output.ts @@ -0,0 +1,3 @@ +import gamut = require('@skillsoft/gamut'); +export const resolved = require.resolve('@skillsoft/gamut/Video'); +export { gamut }; diff --git a/packages/gamut-codemods/src/migrations/scope-rename/index.ts b/packages/gamut-codemods/src/migrations/scope-rename/index.ts new file mode 100644 index 0000000000..0c29583d10 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/scope-rename/index.ts @@ -0,0 +1,109 @@ +import { removedPackageFor, renameSpecifier } from '../../lib/manifest'; +import type { StringNode } from '../../lib/module-strings'; +import { findModuleStrings, isStringLiteral } from '../../lib/module-strings'; +import type { Edit, SpliceMigration } from '../../lib/types'; + +/* + Swaps old package names for new ones in every module position: imports, + exports, require, mocks, `declare module`, import types. Runs last, since + every other migration is written against the old names. + + Any other string that exactly names an old package (Next's + transpilePackages, Module Federation shared keys, lint ignore lists) is + rewritten too. Across mono, platform, front, and talent-intelligence every + one of those was a correct rename, so they get one warning per file + asking for a look at the diff, not one per string. + + This is a splice migration: it edits the text inside the quotes and never + asks recast to print. Mutating even one literal's value made recast + reprint neighbouring statements in semicolon-less files, including JSX + text whitespace. + + Package names in comments (JSDoc `@see`, usage examples) are renamed as + well. `/dist` paths and removed packages are left for the leftovers + report, since there's no name that's right for them. +*/ + +interface CommentNode { + value: string; + start: number; + end: number; +} + +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); + +/* Old names, not part of a longer name and not followed by `/dist`. */ +const commentPattern = (from: string[]) => + new RegExp( + `(? b.length - a.length) + .map(escape) + .join('|')})(?![\\w-])(?!/dist(?![\\w-]))`, + 'g' + ); + +export const scopeRename: SpliceMigration = { + name: 'scope-rename', + kind: 'splice', + description: 'Swap old package specifiers for their new names.', + edits({ j, root, manifest, warn }) { + const edits: Edit[] = []; + const moduleStrings = new Set(findModuleStrings(j, root)); + const outsideImports: string[] = []; + + const visit = (literal: StringNode, inModulePosition: boolean) => { + const removed = removedPackageFor(manifest, literal.value); + if (removed) { + warn( + literal, + `'${removed}' has no replacement package. ${manifest.removedPackages[removed].note}` + ); + return; + } + const next = renameSpecifier(manifest, literal.value); + if (!next) return; + if (!inModulePosition) outsideImports.push(literal.value); + /* Inside the quotes only, so the file's quote style survives. */ + edits.push({ + start: literal.start + 1, + end: literal.end - 1, + text: next, + }); + }; + + for (const literal of moduleStrings) visit(literal, true); + + root + .find(j.Node) + .filter((p) => isStringLiteral(p.node) && !moduleStrings.has(p.node)) + .forEach((p) => visit(p.node as StringNode, false)); + + const pattern = commentPattern(Object.keys(manifest.packages)); + const seen = new Set(); + /* j.Comment paths point at the owning node, so walk node.comments. */ + root.find(j.Node).forEach((p) => { + const comments = (p.node as { comments?: CommentNode[] }).comments ?? []; + for (const comment of comments) { + if (seen.has(comment.start)) continue; + seen.add(comment.start); + for (const match of comment.value.matchAll(pattern)) { + const next = manifest.packages[match[0]]?.to; + if (!next) continue; + /* +2 skips the `//` or `/*` that `value` leaves out. */ + const start = comment.start + 2 + (match.index ?? 0); + edits.push({ start, end: start + match[0].length, text: next }); + } + } + }); + + if (outsideImports.length > 0) { + const names = [...new Set(outsideImports)].join(', '); + warn( + null, + `renamed ${outsideImports.length} package-name string(s) outside imports (${names}). Check the diff.` + ); + } + + return edits; + }, +}; diff --git a/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.input.yml b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.input.yml new file mode 100644 index 0000000000..6452a33ad1 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.input.yml @@ -0,0 +1,2 @@ +npmPreapprovedPackages: + - eslint-plugin-gamut # lint rules diff --git a/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.output.yml b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.output.yml new file mode 100644 index 0000000000..7a6d8c80bf --- /dev/null +++ b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/no-glob.output.yml @@ -0,0 +1,2 @@ +npmPreapprovedPackages: + - '@skillsoft/eslint-plugin-gamut' # lint rules diff --git a/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.input.yml b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.input.yml new file mode 100644 index 0000000000..8c251e0841 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.input.yml @@ -0,0 +1,10 @@ +npmPreapprovedPackages: + - '@skillsoft/*' + - 'eslint-plugin-gamut' + - "@codecademy/gamut-kit" + - react + +packageExtensions: + '@codecademy/gamut@*': + peerDependencies: + react: '*' diff --git a/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.output.yml b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.output.yml new file mode 100644 index 0000000000..0d09a98e03 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/yarnrc/__testfixtures__/preapproved.output.yml @@ -0,0 +1,9 @@ +npmPreapprovedPackages: + - '@skillsoft/*' + - "@codecademy/gamut-kit" + - react + +packageExtensions: + '@codecademy/gamut@*': + peerDependencies: + react: '*' diff --git a/packages/gamut-codemods/src/migrations/yarnrc/index.ts b/packages/gamut-codemods/src/migrations/yarnrc/index.ts new file mode 100644 index 0000000000..34074ee969 --- /dev/null +++ b/packages/gamut-codemods/src/migrations/yarnrc/index.ts @@ -0,0 +1,45 @@ +import { renameSpecifier } from '../../lib/manifest'; +import type { FileMigration } from '../../lib/types'; + +/* + Package names listed in .yarnrc.yml, like npmPreapprovedPackages: + + npmPreapprovedPackages: + - '@skillsoft/*' + - 'eslint-plugin-gamut' + + An old name is renamed, or dropped if a scope glob in the same file + (`'@skillsoft/*'`) already covers the new one. Only exact list items are + touched; anything more involved shows up in the leftovers report. +*/ + +const LIST_ITEM = /^(\s*-\s*)(['"]?)([^'"\s#]+)\2(\s*(#.*)?)$/; + +export const yarnrc: FileMigration = { + name: 'yarnrc', + kind: 'file', + description: 'Rename or drop old package names in .yarnrc.yml lists.', + match: (file) => /(^|\/)\.yarnrc\.yml$/.test(file), + run({ source, manifest }) { + const lines = source.split('\n'); + const globs = lines + .map((line) => LIST_ITEM.exec(line)?.[3]) + .filter((value): value is string => !!value?.endsWith('/*')) + .map((glob) => glob.slice(0, -1)); + + let changed = false; + const out = lines.flatMap((line) => { + const match = LIST_ITEM.exec(line); + const next = match && renameSpecifier(manifest, match[3]); + if (!match || !next) return [line]; + changed = true; + if (globs.some((prefix) => next.startsWith(prefix))) return []; + const [, lead, quote, , trail] = match; + /* `@` is reserved in YAML, so a plain scalar can't start with it. */ + const q = quote || (next.startsWith('@') ? "'" : ''); + return [`${lead}${q}${next}${q}${trail}`]; + }); + + return changed ? out.join('\n') : null; + }, +}; diff --git a/packages/gamut-codemods/src/presets/scope-swap/index.ts b/packages/gamut-codemods/src/presets/scope-swap/index.ts index 69f1682399..3f5d5d99ba 100644 --- a/packages/gamut-codemods/src/presets/scope-swap/index.ts +++ b/packages/gamut-codemods/src/presets/scope-swap/index.ts @@ -1,15 +1,34 @@ import type { Preset } from '../../lib/types'; +import { mdxImports } from '../../migrations/mdx-imports'; +import { mfShared } from '../../migrations/mf-shared'; +import { packageJson } from '../../migrations/package-json'; +import { scopeRename } from '../../migrations/scope-rename'; +import { yarnrc } from '../../migrations/yarnrc'; import { manifest } from './manifest'; export const scopeSwap: Preset = { name: 'scope-swap', description: 'Move from @codecademy/gamut* to @skillsoft/gamut*.', manifest, - migrations: [], + migrations: [ + mfShared, + /* Last: everything above matches on @codecademy names. */ + scopeRename, + packageJson, + mdxImports, + yarnrc, + ], checklist: [ 'Run your formatter and lint autofix (prettier --write, then eslint --fix). Split imports come out in recast style, `@skillsoft` sorts differently from `@codecademy` for import-order rules like simple-import-sort, and the shorter scope lets some wrapped imports fit on one line.', 'Reinstall (yarn), then type-check and run your tests.', 'Work through the warnings above and the leftovers list.', ], - conditionalChecklist: {}, + conditionalChecklist: { + 'removed:@codecademy/gamut-kit': + "gamut-kit is gone. Delete anything that reads it for versions, such as a sync-gamut-deps script. @skillsoft/gamut-styles has @emotion/cache and stylis peer dependencies, so add them if yarn warns that they aren't provided.", + 'newer-than-source': + 'Some @codecademy packages were on a newer release than @skillsoft has caught up to (see the package-json warnings). Expect type errors wherever the code uses what that release added, and check the upstream CHANGELOG for it.', + 'mf-shared': + "Module Federation shared config changed. Gamut is now a real singleton: run the host and every remote together, and update the remotes' shared config to match.", + }, }; diff --git a/packages/gamut-codemods/src/presets/scope-swap/manifest.ts b/packages/gamut-codemods/src/presets/scope-swap/manifest.ts index 6802bda554..6605d88983 100644 --- a/packages/gamut-codemods/src/presets/scope-swap/manifest.ts +++ b/packages/gamut-codemods/src/presets/scope-swap/manifest.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; + import type { Manifest } from '../../lib/manifest'; /* @@ -9,6 +12,16 @@ import type { Manifest } from '../../lib/manifest'; built packages, so a row can't reference an export that doesn't exist. */ +/* + This package is in the changesets `fixed` group with the packages it + migrates to, so its own version is theirs. +*/ +const { version } = JSON.parse( + fs.readFileSync(path.join(__dirname, '../../../package.json'), 'utf8') +) as { version: string }; + +const GROUP_RANGE = `^${version}`; + export const manifest: Manifest = { packages: { '@codecademy/gamut': { to: '@skillsoft/gamut' }, @@ -21,11 +34,60 @@ export const manifest: Manifest = { 'eslint-plugin-gamut': { to: '@skillsoft/eslint-plugin-gamut' }, }, - removedPackages: {}, + removedPackages: { + '@codecademy/gamut-kit': { + expandTo: [ + '@codecademy/gamut', + '@codecademy/gamut-icons', + '@codecademy/gamut-illustrations', + '@codecademy/gamut-patterns', + '@codecademy/gamut-styles', + '@codecademy/gamut-tests', + '@codecademy/variance', + ], + /* gamut-tests is test-only, so it has no business in a runtime share. */ + mfSharedAs: [ + '@codecademy/gamut', + '@codecademy/gamut-icons', + '@codecademy/gamut-illustrations', + '@codecademy/gamut-patterns', + '@codecademy/gamut-styles', + '@codecademy/variance', + ], + note: 'gamut-kit is not published under @skillsoft. Depend on the individual packages instead.', + }, + }, - targetVersions: {}, + targetVersions: { + '@skillsoft/gamut': GROUP_RANGE, + '@skillsoft/gamut-styles': GROUP_RANGE, + '@skillsoft/gamut-icons': GROUP_RANGE, + '@skillsoft/gamut-illustrations': GROUP_RANGE, + '@skillsoft/gamut-patterns': GROUP_RANGE, + '@skillsoft/gamut-tests': GROUP_RANGE, + '@skillsoft/variance': GROUP_RANGE, + /* + Versioned outside the fixed group. manifest.test.ts fails if this + falls behind packages/eslint-plugin-gamut/package.json. + */ + '@skillsoft/eslint-plugin-gamut': '^0.0.1', + }, - sourceVersions: {}, + /* + The upstream @codecademy release each package was forked from (the top + of its CHANGELOG.md before any @skillsoft entries). Bump these when + upstream changes are synced in. + */ + sourceVersions: { + '@codecademy/gamut': '73.6.1', + '@codecademy/gamut-styles': '21.2.0', + '@codecademy/gamut-icons': '10.2.0', + '@codecademy/gamut-illustrations': '1.1.0', + '@codecademy/gamut-patterns': '1.1.0', + '@codecademy/gamut-tests': '7.1.0', + '@codecademy/variance': '1.1.0', + 'eslint-plugin-gamut': '3.1.0', + }, movedExports: [], From b3a27cdacc39fb915e3c03dda0b0aead70c724fb Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 20 Oct 2026 09:51:13 -0400 Subject: [PATCH 03/16] feat(gamut-codemods): move Video imports and add Markdown video overrides moved-exports moves Video and VideoProps from the root to @codecademy/gamut/Video (scope-rename then swaps the scope). markdown-video-overrides adds explicit iframeOverride/videoOverride to each , since it no longer renders video through Video by default, and drops the removed skipDefaultOverrides keys. tsconfig-dom warns about tsconfigs without "dom", which Video used to supply. On the Gamut side, a Video/package.json stub makes the subpath resolve under node10, MarkdownVideo no longer passes NaN sizes, and a one-line