diff --git a/package-lock.json b/package-lock.json index 55b3f9b..a79137c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@clack/prompts": "^1.3.0", + "@e18e/web-features-codemods": "^0.2.0", "@publint/pack": "^0.1.4", "core-js-compat": "^3.48.0", "fast-wrap-ansi": "^0.2.0", @@ -895,6 +896,179 @@ "node": ">= 20.12.0" } }, + "node_modules/@e18e/web-features-codemods": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@e18e/web-features-codemods/-/web-features-codemods-0.2.0.tgz", + "integrity": "sha512-KxUzUEoBzyn9SwdIA5mtOcngXxXRsKx0aE9t+tpYVCZNhZhTQNC9yYmP8exVNPpS5DNjjd51c61uLZjP4+QCTA==", + "license": "MIT", + "dependencies": { + "@ast-grep/napi": "^0.40.0" + } + }, + "node_modules/@e18e/web-features-codemods/node_modules/@ast-grep/napi": { + "version": "0.40.5", + "resolved": "https://registry.npmjs.org/@ast-grep/napi/-/napi-0.40.5.tgz", + "integrity": "sha512-hJA62OeBKUQT68DD2gDyhOqJxZxycqg8wLxbqjgqSzYttCMSDL9tiAQ9abgekBYNHudbJosm9sWOEbmCDfpX2A==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@ast-grep/napi-darwin-arm64": "0.40.5", + "@ast-grep/napi-darwin-x64": "0.40.5", + "@ast-grep/napi-linux-arm64-gnu": "0.40.5", + "@ast-grep/napi-linux-arm64-musl": "0.40.5", + "@ast-grep/napi-linux-x64-gnu": "0.40.5", + "@ast-grep/napi-linux-x64-musl": "0.40.5", + "@ast-grep/napi-win32-arm64-msvc": "0.40.5", + "@ast-grep/napi-win32-ia32-msvc": "0.40.5", + "@ast-grep/napi-win32-x64-msvc": "0.40.5" + } + }, + "node_modules/@e18e/web-features-codemods/node_modules/@ast-grep/napi-darwin-arm64": { + "version": "0.40.5", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-darwin-arm64/-/napi-darwin-arm64-0.40.5.tgz", + "integrity": "sha512-2F072fGN0WTq7KI3okuEnkGJVEHLbi56Bw1H6NAMf7j2mJJeQWsRyGOMcyNnUXZDeNdvoMH0OB2a5wwUegY/nQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@e18e/web-features-codemods/node_modules/@ast-grep/napi-darwin-x64": { + "version": "0.40.5", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-darwin-x64/-/napi-darwin-x64-0.40.5.tgz", + "integrity": "sha512-dJMidHZhhxuLBYNi6/FKI812jQ7wcFPSKkVPwviez2D+KvYagapUMAV/4dJ7FCORfguVk8Y0jpPAlYmWRT5nvA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@e18e/web-features-codemods/node_modules/@ast-grep/napi-linux-arm64-gnu": { + "version": "0.40.5", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-linux-arm64-gnu/-/napi-linux-arm64-gnu-0.40.5.tgz", + "integrity": "sha512-nBRCbyoS87uqkaw4Oyfe5VO+SRm2B+0g0T8ME69Qry9ShMf41a2bTdpcQx9e8scZPogq+CTwDHo3THyBV71l9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@e18e/web-features-codemods/node_modules/@ast-grep/napi-linux-arm64-musl": { + "version": "0.40.5", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-linux-arm64-musl/-/napi-linux-arm64-musl-0.40.5.tgz", + "integrity": "sha512-/qKsmds5FMoaEj6FdNzepbmLMtlFuBLdrAn9GIWCqOIcVcYvM1Nka8+mncfeXB/MFZKOrzQsQdPTWqrrQzXLrA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@e18e/web-features-codemods/node_modules/@ast-grep/napi-linux-x64-gnu": { + "version": "0.40.5", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-linux-x64-gnu/-/napi-linux-x64-gnu-0.40.5.tgz", + "integrity": "sha512-DP4oDbq7f/1A2hRTFLhJfDFR6aI5mRWdEfKfHzRItmlKsR9WlcEl1qDJs/zX9R2EEtIDsSKRzuJNfJllY3/W8Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@e18e/web-features-codemods/node_modules/@ast-grep/napi-linux-x64-musl": { + "version": "0.40.5", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-linux-x64-musl/-/napi-linux-x64-musl-0.40.5.tgz", + "integrity": "sha512-BRZUvVBPUNpWPo6Ns8chXVzxHPY+k9gpsubGTHy92Q26ecZULd/dTkWWdnvfhRqttsSQ9Pe/XQdi5+hDQ6RYcg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@e18e/web-features-codemods/node_modules/@ast-grep/napi-win32-arm64-msvc": { + "version": "0.40.5", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-win32-arm64-msvc/-/napi-win32-arm64-msvc-0.40.5.tgz", + "integrity": "sha512-y95zSEwc7vhxmcrcH0GnK4ZHEBQrmrszRBNQovzaciF9GUqEcCACNLoBesn4V47IaOp4fYgD2/EhGRTIBFb2Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@e18e/web-features-codemods/node_modules/@ast-grep/napi-win32-ia32-msvc": { + "version": "0.40.5", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-win32-ia32-msvc/-/napi-win32-ia32-msvc-0.40.5.tgz", + "integrity": "sha512-K/u8De62iUnFCzVUs7FBdTZ2Jrgc5/DLHqjpup66KxZ7GIM9/HGME/O8aSoPkpcAeCD4TiTZ11C1i5p5H98hTg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@e18e/web-features-codemods/node_modules/@ast-grep/napi-win32-x64-msvc": { + "version": "0.40.5", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-win32-x64-msvc/-/napi-win32-x64-msvc-0.40.5.tgz", + "integrity": "sha512-dqm5zg/o4Nh4VOQPEpMS23ot8HVd22gG0eg01t4CFcZeuzyuSgBlOL3N7xLbz3iH2sVkk7keuBwAzOIpTqziNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", diff --git a/package.json b/package.json index d1dd57a..3f618e0 100644 --- a/package.json +++ b/package.json @@ -47,8 +47,10 @@ "homepage": "https://github.com/e18e/cli#readme", "dependencies": { "@clack/prompts": "^1.3.0", + "@e18e/web-features-codemods": "^0.2.0", "fast-wrap-ansi": "^0.2.0", "@publint/pack": "^0.1.4", + "fast-wrap-ansi": "^0.2.0", "fdir": "^6.5.0", "gunshi": "^0.29.5", "lockparse": "^0.5.0", diff --git a/src/analyze/core-js.ts b/src/analyze/core-js.ts index f7303d5..4d4e3d2 100644 --- a/src/analyze/core-js.ts +++ b/src/analyze/core-js.ts @@ -2,6 +2,7 @@ import {glob} from 'tinyglobby'; import {minVersion} from 'semver'; import {relative, join} from 'path'; import type {AnalysisContext, ReportPluginResult} from '../types.js'; +import {SOURCE_GLOB, SOURCE_IGNORE} from '../utils/source-files.js'; import coreJsCompat from 'core-js-compat'; @@ -12,15 +13,6 @@ const BROAD_IMPORTS = new Set([ 'core-js/full' ]); -const SOURCE_GLOB = ['**/*.{js,ts,mjs,cjs,jsx,tsx}']; -const SOURCE_IGNORE = [ - '**/node_modules/**', - '**/dist/**', - '**/build/**', - '**/coverage/**', - '**/lib/**' -]; - const IMPORT_RE = /(?:import\s+(?:.*\s+from\s+)?|require\s*\()\s*['"]([^'"]+)['"]/g; diff --git a/src/analyze/report.ts b/src/analyze/report.ts index 01e6f0c..37e631e 100644 --- a/src/analyze/report.ts +++ b/src/analyze/report.ts @@ -17,13 +17,15 @@ import {getPackageJson, detectLockfile} from '../utils/package-json.js'; import {parse as parseLockfile} from 'lockparse'; import {runDuplicateDependencyAnalysis} from './duplicate-dependencies.js'; import {runCoreJsAnalysis} from './core-js.js'; +import {runWebFeaturesCodemodsAnalysis} from './web-features-codemods.js'; const plugins: ReportPlugin[] = [ runPublint, runReplacements, runDependencyAnalysis, runDuplicateDependencyAnalysis, - runCoreJsAnalysis + runCoreJsAnalysis, + runWebFeaturesCodemodsAnalysis ]; async function computeInfo(fileSystem: FileSystem) { diff --git a/src/analyze/web-features-codemods.ts b/src/analyze/web-features-codemods.ts new file mode 100644 index 0000000..c0c8ae3 --- /dev/null +++ b/src/analyze/web-features-codemods.ts @@ -0,0 +1,69 @@ +import {glob} from 'tinyglobby'; +import {join, relative} from 'node:path'; +import * as WebFeatureCodemodExports from '@e18e/web-features-codemods'; +// TODO: change this once CodeMod is exported from @e18e/web-features-codemods +import type {CodeMod} from '@e18e/web-features-codemods/lib/shared.js'; +import type {AnalysisContext, ReportPluginResult} from '../types.js'; +import {SOURCE_GLOB, SOURCE_IGNORE} from '../utils/source-files.js'; + +function isWebFeatureCodemod(value: unknown): value is CodeMod { + return ( + typeof value === 'object' && + value !== null && + 'test' in value && + typeof value.test === 'function' && + 'apply' in value && + typeof value.apply === 'function' + ); +} + +const webFeatureCodemods = Object.entries(WebFeatureCodemodExports).filter( + (entry): entry is [string, CodeMod] => isWebFeatureCodemod(entry[1]) +); + +export async function runWebFeaturesCodemodsAnalysis( + context: AnalysisContext +): Promise { + const messages: ReportPluginResult['messages'] = []; + + const srcGlobs = context.options?.src; + const patterns = srcGlobs && srcGlobs.length > 0 ? srcGlobs : SOURCE_GLOB; + const allFiles = await glob(patterns, { + cwd: context.root, + ignore: SOURCE_IGNORE + }); + // filter out any paths that escaped context.root via ../ + const files = allFiles.filter( + (f) => !relative(context.root, join(context.root, f)).startsWith('..') + ); + + for (const filePath of files) { + let source: string; + try { + source = await context.fs.readFile(filePath); + } catch { + continue; + } + + const matches: string[] = []; + for (const [name, codemod] of webFeatureCodemods) { + try { + if (codemod.test({source})) { + matches.push(name); + } + } catch { + continue; + } + } + + if (matches.length > 0) { + messages.push({ + severity: 'suggestion', + score: 0, + message: `File "${filePath}" can use newer web features: ${matches.join(', ')}.` + }); + } + } + + return {messages}; +} diff --git a/src/test/analyze/__snapshots__/web-features-codemods.test.ts.snap b/src/test/analyze/__snapshots__/web-features-codemods.test.ts.snap new file mode 100644 index 0000000..1dc544a --- /dev/null +++ b/src/test/analyze/__snapshots__/web-features-codemods.test.ts.snap @@ -0,0 +1,45 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`runWebFeaturesCodemodsAnalysis > handles a file with multiple matches 1`] = ` +[ + { + "message": "File "index.js" can use newer web features: arrayAt, exponentiation.", + "score": 0, + "severity": "suggestion", + }, +] +`; + +exports[`runWebFeaturesCodemodsAnalysis > handles a file with no matches 1`] = `[]`; + +exports[`runWebFeaturesCodemodsAnalysis > handles a file with one match 1`] = ` +[ + { + "message": "File "index.js" can use newer web features: arrayAt.", + "score": 0, + "severity": "suggestion", + }, +] +`; + +exports[`runWebFeaturesCodemodsAnalysis > handles multiple occurrences of the same match in one file 1`] = ` +[ + { + "message": "File "index.js" can use newer web features: arrayAt.", + "score": 0, + "severity": "suggestion", + }, +] +`; + +exports[`runWebFeaturesCodemodsAnalysis > ignores a file path outside of the root 1`] = `[]`; + +exports[`runWebFeaturesCodemodsAnalysis > respects the src option 1`] = ` +[ + { + "message": "File "src/index.js" can use newer web features: arrayAt.", + "score": 0, + "severity": "suggestion", + }, +] +`; diff --git a/src/test/analyze/web-features-codemods.test.ts b/src/test/analyze/web-features-codemods.test.ts new file mode 100644 index 0000000..b54e0c8 --- /dev/null +++ b/src/test/analyze/web-features-codemods.test.ts @@ -0,0 +1,146 @@ +import {describe, it, expect, beforeEach, afterEach} from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {runWebFeaturesCodemodsAnalysis} from '../../analyze/web-features-codemods.js'; +import {LocalFileSystem} from '../../local-file-system.js'; +import {createTempDir, cleanupTempDir} from '../utils.js'; +import type {AnalysisContext} from '../../types.js'; + +function makeContext( + tempDir: string, + overrides: Partial = {} +): AnalysisContext { + return { + fs: new LocalFileSystem(tempDir), + root: tempDir, + messages: [], + stats: { + name: 'test-package', + version: '1.0.0', + dependencyCount: {production: 0, development: 0}, + extraStats: [] + }, + lockfile: { + type: 'npm', + packages: [], + root: { + name: 'test-package', + version: '1.0.0', + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + } + }, + packageFile: { + name: 'test-package', + version: '1.0.0' + }, + ...overrides + }; +} + +describe('runWebFeaturesCodemodsAnalysis', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await createTempDir(); + }); + + afterEach(async () => { + await cleanupTempDir(tempDir); + }); + + it('handles a file with no matches', async () => { + await fs.writeFile(path.join(tempDir, 'index.js'), 'const value = 1;\n'); + + const {messages} = await runWebFeaturesCodemodsAnalysis( + makeContext(tempDir) + ); + + expect(messages).toMatchSnapshot(); + }); + + it('handles a file with one match', async () => { + await fs.writeFile( + path.join(tempDir, 'index.js'), + 'const last = items[items.length - 1];\n' + ); + + const {messages} = await runWebFeaturesCodemodsAnalysis( + makeContext(tempDir) + ); + + expect(messages).toMatchSnapshot(); + }); + + it('handles a file with multiple matches', async () => { + await fs.writeFile( + path.join(tempDir, 'index.js'), + [ + 'const last = items[items.length - 1];', + 'const squared = Math.pow(value, 2);' + ].join('\n') + ); + + const {messages} = await runWebFeaturesCodemodsAnalysis( + makeContext(tempDir) + ); + + expect(messages).toMatchSnapshot(); + }); + + it('handles multiple occurrences of the same match in one file', async () => { + await fs.writeFile( + path.join(tempDir, 'index.js'), + [ + 'const lastItem = items[items.length - 1];', + 'const lastValue = values[values.length - 1];' + ].join('\n') + ); + + const {messages} = await runWebFeaturesCodemodsAnalysis( + makeContext(tempDir) + ); + + expect(messages).toMatchSnapshot(); + }); + + it('respects the src option', async () => { + await fs.mkdir(path.join(tempDir, 'src'), {recursive: true}); + await fs.mkdir(path.join(tempDir, 'other'), {recursive: true}); + await fs.writeFile( + path.join(tempDir, 'src', 'index.js'), + 'const last = items[items.length - 1];\n' + ); + await fs.writeFile( + path.join(tempDir, 'other', 'index.js'), + 'const last = values[values.length - 1];\n' + ); + + const {messages} = await runWebFeaturesCodemodsAnalysis( + makeContext(tempDir, {options: {src: ['src/**/*.js']}}) + ); + + expect(messages).toMatchSnapshot(); + }); + + it('ignores a file path outside of the root', async () => { + const outsideDir = await createTempDir(); + try { + await fs.writeFile( + path.join(outsideDir, 'index.js'), + 'const last = items[items.length - 1];\n' + ); + + const outsidePattern = `../${path.basename(outsideDir)}/index.js`; + const {messages} = await runWebFeaturesCodemodsAnalysis( + makeContext(tempDir, {options: {src: [outsidePattern]}}) + ); + + expect(messages).toMatchSnapshot(); + } finally { + await cleanupTempDir(outsideDir); + } + }); +}); diff --git a/src/utils/source-files.ts b/src/utils/source-files.ts new file mode 100644 index 0000000..9b54e05 --- /dev/null +++ b/src/utils/source-files.ts @@ -0,0 +1,9 @@ +export const SOURCE_GLOB = ['**/*.{js,ts,mjs,cjs,jsx,tsx}']; + +export const SOURCE_IGNORE = [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/coverage/**', + '**/lib/**' +];