From 81662eda65262093f31055cdeca13cd730592ddf Mon Sep 17 00:00:00 2001 From: Peter Pal Hudak Date: Mon, 13 Apr 2026 15:17:17 +0200 Subject: [PATCH 1/2] feat(ui-codemods): add codemod to support versioned import updates INSTUI-4954 --- docs/guides/upgrade-guide.md | 75 ++++- packages/ui-codemods/.gitignore | 1 + .../updateInstUIImportVersions.test.ts | 236 ++++++++++++++++ packages/ui-codemods/lib/index.ts | 4 +- .../lib/updateInstUIImportVersions.ts | 261 ++++++++++++++++++ .../ui-codemods/lib/utils/codemodHelpers.ts | 1 + .../lib/utils/instUICodemodExecutor.ts | 8 +- packages/ui-codemods/package.json | 4 +- .../scripts/generateVersionedExports.ts | 131 +++++++++ scripts/bootstrap.js | 1 + 10 files changed, 707 insertions(+), 15 deletions(-) create mode 100644 packages/ui-codemods/lib/__node_tests__/updateInstUIImportVersions.test.ts create mode 100644 packages/ui-codemods/lib/updateInstUIImportVersions.ts create mode 100644 packages/ui-codemods/scripts/generateVersionedExports.ts diff --git a/docs/guides/upgrade-guide.md b/docs/guides/upgrade-guide.md index 83cfd153fc..cf2fca772d 100644 --- a/docs/guides/upgrade-guide.md +++ b/docs/guides/upgrade-guide.md @@ -1371,25 +1371,78 @@ type: embed ## Codemods -To ease the upgrade, we provide codemods that will automate most of the changes. Pay close attention to its output, it cannot refactor complex code! The codemod scripts can be run via the following commands: +### updateInstUIImportVersions + +Rewrites `@instructure/*` import paths to a specific version subpath (e.g. `@instructure/ui-buttons/v11_7`). Only touches known versioned components — utilities and non-versioned symbols are left untouched. Imports mixing versioned components with other symbols are automatically split into two declarations. ```sh --- type: code --- -npm install @instructure/ui-codemods@12 -npx jscodeshift@17.3.0 -t node_modules/@instructure/ui-codemods/lib/instUIv12Codemods.ts --usePrettier=false +npm install @instructure/ui-codemods ``` -where `` is the path to the directory (and its subdirectories) to be transformed. +It is worth adding `--extensions=ts,tsx` for TypeScript codebases. Also exclude `node_modules` and declaration files to avoid false matches: + +```sh +--- +type: code +--- +npx jscodeshift@latest ... \ + --extensions=ts,tsx \ + --ignore-pattern="**/node_modules/**" \ + --ignore-pattern="**/*.d.ts" +``` -The codemods that will do the following: +**Diagnose** — inspect which components are imported and at what version, without modifying files: -- TODO add details -- TODO +```sh +--- +type: code +--- +# inspect all versioned imports +npx jscodeshift@latest -t node_modules/@instructure/ui-codemods/lib/updateInstUIImportVersions.ts \ + --extensions=ts,tsx \ + --ignore-pattern="**/node_modules/**" --ignore-pattern="**/*.d.ts" \ + --diagnose=true + +# inspect only specific components +npx jscodeshift@latest -t node_modules/@instructure/ui-codemods/lib/updateInstUIImportVersions.ts \ + --extensions=ts,tsx \ + --ignore-pattern="**/node_modules/**" --ignore-pattern="**/*.d.ts" \ + --diagnose=true --components=Button,Alert +``` + +**Update** — rewrite import paths: + +```sh +--- +type: code +--- +# all versioned components → v11.7 +npx jscodeshift@latest -t node_modules/@instructure/ui-codemods/lib/updateInstUIImportVersions.ts \ + --extensions=ts,tsx \ + --ignore-pattern="**/node_modules/**" --ignore-pattern="**/*.d.ts" \ + --versionTo=v11.7 + +# only imports already pinned to v11.6 +npx jscodeshift@latest -t node_modules/@instructure/ui-codemods/lib/updateInstUIImportVersions.ts \ + --extensions=ts,tsx \ + --ignore-pattern="**/node_modules/**" --ignore-pattern="**/*.d.ts" \ + --versionFrom=v11.6 --versionTo=v11.7 + +# specific components only +npx jscodeshift@latest -t node_modules/@instructure/ui-codemods/lib/updateInstUIImportVersions.ts \ + --extensions=ts,tsx \ + --ignore-pattern="**/node_modules/**" --ignore-pattern="**/*.d.ts" \ + --versionTo=v11.7 --components=Button,Alert +``` -Options for the codemod: +Options: -- `filename`: if specified then emitted warnings are written to this file. -- `usePrettier`: if `true` the transformed code will be run through Prettier. You can customize this through a [Prettier - config file](https://prettier.io/docs/configuration.html) +- `versionTo`: target version, e.g. `v11.7`. Required for update mode. +- `versionFrom`: only rewrite imports already pinned to this version. +- `components`: comma-separated component names. Defaults to all known versioned exports. +- `diagnose`: report matching imports without modifying files. +- `fileName`: write parse errors and transform failures to this file. +- `usePrettier`: run output through Prettier (default `true`). diff --git a/packages/ui-codemods/.gitignore b/packages/ui-codemods/.gitignore index a7c47537dc..d01e61690d 100644 --- a/packages/ui-codemods/.gitignore +++ b/packages/ui-codemods/.gitignore @@ -1 +1,2 @@ types/ +lib/generated/ diff --git a/packages/ui-codemods/lib/__node_tests__/updateInstUIImportVersions.test.ts b/packages/ui-codemods/lib/__node_tests__/updateInstUIImportVersions.test.ts new file mode 100644 index 0000000000..2869674426 --- /dev/null +++ b/packages/ui-codemods/lib/__node_tests__/updateInstUIImportVersions.test.ts @@ -0,0 +1,236 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/// +// eslint-disable-next-line no-restricted-imports +import { runInlineTest } from 'jscodeshift/src/testUtils' +import jscodeshift from 'jscodeshift' +import updateInstUIImportVersions from '../updateInstUIImportVersions' + +const FILE_PATH = 'test.tsx' + +function makeApi(reportFn: (msg: string) => void = () => {}) { + const j = jscodeshift.withParser('tsx') + return { + jscodeshift: j, + j, + stats: () => {}, + report: reportFn + } +} + +function makeFileInfo(source: string) { + return { path: FILE_PATH, source } +} + +describe('updateInstUIImportVersions', () => { + it('rewrites unversioned @instructure import to target version', () => { + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7' }, + makeFileInfo(`import { Button } from '@instructure/ui'`), + `import { Button } from '@instructure/ui/v11_7'` + ) + }) + + it('rewrites versioned import from versionFrom to versionTo', () => { + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7', versionFrom: 'v11.6' }, + makeFileInfo(`import { Button } from '@instructure/ui/v11_6'`), + `import { Button } from '@instructure/ui/v11_7'` + ) + }) + + it('returns null when versionFrom does not match', () => { + const result = updateInstUIImportVersions( + makeFileInfo(`import { Button } from '@instructure/ui/v11_5'`), + makeApi() as Parameters[1], + { versionTo: 'v11.7', versionFrom: 'v11.6' } + ) + expect(result).toBeNull() + }) + + it('rewrites only imports containing the specified component', () => { + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7', components: 'Button' }, + makeFileInfo( + [ + `import { Button } from '@instructure/ui'`, + `import { Alert } from '@instructure/ui-alerts'` + ].join('\n') + ), + // Only the import that contains 'Button' is rewritten + [ + `import { Button } from '@instructure/ui/v11_7'`, + `import { Alert } from '@instructure/ui-alerts'` + ].join('\n') + ) + }) + + it('returns null when no imports match the specified component', () => { + const result = updateInstUIImportVersions( + makeFileInfo(`import { Alert } from '@instructure/ui'`), + makeApi() as Parameters[1], + { versionTo: 'v11.7', components: 'Button' } + ) + expect(result).toBeNull() + }) + + it('returns null for non-@instructure imports', () => { + const result = updateInstUIImportVersions( + makeFileInfo(`import { something } from 'some-lib'`), + makeApi() as Parameters[1], + { versionTo: 'v11.7' } + ) + expect(result).toBeNull() + }) + + it('does not rewrite non-component symbols when no --components given', () => { + // 'withStyle' is not in versionedExports, so it should be skipped + const result = updateInstUIImportVersions( + makeFileInfo(`import { withStyle } from '@instructure/ui'`), + makeApi() as Parameters[1], + { versionTo: 'v11.7' } + ) + expect(result).toBeNull() + }) + + it('splits mixed import: non-component stays at original path, component moves to versioned path', () => { + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7' }, + makeFileInfo(`import {canvas, Button} from "@instructure/ui"`), + // canvas is not in versionedExports, Button is → split into two imports + // (Prettier normalises to single quotes and adds spaces inside braces) + `import { canvas } from '@instructure/ui'\nimport { Button } from '@instructure/ui/v11_7'` + ) + }) + + it('does not add semicolons to split imports when usePrettier=false and file has no semis', () => { + // canvas is not in versionedExports (utility), Button is → triggers a split + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7', usePrettier: false }, + makeFileInfo(`import {canvas, Button} from '@instructure/ui'`), + `import { canvas } from '@instructure/ui'\nimport { Button } from '@instructure/ui/v11_7'` + ) + }) + + it('preserves semicolons in split imports when file uses semis and usePrettier=false', () => { + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7', usePrettier: false }, + makeFileInfo(`import {canvas, Button} from '@instructure/ui';`), + `import { canvas } from '@instructure/ui';\nimport { Button } from '@instructure/ui/v11_7';` + ) + }) + + it('moves type specifier to versioned path when its name is a known component', () => { + // 'type Alert' — inline type modifier, Alert is in versionedExports + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7', components: 'Button,Alert' }, + makeFileInfo(`import { Button, type Alert } from '@instructure/ui'`), + `import { Button, type Alert } from '@instructure/ui/v11_7'` + ) + }) + + it('keeps type-only specifier at original path when name is not in components', () => { + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7', components: 'Button' }, + makeFileInfo( + `import { Button, type ButtonProps } from '@instructure/ui'` + ), + // ButtonProps not in components list → split + `import { type ButtonProps } from '@instructure/ui'\nimport { Button } from '@instructure/ui/v11_7'` + ) + }) + + it('leaves default imports and type-only named imports untouched when not in components', () => { + const result = updateInstUIImportVersions( + makeFileInfo(`import Service, { type Config } from '@instructure/ui'`), + makeApi() as Parameters[1], + { versionTo: 'v11.7' } + ) + // Neither 'Service' (default) nor 'Config' (type, not in versionedExports) match + expect(result).toBeNull() + }) + + it('returns null when versionTo is not provided', () => { + const result = updateInstUIImportVersions( + makeFileInfo(`import { Button } from '@instructure/ui'`), + makeApi() as Parameters[1], + {} + ) + expect(result).toBeNull() + }) + + it('diagnose mode reports matching imports', () => { + const reported: string[] = [] + const api = makeApi((msg: string) => reported.push(msg)) + + updateInstUIImportVersions( + makeFileInfo(`import { Button, Alert } from '@instructure/ui/v11_6'`), + api as Parameters[1], + { diagnose: true } + ) + + expect(reported).toHaveLength(1) + expect(reported[0]).toContain('Button') + expect(reported[0]).toContain('Alert') + expect(reported[0]).toContain('v11_6') + }) + + it('diagnose mode filters by component name', () => { + const reported: string[] = [] + const api = makeApi((msg: string) => reported.push(msg)) + + updateInstUIImportVersions( + makeFileInfo(`import { Button, Alert } from '@instructure/ui/v11_6'`), + api as Parameters[1], + { diagnose: true, components: 'Button' } + ) + + expect(reported).toHaveLength(1) + expect(reported[0]).toContain('Button') + expect(reported[0]).not.toContain('Alert') + }) + + it('diagnose mode shows "oldest" label for unversioned imports', () => { + const reported: string[] = [] + const api = makeApi((msg: string) => reported.push(msg)) + + updateInstUIImportVersions( + makeFileInfo(`import { Button } from '@instructure/ui'`), + api as Parameters[1], + { diagnose: true } + ) + + expect(reported).toHaveLength(1) + expect(reported[0]).toContain('oldest') + }) +}) diff --git a/packages/ui-codemods/lib/index.ts b/packages/ui-codemods/lib/index.ts index 4dae8dd778..166f9b0e2f 100644 --- a/packages/ui-codemods/lib/index.ts +++ b/packages/ui-codemods/lib/index.ts @@ -30,6 +30,7 @@ import renameGetComputedStyleToGetCSSStyleDeclaration from './renameGetComputedS import warnTableCaptionMissing from './warnTableCaptionMissing' import warnCodeEditorRemoved from './warnCodeEditorRemoved' import migrateToNewIcons from './migrateToNewIcons' +import updateInstUIImportVersions from './updateInstUIImportVersions' export { updateV10Breaking, @@ -39,5 +40,6 @@ export { renameGetComputedStyleToGetCSSStyleDeclaration, warnTableCaptionMissing, warnCodeEditorRemoved, - migrateToNewIcons + migrateToNewIcons, + updateInstUIImportVersions } diff --git a/packages/ui-codemods/lib/updateInstUIImportVersions.ts b/packages/ui-codemods/lib/updateInstUIImportVersions.ts new file mode 100644 index 0000000000..82440f2a4f --- /dev/null +++ b/packages/ui-codemods/lib/updateInstUIImportVersions.ts @@ -0,0 +1,261 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { + API, + ASTPath, + Collection, + FileInfo, + ImportDeclaration, + ImportDefaultSpecifier, + ImportNamespaceSpecifier, + ImportSpecifier, + JSCodeshift, + Transform +} from 'jscodeshift' +import type { InstUICodemod } from './utils/instUICodemodExecutor' +import { findImportPath, printWarning } from './utils/codemodHelpers' +import { isImportSpecifier } from './utils/codemodTypeCheckers' +import instUICodemodExecutor from './utils/instUICodemodExecutor' +import versionedExports from './generated/versionedExports' + +type ParsedImportPath = { + base: string + version: string +} +type Specifier = + | ImportSpecifier + | ImportDefaultSpecifier + | ImportNamespaceSpecifier + +function normalizeVersion(raw: string): string { + return raw.replace(/\./g, '_') +} + +function parseImportPath(importPath: string): ParsedImportPath | null { + const match = importPath.match(/^(@instructure\/[^/]+)(\/(.+))?$/) + if (!match) return null + return { base: match[1], version: match[3] ?? '' } +} + +function buildImportPath(packageName: string, version: string): string { + return version ? `${packageName}/${version}` : packageName +} + +function detectQuoteStyle(source: string): 'single' | 'double' { + const match = source.match(/from\s+(['"])@instructure\//) + return match?.[1] === '"' ? 'double' : 'single' +} + +function detectSemi(source: string): boolean { + const match = source.match(/^import\b.*?from\s+['"][^'"]+['"]\s*(;?)\s*$/m) + return match?.[1] === ';' +} + +function stripImportSemis(source: string): string { + return source.replace(/^(import\b.*?from\s+['"][^'"]+['"]\s*);$/gm, '$1') +} + +function parseComponents(raw: unknown): string[] | undefined { + return raw + ? String(raw) + .split(',') + .map((s) => s.trim()) + : undefined +} + +// Diagnose mode: reports all @instructure imports found in the file. +function runDiagnose( + j: JSCodeshift, + root: Collection, + file: FileInfo, + api: API, + components: string[] | undefined +): void { + findImportPath(j, root, '@instructure/', false).forEach( + (path: ASTPath) => { + const importPath = path.node.source.value as string + const parsed = parseImportPath(importPath) + if (!parsed) return + + const matched: string[] = [] + path.node.specifiers?.forEach((specifier: Specifier) => { + if (isMatchingSpecifier(specifier, components)) + matched.push(String(specifier.imported.name)) + }) + + if (matched.length === 0) return + + const line = path.node.loc?.start.line ?? 0 + api.report( + `${file.path}:${line} ${importPath} [${ + parsed.version || 'oldest' + }] { ${matched.join(', ')} }` + ) + } + ) +} + +function isMatchingSpecifier( + s: Specifier, + components: string[] | undefined +): s is ImportSpecifier { + return ( + isImportSpecifier(s) && + (!components || components.includes(String(s.imported.name))) + ) +} + +// Transform mode: rewrites @instructure import paths to the target version. +// When a single import mixes versioned components with unversioned symbols, +// the import is split: unversioned symbols stay at the original path, versioned +// components are moved to a new import at the target path. +const updateInstUIImportVersionsCodemod = + ( + components: string[] | undefined, + versionTo: string, + versionFrom: string | undefined, + report?: (msg: string) => void + ): InstUICodemod => + (j, root, filePath) => { + let hasChanges = false + + findImportPath(j, root, '@instructure/', false).forEach( + (path: ASTPath) => { + const importPath = path.node.source.value as string + const parsed = parseImportPath(importPath) + if (!parsed) return + + if (versionFrom && parsed.version !== versionFrom) return + + const allSpecifiers = path.node.specifiers ?? [] + const matching = allSpecifiers.filter((s) => + isMatchingSpecifier(s, components) + ) + + if (matching.length === 0) return + + const newPath = buildImportPath(parsed.base, versionTo) + if (newPath === importPath) return + + const remaining = allSpecifiers.filter( + (s) => !isMatchingSpecifier(s, components) + ) + + const line = path.node.loc?.start.line ?? 0 + + if (remaining.length === 0) { + // All specifiers match — simple path rewrite + // eslint-disable-next-line no-param-reassign + path.node.source.value = newPath + report?.(`${filePath}:${line} '${importPath}' → '${newPath}'`) + } else { + // Mixed import — split into two declarations: + // keep non-matching specifiers at the original path, + // move matching specifiers to the new versioned path. + // eslint-disable-next-line no-param-reassign + path.node.specifiers = remaining + const newDecl = j.importDeclaration( + matching, + j.stringLiteral(newPath) + ) + newDecl.importKind = path.node.importKind + path.insertAfter(newDecl) + report?.( + `${filePath}:${line} '${importPath}' → (split) '${newPath}'` + ) + } + + hasChanges = true + } + ) + + return hasChanges + } + +const updateInstUIImportVersions: Transform = (file, api, options) => { + // When no --components filter is given, fall back to the full list of known + const components = parseComponents(options.components) ?? [ + ...versionedExports + ] + + if (options.diagnose) { + const j = api.jscodeshift.withParser('tsx') + let root + try { + root = j(file.source) + } catch (e) { + printWarning( + file.path, + undefined, + `Failed to parse file: ${e instanceof Error ? e.message : String(e)}` + ) + return null + } + runDiagnose(j, root, file, api, components) + return null + } + + if (!options.versionTo) return null + + const versionTo = normalizeVersion(String(options.versionTo)) + const versionFrom = options.versionFrom + ? normalizeVersion(String(options.versionFrom)) + : undefined + + const quote = detectQuoteStyle(file.source) + const hasSemi = detectSemi(file.source) + + try { + const result = instUICodemodExecutor( + updateInstUIImportVersionsCodemod( + components, + versionTo, + versionFrom, + api.report + ), + file, + api, + { ...options, quote } + ) + // jscodeshift's pretty-printer adds semicolons to import statements by + // default, but we want to preserve the original style when not using + // Prettier. If Prettier is enabled, we assume it will handle semicolons + // according to the user's configuration. + if (result && !hasSemi && options.usePrettier === false) { + return stripImportSemis(result) + } + return result + } catch (e) { + printWarning( + file.path, + undefined, + `Transform failed: ${e instanceof Error ? e.message : String(e)}` + ) + throw e + } +} + +export default updateInstUIImportVersions +export { updateInstUIImportVersionsCodemod } diff --git a/packages/ui-codemods/lib/utils/codemodHelpers.ts b/packages/ui-codemods/lib/utils/codemodHelpers.ts index 9998d36223..3bbde9ce94 100644 --- a/packages/ui-codemods/lib/utils/codemodHelpers.ts +++ b/packages/ui-codemods/lib/utils/codemodHelpers.ts @@ -728,6 +728,7 @@ export { findAttribute, findImport, findEveryImport, + findImportPath, // editing replaceImport, addImportIfNeeded, diff --git a/packages/ui-codemods/lib/utils/instUICodemodExecutor.ts b/packages/ui-codemods/lib/utils/instUICodemodExecutor.ts index 30cd4fc1c7..48d942e25b 100644 --- a/packages/ui-codemods/lib/utils/instUICodemodExecutor.ts +++ b/packages/ui-codemods/lib/utils/instUICodemodExecutor.ts @@ -56,7 +56,11 @@ const instUICodemodExecutor = ( instUICodemods: InstUICodemod[] | InstUICodemod, file: FileInfo, api: API, - options?: { fileName?: string; usePrettier?: boolean } + options?: { + fileName?: string + usePrettier?: boolean + quote?: 'single' | 'double' | 'auto' + } ) => { const j = api.jscodeshift.withParser('tsx') const root = j(file.source) @@ -80,7 +84,7 @@ const instUICodemodExecutor = ( const shouldUsePrettier = options?.usePrettier !== false return shouldUsePrettier ? formatSource(root.toSource(), file.path) - : root.toSource() + : root.toSource({ quote: options?.quote ?? 'double' }) } else { return null } diff --git a/packages/ui-codemods/package.json b/packages/ui-codemods/package.json index 1b275c260e..0363c53ab1 100644 --- a/packages/ui-codemods/package.json +++ b/packages/ui-codemods/package.json @@ -15,7 +15,9 @@ "test:vitest": "vitest --watch=false", "lint": "ui-scripts lint", "lint:fix": "ui-scripts lint --fix", - "ts:check": "tsc -p tsconfig.build.json --noEmit --emitDeclarationOnly false" + "ts:check": "tsc -p tsconfig.build.json --noEmit --emitDeclarationOnly false", + "generate:versioned-exports": "node --experimental-strip-types scripts/generateVersionedExports.ts", + "prepare-build": "pnpm run generate:versioned-exports" }, "license": "MIT", "dependencies": { diff --git a/packages/ui-codemods/scripts/generateVersionedExports.ts b/packages/ui-codemods/scripts/generateVersionedExports.ts new file mode 100644 index 0000000000..d1e11299f8 --- /dev/null +++ b/packages/ui-codemods/scripts/generateVersionedExports.ts @@ -0,0 +1,131 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Generates versionedExports.ts from the version barrel files in @instructure/ui. + * Run with: node --experimental-strip-types scripts/generateVersionedExports.ts + * from the packages/ui-codemods directory. + */ + +import { resolve } from 'node:path' +import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import ts from 'typescript' + +const VERSIONED_INSTUI = /^@instructure\/[^/]+\/v\d+_\d+$/ + +const uiSrcDir = resolve(import.meta.dirname, '../../ui/src') +const outputFile = resolve( + import.meta.dirname, + '../lib/generated/versionedExports.ts' +) + +function parseVersionNumbers(filename: string): [number, number] { + const m = filename.match(/^v(\d+)_(\d+)\.ts$/) + return m ? [Number(m[1]), Number(m[2])] : [0, 0] +} + +function findLatestVersionFile(dir: string): string { + const latest = readdirSync(dir) + .filter((f) => /^v\d+_\d+\.ts$/.test(f)) + .sort((a, b) => { + const [amaj, amin] = parseVersionNumbers(a) + const [bmaj, bmin] = parseVersionNumbers(b) + return amaj - bmaj || amin - bmin + }) + .at(-1) + if (!latest) throw new Error(`No version files found in ${dir}`) + return resolve(dir, latest) +} + +function isVersionedInstUIExport(node: ts.ExportDeclaration): boolean { + const { moduleSpecifier } = node + return ( + moduleSpecifier !== undefined && + ts.isStringLiteral(moduleSpecifier) && + VERSIONED_INSTUI.test(moduleSpecifier.text) + ) +} + +function getExportedNames(node: ts.ExportDeclaration): string[] { + const { exportClause } = node + if (!exportClause || !ts.isNamedExports(exportClause)) return [] + return exportClause.elements.map((el) => el.name.text) +} + +function parseVersionedComponents(filePath: string): string[] { + const source = readFileSync(filePath, 'utf-8') + const sourceFile = ts.createSourceFile( + filePath, + source, + ts.ScriptTarget.Latest + ) + + return sourceFile.statements + .filter(ts.isExportDeclaration) + .filter(isVersionedInstUIExport) + .flatMap(getExportedNames) +} + +function generateFileContent(components: string[]): string { + const nameList = components.map((n) => `'${n}'`).join(', ') + return `\ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// Auto-generated by scripts/generateVersionedExports.ts — do not edit manually. +// Re-run the script when a new version barrel file is added to @instructure/ui. + +const versionedExports: string[] = [${nameList}] + +export default versionedExports +` +} + +const filePath = findLatestVersionFile(uiSrcDir) +const components = parseVersionedComponents(filePath) + +mkdirSync(resolve(outputFile, '..'), { recursive: true }) +writeFileSync(outputFile, generateFileContent(components), 'utf-8') +process.stdout.write(`Written: ${outputFile}\n`) diff --git a/scripts/bootstrap.js b/scripts/bootstrap.js index ee8bc58bd5..ae26ef9cbf 100755 --- a/scripts/bootstrap.js +++ b/scripts/bootstrap.js @@ -38,6 +38,7 @@ function buildProject() { } execSync('pnpm --filter @instructure/ui-icons prepare-build', opts) + execSync('pnpm --filter @instructure/ui-codemods prepare-build', opts) console.info('Building packages with Babel...') try { From 7da83787807b5c55b9eca318acdd7083bf535a1a Mon Sep 17 00:00:00 2001 From: Peter Pal Hudak Date: Tue, 21 Apr 2026 15:04:40 +0200 Subject: [PATCH 2/2] refactor(ui-codemods): fix review comments --- docs/guides/upgrade-guide.md | 5 +- .../updateInstUIImportVersions.test.ts | 136 +++++++++++++++--- .../lib/updateInstUIImportVersions.ts | 31 +++- packages/ui-codemods/package.json | 3 +- .../scripts/generateVersionedExports.ts | 10 +- scripts/bootstrap.js | 10 +- 6 files changed, 160 insertions(+), 35 deletions(-) diff --git a/docs/guides/upgrade-guide.md b/docs/guides/upgrade-guide.md index cf2fca772d..0e96322e26 100644 --- a/docs/guides/upgrade-guide.md +++ b/docs/guides/upgrade-guide.md @@ -1388,10 +1388,11 @@ It is worth adding `--extensions=ts,tsx` for TypeScript codebases. Also exclude --- type: code --- -npx jscodeshift@latest ... \ +npx jscodeshift@latest node_modules/@instructure/ui-codemods/lib/updateInstUIImportVersions.ts \ --extensions=ts,tsx \ --ignore-pattern="**/node_modules/**" \ - --ignore-pattern="**/*.d.ts" + --ignore-pattern="**/*.d.ts" \ + --diagnose=true ``` **Diagnose** — inspect which components are imported and at what version, without modifying files: diff --git a/packages/ui-codemods/lib/__node_tests__/updateInstUIImportVersions.test.ts b/packages/ui-codemods/lib/__node_tests__/updateInstUIImportVersions.test.ts index 2869674426..36175a6069 100644 --- a/packages/ui-codemods/lib/__node_tests__/updateInstUIImportVersions.test.ts +++ b/packages/ui-codemods/lib/__node_tests__/updateInstUIImportVersions.test.ts @@ -100,15 +100,25 @@ describe('updateInstUIImportVersions', () => { }) it('returns null for non-@instructure imports', () => { - const result = updateInstUIImportVersions( - makeFileInfo(`import { something } from 'some-lib'`), - makeApi() as Parameters[1], - { versionTo: 'v11.7' } - ) - expect(result).toBeNull() + // Includes component names that are in versionedExports but come from other packages — + // the codemod must check the package, not just the import name. + const cases = [ + `import { something } from 'some-lib'`, + `import { Button } from 'react-bootstrap'`, + `import { Button } from '@mui/material'`, + `import { Alert } from 'antd'` + ] + for (const source of cases) { + const result = updateInstUIImportVersions( + makeFileInfo(source), + makeApi() as Parameters[1], + { versionTo: 'v11.7' } + ) + expect(result, source).toBeNull() + } }) - it('does not rewrite non-component symbols when no --components given', () => { + it('does not rewrite non-versioned components', () => { // 'withStyle' is not in versionedExports, so it should be skipped const result = updateInstUIImportVersions( makeFileInfo(`import { withStyle } from '@instructure/ui'`), @@ -118,24 +128,38 @@ describe('updateInstUIImportVersions', () => { expect(result).toBeNull() }) - it('splits mixed import: non-component stays at original path, component moves to versioned path', () => { + it('does not split mixed @instructure/ui import: moves all specifiers to versioned path', () => { + // @instructure/ui version files re-export everything including non-component + // symbols (e.g. canvas), so splitting is unnecessary — rewrite the whole import. runInlineTest( updateInstUIImportVersions, { versionTo: 'v11.7' }, makeFileInfo(`import {canvas, Button} from "@instructure/ui"`), - // canvas is not in versionedExports, Button is → split into two imports - // (Prettier normalises to single quotes and adds spaces inside braces) - `import { canvas } from '@instructure/ui'\nimport { Button } from '@instructure/ui/v11_7'` + `import { canvas, Button } from '@instructure/ui/v11_7'` + ) + }) + + it('splits mixed import from an individual @instructure package: non-component stays, component moves', () => { + // TestComponent is not in versionedExports, Button is → triggers a split + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7' }, + makeFileInfo( + `import {TestComponent, Button} from "@instructure/ui-button"` + ), + // TestComponent is not in versionedExports, Button is → split + `import { TestComponent } from '@instructure/ui-button'\nimport { Button } from '@instructure/ui-button/v11_7'` ) }) it('does not add semicolons to split imports when usePrettier=false and file has no semis', () => { - // canvas is not in versionedExports (utility), Button is → triggers a split runInlineTest( updateInstUIImportVersions, { versionTo: 'v11.7', usePrettier: false }, - makeFileInfo(`import {canvas, Button} from '@instructure/ui'`), - `import { canvas } from '@instructure/ui'\nimport { Button } from '@instructure/ui/v11_7'` + makeFileInfo( + `import {TestComponent, Button} from '@instructure/ui-button'` + ), + `import { TestComponent } from '@instructure/ui-button'\nimport { Button } from '@instructure/ui-button/v11_7'` ) }) @@ -143,8 +167,10 @@ describe('updateInstUIImportVersions', () => { runInlineTest( updateInstUIImportVersions, { versionTo: 'v11.7', usePrettier: false }, - makeFileInfo(`import {canvas, Button} from '@instructure/ui';`), - `import { canvas } from '@instructure/ui';\nimport { Button } from '@instructure/ui/v11_7';` + makeFileInfo( + `import {TestComponent, Button} from '@instructure/ui-button';` + ), + `import { TestComponent } from '@instructure/ui-button';\nimport { Button } from '@instructure/ui-button/v11_7';` ) }) @@ -158,15 +184,16 @@ describe('updateInstUIImportVersions', () => { ) }) - it('keeps type-only specifier at original path when name is not in components', () => { + it('moves all specifiers to versioned path when source is @instructure/ui, even with --components filter', () => { + // ButtonProps is not in the components filter, but @instructure/ui + // version files re-export everything — no split, whole import moves. runInlineTest( updateInstUIImportVersions, { versionTo: 'v11.7', components: 'Button' }, makeFileInfo( `import { Button, type ButtonProps } from '@instructure/ui'` ), - // ButtonProps not in components list → split - `import { type ButtonProps } from '@instructure/ui'\nimport { Button } from '@instructure/ui/v11_7'` + `import { Button, type ButtonProps } from '@instructure/ui/v11_7'` ) }) @@ -233,4 +260,75 @@ describe('updateInstUIImportVersions', () => { expect(reported).toHaveLength(1) expect(reported[0]).toContain('oldest') }) + + it('rewrites all matching imports in a file with 10+ @instructure imports', () => { + const input = [ + `import React from 'react'`, + `import { Alert } from '@instructure/ui'`, + `import { Avatar } from '@instructure/ui'`, + `import { Badge } from '@instructure/ui'`, + `import { Button } from '@instructure/ui'`, + `import { Checkbox } from '@instructure/ui'`, + `import { Flex, FlexItem } from '@instructure/ui'`, + `import { Heading } from '@instructure/ui'`, + `import { Modal, ModalBody, ModalFooter } from '@instructure/ui'`, + `import { Select } from '@instructure/ui'`, + `import { Spinner } from '@instructure/ui'`, + `import { Text } from '@instructure/ui'`, + `import { TextInput } from '@instructure/ui'`, + `import type { SomeType } from 'some-lib'` + ].join('\n') + + const expected = [ + `import React from 'react'`, + `import { Alert } from '@instructure/ui/v11_7'`, + `import { Avatar } from '@instructure/ui/v11_7'`, + `import { Badge } from '@instructure/ui/v11_7'`, + `import { Button } from '@instructure/ui/v11_7'`, + `import { Checkbox } from '@instructure/ui/v11_7'`, + `import { Flex, FlexItem } from '@instructure/ui/v11_7'`, + `import { Heading } from '@instructure/ui/v11_7'`, + `import { Modal, ModalBody, ModalFooter } from '@instructure/ui/v11_7'`, + `import { Select } from '@instructure/ui/v11_7'`, + `import { Spinner } from '@instructure/ui/v11_7'`, + `import { Text } from '@instructure/ui/v11_7'`, + `import { TextInput } from '@instructure/ui/v11_7'`, + `import type { SomeType } from 'some-lib'` + ].join('\n') + + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7' }, + makeFileInfo(input), + expected + ) + }) + + it('rewrites named imports from individual @instructure packages', () => { + runInlineTest( + updateInstUIImportVersions, + { versionTo: 'v11.7' }, + makeFileInfo( + [ + `import { Button } from '@instructure/ui-button'`, + `import { Alert } from '@instructure/ui-alerts'`, + `import { Spinner } from '@instructure/ui-spinner'` + ].join('\n') + ), + [ + `import { Button } from '@instructure/ui-button/v11_7'`, + `import { Alert } from '@instructure/ui-alerts/v11_7'`, + `import { Spinner } from '@instructure/ui-spinner/v11_7'` + ].join('\n') + ) + }) + + it('does not rewrite default imports from @instructure packages', () => { + const result = updateInstUIImportVersions( + makeFileInfo(`import Button from '@instructure/ui-button'`), + makeApi() as Parameters[1], + { versionTo: 'v11.7' } + ) + expect(result).toBeNull() + }) }) diff --git a/packages/ui-codemods/lib/updateInstUIImportVersions.ts b/packages/ui-codemods/lib/updateInstUIImportVersions.ts index 82440f2a4f..be04cb3483 100644 --- a/packages/ui-codemods/lib/updateInstUIImportVersions.ts +++ b/packages/ui-codemods/lib/updateInstUIImportVersions.ts @@ -53,6 +53,9 @@ function normalizeVersion(raw: string): string { return raw.replace(/\./g, '_') } +// parseImportPath('@instructure/ui-button') → { base: '@instructure/ui-button', version: '' } +// parseImportPath('@instructure/ui-button/v11_7') → { base: '@instructure/ui-button', version: 'v11_7' } +// parseImportPath('react') → null function parseImportPath(importPath: string): ParsedImportPath | null { const match = importPath.match(/^(@instructure\/[^/]+)(\/(.+))?$/) if (!match) return null @@ -63,18 +66,28 @@ function buildImportPath(packageName: string, version: string): string { return version ? `${packageName}/${version}` : packageName } +// detectQuoteStyle(`import { Foo } from "@instructure/ui-button"`) → 'double' +// detectQuoteStyle(`import { Foo } from '@instructure/ui-button'`) → 'single' function detectQuoteStyle(source: string): 'single' | 'double' { const match = source.match(/from\s+(['"])@instructure\//) return match?.[1] === '"' ? 'double' : 'single' } +// detectSemi(`import { Foo } from '@instructure/ui-button';`) → true +// detectSemi(`import { Foo } from '@instructure/ui-button'`) → false function detectSemi(source: string): boolean { - const match = source.match(/^import\b.*?from\s+['"][^'"]+['"]\s*(;?)\s*$/m) - return match?.[1] === ';' + const importLine = source.match( + /^import\b[^'"\n]*from[ \t]+['"][^'"]+['"][^\n]*/m + )?.[0] + return importLine ? /;[ \t]*$/.test(importLine) : false } +// stripImportSemis(`import { Foo } from '@instructure/ui-button';`) → `import { Foo } from '@instructure/ui-button'` function stripImportSemis(source: string): string { - return source.replace(/^(import\b.*?from\s+['"][^'"]+['"]\s*);$/gm, '$1') + return source.replace( + /^(import\b[^'"\n]*from[ \t]+['"][^'"]+['"][ \t]*);$/gm, + '$1' + ) } function parseComponents(raw: unknown): string[] | undefined { @@ -165,15 +178,19 @@ const updateInstUIImportVersionsCodemod = const line = path.node.loc?.start.line ?? 0 - if (remaining.length === 0) { - // All specifiers match — simple path rewrite + if (remaining.length === 0 || parsed.base === '@instructure/ui') { + // Simple path rewrite: either all specifiers match, or the source + // is @instructure/ui whose version files re-export every + // symbol (including non-component utilities like canvas) — no split + // needed. Individual @instructure/* packages are split below because + // they don't have version files for their non-component exports. // eslint-disable-next-line no-param-reassign path.node.source.value = newPath report?.(`${filePath}:${line} '${importPath}' → '${newPath}'`) } else { - // Mixed import — split into two declarations: + // Mixed import from an individual @instructure package — split: // keep non-matching specifiers at the original path, - // move matching specifiers to the new versioned path. + // move matching specifiers to the versioned path. // eslint-disable-next-line no-param-reassign path.node.specifiers = remaining const newDecl = j.importDeclaration( diff --git a/packages/ui-codemods/package.json b/packages/ui-codemods/package.json index 0363c53ab1..354e445bba 100644 --- a/packages/ui-codemods/package.json +++ b/packages/ui-codemods/package.json @@ -16,8 +16,7 @@ "lint": "ui-scripts lint", "lint:fix": "ui-scripts lint --fix", "ts:check": "tsc -p tsconfig.build.json --noEmit --emitDeclarationOnly false", - "generate:versioned-exports": "node --experimental-strip-types scripts/generateVersionedExports.ts", - "prepare-build": "pnpm run generate:versioned-exports" + "generate:versioned-exports": "node --experimental-strip-types scripts/generateVersionedExports.ts" }, "license": "MIT", "dependencies": { diff --git a/packages/ui-codemods/scripts/generateVersionedExports.ts b/packages/ui-codemods/scripts/generateVersionedExports.ts index d1e11299f8..75c3398f49 100644 --- a/packages/ui-codemods/scripts/generateVersionedExports.ts +++ b/packages/ui-codemods/scripts/generateVersionedExports.ts @@ -23,7 +23,7 @@ */ /** - * Generates versionedExports.ts from the version barrel files in @instructure/ui. + * Generates a list of all versioned components based on the @instructure/ui meta package * Run with: node --experimental-strip-types scripts/generateVersionedExports.ts * from the packages/ui-codemods directory. */ @@ -32,6 +32,7 @@ import { resolve } from 'node:path' import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import ts from 'typescript' +// '@instructure/ui-button/v8_0' → matches; '@instructure/ui-button' or 'react' → no match const VERSIONED_INSTUI = /^@instructure\/[^/]+\/v\d+_\d+$/ const uiSrcDir = resolve(import.meta.dirname, '../../ui/src') @@ -40,6 +41,9 @@ const outputFile = resolve( '../lib/generated/versionedExports.ts' ) +// parseVersionNumbers('v8_0.ts') → [8, 0] +// parseVersionNumbers('v10_3.ts') → [10, 3] +// parseVersionNumbers('other.ts') → [0, 0] function parseVersionNumbers(filename: string): [number, number] { const m = filename.match(/^v(\d+)_(\d+)\.ts$/) return m ? [Number(m[1]), Number(m[2])] : [0, 0] @@ -47,7 +51,7 @@ function parseVersionNumbers(filename: string): [number, number] { function findLatestVersionFile(dir: string): string { const latest = readdirSync(dir) - .filter((f) => /^v\d+_\d+\.ts$/.test(f)) + .filter((f) => /^v\d+_\d+\.ts$/.test(f)) // matches 'v8_0.ts', 'v10_3.ts'; skips 'index.ts' etc. .sort((a, b) => { const [amaj, amin] = parseVersionNumbers(a) const [bmaj, bmin] = parseVersionNumbers(b) @@ -115,7 +119,7 @@ function generateFileContent(components: string[]): string { */ // Auto-generated by scripts/generateVersionedExports.ts — do not edit manually. -// Re-run the script when a new version barrel file is added to @instructure/ui. +// Re-run the script when a new version file is added to @instructure/ui. const versionedExports: string[] = [${nameList}] diff --git a/scripts/bootstrap.js b/scripts/bootstrap.js index ae26ef9cbf..76331e631b 100755 --- a/scripts/bootstrap.js +++ b/scripts/bootstrap.js @@ -1,5 +1,4 @@ #! /usr/bin/env node -/* eslint-disable no-console */ /* * The MIT License (MIT) @@ -38,7 +37,14 @@ function buildProject() { } execSync('pnpm --filter @instructure/ui-icons prepare-build', opts) - execSync('pnpm --filter @instructure/ui-codemods prepare-build', opts) + + // Executes a ui-codemods script to generate a versioned components list + // from the ui metapackage's latest re-export file. This is required for + // the updateInstUIImportVersions codemod's diagnose mode. + execSync( + 'pnpm --filter @instructure/ui-codemods generate:versioned-exports', + opts + ) console.info('Building packages with Babel...') try {