diff --git a/eslint.config.js b/eslint.config.js index dedda10..5e5c704 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,7 +14,7 @@ export default [ '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unused-vars': [ 'error', - {varsIgnorePattern: '^[A-Z_]'} + {varsIgnorePattern: '^[A-Z_]', argsIgnorePattern: '^_'} ] } } diff --git a/src/analyze/attw.ts b/src/analyze/attw.ts index adccb7e..78e0fdc 100644 --- a/src/analyze/attw.ts +++ b/src/analyze/attw.ts @@ -5,12 +5,13 @@ import { } from '@arethetypeswrong/core'; import {groupProblemsByKind} from '@arethetypeswrong/core/utils'; import {filterProblems, problemKindInfo} from '@arethetypeswrong/core/problems'; -import {ReportPluginResult} from '../types.js'; +import {ReportPluginResult, type Options} from '../types.js'; import type {FileSystem} from '../file-system.js'; import {TarballFileSystem} from '../tarball-file-system.js'; export async function runAttw( - fileSystem: FileSystem + fileSystem: FileSystem, + _options?: Options ): Promise { const result: ReportPluginResult = { messages: [] diff --git a/src/analyze/dependencies.ts b/src/analyze/dependencies.ts index 4e0810a..12bb722 100644 --- a/src/analyze/dependencies.ts +++ b/src/analyze/dependencies.ts @@ -4,7 +4,8 @@ import type { PackageJsonLike, ReportPluginResult, Message, - Stats + Stats, + Options } from '../types.js'; import {FileSystem} from '../file-system.js'; import {normalizePath} from '../utils/path.js'; @@ -147,7 +148,8 @@ async function parsePackageJson( // Keep the existing tarball analysis for backward compatibility export async function runDependencyAnalysis( - fileSystem: FileSystem + fileSystem: FileSystem, + _options?: Options ): Promise { const packageFiles = await fileSystem.listPackageFiles(); const rootDir = await fileSystem.getRootDir(); diff --git a/src/analyze/publint.ts b/src/analyze/publint.ts index ad80798..bba8fce 100644 --- a/src/analyze/publint.ts +++ b/src/analyze/publint.ts @@ -1,11 +1,12 @@ import {publint} from 'publint'; import {formatMessage} from 'publint/utils'; -import {ReportPluginResult} from '../types.js'; +import {ReportPluginResult, type Options} from '../types.js'; import type {FileSystem} from '../file-system.js'; import {TarballFileSystem} from '../tarball-file-system.js'; export async function runPublint( - fileSystem: FileSystem + fileSystem: FileSystem, + _options?: Options ): Promise { const result: ReportPluginResult = { messages: [] diff --git a/src/analyze/replacements.ts b/src/analyze/replacements.ts index 7d9b40f..7f0ee00 100644 --- a/src/analyze/replacements.ts +++ b/src/analyze/replacements.ts @@ -1,10 +1,17 @@ import * as replacements from 'module-replacements'; +import type {ManifestModule, ModuleReplacement} from 'module-replacements'; import {ReportPluginResult} from '../types.js'; import type {FileSystem} from '../file-system.js'; import {getPackageJson} from '../file-system-utils.js'; -import semverSatisfies from 'semver/functions/satisfies.js'; -import semverLessThan from 'semver/ranges/ltr.js'; -import {minVersion, validRange} from 'semver'; +import {resolve, dirname, basename} from 'node:path'; +import { + satisfies as semverSatisfies, + ltr as semverLessThan, + minVersion, + validRange +} from 'semver'; +import {LocalFileSystem} from '../local-file-system.js'; +import type {Options} from '../types.js'; /** * Generates a standard URL to the docs of a given rule @@ -24,6 +31,42 @@ export function getMdnUrl(path: string): string { return `https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/${path}`; } +async function loadCustomManifests( + manifestPaths?: string[] +): Promise { + if (!manifestPaths || manifestPaths.length === 0) { + return []; + } + + const customReplacements: ModuleReplacement[] = []; + + for (const manifestPath of manifestPaths) { + try { + const absolutePath = resolve(manifestPath); + const manifestDir = dirname(absolutePath); + const manifestFileName = basename(absolutePath); + const localFileSystem = new LocalFileSystem(manifestDir); + const manifestContent = await localFileSystem.readFile( + `/${manifestFileName}` + ); + const manifest: ManifestModule = JSON.parse(manifestContent); + + if ( + manifest.moduleReplacements && + Array.isArray(manifest.moduleReplacements) + ) { + customReplacements.push(...manifest.moduleReplacements); + } + } catch (error) { + console.warn( + `Warning: Failed to load custom manifest from ${manifestPath}: ${error}` + ); + } + } + + return customReplacements; +} + function isNodeEngineCompatible( requiredNode: string, enginesNode: string @@ -47,7 +90,8 @@ function isNodeEngineCompatible( } export async function runReplacements( - fileSystem: FileSystem + fileSystem: FileSystem, + options?: Options ): Promise { const result: ReportPluginResult = { messages: [] @@ -60,8 +104,18 @@ export async function runReplacements( return result; } + // Load custom manifests + const customReplacements = await loadCustomManifests(options?.manifest); + + // Combine custom and built-in replacements + const allReplacements = [ + ...customReplacements, + ...replacements.all.moduleReplacements + ]; + for (const name of Object.keys(packageJson.dependencies)) { - const replacement = replacements.all.moduleReplacements.find( + // Find replacement (custom replacements take precedence due to order) + const replacement = allReplacements.find( (replacement) => replacement.moduleName === name ); @@ -69,6 +123,7 @@ export async function runReplacements( continue; } + // Handle each replacement type using the same logic for both custom and built-in if (replacement.type === 'none') { result.messages.push({ severity: 'warning', @@ -101,17 +156,21 @@ export async function runReplacements( replacement.nodeVersion && !enginesNode ? ` Required Node >= ${replacement.nodeVersion}.` : ''; + const message = `Module "${name}" can be replaced with native functionality. Use "${replacement.replacement}" instead.${requires}`; + const fullMessage = `${message} You can read more at ${mdnPath}.`; result.messages.push({ severity: 'warning', score: 0, - message: `Module "${name}" can be replaced with native functionality. Use "${replacement.replacement}" instead. You can read more at ${mdnPath}.${requires}` + message: fullMessage }); } else if (replacement.type === 'documented') { const docUrl = getDocsUrl(replacement.docPath); + const message = `Module "${name}" can be replaced with a more performant alternative.`; + const fullMessage = `${message} See the list of available alternatives at ${docUrl}.`; result.messages.push({ severity: 'warning', score: 0, - message: `Module "${name}" can be replaced with a more performant alternative. See the list of available alternatives at ${docUrl}.` + message: fullMessage }); } } diff --git a/src/analyze/report.ts b/src/analyze/report.ts index b7f839f..2c5d0c6 100644 --- a/src/analyze/report.ts +++ b/src/analyze/report.ts @@ -65,7 +65,7 @@ export async function report(options: Options) { } for (const plugin of plugins) { - const result = await plugin(fileSystem); + const result = await plugin(fileSystem, options); for (const message of result.messages) { messages.push(message); diff --git a/src/commands/analyze.meta.ts b/src/commands/analyze.meta.ts index e7bfd03..4071898 100644 --- a/src/commands/analyze.meta.ts +++ b/src/commands/analyze.meta.ts @@ -22,6 +22,12 @@ export const meta = { choices: ['debug', 'info', 'warn', 'error'], default: 'info', description: 'Set the log level (debug | info | warn | error)' + }, + manifest: { + type: 'string', + array: true, + description: + 'Path(s) to custom manifest file(s) for module replacements analysis' } } } as const; diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index ceb7f03..717b1c1 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -77,8 +77,22 @@ export async function run(ctx: CommandContext) { } } - // Analyze - const {stats, messages} = await report({root, pack}); + // Then analyze the tarball + const rawCustomManifests = ctx.values['manifest']; + + // NOTE: Gunshi quirk - array arguments are sometimes returned as single strings + // when only one value is provided, so we need to handle both cases + const customManifests = Array.isArray(rawCustomManifests) + ? rawCustomManifests + : rawCustomManifests + ? [rawCustomManifests] + : []; + + const {stats, messages} = await report({ + root, + pack, + manifest: customManifests + }); prompts.log.info('Summary'); diff --git a/src/test/__snapshots__/custom-manifests.test.ts.snap b/src/test/__snapshots__/custom-manifests.test.ts.snap new file mode 100644 index 0000000..2ebaff8 --- /dev/null +++ b/src/test/__snapshots__/custom-manifests.test.ts.snap @@ -0,0 +1,106 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Custom Manifests > should handle invalid manifest files gracefully 1`] = `[]`; + +exports[`Custom Manifests > should load and use custom manifest files 1`] = ` +[ + { + "message": "Module "@e18e/fake-0" can be replaced. Use picocolors, kleur, or native console styling.", + "score": 0, + "severity": "warning", + }, + { + "message": "Module "@e18e/fake-1" can be replaced. Use native JavaScript methods or specific lodash functions.", + "score": 0, + "severity": "warning", + }, + { + "message": "Module "@e18e/fake-2" can be replaced with native functionality. Use "Intl.DateTimeFormat or Date methods" instead. Required Node >= 12.0.0. You can read more at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat.", + "score": 0, + "severity": "warning", + }, + { + "message": "Module "@e18e/fake-3" can be replaced with a more performant alternative. See the list of available alternatives at https://github.com/es-tooling/eslint-plugin-depend/blob/main/docs/rules/request-alternatives.md.", + "score": 0, + "severity": "warning", + }, + { + "message": "Module "@e18e/fake-4" can be removed, and native functionality used instead", + "score": 0, + "severity": "warning", + }, +] +`; + +exports[`Custom Manifests > should load multiple manifest files 1`] = ` +[ + { + "message": "Module "@e18e/fake-0" can be replaced. Use picocolors, kleur, or native console styling.", + "score": 0, + "severity": "warning", + }, + { + "message": "Module "@e18e/fake-1" can be replaced. Use native JavaScript methods or specific lodash functions.", + "score": 0, + "severity": "warning", + }, + { + "message": "Module "@e18e/fake-2" can be replaced with native functionality. Use "Intl.DateTimeFormat or Date methods" instead. Required Node >= 12.0.0. You can read more at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat.", + "score": 0, + "severity": "warning", + }, + { + "message": "Module "@e18e/fake-3" can be replaced with a more performant alternative. See the list of available alternatives at https://github.com/es-tooling/eslint-plugin-depend/blob/main/docs/rules/request-alternatives.md.", + "score": 0, + "severity": "warning", + }, + { + "message": "Module "@e18e/fake-4" can be removed, and native functionality used instead", + "score": 0, + "severity": "warning", + }, + { + "message": "Module "@e18e/fake-5" can be replaced. Use Fastify, Koa, or native Node.js http module.", + "score": 0, + "severity": "warning", + }, + { + "message": "Module "@e18e/fake-6" can be removed, and native functionality used instead", + "score": 0, + "severity": "warning", + }, +] +`; + +exports[`Custom Manifests > should prioritize custom replacements over built-in ones 1`] = ` +{ + "withCustom": [ + { + "message": "Module "@e18e/fake-0" can be replaced. Use picocolors, kleur, or native console styling.", + "score": 0, + "severity": "warning", + }, + { + "message": "Module "@e18e/fake-1" can be replaced. Use native JavaScript methods or specific lodash functions.", + "score": 0, + "severity": "warning", + }, + { + "message": "Module "@e18e/fake-2" can be replaced with native functionality. Use "Intl.DateTimeFormat or Date methods" instead. Required Node >= 12.0.0. You can read more at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat.", + "score": 0, + "severity": "warning", + }, + { + "message": "Module "@e18e/fake-3" can be replaced with a more performant alternative. See the list of available alternatives at https://github.com/es-tooling/eslint-plugin-depend/blob/main/docs/rules/request-alternatives.md.", + "score": 0, + "severity": "warning", + }, + { + "message": "Module "@e18e/fake-4" can be removed, and native functionality used instead", + "score": 0, + "severity": "warning", + }, + ], + "withoutCustom": [], +} +`; diff --git a/src/test/custom-manifests.test.ts b/src/test/custom-manifests.test.ts new file mode 100644 index 0000000..03808b8 --- /dev/null +++ b/src/test/custom-manifests.test.ts @@ -0,0 +1,75 @@ +import {describe, it, expect} from 'vitest'; +import {runReplacements} from '../analyze/replacements.js'; +import {LocalFileSystem} from '../local-file-system.js'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {dirname} from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +describe('Custom Manifests', () => { + it('should load and use custom manifest files', async () => { + const testDir = join(__dirname, '../../test/fixtures/fake-modules'); + const fileSystem = new LocalFileSystem(testDir); + const customManifestPath = join( + __dirname, + '../../test/fixtures/custom-manifest.json' + ); + + const result = await runReplacements(fileSystem, { + manifest: [customManifestPath] + }); + + expect(result.messages).toMatchSnapshot(); + }); + + it('should handle invalid manifest files gracefully', async () => { + const testDir = join(__dirname, '../../test/fixtures/fake-modules'); + const fileSystem = new LocalFileSystem(testDir); + const invalidManifestPath = 'non-existent-file.json'; + + const result = await runReplacements(fileSystem, { + manifest: [invalidManifestPath] + }); + + expect(result.messages).toMatchSnapshot(); + }); + + it('should prioritize custom replacements over built-in ones', async () => { + const testDir = join(__dirname, '../../test/fixtures/fake-modules'); + const fileSystem = new LocalFileSystem(testDir); + const customManifestPath = join( + __dirname, + '../../test/fixtures/custom-manifest.json' + ); + + const resultWithCustom = await runReplacements(fileSystem, { + manifest: [customManifestPath] + }); + const resultWithoutCustom = await runReplacements(fileSystem); + + expect({ + withCustom: resultWithCustom.messages, + withoutCustom: resultWithoutCustom.messages + }).toMatchSnapshot(); + }); + + it('should load multiple manifest files', async () => { + const testDir = join(__dirname, '../../test/fixtures/fake-modules'); + const fileSystem = new LocalFileSystem(testDir); + const manifest1Path = join( + __dirname, + '../../test/fixtures/custom-manifest.json' + ); + const manifest2Path = join( + __dirname, + '../../test/fixtures/custom-manifest-2.json' + ); + + const result = await runReplacements(fileSystem, { + manifest: [manifest1Path, manifest2Path] + }); + + expect(result.messages).toMatchSnapshot(); + }); +}); diff --git a/src/types.ts b/src/types.ts index 8244e26..3e5ac8e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,6 +18,7 @@ export type PackType = export interface Options { root?: string; pack?: PackType; + manifest?: string[]; } export interface StatLike { @@ -72,5 +73,6 @@ export interface ReportPluginResult { } export type ReportPlugin = ( - fileSystem: FileSystem + fileSystem: FileSystem, + options?: Options ) => Promise; diff --git a/test/fixtures/custom-manifest-2.json b/test/fixtures/custom-manifest-2.json new file mode 100644 index 0000000..a979190 --- /dev/null +++ b/test/fixtures/custom-manifest-2.json @@ -0,0 +1,14 @@ +{ + "moduleReplacements": [ + { + "moduleName": "@e18e/fake-5", + "type": "simple", + "replacement": "Use Fastify, Koa, or native Node.js http module" + }, + { + "moduleName": "@e18e/fake-6", + "type": "none", + "replacement": "Use native JSON.parse or built-in middleware" + } + ] +} diff --git a/test/fixtures/custom-manifest.json b/test/fixtures/custom-manifest.json new file mode 100644 index 0000000..6170e1a --- /dev/null +++ b/test/fixtures/custom-manifest.json @@ -0,0 +1,32 @@ +{ + "moduleReplacements": [ + { + "moduleName": "@e18e/fake-0", + "type": "simple", + "replacement": "Use picocolors, kleur, or native console styling" + }, + { + "moduleName": "@e18e/fake-1", + "type": "simple", + "replacement": "Use native JavaScript methods or specific lodash functions" + }, + { + "moduleName": "@e18e/fake-2", + "type": "native", + "replacement": "Intl.DateTimeFormat or Date methods", + "mdnPath": "Global_Objects/Intl/DateTimeFormat", + "nodeVersion": "12.0.0" + }, + { + "moduleName": "@e18e/fake-3", + "type": "documented", + "replacement": "node-fetch, axios, or native fetch", + "docPath": "request-alternatives" + }, + { + "moduleName": "@e18e/fake-4", + "type": "none", + "replacement": "Use native Promise methods" + } + ] +} diff --git a/test/fixtures/fake-modules/package.json b/test/fixtures/fake-modules/package.json new file mode 100644 index 0000000..9e757cf --- /dev/null +++ b/test/fixtures/fake-modules/package.json @@ -0,0 +1,13 @@ +{ + "name": "fake-modules-test", + "version": "1.0.0", + "dependencies": { + "@e18e/fake-0": "^1.0.0", + "@e18e/fake-1": "^2.0.0", + "@e18e/fake-2": "^3.0.0", + "@e18e/fake-3": "^4.0.0", + "@e18e/fake-4": "^5.0.0", + "@e18e/fake-5": "^6.0.0", + "@e18e/fake-6": "^7.0.0" + } +}