diff --git a/recipes/correct-ts-specifiers/.codemodrc.json b/recipes/correct-ts-specifiers/.codemodrc.json deleted file mode 100644 index 227dd458..00000000 --- a/recipes/correct-ts-specifiers/.codemodrc.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "https://codemod-utils.s3.us-west-1.amazonaws.com/configuration_schema.json", - "name": "@nodejs/correct-ts-specifiers", - "description": "Replace erroneous 'js' or omitted file extensions of import specifiers in TypeScript files.", - "version": "1.0.0", - "engine": "workflow", - "private": false, - "entry": "./src/workflow.ts", - "arguments": [], - "meta": { - "git": "https://github.com/nodejs/userland-migrations/tree/main/recipes/correct-ts-specifiers", - "tags": [ - "esm", - "import", - "node", - "typescript" - ] - } -} diff --git a/recipes/correct-ts-specifiers/codemod.yaml b/recipes/correct-ts-specifiers/codemod.yaml new file mode 100644 index 00000000..95dd3ebd --- /dev/null +++ b/recipes/correct-ts-specifiers/codemod.yaml @@ -0,0 +1,23 @@ +schema_version: "1.0" +name: "@nodejs/correct-ts-specifiers" +version: "1.0.0" +description: "Replace erroneous 'js' or omitted file extensions of import specifiers in TypeScript files." +author: "Jacob Smith" +license: "MIT" +workflow: "workflow.yaml" +category: "migration" + +targets: + languages: + - "javascript" + - "typescript" + +keywords: + - "transformation" + - "migration" + - "esm" + - "typescript" + +registry: + access: "public" + visibility: "public" diff --git a/recipes/correct-ts-specifiers/package.json b/recipes/correct-ts-specifiers/package.json index 8a70b970..690bd327 100644 --- a/recipes/correct-ts-specifiers/package.json +++ b/recipes/correct-ts-specifiers/package.json @@ -3,13 +3,8 @@ "version": "1.0.0", "description": "Replace erroneous 'js' or omitted file extensions of import specifiers in TypeScript files.", "type": "module", - "main": "./src/workflow.ts", - "engines": { - "node": ">=22.15.0" - }, "scripts": { - "start": "node --no-warnings --experimental-import-meta-resolve --experimental-strip-types ./src/workflow.ts", - "test-legacy": "node --no-warnings --experimental-import-meta-resolve --experimental-test-module-mocks --experimental-test-snapshots --experimental-strip-types --import='@nodejs/codemod-utils/snapshots' --test --experimental-test-coverage --test-coverage-include='src/**/*' --test-coverage-exclude='**/*.test.ts' './**/*.test.ts'" + "test": "node --no-warnings --experimental-import-meta-resolve --experimental-test-module-mocks --experimental-test-snapshots --experimental-strip-types --import='@nodejs/codemod-utils/snapshots' --test --experimental-test-coverage --test-coverage-include='src/**/*' --test-coverage-exclude='**/*.test.ts' './**/*.test.ts'" }, "repository": { "type": "git", @@ -17,11 +12,6 @@ "directory": "recipes/correct-ts-specifiers", "bugs": "https://github.com/nodejs/userland-migrations/issues" }, - "files": [ - "README.md", - ".codemodrc.json", - "bundle.js" - ], "keywords": [ "codemod", "esm", @@ -31,11 +21,10 @@ "license": "MIT", "homepage": "https://github.com/nodejs/userland-migrations/tree/main/correct-ts-specifiers#readme", "dependencies": { - "@codemod.com/workflow": "^0.0.31", - "@nodejs-loaders/alias": "^2.1.2" + "@nodejs/codemod-utils": "*" }, "devDependencies": { - "@nodejs/codemod-utils": "*", + "@codemod.com/jssg-types": "^1.5.0", "@types/node": "^25.5.0" } } diff --git a/recipes/correct-ts-specifiers/src/fexists.test.ts b/recipes/correct-ts-specifiers/src/fexists.test.ts index 248e0eec..b89b6781 100644 --- a/recipes/correct-ts-specifiers/src/fexists.test.ts +++ b/recipes/correct-ts-specifiers/src/fexists.test.ts @@ -4,13 +4,16 @@ import { fileURLToPath } from 'node:url'; import type { FSAbsolutePath } from './index.d.ts'; -type FSAccess = typeof import('node:fs/promises').access; +type FSAccess = typeof import('fs').promises.access; type FExists = typeof import('./fexists.ts').fexists; -type ResolveSpecifier = typeof import('./resolve-specifier.ts').resolveSpecifier; +type ResolveSpecifier = + typeof import('./resolve-specifier.ts').resolveSpecifier; const RESOLVED_SPECIFIER_ERR = 'Resolved specifier did not match expected'; -describe('fexists', { concurrency: false /* concurrency clobbers `before`s */ }, () => { +describe('fexists', { + concurrency: false /* concurrency clobbers `before`s */, +}, () => { const parentPath = '/tmp/test.ts'; const constants = { F_OK: null }; @@ -20,10 +23,10 @@ describe('fexists', { concurrency: false /* concurrency clobbers `before`s */ }, before(() => { const access = mock.fn(); ({ mock: mock__access } = access); - mock.module('node:fs/promises', { + mock.module('fs', { namedExports: { - access, constants, + promises: { access }, }, }); @@ -34,16 +37,18 @@ describe('fexists', { concurrency: false /* concurrency clobbers `before`s */ }, resolveSpecifier, }, }); - mock__resolveSpecifier.mockImplementation(function MOCK__resolveSpecifier(_pp, specifier) { - return specifier; - }); + mock__resolveSpecifier.mockImplementation( + function MOCK__resolveSpecifier(_pp, specifier) { + return specifier; + }, + ); }); describe('when the file exists', () => { let fexists: FExists; before(async () => { - mock__access.mockImplementation(async function MOCK_access() { }); + mock__access.mockImplementation(async function MOCK_access() {}); ({ fexists } = await import('./fexists.ts')); }); @@ -59,21 +64,33 @@ describe('fexists', { concurrency: false /* concurrency clobbers `before`s */ }, ) as FSAbsolutePath; assert.equal(await fexists(parentUrl, specifier), true); - assert.equal(mock__access.calls[0].arguments[0], specifier, RESOLVED_SPECIFIER_ERR); + assert.equal( + mock__access.calls[0].arguments[0], + specifier, + RESOLVED_SPECIFIER_ERR, + ); }); it('should return `true` for a relative specifier', async () => { const specifier = 'exists.js'; assert.equal(await fexists(parentPath, specifier), true); - assert.equal(mock__access.calls[0].arguments[0], specifier, RESOLVED_SPECIFIER_ERR); + assert.equal( + mock__access.calls[0].arguments[0], + specifier, + RESOLVED_SPECIFIER_ERR, + ); }); it('should return `true` for specifier with a query parameter', async () => { const specifier = 'exists.js?v=1'; assert.equal(await fexists(parentPath, specifier), true); - assert.equal(mock__access.calls[0].arguments[0], specifier, RESOLVED_SPECIFIER_ERR); + assert.equal( + mock__access.calls[0].arguments[0], + specifier, + RESOLVED_SPECIFIER_ERR, + ); }); it('should return `true` for an absolute specifier', async () => { @@ -86,7 +103,10 @@ describe('fexists', { concurrency: false /* concurrency clobbers `before`s */ }, }); it('should return `true` for a URL', async () => { - assert.equal(await fexists(parentPath, 'file://localhost/foo/exists.js'), true); + assert.equal( + await fexists(parentPath, 'file://localhost/foo/exists.js'), + true, + ); assert.equal( mock__access.calls[0].arguments[0], 'file://localhost/foo/exists.js', @@ -114,34 +134,54 @@ describe('fexists', { concurrency: false /* concurrency clobbers `before`s */ }, const specifier = 'noexists.js'; assert.equal(await fexists(parentPath, specifier), false); - assert.equal(mock__access.calls[0].arguments[0], specifier, RESOLVED_SPECIFIER_ERR); + assert.equal( + mock__access.calls[0].arguments[0], + specifier, + RESOLVED_SPECIFIER_ERR, + ); }); it('should return `false` for a relative specifier', async () => { const specifier = 'noexists.js?v=1'; assert.equal(await fexists(parentPath, specifier), false); - assert.equal(mock__access.calls[0].arguments[0], specifier, RESOLVED_SPECIFIER_ERR); + assert.equal( + mock__access.calls[0].arguments[0], + specifier, + RESOLVED_SPECIFIER_ERR, + ); }); it('should return `false` for an absolute specifier', async () => { const specifier = '/tmp/foo/noexists.js'; assert.equal(await fexists(parentPath, specifier), false); - assert.equal(mock__access.calls[0].arguments[0], specifier, RESOLVED_SPECIFIER_ERR); + assert.equal( + mock__access.calls[0].arguments[0], + specifier, + RESOLVED_SPECIFIER_ERR, + ); }); it('should return `false` for a URL specifier', async () => { const specifier = 'file://localhost/foo/noexists.js'; assert.equal(await fexists(parentPath, specifier), false); - assert.equal(mock__access.calls[0].arguments[0], specifier, RESOLVED_SPECIFIER_ERR); + assert.equal( + mock__access.calls[0].arguments[0], + specifier, + RESOLVED_SPECIFIER_ERR, + ); }); it('should return `false` when the specifier can’t be resolved', async () => { - mock__resolveSpecifier.mockImplementationOnce(function MOCK__resolveSpecifier(_pp, _specifier) { - throw Object.assign(new Error('ERR_MODULE_NOT_FOUND'), { code: 'ERR_MODULE_NOT_FOUND' }); - }); + mock__resolveSpecifier.mockImplementationOnce( + function MOCK__resolveSpecifier(_pp, _specifier) { + throw Object.assign(new Error('ERR_MODULE_NOT_FOUND'), { + code: 'ERR_MODULE_NOT_FOUND', + }); + }, + ); assert.equal(await fexists(parentPath, 'noexists'), false); }); diff --git a/recipes/correct-ts-specifiers/src/fexists.ts b/recipes/correct-ts-specifiers/src/fexists.ts index 5bb48360..f87d544b 100644 --- a/recipes/correct-ts-specifiers/src/fexists.ts +++ b/recipes/correct-ts-specifiers/src/fexists.ts @@ -1,4 +1,5 @@ -import { access, constants } from 'node:fs/promises'; +// biome-ignore lint/style/useNodejsImportProtocol: JSSG runtime resolves 'fs' for this codemod. +import { constants, promises as fs } from 'fs'; import type { FSAbsolutePath, @@ -13,9 +14,13 @@ export function fexists( ) { let resolvedSpecifier: FSAbsolutePath; try { - resolvedSpecifier = resolveSpecifier(parentPath, specifier) as FSAbsolutePath; + resolvedSpecifier = resolveSpecifier( + parentPath, + specifier, + ) as FSAbsolutePath; } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ERR_MODULE_NOT_FOUND') throw err; + if ((err as NodeJS.ErrnoException).code !== 'ERR_MODULE_NOT_FOUND') + throw err; return false; } @@ -23,7 +28,7 @@ export function fexists( } export const fexistsResolved = (resolvedSpecifier: FSAbsolutePath) => - access(resolvedSpecifier, constants.F_OK).then( + fs.access(resolvedSpecifier, constants.F_OK).then( () => true, () => false, ); diff --git a/recipes/correct-ts-specifiers/src/fixtures/e2e/test.ts b/recipes/correct-ts-specifiers/src/fixtures/e2e/test.ts index 34df1387..e64f7454 100644 --- a/recipes/correct-ts-specifiers/src/fixtures/e2e/test.ts +++ b/recipes/correct-ts-specifiers/src/fixtures/e2e/test.ts @@ -1,3 +1,4 @@ +// git restore --source main -- recipes/correct-ts-specifiers/src/fixtures/e2e/test.ts import { URL } from 'node:url'; import { bar } from '@dep/bar'; diff --git a/recipes/correct-ts-specifiers/src/get-not-found-url.ts b/recipes/correct-ts-specifiers/src/get-not-found-url.ts index 7c8a8e66..0c1e09d5 100644 --- a/recipes/correct-ts-specifiers/src/get-not-found-url.ts +++ b/recipes/correct-ts-specifiers/src/get-not-found-url.ts @@ -2,5 +2,8 @@ import { pathToFileURL } from 'node:url'; import type { FSAbsolutePath, ResolvedSpecifier } from './index.d.ts'; -export const getNotFoundUrl = (err: NodeJS.ErrnoException & { url?: FSAbsolutePath }) => - pathToFileURL(err?.url ?? err.message.split("'")[1])?.href as ResolvedSpecifier; +export const getNotFoundUrl = ( + err: NodeJS.ErrnoException & { url?: FSAbsolutePath }, +) => + pathToFileURL(err?.url ?? err.message.split("'")[1]) + ?.href as ResolvedSpecifier; diff --git a/recipes/correct-ts-specifiers/src/is-dir.test.ts b/recipes/correct-ts-specifiers/src/is-dir.test.ts index f5487469..1a3800a0 100644 --- a/recipes/correct-ts-specifiers/src/is-dir.test.ts +++ b/recipes/correct-ts-specifiers/src/is-dir.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { type Mock, before, describe, it, mock } from 'node:test'; -type LStat = typeof import('node:fs/promises').lstat; +type LStat = typeof import('fs').promises.lstat; type ResolveSpecifier = typeof import('./is-dir.ts').isDir; type IsDir = typeof import('./resolve-specifier.ts').resolveSpecifier; @@ -21,7 +21,7 @@ describe('Is a directory', { concurrency: true }, () => { mock_lstat = lstat.mock; mock_resolveSpecifier = resolveSpecifier.mock; - mock.module('node:fs/promises', { namedExports: { lstat } }); + mock.module('fs', { namedExports: { promises: { lstat } } }); mock.module('./resolve-specifier.ts', { namedExports: { resolveSpecifier }, }); diff --git a/recipes/correct-ts-specifiers/src/is-dir.ts b/recipes/correct-ts-specifiers/src/is-dir.ts index 894664a2..25784acd 100644 --- a/recipes/correct-ts-specifiers/src/is-dir.ts +++ b/recipes/correct-ts-specifiers/src/is-dir.ts @@ -1,4 +1,5 @@ -import { lstat } from 'node:fs/promises'; +// biome-ignore lint/style/useNodejsImportProtocol: JSSG runtime resolves 'fs' for this codemod. +import { promises as fs } from 'fs'; import type { FSAbsolutePath, @@ -8,16 +9,20 @@ import type { } from './index.d.ts'; import { resolveSpecifier } from './resolve-specifier.ts'; -export async function isDir(parentPath: FSAbsolutePath | ResolvedSpecifier, specifier: Specifier) { +export async function isDir( + parentPath: FSAbsolutePath | ResolvedSpecifier, + specifier: Specifier, +) { let resolvedSpecifier: ResolvedSpecifier | NodeModSpecifier; try { resolvedSpecifier = resolveSpecifier(parentPath, specifier); } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND') return null; + if ((err as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND') + return null; } try { - const stat = await lstat(resolvedSpecifier!); + const stat = await fs.lstat(resolvedSpecifier!); return stat.isDirectory(); } catch { return null; diff --git a/recipes/correct-ts-specifiers/src/is-ignorable-specifier.ts b/recipes/correct-ts-specifiers/src/is-ignorable-specifier.ts index 0a82162c..98d94982 100644 --- a/recipes/correct-ts-specifiers/src/is-ignorable-specifier.ts +++ b/recipes/correct-ts-specifiers/src/is-ignorable-specifier.ts @@ -1,6 +1,8 @@ import { isBuiltin } from 'node:module'; import { extname, sep } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; import { jsExts, tsExts } from './exts.ts'; import type { FSAbsolutePath, ResolvedSpecifier } from './index.d.ts'; @@ -12,7 +14,10 @@ import { getNotFoundUrl } from './get-not-found-url.ts'; * @param parentPath The module containing the provided specifier * @param specifier The specifier to check. */ -export function isIgnorableSpecifier(parentPath: FSAbsolutePath, specifier: string) { +export function isIgnorableSpecifier( + parentPath: FSAbsolutePath, + specifier: string, +) { if (isBuiltin(specifier)) return true; if (specifier.startsWith('data:')) return true; @@ -27,28 +32,38 @@ export function isIgnorableSpecifier(parentPath: FSAbsolutePath, specifier: stri if (specifier[0] === sep /* '/' */) return false; if (specifier.startsWith(`.${sep}`) /* './' */) return false; + if (specifier.startsWith('..')) return false; if (specifier.startsWith('file://')) return false; + if (isMatchInTsConfigPaths(specifier)) return false; + if (specifier.startsWith('#')) return true; - let resolvedSpecifier: ResolvedSpecifier; + const importMetaResolve = ( + import.meta as { resolve?: (specifier: string, parent?: string) => string } + ).resolve; + if (typeof importMetaResolve !== 'function') return true; + + let resolvedSpecifier: ResolvedSpecifier | undefined; try { - resolvedSpecifier = import.meta.resolve( + resolvedSpecifier = importMetaResolve( specifier, pathToFileURL(parentPath).href, - ) as ResolvedSpecifier; // This requires `--experimental-import-meta-resolve` + ) as ResolvedSpecifier; // This requires `--experimental-import-meta-resolve` } catch (err) { if ( - !(err instanceof Error) - || !IGNORABLE_RESOLVE_ERRORS.has((err as NodeJS.ErrnoException).code!) + !(err instanceof Error) || + !IGNORABLE_RESOLVE_ERRORS.has((err as NodeJS.ErrnoException).code!) ) throw err; resolvedSpecifier = getNotFoundUrl(err); - } finally { - /* biome-ignore lint/correctness/noUnsafeFinally: This does not blindly override the control - flow the rule is meant to protect */ - if (resolvesToNodeModule(resolvedSpecifier!, parentPath, specifier)) return true; } + if ( + resolvedSpecifier && + resolvesToNodeModule(resolvedSpecifier, parentPath, specifier) + ) + return true; + return false; } @@ -56,3 +71,53 @@ const IGNORABLE_RESOLVE_ERRORS = new Set([ 'ERR_MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED', // This is a problem with the node_module itself ]); + +let cachedTsConfigPathKeys: string[] | null | undefined; + +function isMatchInTsConfigPaths(specifier: string): boolean { + const keys = getTsConfigPathKeys(); + if (!keys?.length) return false; + + for (const key of keys) { + const star = key.indexOf('*'); + if (star === -1) { + if (specifier === key) return true; + continue; + } + + const prefix = key.slice(0, star); + const suffix = key.slice(star + 1); + if ( + specifier.startsWith(prefix) && + (!suffix || specifier.endsWith(suffix)) + ) { + return true; + } + } + + return false; +} + +function getTsConfigPathKeys(): string[] | null { + if (cachedTsConfigPathKeys !== undefined) return cachedTsConfigPathKeys; + + try { + const raw = readFileSync( + resolvePath(process.cwd(), 'tsconfig.json'), + 'utf8', + ); + const withoutComments = raw + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/.*$/gm, '$1'); + const withoutTrailingCommas = withoutComments.replace(/,\s*([}\]])/g, '$1'); + const parsed = JSON.parse(withoutTrailingCommas) as { + compilerOptions?: { paths?: Record }; + }; + + cachedTsConfigPathKeys = Object.keys(parsed.compilerOptions?.paths ?? {}); + return cachedTsConfigPathKeys; + } catch { + cachedTsConfigPathKeys = null; + return cachedTsConfigPathKeys; + } +} diff --git a/recipes/correct-ts-specifiers/src/logger.ts b/recipes/correct-ts-specifiers/src/logger.ts new file mode 100644 index 00000000..9215f535 --- /dev/null +++ b/recipes/correct-ts-specifiers/src/logger.ts @@ -0,0 +1,11 @@ +import type { FSAbsolutePath, ResolvedSpecifier } from './index.d.ts'; + +type LogLevel = 'warn' | 'error'; + +export function logger( + parentPath: FSAbsolutePath | ResolvedSpecifier, + level: LogLevel, + message: string, +) { + console[level](`[correct-ts-specifiers] ${parentPath}: ${message}`); +} diff --git a/recipes/correct-ts-specifiers/src/map-imports.test.ts b/recipes/correct-ts-specifiers/src/map-imports.test.ts index 6a56f0fa..0d3a6d49 100644 --- a/recipes/correct-ts-specifiers/src/map-imports.test.ts +++ b/recipes/correct-ts-specifiers/src/map-imports.test.ts @@ -5,19 +5,20 @@ import { fileURLToPath } from 'node:url'; import { dExts } from './exts.ts'; import type { FSAbsolutePath } from './index.d.ts'; - -type Logger = typeof import('@nodejs/codemod-utils/logger').logger; +type Logger = typeof import('./logger.ts').logger; type MapImports = typeof import('./map-imports.ts').mapImports; describe('Map Imports', { concurrency: true }, () => { - const originatingFilePath = fileURLToPath(import.meta.resolve('./test.ts')) as FSAbsolutePath; + const originatingFilePath = fileURLToPath( + import.meta.resolve('./test.ts'), + ) as FSAbsolutePath; let mock__log: Mock['mock']; let mapImports: MapImports; before(async () => { const logger = mock.fn(); ({ mock: mock__log } = logger); - mock.module('@nodejs/codemod-utils/logger', { + mock.module('./logger.ts', { namedExports: { logger }, }); @@ -77,7 +78,10 @@ describe('Map Imports', { concurrency: true }, () => { for (const dExt of dExts) { const extType = dExt.split('.').pop(); const specifierBase = `./fixtures/d/unambiguous/${extType}/index`; - const output = await mapImports(originatingFilePath, `${specifierBase}.js`); + const output = await mapImports( + originatingFilePath, + `${specifierBase}.js`, + ); assert.equal(output.replacement, `${specifierBase}${dExt}`); assert.equal(output.isType, true); diff --git a/recipes/correct-ts-specifiers/src/map-imports.ts b/recipes/correct-ts-specifiers/src/map-imports.ts index 94d4139c..2141d9e3 100644 --- a/recipes/correct-ts-specifiers/src/map-imports.ts +++ b/recipes/correct-ts-specifiers/src/map-imports.ts @@ -1,4 +1,4 @@ -import { logger } from '@nodejs/codemod-utils/logger'; +import { logger } from './logger.ts'; import type { FSAbsolutePath, Specifier } from './index.d.ts'; import { fexists } from './fexists.ts'; import { isDir } from './is-dir.ts'; @@ -19,10 +19,16 @@ export const mapImports = async ( }> => { if (isIgnorableSpecifier(parentPath, specifier)) return {}; - const { isType, replacement } = await replaceJSExtWithTSExt(parentPath, specifier); + const { isType, replacement } = await replaceJSExtWithTSExt( + parentPath, + specifier, + ); if (replacement) { - if ((await fexists(parentPath, specifier)) && !(await isDir(parentPath, specifier))) { + if ( + (await fexists(parentPath, specifier)) && + !(await isDir(parentPath, specifier)) + ) { logger( parentPath, 'warn', diff --git a/recipes/correct-ts-specifiers/src/replace-js-ext-with-ts-ext.test.ts b/recipes/correct-ts-specifiers/src/replace-js-ext-with-ts-ext.test.ts index b2fcc145..6ca74e3f 100644 --- a/recipes/correct-ts-specifiers/src/replace-js-ext-with-ts-ext.test.ts +++ b/recipes/correct-ts-specifiers/src/replace-js-ext-with-ts-ext.test.ts @@ -1,6 +1,14 @@ import assert from 'node:assert/strict'; import path from 'node:path'; -import { type Mock, after, afterEach, before, describe, it, mock } from 'node:test'; +import { + type Mock, + after, + afterEach, + before, + describe, + it, + mock, +} from 'node:test'; import { fileURLToPath } from 'node:url'; import { dExts, jsExts, suspectExts, tsExts } from './exts.ts'; @@ -8,12 +16,18 @@ import type { FSAbsolutePath } from './index.d.ts'; type MockModuleContext = ReturnType; -type Logger = typeof import('@nodejs/codemod-utils/logger').logger; -type ReplaceJSExtWithTSExt = typeof import('./replace-js-ext-with-ts-ext.ts').replaceJSExtWithTSExt; +type Logger = typeof import('./logger.ts').logger; +type ReplaceJSExtWithTSExt = + typeof import('./replace-js-ext-with-ts-ext.ts').replaceJSExtWithTSExt; describe('Correcting ts file extensions', { concurrency: true }, () => { - const originatingFilePath = fileURLToPath(import.meta.resolve('./test.ts')) as FSAbsolutePath; - const fixturesDir = path.join(import.meta.dirname, 'fixtures/e2e') as FSAbsolutePath; + const originatingFilePath = fileURLToPath( + import.meta.resolve('./test.ts'), + ) as FSAbsolutePath; + const fixturesDir = path.join( + import.meta.dirname, + 'fixtures/e2e', + ) as FSAbsolutePath; const catSpecifier = path.join(fixturesDir, 'Cat.ts') as FSAbsolutePath; let mock__log: Mock['mock']; @@ -23,11 +37,13 @@ describe('Correcting ts file extensions', { concurrency: true }, () => { before(async () => { const logger = mock.fn(); ({ mock: mock__log } = logger); - mock__logger = mock.module('@nodejs/codemod-utils/logger', { + mock__logger = mock.module('./logger.ts', { namedExports: { logger }, }); - ({ replaceJSExtWithTSExt } = await import('./replace-js-ext-with-ts-ext.ts')); + ({ replaceJSExtWithTSExt } = await import( + './replace-js-ext-with-ts-ext.ts' + )); }); afterEach(() => { @@ -46,9 +62,15 @@ describe('Correcting ts file extensions', { concurrency: true }, () => { describe('unambiguous match', () => { it('should return an updated specifier', async () => { for (const jsExt of jsExts) { - const output = await replaceJSExtWithTSExt(originatingFilePath, `./fixtures/rep${jsExt}`); + const output = await replaceJSExtWithTSExt( + originatingFilePath, + `./fixtures/rep${jsExt}`, + ); - assert.equal(output.replacement, `./fixtures/rep${suspectExts[jsExt]}`); + assert.equal( + output.replacement, + `./fixtures/rep${suspectExts[jsExt]}`, + ); assert.equal(output.isType, false); } }); @@ -58,13 +80,17 @@ describe('Correcting ts file extensions', { concurrency: true }, () => { describe('ambiguous match', () => { it('should skip and log error', async () => { const base = './fixtures/d/ambiguous/index'; - const output = await replaceJSExtWithTSExt(originatingFilePath, `${base}.js`); + const output = await replaceJSExtWithTSExt( + originatingFilePath, + `${base}.js`, + ); assert.equal(output.replacement, null); const { 2: msg } = mock__log.calls[0].arguments; assert.match(msg, /disambiguate/); - for (const dExt of dExts) assert.match(msg, new RegExp(`${base}${dExt}`)); + for (const dExt of dExts) + assert.match(msg, new RegExp(`${base}${dExt}`)); }); }); @@ -72,7 +98,10 @@ describe('Correcting ts file extensions', { concurrency: true }, () => { it('should return an updated specifier', async () => { for (const dExt of dExts) { const base = `./fixtures/d/unambiguous/${dExt.split('.').pop()}/index`; - const output = await replaceJSExtWithTSExt(originatingFilePath, `${base}.js`); + const output = await replaceJSExtWithTSExt( + originatingFilePath, + `${base}.js`, + ); assert.equal(output.replacement, `${base}${dExt}`); assert.equal(output.isType, true); @@ -96,7 +125,10 @@ describe('Correcting ts file extensions', { concurrency: true }, () => { describe('mapped extension does NOT exist', () => { it('should skip and log error', async () => { for (const jsExt of jsExts) { - const output = await replaceJSExtWithTSExt(originatingFilePath, `./fixtures/skip${jsExt}`); + const output = await replaceJSExtWithTSExt( + originatingFilePath, + `./fixtures/skip${jsExt}`, + ); assert.equal(output.replacement, null); assert.equal(output.isType, undefined); @@ -115,7 +147,10 @@ describe('Correcting ts file extensions', { concurrency: true }, () => { base, ); - assert.equal(output.replacement, `${base}${base.endsWith('/') ? '' : '/'}index${jsExt}`); + assert.equal( + output.replacement, + `${base}${base.endsWith('/') ? '' : '/'}index${jsExt}`, + ); assert.equal(output.isType, false); } @@ -127,7 +162,10 @@ describe('Correcting ts file extensions', { concurrency: true }, () => { base, ); - assert.equal(output.replacement, `${base}${base.endsWith('/') ? '' : '/'}index${tsExt}`); + assert.equal( + output.replacement, + `${base}${base.endsWith('/') ? '' : '/'}index${tsExt}`, + ); assert.equal(output.isType, false); } } @@ -165,7 +203,10 @@ describe('Correcting ts file extensions', { concurrency: true }, () => { describe('specifier contains a directory with a file extension', () => { it('should replace only the file extension', async () => { const originalSpecifier = './fixtures/e2e/qux.js'; - const { isType, replacement } = await replaceJSExtWithTSExt(originatingFilePath, originalSpecifier); + const { isType, replacement } = await replaceJSExtWithTSExt( + originatingFilePath, + originalSpecifier, + ); assert.equal(isType, false); assert.equal(replacement, `${originalSpecifier}/index.ts`); diff --git a/recipes/correct-ts-specifiers/src/replace-js-ext-with-ts-ext.ts b/recipes/correct-ts-specifiers/src/replace-js-ext-with-ts-ext.ts index 526bdff5..5266a1ff 100644 --- a/recipes/correct-ts-specifiers/src/replace-js-ext-with-ts-ext.ts +++ b/recipes/correct-ts-specifiers/src/replace-js-ext-with-ts-ext.ts @@ -1,9 +1,20 @@ import { extname } from 'node:path'; -import { logger } from '@nodejs/codemod-utils/logger'; - -import type { FSAbsolutePath, NodeModSpecifier, ResolvedSpecifier, Specifier } from './index.d.ts'; -import { type DExt, type JSExt, type TSExt, extSets, suspectExts } from './exts.ts'; +import { logger } from './logger.ts'; + +import type { + FSAbsolutePath, + NodeModSpecifier, + ResolvedSpecifier, + Specifier, +} from './index.d.ts'; +import { + type DExt, + type JSExt, + type TSExt, + extSets, + suspectExts, +} from './exts.ts'; import { fexists } from './fexists.ts'; import { isDir } from './is-dir.ts'; @@ -33,14 +44,20 @@ export const replaceJSExtWithTSExt = async ( if (!extname(specifier)) { specifier += '.js'; - if (await fexists(parentPath, specifier)) return { replacement: specifier, isType: false }; + if (await fexists(parentPath, specifier)) + return { replacement: specifier, isType: false }; } const oExt = extname(specifier) as JSExt; - const replacement = composeReplacement(specifier, oExt, rExt ?? suspectExts[oExt] ?? ''); + const replacement = composeReplacement( + specifier, + oExt, + rExt ?? suspectExts[oExt] ?? '', + ); - if (await fexists(parentPath, replacement)) return { replacement, isType: false }; + if (await fexists(parentPath, replacement)) + return { replacement, isType: false }; for (const extSet of extSets) { const result = await checkSet(parentPath, specifier, oExt, extSet); @@ -61,7 +78,8 @@ const composeReplacement = ( specifier: Specifier, oExt: JSExt, rExt: DExt | JSExt | TSExt | '', -): Specifier => (oExt ? replaceFileExt(specifier, oExt, rExt) : `${specifier}${rExt}`); +): Specifier => + oExt ? replaceFileExt(specifier, oExt, rExt) : `${specifier}${rExt}`; /** * Replace a file extension within a potentially complex fs path or specifier. @@ -72,7 +90,11 @@ const composeReplacement = ( * @example replaceExt('./qux.js/index.js', '.js', '.ts') → './qux.js/index.ts' * @example replaceExt('./qux.cjs/index.cjs', '.cjs', '.cts') → './qux.cjs/index.cts' */ -function replaceFileExt(str: string, oExt: JSExt, rExt: DExt | JSExt | TSExt | '') { +function replaceFileExt( + str: string, + oExt: JSExt, + rExt: DExt | JSExt | TSExt | '', +) { const i = str.lastIndexOf(oExt); return `${str.slice(0, i)}${rExt}${str.slice(i + oExt.length)}`; } @@ -99,11 +121,13 @@ async function checkSet( for (const ext of exts) { if (ext === oExt) continue; const potential = composeReplacement(specifier, oExt, ext); - if (await fexists(parentPath, potential)) found.add((replacement = potential)); + if (await fexists(parentPath, potential)) + found.add((replacement = potential)); } if (found.size) { - if (found.size === 1) return { isType: exts[0].startsWith('.d'), replacement: replacement! }; + if (found.size === 1) + return { isType: exts[0].startsWith('.d'), replacement: replacement! }; logger( parentPath, diff --git a/recipes/correct-ts-specifiers/src/resolve-specifier.ts b/recipes/correct-ts-specifiers/src/resolve-specifier.ts index 4071039e..de08b212 100644 --- a/recipes/correct-ts-specifiers/src/resolve-specifier.ts +++ b/recipes/correct-ts-specifiers/src/resolve-specifier.ts @@ -1,14 +1,14 @@ +import { readFileSync } from 'node:fs'; +import { dirname } from 'node:path'; import { isAbsolute } from 'node:path'; +import { resolve as resolvePath } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; - -/* node:coverage disable */ import type { FSAbsolutePath, NodeModSpecifier, ResolvedSpecifier, Specifier, } from './index.d.ts'; -/* node:coverage enable */ import { getNotFoundUrl } from './get-not-found-url.ts'; import { resolvesToNodeModule } from './resolves-to-node-module.ts'; @@ -21,7 +21,15 @@ export function resolveSpecifier( parentPath: FSAbsolutePath | ResolvedSpecifier, specifier: Specifier, ): FSAbsolutePath | NodeModSpecifier { - if (URL.canParse(specifier)) return fileURLToPath(specifier) as FSAbsolutePath; + if (URL.canParse(specifier)) + return fileURLToPath(specifier) as FSAbsolutePath; + + const importMetaResolve = ( + import.meta as { resolve?: (specifier: string, parent?: string) => string } + ).resolve; + if (typeof importMetaResolve !== 'function') { + return resolveWithoutImportMeta(parentPath, specifier); + } // import.meta.resolve() gives access to node's resolution algorithm, which is necessary to handle // a myriad of non-obvious routes, like pjson subimports and the result of any hooks that may be @@ -32,15 +40,22 @@ export function resolveSpecifier( ) as ResolvedSpecifier; try { - const interimResolvedUrl = import.meta.resolve(specifier, parentUrl) as ResolvedSpecifier; + const interimResolvedUrl = importMetaResolve( + specifier, + parentUrl, + ) as ResolvedSpecifier; - if (resolvesToNodeModule(interimResolvedUrl, parentUrl, specifier)) return specifier as NodeModSpecifier; + if (resolvesToNodeModule(interimResolvedUrl, parentUrl, specifier)) + return specifier as NodeModSpecifier; resolvedSpecifierUrl = interimResolvedUrl; //! let continue to `fileURLToPath` below } catch (err) { if (!(err instanceof Error)) throw err; - if ( + const tsConfigResolved = resolveViaTsConfigPaths(specifier); + if (tsConfigResolved) { + resolvedSpecifierUrl = tsConfigResolved; + } else if ( (err as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND' && resolvesToNodeModule(getNotFoundUrl(err), parentUrl, specifier) ) { @@ -58,3 +73,207 @@ export function resolveSpecifier( return fileURLToPath(resolvedSpecifierUrl) as FSAbsolutePath; } + +function resolveWithoutImportMeta( + parentPath: FSAbsolutePath | ResolvedSpecifier, + specifier: Specifier, +): FSAbsolutePath | NodeModSpecifier { + if (isAbsolute(specifier)) return specifier as FSAbsolutePath; + + if (specifier.startsWith('./') || specifier.startsWith('../')) { + const parentFsPath = isAbsolute(parentPath) + ? parentPath + : (fileURLToPath(parentPath) as FSAbsolutePath); + const resolved = resolvePath(dirname(parentFsPath), specifier); + return resolved as FSAbsolutePath; + } + + const packageImportResolved = resolveViaPackageImports(specifier); + if (packageImportResolved) { + return fileURLToPath(packageImportResolved) as FSAbsolutePath; + } + + const tsConfigResolved = resolveViaTsConfigPaths(specifier); + if (tsConfigResolved) { + return fileURLToPath(tsConfigResolved) as FSAbsolutePath; + } + + return specifier as NodeModSpecifier; +} + +type TsConfigPathMap = { + keyPrefix: string; + keySuffix: string; + targetPrefix: string; + targetSuffix: string; +}; + +type TsConfigPaths = { + baseDir: string; + entries: TsConfigPathMap[]; +}; + +let cachedTsConfigPaths: TsConfigPaths | null | undefined; +let cachedPackageImports: Record | null | undefined; + +function resolveViaTsConfigPaths( + specifier: Specifier, +): ResolvedSpecifier | null { + if ( + specifier.startsWith('.') || + specifier.startsWith('/') || + specifier.startsWith('#') + ) { + return null; + } + + const tsConfigPaths = getTsConfigPaths(); + if (!tsConfigPaths) return null; + + for (const entry of tsConfigPaths.entries) { + const matched = matchPathPattern( + specifier, + entry.keyPrefix, + entry.keySuffix, + entry.targetPrefix, + entry.targetSuffix, + ); + + if (!matched) continue; + + const absolutePath = isAbsolute(matched) + ? matched + : resolvePath(tsConfigPaths.baseDir, matched); + + return pathToFileURL(absolutePath).href as ResolvedSpecifier; + } + + return null; +} + +function getTsConfigPaths(): TsConfigPaths | null { + if (cachedTsConfigPaths !== undefined) return cachedTsConfigPaths; + + try { + const raw = readFileSync( + resolvePath(process.cwd(), 'tsconfig.json'), + 'utf8', + ); + const json = parseJsonc(raw) as { + compilerOptions?: { + baseUrl?: string; + paths?: Record; + }; + }; + + const paths = json.compilerOptions?.paths; + if (!paths) { + cachedTsConfigPaths = null; + return cachedTsConfigPaths; + } + + const baseDir = resolvePath( + process.cwd(), + json.compilerOptions?.baseUrl ?? '.', + ); + const entries: TsConfigPathMap[] = []; + + for (const [pattern, targets] of Object.entries(paths)) { + if (!targets.length) continue; + + const firstTarget = targets[0]; + const [keyPrefix, keySuffix] = splitStarPattern(pattern); + const [targetPrefix, targetSuffix] = splitStarPattern(firstTarget); + + entries.push({ keyPrefix, keySuffix, targetPrefix, targetSuffix }); + } + + cachedTsConfigPaths = { baseDir, entries }; + return cachedTsConfigPaths; + } catch { + cachedTsConfigPaths = null; + return cachedTsConfigPaths; + } +} + +function resolveViaPackageImports( + specifier: Specifier, +): ResolvedSpecifier | null { + if (!specifier.startsWith('#')) return null; + + const imports = getPackageImports(); + if (!imports) return null; + + const direct = imports[specifier]; + if (direct) { + const resolved = resolvePath(process.cwd(), direct); + return pathToFileURL(resolved).href as ResolvedSpecifier; + } + + for (const [key, target] of Object.entries(imports)) { + const [keyPrefix, keySuffix] = splitStarPattern(key); + const [targetPrefix, targetSuffix] = splitStarPattern(target); + const matched = matchPathPattern( + specifier, + keyPrefix, + keySuffix, + targetPrefix, + targetSuffix, + ); + + if (!matched) continue; + + const resolved = resolvePath(process.cwd(), matched); + return pathToFileURL(resolved).href as ResolvedSpecifier; + } + + return null; +} + +function getPackageImports(): Record | null { + if (cachedPackageImports !== undefined) return cachedPackageImports; + + try { + const raw = readFileSync( + resolvePath(process.cwd(), 'package.json'), + 'utf8', + ); + const parsed = JSON.parse(raw) as { imports?: Record }; + cachedPackageImports = parsed.imports ?? null; + return cachedPackageImports; + } catch { + cachedPackageImports = null; + return cachedPackageImports; + } +} + +function parseJsonc(content: string): unknown { + const withoutComments = content + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/.*$/gm, '$1'); + const withoutTrailingCommas = withoutComments.replace(/,\s*([}\]])/g, '$1'); + return JSON.parse(withoutTrailingCommas); +} + +function splitStarPattern(pattern: string): [string, string] { + const star = pattern.indexOf('*'); + if (star === -1) return [pattern, '']; + return [pattern.slice(0, star), pattern.slice(star + 1)]; +} + +function matchPathPattern( + value: string, + keyPrefix: string, + keySuffix: string, + targetPrefix: string, + targetSuffix: string, +): string | null { + if (!value.startsWith(keyPrefix)) return null; + if (keySuffix && !value.endsWith(keySuffix)) return null; + + const wildcardValue = value.slice( + keyPrefix.length, + value.length - keySuffix.length, + ); + return `${targetPrefix}${wildcardValue}${targetSuffix}`; +} diff --git a/recipes/correct-ts-specifiers/src/resolves-to-node-module.ts b/recipes/correct-ts-specifiers/src/resolves-to-node-module.ts index 7b518ce9..56141b5a 100644 --- a/recipes/correct-ts-specifiers/src/resolves-to-node-module.ts +++ b/recipes/correct-ts-specifiers/src/resolves-to-node-module.ts @@ -8,18 +8,26 @@ export function resolvesToNodeModule( parentLocus: FSAbsolutePath | ResolvedSpecifier, ogSpecifier: string, ) { - if (!resolvedUrl) throw new TypeError('Specifier resolved to nothing.', { - cause: `'${ogSpecifier}' in ${parentLocus}`, - }); + if (!resolvedUrl) + throw new TypeError('Specifier resolved to nothing.', { + cause: `'${ogSpecifier}' in ${parentLocus}`, + }); - if (!URL.canParse(resolvedUrl)) throw new TypeError(`resolvedUrl from '${parentLocus}' must be a file url string: ${resolvedUrl}`, { - cause: `'${ogSpecifier}' in ${parentLocus}`, - }); + if (!URL.canParse(resolvedUrl)) + throw new TypeError( + `resolvedUrl from '${parentLocus}' must be a file url string: ${resolvedUrl}`, + { + cause: `'${ogSpecifier}' in ${parentLocus}`, + }, + ); - const parentUrl = isAbsolute(parentLocus) ? pathToFileURL(parentLocus).href : parentLocus; + const parentUrl = isAbsolute(parentLocus) + ? pathToFileURL(parentLocus).href + : parentLocus; let i = 0; // Track the last common character to determine where to check for a node module. - for (let n = resolvedUrl.length; i < n; i++) if (resolvedUrl[i] !== parentUrl[i]) break; + for (let n = resolvedUrl.length; i < n; i++) + if (resolvedUrl[i] !== parentUrl[i]) break; // The first segment of rest of the resolved url needs to exactly match 'node_module' (it could be // something like 'fake_node_modules') to be a real node module dependency. diff --git a/recipes/correct-ts-specifiers/src/workflow.test.snap.cjs b/recipes/correct-ts-specifiers/src/workflow.test.snap.cjs index 0b7d1d37..1242043c 100644 --- a/recipes/correct-ts-specifiers/src/workflow.test.snap.cjs +++ b/recipes/correct-ts-specifiers/src/workflow.test.snap.cjs @@ -1,3 +1,3 @@ exports[`workflow > should update bad specifiers and ignore good ones 1`] = ` -"import { URL } from 'node:url';\\n\\nimport { bar } from '@dep/bar';\\nimport { foo } from 'foo';\\n\\nimport { Bird } from './Bird/index.ts';\\nimport { Cat } from './Cat.ts';\\nimport { Dog } from '…/Dog/index.mts';\\nimport { baseUrl } from '#config.js';\\nimport { qux } from './qux.js/index.ts';\\n\\nexport type { Zed } from './zed.d.ts';\\n\\n// should.js be unchanged\\n\\nconst nil = await import('./nil.ts');\\n\\nconst bird = new Bird('Tweety');\\nconst cat = new Cat('Milo');\\nconst dog = new Dog('Otis');\\n\\nexport const makeLink = (path: URL) => (new URL(path, baseUrl)).href;\\n\\nconsole.log('bird:', bird);\\nconsole.log('cat:', cat);\\nconsole.log('dog:', dog);\\nconsole.log('foo:', foo);\\nconsole.log('bar:', bar);\\nconsole.log('nil:', nil);\\n" +"// git restore --source main -- recipes/correct-ts-specifiers/src/fixtures/e2e/test.ts\\nimport { URL } from 'node:url';\\n\\nimport { bar } from '@dep/bar';\\nimport { foo } from 'foo';\\n\\nimport { Bird } from './Bird/index.ts';\\nimport { Cat } from './Cat.ts';\\nimport { Dog } from '…/Dog/index.mts';\\nimport { baseUrl } from '#config.js';\\nimport { qux } from './qux.js/index.ts';\\n\\nexport type { Zed } from './zed.d.ts';\\n\\n// should.js be unchanged\\n\\nconst nil = await import('./nil.ts');\\n\\nconst bird = new Bird('Tweety');\\nconst cat = new Cat('Milo');\\nconst dog = new Dog('Otis');\\n\\nexport const makeLink = (path: URL) => (new URL(path, baseUrl)).href;\\n\\nconsole.log('bird:', bird);\\nconsole.log('cat:', cat);\\nconsole.log('dog:', dog);\\nconsole.log('foo:', foo);\\nconsole.log('bar:', bar);\\nconsole.log('nil:', nil);\\n" `; diff --git a/recipes/correct-ts-specifiers/src/workflow.test.ts b/recipes/correct-ts-specifiers/src/workflow.test.ts index ae32bcfe..d9e38c66 100644 --- a/recipes/correct-ts-specifiers/src/workflow.test.ts +++ b/recipes/correct-ts-specifiers/src/workflow.test.ts @@ -1,5 +1,4 @@ import { resolve } from 'node:path'; -import { execPath } from 'node:process'; import { readFile } from 'node:fs/promises'; import { describe, it } from 'node:test'; import { fileURLToPath } from 'node:url'; @@ -10,20 +9,37 @@ describe('workflow', () => { const e2eFixtPath = fileURLToPath(import.meta.resolve('./fixtures/e2e/')); await spawnPromisified( - execPath, + 'npx', [ - '--no-warnings', - '--experimental-strip-types', - '--experimental-import-meta-resolve', - '../../workflow.ts', + 'codemod', + 'workflow', + 'run', + '-w', + '../../../workflow.yaml', + '-t', + '.', + '--allow-fs', + '--allow-dirty', + '--no-interactive', ], { cwd: e2eFixtPath, stdio: 'inherit', + env: { + ...process.env, + NODE_OPTIONS: [ + process.env.NODE_OPTIONS, + '--experimental-import-meta-resolve', + ] + .filter(Boolean) + .join(' '), + }, }, ); - const result = await readFile(resolve(e2eFixtPath, 'test.ts'), { encoding: 'utf-8' }); + const result = await readFile(resolve(e2eFixtPath, 'test.ts'), { + encoding: 'utf-8', + }); t.assert.snapshot(result); }); diff --git a/recipes/correct-ts-specifiers/src/workflow.ts b/recipes/correct-ts-specifiers/src/workflow.ts index 566b915a..e4707a4f 100644 --- a/recipes/correct-ts-specifiers/src/workflow.ts +++ b/recipes/correct-ts-specifiers/src/workflow.ts @@ -1,68 +1,70 @@ -import module from 'node:module'; -import type { RegisterHooksOptions } from 'node:module'; - -import { type Api, api } from '@codemod.com/workflow'; -import type { Helpers } from '@codemod.com/workflow/dist/jsFam.d.ts'; +import type { Edit, SgRoot, SgNode } from '@codemod.com/jssg-types/main'; +import type Js from '@codemod.com/jssg-types/langs/javascript'; import { mapImports } from './map-imports.ts'; import type { FSAbsolutePath } from './index.d.ts'; -import * as aliasLoader from '@nodejs-loaders/alias/alias.loader.mjs'; -module.registerHooks(aliasLoader as RegisterHooksOptions); +export default async function transform( + root: SgRoot, +): Promise { + const rootNode = root.root(); + const filepath = root.filename() as FSAbsolutePath; + const edits: Edit[] = []; -export async function workflow({ contexts, files }: Api) { - await files(globPattern).jsFam(processModule); + const statements = rootNode.findAll({ + rule: { + any: [ + { kind: 'import_statement' }, + { kind: 'export_statement', has: { kind: 'string' } }, + { pattern: 'import("$$$_")' }, + { pattern: "import('$$$_')" }, + ], + }, + }); - async function processModule({ astGrep }: Helpers) { - const filepath = contexts.getFileContext().file as FSAbsolutePath; + for (const statement of statements) { + const statementEdit = await maybeUpdateStatement(statement, filepath); + if (statementEdit) edits.push(statementEdit); + } - await astGrep({ - rule: { - any: [ - { kind: 'import_statement' }, - { kind: 'export_statement', has: { kind: 'string' } }, - { pattern: 'import("$$$_")' }, - { pattern: "import('$$$_')" }, - ], - }, - }).replace(async ({ getNode }) => { - const statement = getNode(); - const importSpecifier = statement.find({ - rule: { - kind: 'string_fragment', - inside: { kind: 'string' }, - }, - }); + if (!edits.length) return null; - if (!importSpecifier) return; + return rootNode.commitEdits(edits); +} - const { isType, replacement } = await mapImports( - filepath, - importSpecifier.text(), - ); +async function maybeUpdateStatement( + statement: SgNode, + filepath: FSAbsolutePath, +): Promise { + const importSpecifier = statement.find({ + rule: { + kind: 'string_fragment', + inside: { kind: 'string' }, + }, + }); - if (!replacement) return; + if (!importSpecifier) return null; - const edits = [importSpecifier.replace(replacement)]; + const original = importSpecifier.text(); + const { isType, replacement } = await mapImports(filepath, original); - if ( - isType && - !statement.children().some((node) => node.kind() === 'type') - ) { - const clause = statement.find({ - rule: { - any: [{ kind: 'import_clause' }, { kind: 'export_clause' }], - }, - }); + if (!replacement) return null; - if (clause) edits[1] = clause.replace(`type ${clause.text()}`); - } + const statementEdits: Edit[] = []; + if (replacement !== original) + statementEdits.push(importSpecifier.replace(replacement)); - return statement.commitEdits(edits); + if (isType && !statement.children().some((node) => node.kind() === 'type')) { + const clause = statement.find({ + rule: { + any: [{ kind: 'import_clause' }, { kind: 'export_clause' }], + }, }); + + if (clause) statementEdits.push(clause.replace(`type ${clause.text()}`)); } -} -const globPattern = '**/*.{cjs,mjs,js,jsx,?(d.)cts,?(d.)mts,?(d.)ts,tsx}'; + if (!statementEdits.length) return null; -workflow(api); + return statement.replace(statement.commitEdits(statementEdits)); +} diff --git a/recipes/correct-ts-specifiers/workflow.yaml b/recipes/correct-ts-specifiers/workflow.yaml new file mode 100644 index 00000000..e7ba2448 --- /dev/null +++ b/recipes/correct-ts-specifiers/workflow.yaml @@ -0,0 +1,27 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod-com/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + runtime: + type: direct + steps: + - name: Correct TypeScript import specifiers for Node.js and modern runtimes + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.cjs" + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript