From 70403e034bbbc808198015ba11a59258fe136478 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Tue, 12 May 2026 08:20:46 -0500 Subject: [PATCH 01/10] feat(analyze): add shared runtime target resolution --- package-lock.json | 35 ++-- package.json | 4 +- src/analyze/report.ts | 21 ++- src/commands/analyze.meta.ts | 10 + src/commands/analyze.ts | 23 ++- src/index.ts | 11 ++ src/targets/resolve-runtime-target.ts | 119 ++++++++++++ src/targets/runtime-target.ts | 47 +++++ src/test/__snapshots__/cli.test.ts.snap | 2 + src/test/analyze/core-js.test.ts | 51 +++-- src/test/analyze/dependencies.test.ts | 7 +- .../analyze/web-features-codemods.test.ts | 51 +++-- src/test/custom-manifests.test.ts | 10 +- src/test/duplicate-dependencies.test.ts | 18 +- src/test/plugin-runner.test.ts | 22 ++- src/test/resolve-runtime-target.test.ts | 175 ++++++++++++++++++ src/test/utils.ts | 16 ++ src/types.ts | 8 + 18 files changed, 570 insertions(+), 60 deletions(-) create mode 100644 src/targets/resolve-runtime-target.ts create mode 100644 src/targets/runtime-target.ts create mode 100644 src/test/resolve-runtime-target.test.ts diff --git a/package-lock.json b/package-lock.json index ab9655c..6a90839 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@clack/prompts": "^1.3.0", "@e18e/web-features-codemods": "^0.2.0", "@publint/pack": "^0.1.4", + "browserslist": "^4.28.2", "core-js-compat": "^3.48.0", "fast-wrap-ansi": "^0.2.0", "fdir": "^6.5.0", @@ -2240,9 +2241,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.10", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", - "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -2274,9 +2275,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "funding": [ { "type": "opencollective", @@ -2293,11 +2294,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -2313,9 +2314,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001781", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", - "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", "funding": [ { "type": "opencollective", @@ -2471,9 +2472,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.325", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", - "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==", + "version": "1.5.353", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", "license": "ISC" }, "node_modules/es-module-lexer": { diff --git a/package.json b/package.json index 04d15b9..a5228f2 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,9 @@ "dependencies": { "@clack/prompts": "^1.3.0", "@e18e/web-features-codemods": "^0.2.0", - "fast-wrap-ansi": "^0.2.0", "@publint/pack": "^0.1.4", + "browserslist": "^4.28.2", + "core-js-compat": "^3.48.0", "fast-wrap-ansi": "^0.2.0", "fdir": "^6.5.0", "gunshi": "^0.29.5", @@ -59,7 +60,6 @@ "obug": "^2.1.1", "package-manager-detector": "^1.6.0", "publint": "^0.3.20", - "core-js-compat": "^3.48.0", "semver": "^7.8.0", "tinyglobby": "^0.2.16" }, diff --git a/src/analyze/report.ts b/src/analyze/report.ts index 37e631e..1e3837c 100644 --- a/src/analyze/report.ts +++ b/src/analyze/report.ts @@ -18,6 +18,10 @@ 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'; +import { + resolveRuntimeTarget, + formatResolvedRuntimeTargetSummary +} from '../targets/resolve-runtime-target.js'; const plugins: ReportPlugin[] = [ runPublint, @@ -93,6 +97,13 @@ export async function report(options: Options) { extraStats: [] }; + const resolvedRuntimeTarget = resolveRuntimeTarget({ + root, + packageFile, + runtime: options?.runtime, + browserslistQuery: options?.browserslistQuery + }); + const context: AnalysisContext = { fs: fileSystem, root, @@ -100,10 +111,18 @@ export async function report(options: Options) { lockfile: parsedLock, stats, messages, - options + options, + resolvedRuntimeTarget }; await runPlugins(context, plugins); + stats.extraStats ??= []; + stats.extraStats.push({ + name: 'analyzeTarget', + label: 'Analyze target', + value: formatResolvedRuntimeTargetSummary(resolvedRuntimeTarget) + }); + const info = await computeInfo(fileSystem); return {info, messages, stats}; diff --git a/src/commands/analyze.meta.ts b/src/commands/analyze.meta.ts index 6881afb..f94b391 100644 --- a/src/commands/analyze.meta.ts +++ b/src/commands/analyze.meta.ts @@ -45,6 +45,16 @@ export const meta = { multiple: true, description: 'Glob pattern(s) for source files to scan for imports (e.g. "src/**/*.ts"). Defaults to scanning all JS/TS files from the project root.' + }, + runtime: { + type: 'string', + description: + 'Target runtime for replacement engine matching: any, browser, nodejs, deno, bun, cloudflare. Default: inferred (browser when Browserslist is present, else nodejs).' + }, + 'browserslist-query': { + type: 'string', + description: + 'Override Browserslist targets (e.g. "baseline widely available"). Overrides project Browserslist config; see https://web.dev/articles/use-baseline-with-browserslist' } } } as const; diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index 09ad0f5..f98c07f 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -8,6 +8,7 @@ import {enableDebug} from '../logger.js'; import {wrapAnsi} from 'fast-wrap-ansi'; import {parseCategories} from '../categories.js'; import type {Message} from '../types.js'; +import {parseTargetRuntime} from '../targets/runtime-target.js'; function formatBytes(bytes: number) { const units = ['B', 'KB', 'MB', 'GB']; @@ -79,6 +80,20 @@ export async function run(ctx: CommandContext) { process.exit(1); } + let parsedRuntime: ReturnType; + try { + parsedRuntime = parseTargetRuntime(ctx.values.runtime); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const descriptiveMessage = `Invalid --runtime: ${message}`; + if (jsonOutput) { + process.stderr.write(`Error: ${descriptiveMessage}\n`); + } else { + prompts.cancel(descriptiveMessage); + } + process.exit(1); + } + // Path can be a directory (analyze project) if (providedPath) { let stat: Stats | null; @@ -102,12 +117,18 @@ export async function run(ctx: CommandContext) { const customManifests = ctx.values['manifest']; const srcDirs = ctx.values['src']; + const browserslistQuery = ctx.values['browserslist-query']; const {stats, messages} = await report({ root, manifest: customManifests, src: srcDirs, - categories: parsedCategories + categories: parsedCategories, + runtime: parsedRuntime, + browserslistQuery: + typeof browserslistQuery === 'string' && browserslistQuery.trim() !== '' + ? browserslistQuery.trim() + : undefined }); const thresholdRank = FAIL_THRESHOLD_RANK[logLevel] ?? 0; diff --git a/src/index.ts b/src/index.ts index 716539b..a30ef79 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,17 @@ import type {PackageModuleType} from './compute-type.js'; export type {Message, Options, PackageModuleType, Stat}; +export type { + ResolvedRuntimeTarget, + RuntimePrimarySource, + TargetRuntime +} from './targets/runtime-target.js'; +export {parseTargetRuntime, TARGET_RUNTIMES} from './targets/runtime-target.js'; +export { + resolveRuntimeTarget, + formatResolvedRuntimeTargetSummary +} from './targets/resolve-runtime-target.js'; + export {report} from './analyze/report.js'; // Core modules - reusable logic for external tools diff --git a/src/targets/resolve-runtime-target.ts b/src/targets/resolve-runtime-target.ts new file mode 100644 index 0000000..b323876 --- /dev/null +++ b/src/targets/resolve-runtime-target.ts @@ -0,0 +1,119 @@ +import browserslist from 'browserslist'; +import type {PackageJsonLike} from '../types.js'; +import type { + ResolvedRuntimeTarget, + RuntimePrimarySource, + TargetRuntime +} from './runtime-target.js'; + +export interface ResolveRuntimeTargetInput { + root: string; + packageFile: PackageJsonLike; + /** CLI override; wins over project Browserslist config. */ + browserslistQuery?: string; + /** CLI explicit runtime; if omitted, inferred from resolution path. */ + runtime?: TargetRuntime; +} + +function normalizeBrowserslistQueries( + value: string | undefined +): string[] | undefined { + if (value === undefined) { + return undefined; + } + const trimmed = value.trim(); + if (trimmed === '') { + return undefined; + } + return [trimmed]; +} + +function queriesFromProject(root: string): string[] | undefined { + const loaded = browserslist.loadConfig({path: root}); + if (!loaded || loaded.length === 0) { + return undefined; + } + return [...loaded]; +} + +function inferRuntime( + explicit: TargetRuntime | undefined, + hasBrowserslistQueries: boolean +): TargetRuntime { + if (explicit !== undefined) { + return explicit; + } + if (hasBrowserslistQueries) { + return 'browser'; + } + return 'nodejs'; +} + +/** + * Resolves effective analysis targets with precedence: + * CLI `--browserslist-query` > project Browserslist > `engines.node` > default. + */ +export function resolveRuntimeTarget( + input: ResolveRuntimeTargetInput +): ResolvedRuntimeTarget { + const { + root, + packageFile, + browserslistQuery: cliQuery, + runtime: runtimeCli + } = input; + + const nodeRange = packageFile.engines?.node; + + let primarySource: RuntimePrimarySource = 'default'; + let browserslistQueries: string[] | undefined; + + const cliNormalized = normalizeBrowserslistQueries(cliQuery); + if (cliNormalized) { + primarySource = 'cli-browserslist'; + browserslistQueries = cliNormalized; + } else { + const fromProject = queriesFromProject(root); + if (fromProject) { + primarySource = 'project-browserslist'; + browserslistQueries = fromProject; + } else if (nodeRange) { + primarySource = 'engines-node'; + } + } + + const runtime = inferRuntime( + runtimeCli, + browserslistQueries !== undefined && browserslistQueries.length > 0 + ); + + return { + runtime, + primarySource, + browserslistQueries, + nodeRange + }; +} + +/** One-line summary for CLI / `stats.extraStats` (Analyze target row). */ +export function formatResolvedRuntimeTargetSummary( + t: ResolvedRuntimeTarget +): string { + const queries = t.browserslistQueries?.join(', '); + switch (t.primarySource) { + case 'cli-browserslist': + return queries + ? `${t.runtime} (CLI Browserslist: ${queries})` + : `${t.runtime} (CLI Browserslist)`; + case 'project-browserslist': + return queries + ? `${t.runtime} (Browserslist: ${queries})` + : `${t.runtime} (Browserslist)`; + case 'engines-node': + return t.nodeRange + ? `${t.runtime} (engines.node: ${t.nodeRange})` + : `${t.runtime} (engines.node)`; + default: + return `${t.runtime} (default — no Browserslist or engines.node)`; + } +} diff --git a/src/targets/runtime-target.ts b/src/targets/runtime-target.ts new file mode 100644 index 0000000..cf63b44 --- /dev/null +++ b/src/targets/runtime-target.ts @@ -0,0 +1,47 @@ +/** + * Runtime dimension for replacement engine matching. + */ +export const TARGET_RUNTIMES = [ + 'any', + 'browser', + 'nodejs', + 'deno', + 'bun', + 'cloudflare' +] as const; + +export type TargetRuntime = (typeof TARGET_RUNTIMES)[number]; + +const RUNTIME_SET = new Set(TARGET_RUNTIMES); + +export type RuntimePrimarySource = + | 'cli-browserslist' + | 'project-browserslist' + | 'engines-node' + | 'default'; + +export interface ResolvedRuntimeTarget { + /** Effective runtime for `engines_match_runtime`-style checks. */ + runtime: TargetRuntime; + /** Highest-precedence source that supplied browserlist-style targets. */ + primarySource: RuntimePrimarySource; + /** Queries passed to tools that accept Browserslist (e.g. core-js-compat). */ + browserslistQueries: string[] | undefined; + /** `package.json#engines.node` when present (always from manifest). */ + nodeRange: string | undefined; +} + +export function parseTargetRuntime( + value: string | undefined +): TargetRuntime | undefined { + if (value === undefined || value === '') { + return undefined; + } + const trimmed = value.trim(); + if (!RUNTIME_SET.has(trimmed)) { + throw new Error( + `Invalid --runtime "${value}". Valid values: ${TARGET_RUNTIMES.join(', ')}` + ); + } + return trimmed as TargetRuntime; +} diff --git a/src/test/__snapshots__/cli.test.ts.snap b/src/test/__snapshots__/cli.test.ts.snap index bd6e3f4..c170da7 100644 --- a/src/test/__snapshots__/cli.test.ts.snap +++ b/src/test/__snapshots__/cli.test.ts.snap @@ -11,6 +11,7 @@ exports[`CLI > should display package report 1`] = ` │ Install Size 53.0 B │ Dependencies 1 (1 production, 0 development) │ Duplicate Dependency Count 0 +│ Analyze target nodejs (default — no Browserslist or engines.node) │ ● Results: │ @@ -33,6 +34,7 @@ exports[`CLI > should run successfully with default options 1`] = ` │ Install Size 53.0 B │ Dependencies 1 (1 production, 0 development) │ Duplicate Dependency Count 0 +│ Analyze target nodejs (default — no Browserslist or engines.node) │ ● Results: │ diff --git a/src/test/analyze/core-js.test.ts b/src/test/analyze/core-js.test.ts index 799a5ea..9917650 100644 --- a/src/test/analyze/core-js.test.ts +++ b/src/test/analyze/core-js.test.ts @@ -4,8 +4,12 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import {runCoreJsAnalysis} from '../../analyze/core-js.js'; import {LocalFileSystem} from '../../local-file-system.js'; -import {createTempDir, cleanupTempDir} from '../utils.js'; -import type {AnalysisContext} from '../../types.js'; +import { + createTempDir, + cleanupTempDir, + testResolvedRuntimeTarget +} from '../utils.js'; +import type {AnalysisContext, PackageJsonLike} from '../../types.js'; const cjsRequire = createRequire(import.meta.url); const {compat} = cjsRequire('core-js-compat') as { @@ -26,17 +30,41 @@ function makeContext( tempDir: string, overrides: Partial = {} ): AnalysisContext { + const { + resolvedRuntimeTarget: rtOverride, + packageFile: pkgOverride, + options: optionsOverride, + fs: fsOverride, + root: rootOverride, + messages: messagesOverride, + stats: statsOverride, + lockfile: lockfileOverride, + ...rest + } = overrides; + + const packageFile = (pkgOverride ?? { + name: 'test-package', + version: '1.0.0' + }) as PackageJsonLike; + const root = rootOverride ?? tempDir; + const resolvedRuntimeTarget = + rtOverride ?? + testResolvedRuntimeTarget(root, packageFile, { + runtime: optionsOverride?.runtime, + browserslistQuery: optionsOverride?.browserslistQuery + }); + return { - fs: new LocalFileSystem(tempDir), - root: tempDir, - messages: [], - stats: { + fs: fsOverride ?? new LocalFileSystem(tempDir), + root, + messages: messagesOverride ?? [], + stats: statsOverride ?? { name: 'test-package', version: '1.0.0', dependencyCount: {production: 0, development: 0}, extraStats: [] }, - lockfile: { + lockfile: lockfileOverride ?? { type: 'npm', packages: [], root: { @@ -48,11 +76,10 @@ function makeContext( peerDependencies: [] } }, - packageFile: { - name: 'test-package', - version: '1.0.0' - }, - ...overrides + packageFile, + options: optionsOverride, + resolvedRuntimeTarget, + ...rest }; } diff --git a/src/test/analyze/dependencies.test.ts b/src/test/analyze/dependencies.test.ts index 3616734..dfefe34 100644 --- a/src/test/analyze/dependencies.test.ts +++ b/src/test/analyze/dependencies.test.ts @@ -6,6 +6,7 @@ import { cleanupTempDir, createTestPackage, createTestPackageWithDependencies, + testResolvedRuntimeTarget, type TestPackage } from '../utils.js'; import type {AnalysisContext} from '../../types.js'; @@ -48,7 +49,11 @@ describe('analyzeDependencies (local)', () => { packageFile: { name: 'test-package', version: '1.0.0' - } + }, + resolvedRuntimeTarget: testResolvedRuntimeTarget(tempDir, { + name: 'test-package', + version: '1.0.0' + }) }; }); diff --git a/src/test/analyze/web-features-codemods.test.ts b/src/test/analyze/web-features-codemods.test.ts index b54e0c8..3f082b1 100644 --- a/src/test/analyze/web-features-codemods.test.ts +++ b/src/test/analyze/web-features-codemods.test.ts @@ -3,24 +3,52 @@ 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'; +import { + createTempDir, + cleanupTempDir, + testResolvedRuntimeTarget +} from '../utils.js'; +import type {AnalysisContext, PackageJsonLike} from '../../types.js'; function makeContext( tempDir: string, overrides: Partial = {} ): AnalysisContext { + const { + resolvedRuntimeTarget: rtOverride, + packageFile: pkgOverride, + options: optionsOverride, + fs: fsOverride, + root: rootOverride, + messages: messagesOverride, + stats: statsOverride, + lockfile: lockfileOverride, + ...rest + } = overrides; + + const packageFile = (pkgOverride ?? { + name: 'test-package', + version: '1.0.0' + }) as PackageJsonLike; + const root = rootOverride ?? tempDir; + const resolvedRuntimeTarget = + rtOverride ?? + testResolvedRuntimeTarget(root, packageFile, { + runtime: optionsOverride?.runtime, + browserslistQuery: optionsOverride?.browserslistQuery + }); + return { - fs: new LocalFileSystem(tempDir), - root: tempDir, - messages: [], - stats: { + fs: fsOverride ?? new LocalFileSystem(tempDir), + root, + messages: messagesOverride ?? [], + stats: statsOverride ?? { name: 'test-package', version: '1.0.0', dependencyCount: {production: 0, development: 0}, extraStats: [] }, - lockfile: { + lockfile: lockfileOverride ?? { type: 'npm', packages: [], root: { @@ -32,11 +60,10 @@ function makeContext( peerDependencies: [] } }, - packageFile: { - name: 'test-package', - version: '1.0.0' - }, - ...overrides + packageFile, + options: optionsOverride, + resolvedRuntimeTarget, + ...rest }; } diff --git a/src/test/custom-manifests.test.ts b/src/test/custom-manifests.test.ts index 5d95f8d..1c80203 100644 --- a/src/test/custom-manifests.test.ts +++ b/src/test/custom-manifests.test.ts @@ -2,6 +2,7 @@ import {describe, it, expect, afterEach, vi, beforeEach} from 'vitest'; import {runReplacements} from '../analyze/replacements.js'; import {LocalFileSystem} from '../local-file-system.js'; import type {AnalysisContext} from '../types.js'; +import {testResolvedRuntimeTarget} from './utils.js'; import {join} from 'node:path'; import {fileURLToPath} from 'node:url'; import {dirname} from 'node:path'; @@ -15,9 +16,10 @@ describe('Custom Manifests', () => { const testDir = join(__dirname, '../../test/fixtures/fake-modules'); const fileSystem = new LocalFileSystem(testDir); + const pkg = {name: 'test-package', version: '1.0.0' as const}; context = { fs: fileSystem, - root: '.', + root: testDir, messages: [], stats: { name: 'unknown', @@ -40,10 +42,8 @@ describe('Custom Manifests', () => { peerDependencies: [] } }, - packageFile: { - name: 'test-package', - version: '1.0.0' - } + packageFile: pkg, + resolvedRuntimeTarget: testResolvedRuntimeTarget(testDir, pkg) }; }); diff --git a/src/test/duplicate-dependencies.test.ts b/src/test/duplicate-dependencies.test.ts index 37c6a7d..d5ef9a0 100644 --- a/src/test/duplicate-dependencies.test.ts +++ b/src/test/duplicate-dependencies.test.ts @@ -1,6 +1,10 @@ import {describe, it, expect, beforeEach, afterEach} from 'vitest'; import {LocalFileSystem} from '../local-file-system.js'; -import {createTempDir, cleanupTempDir} from './utils.js'; +import { + createTempDir, + cleanupTempDir, + testResolvedRuntimeTarget +} from './utils.js'; import type {AnalysisContext} from '../types.js'; import {runDuplicateDependencyAnalysis} from '../analyze/duplicate-dependencies.js'; import {ParsedDependency} from 'lockparse'; @@ -104,7 +108,11 @@ describe('Duplicate Dependency Detection', () => { packageFile: { name: 'test-package', version: '1.0.0' - } + }, + resolvedRuntimeTarget: testResolvedRuntimeTarget(tempDir, { + name: 'test-package', + version: '1.0.0' + }) }; const stats = await runDuplicateDependencyAnalysis(context); @@ -175,7 +183,11 @@ describe('Duplicate Dependency Detection', () => { packageFile: { name: 'test-package', version: '1.0.0' - } + }, + resolvedRuntimeTarget: testResolvedRuntimeTarget(tempDir, { + name: 'test-package', + version: '1.0.0' + }) }; const stats = await runDuplicateDependencyAnalysis(context); diff --git a/src/test/plugin-runner.test.ts b/src/test/plugin-runner.test.ts index 7c68c39..892ba3a 100644 --- a/src/test/plugin-runner.test.ts +++ b/src/test/plugin-runner.test.ts @@ -2,6 +2,7 @@ import {describe, it, expect} from 'vitest'; import {runPlugins} from '../plugin-runner.js'; import type {FileSystem} from '../file-system.js'; import type {ReportPlugin, Stats, Message, AnalysisContext} from '../types.js'; +import {testResolvedRuntimeTarget} from './utils.js'; const fsMock: FileSystem = { getRootDir: async () => '/', @@ -13,6 +14,9 @@ const fsMock: FileSystem = { const depCounts = {production: 0, development: 0}; +const testPkg = {name: 'test-package', version: '1.0.0' as const}; +const pluginRunnerResolved = testResolvedRuntimeTarget('.', testPkg); + describe('runPlugins', () => { it('should aggregate messages and merge stats with extraStats de-dup', async () => { const pluginA: ReportPlugin = async () => ({ @@ -69,7 +73,8 @@ describe('runPlugins', () => { packageFile: { name: 'test-package', version: '1.0.0' - } + }, + resolvedRuntimeTarget: pluginRunnerResolved }; await runPlugins(context, [pluginA, pluginB, pluginC]); @@ -112,7 +117,8 @@ describe('runPlugins', () => { packageFile: { name: 'test-package', version: '1.0.0' - } + }, + resolvedRuntimeTarget: pluginRunnerResolved }; const noop: ReportPlugin = async () => ({messages: []}); @@ -159,7 +165,8 @@ describe('runPlugins', () => { packageFile: { name: 'test-package', version: '1.0.0' - } + }, + resolvedRuntimeTarget: pluginRunnerResolved }; const boom: ReportPlugin = async () => { @@ -201,7 +208,8 @@ describe('runPlugins', () => { packageFile: { name: 'test-package', version: '1.0.0' - } + }, + resolvedRuntimeTarget: pluginRunnerResolved }; await runPlugins(context, [onlyMsgs]); @@ -249,7 +257,8 @@ describe('runPlugins', () => { packageFile: { name: 'test-package', version: '1.0.0' - } + }, + resolvedRuntimeTarget: pluginRunnerResolved }; await runPlugins(context, [plugin]); @@ -286,7 +295,8 @@ describe('runPlugins', () => { packageFile: { name: 'test-package', version: '1.0.0' - } + }, + resolvedRuntimeTarget: pluginRunnerResolved }; await runPlugins(context, []); diff --git a/src/test/resolve-runtime-target.test.ts b/src/test/resolve-runtime-target.test.ts new file mode 100644 index 0000000..4c7742e --- /dev/null +++ b/src/test/resolve-runtime-target.test.ts @@ -0,0 +1,175 @@ +import {describe, it, expect, beforeEach, afterEach} from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {createTempDir, cleanupTempDir} from './utils.js'; +import { + resolveRuntimeTarget, + formatResolvedRuntimeTargetSummary +} from '../targets/resolve-runtime-target.js'; +import {parseTargetRuntime} from '../targets/runtime-target.js'; + +describe('resolveRuntimeTarget', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await createTempDir(); + }); + + afterEach(async () => { + await cleanupTempDir(tempDir); + }); + + it('uses default when no engines, browserslist, or CLI override', async () => { + await fs.writeFile( + path.join(tempDir, 'package.json'), + JSON.stringify({name: 'p', version: '1.0.0'}) + ); + + const r = resolveRuntimeTarget({ + root: tempDir, + packageFile: {name: 'p', version: '1.0.0'} + }); + + expect(r.primarySource).toBe('default'); + expect(r.runtime).toBe('nodejs'); + expect(r.browserslistQueries).toBeUndefined(); + expect(r.nodeRange).toBeUndefined(); + }); + + it('uses engines.node when no browserslist config', async () => { + const pkg = { + name: 'p', + version: '1.0.0', + engines: {node: '>=18'} + }; + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); + + const r = resolveRuntimeTarget({ + root: tempDir, + packageFile: pkg + }); + + expect(r.primarySource).toBe('engines-node'); + expect(r.runtime).toBe('nodejs'); + expect(r.nodeRange).toBe('>=18'); + expect(r.browserslistQueries).toBeUndefined(); + }); + + it('loads browserslist from package.json and infers browser runtime', async () => { + const pkg = { + name: 'p', + version: '1.0.0', + browserslist: ['defaults'] + }; + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); + + const r = resolveRuntimeTarget({ + root: tempDir, + packageFile: pkg + }); + + expect(r.primarySource).toBe('project-browserslist'); + expect(r.runtime).toBe('browser'); + expect(r.browserslistQueries).toBeDefined(); + expect(r.browserslistQueries?.length).toBeGreaterThan(0); + }); + + it('CLI browserslist query wins over project config', async () => { + const pkg = { + name: 'p', + version: '1.0.0', + browserslist: ['defaults'] + }; + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); + + const r = resolveRuntimeTarget({ + root: tempDir, + packageFile: pkg, + browserslistQuery: 'baseline widely available' + }); + + expect(r.primarySource).toBe('cli-browserslist'); + expect(r.browserslistQueries).toEqual(['baseline widely available']); + expect(r.runtime).toBe('browser'); + }); + + it('respects explicit CLI runtime over inference', async () => { + const pkg = {name: 'p', version: '1.0.0', browserslist: ['defaults']}; + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); + + const r = resolveRuntimeTarget({ + root: tempDir, + packageFile: pkg, + runtime: 'nodejs' + }); + + expect(r.primarySource).toBe('project-browserslist'); + expect(r.runtime).toBe('nodejs'); + }); +}); + +describe('formatResolvedRuntimeTargetSummary', () => { + it('formats default and engines-node cases', () => { + expect( + formatResolvedRuntimeTargetSummary({ + runtime: 'nodejs', + primarySource: 'default', + browserslistQueries: undefined, + nodeRange: undefined + }) + ).toContain('default'); + + expect( + formatResolvedRuntimeTargetSummary({ + runtime: 'nodejs', + primarySource: 'engines-node', + browserslistQueries: undefined, + nodeRange: '>=20' + }) + ).toContain('engines.node'); + expect( + formatResolvedRuntimeTargetSummary({ + runtime: 'nodejs', + primarySource: 'engines-node', + browserslistQueries: undefined, + nodeRange: '>=20' + }) + ).toContain('>=20'); + }); + + it('formats browserslist cases', () => { + expect( + formatResolvedRuntimeTargetSummary({ + runtime: 'browser', + primarySource: 'cli-browserslist', + browserslistQueries: ['baseline widely available'], + nodeRange: undefined + }) + ).toMatch(/CLI Browserslist/); + + expect( + formatResolvedRuntimeTargetSummary({ + runtime: 'browser', + primarySource: 'project-browserslist', + browserslistQueries: ['defaults'], + nodeRange: undefined + }) + ).toMatch(/Browserslist/); + }); +}); + +describe('parseTargetRuntime', () => { + it('returns undefined for empty input', () => { + expect(parseTargetRuntime(undefined)).toBeUndefined(); + expect(parseTargetRuntime('')).toBeUndefined(); + }); + + it('parses valid runtimes', () => { + expect(parseTargetRuntime('browser')).toBe('browser'); + expect(parseTargetRuntime('nodejs')).toBe('nodejs'); + }); + + it('throws on invalid runtime', () => { + expect(() => parseTargetRuntime('nope')).toThrow(/Invalid --runtime/); + }); +}); diff --git a/src/test/utils.ts b/src/test/utils.ts index 62bcbc8..7770bf0 100644 --- a/src/test/utils.ts +++ b/src/test/utils.ts @@ -1,6 +1,9 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; +import type {PackageJsonLike} from '../types.js'; +import type {TargetRuntime} from '../targets/runtime-target.js'; +import {resolveRuntimeTarget} from '../targets/resolve-runtime-target.js'; export interface TestPackage { name: string; @@ -97,3 +100,16 @@ export async function createTestPackageWithDependencies( await createTestPackage(depDir, dep); } } + +export function testResolvedRuntimeTarget( + root: string, + packageFile: PackageJsonLike, + overrides?: {runtime?: TargetRuntime; browserslistQuery?: string} +) { + return resolveRuntimeTarget({ + root, + packageFile, + runtime: overrides?.runtime, + browserslistQuery: overrides?.browserslistQuery + }); +} diff --git a/src/types.ts b/src/types.ts index a39af0c..5aa5f9a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,12 +2,18 @@ import type {FileSystem} from './file-system.js'; import type {Codemod, CodemodOptions} from 'module-replacements-codemods'; import type {ParsedLockFile} from 'lockparse'; import type {ParsedCategories} from './categories.js'; +import type { + ResolvedRuntimeTarget, + TargetRuntime +} from './targets/runtime-target.js'; export interface Options { root?: string; manifest?: string[]; src?: string[]; categories?: ParsedCategories; + runtime?: TargetRuntime; + browserslistQuery?: string; } export interface StatLike { @@ -46,6 +52,7 @@ export interface PackageJsonLike { node?: string; [engineName: string]: string | undefined; }; + browserslist?: string | string[] | Record; } export interface Replacement { @@ -72,4 +79,5 @@ export interface AnalysisContext { packageFile: PackageJsonLike; stats: Stats; messages: Message[]; + resolvedRuntimeTarget: ResolvedRuntimeTarget; } From a06fd1ec141db41e57fd58399b8e05025a8d7b1a Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Tue, 12 May 2026 16:59:01 -0500 Subject: [PATCH 02/10] feat: integrate `enginematch` package for engine compatibility checks --- package-lock.json | 14 +++++++ package.json | 1 + src/analyze/replacements.ts | 59 ++++++++++++--------------- src/targets/resolve-runtime-target.ts | 7 ++++ 4 files changed, 48 insertions(+), 33 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6a90839..ea34e14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@publint/pack": "^0.1.4", "browserslist": "^4.28.2", "core-js-compat": "^3.48.0", + "enginematch": "^0.1.3", "fast-wrap-ansi": "^0.2.0", "fdir": "^6.5.0", "gunshi": "^0.29.5", @@ -2477,6 +2478,19 @@ "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", "license": "ISC" }, + "node_modules/enginematch": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/enginematch/-/enginematch-0.1.3.tgz", + "integrity": "sha512-ne6o7A3ThYUqes9FpRs1NpEw1G0GOPCVHCeNiTZKQfvi1EibqhDJTUbVJmxRpdHfblnRXODCYe4wS4BHi+IRrw==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "semver": "^7.7.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", diff --git a/package.json b/package.json index a5228f2..9f513ba 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@publint/pack": "^0.1.4", "browserslist": "^4.28.2", "core-js-compat": "^3.48.0", + "enginematch": "^0.1.3", "fast-wrap-ansi": "^0.2.0", "fdir": "^6.5.0", "gunshi": "^0.29.5", diff --git a/src/analyze/replacements.ts b/src/analyze/replacements.ts index 7c40c49..db23f90 100644 --- a/src/analyze/replacements.ts +++ b/src/analyze/replacements.ts @@ -4,17 +4,15 @@ import type { EngineConstraint, KnownUrl } from 'module-replacements'; +// enginematch@0.1.3 npm package `main` points at missing `lib/main.js`; use published entry under lib/src (see https://www.npmjs.com/package/enginematch). +import type {PackageJson} from 'enginematch/lib/src/main.js'; +import {satisfies} from 'enginematch/lib/src/main.js'; import type {ReportPluginResult, AnalysisContext} from '../types.js'; +import type {ResolvedRuntimeTarget} from '../targets/runtime-target.js'; import {fixableReplacements} from '../commands/fixable-replacements.js'; import {getPackageJson} from '../utils/package-json.js'; import {getManifestForCategories} from '../categories.js'; 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'; /** @@ -32,44 +30,34 @@ export function resolveUrl(url: KnownUrl): string { } } -function getNodeMinVersion(engines?: EngineConstraint[]): string | undefined { +function getNodejsMinVersion(engines?: EngineConstraint[]): string | undefined { return engines?.find((e) => e.engine === 'nodejs')?.minVersion; } -function isNodeEngineCompatible( - requiredNode: string, - enginesNode: string -): boolean { - const requiredRange = validRange(requiredNode); - const engineRange = validRange(enginesNode); - - if (!requiredRange || !engineRange) { - return true; - } - - const requiredMin = minVersion(requiredRange); - if (!requiredMin) { - return true; - } - - return ( - semverLessThan(requiredMin.version, engineRange) || - semverSatisfies(requiredMin.version, engineRange) - ); +/** `PackageJson` for [enginematch](https://github.com/43081j/enginematch): effective browserslist from resolver precedence, then manifest. */ +function toEngineMatchPackageJson( + packageJson: NonNullable>>, + resolved: ResolvedRuntimeTarget +): PackageJson { + return { + engines: packageJson.engines as Record | undefined, + browserslist: resolved.browserslistQueries ?? packageJson.browserslist + }; } function findFirstCompatibleReplacement( replacementIds: string[], defs: Record, - enginesNode: string | undefined + pkg: PackageJson, + root: string ): ModuleReplacement | undefined { for (const id of replacementIds) { const replacement = defs[id]; if (!replacement) continue; - if (replacement.type === 'native' && enginesNode) { - const nodeVersion = getNodeMinVersion(replacement.engines); - if (nodeVersion && !isNodeEngineCompatible(nodeVersion, enginesNode)) { + const reqs = replacement.engines; + if (reqs?.length) { + if (!satisfies(pkg, {requirements: reqs, cwd: root})) { continue; } } @@ -147,6 +135,10 @@ export async function runReplacements( const fixableByMigrate = new Set(fixableReplacements.map((r) => r.from)); const enginesNode = packageJson.engines?.node; + const pkgForEngines = toEngineMatchPackageJson( + packageJson, + context.resolvedRuntimeTarget + ); for (const name of Object.keys(packageJson.dependencies)) { const mapping = allMappings[name]; @@ -157,7 +149,8 @@ export async function runReplacements( const firstCompatible = findFirstCompatibleReplacement( mapping.replacements, allReplacementDefs, - enginesNode + pkgForEngines, + context.root ); if (!firstCompatible) { continue; @@ -175,7 +168,7 @@ export async function runReplacements( message = `Module "${name}" can be replaced with inline native syntax. ${firstCompatible.description}.`; break; case 'native': { - const nodeVersion = getNodeMinVersion(firstCompatible.engines); + const nodeVersion = getNodejsMinVersion(firstCompatible.engines); const requires = nodeVersion && !enginesNode ? ` Required Node >= ${nodeVersion}.` diff --git a/src/targets/resolve-runtime-target.ts b/src/targets/resolve-runtime-target.ts index b323876..5bdf820 100644 --- a/src/targets/resolve-runtime-target.ts +++ b/src/targets/resolve-runtime-target.ts @@ -1,3 +1,10 @@ +/** + * Direct `browserslist` dependency: `loadConfig` here supplies effective query + * strings for `ResolvedRuntimeTarget` (and the analyze summary). Replacement + * checks use `enginematch` (which also depends on `browserslist`). Keep this + * import until `core-js` / compat tooling consumes the same resolved queries + * and we can reassess dropping the direct dep. + */ import browserslist from 'browserslist'; import type {PackageJsonLike} from '../types.js'; import type { From b5755927dacc2da601ede236a84d683174bf9fae Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Tue, 12 May 2026 17:05:36 -0500 Subject: [PATCH 03/10] fix(tests): update filesystem initialization to use `root` instead of `tempDir` in context creation --- src/test/analyze/core-js.test.ts | 2 +- src/test/analyze/web-features-codemods.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/analyze/core-js.test.ts b/src/test/analyze/core-js.test.ts index 9917650..893fb26 100644 --- a/src/test/analyze/core-js.test.ts +++ b/src/test/analyze/core-js.test.ts @@ -55,7 +55,7 @@ function makeContext( }); return { - fs: fsOverride ?? new LocalFileSystem(tempDir), + fs: fsOverride ?? new LocalFileSystem(root), root, messages: messagesOverride ?? [], stats: statsOverride ?? { diff --git a/src/test/analyze/web-features-codemods.test.ts b/src/test/analyze/web-features-codemods.test.ts index 3f082b1..ba2f884 100644 --- a/src/test/analyze/web-features-codemods.test.ts +++ b/src/test/analyze/web-features-codemods.test.ts @@ -39,7 +39,7 @@ function makeContext( }); return { - fs: fsOverride ?? new LocalFileSystem(tempDir), + fs: fsOverride ?? new LocalFileSystem(root), root, messages: messagesOverride ?? [], stats: statsOverride ?? { From cd15040d56cd265f006bdb91cca5373b665a7217 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Thu, 14 May 2026 09:49:07 -0500 Subject: [PATCH 04/10] chore(deps): update `lockparse` dependency to version 0.5.2 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index ea34e14..a1916ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "fast-wrap-ansi": "^0.2.0", "fdir": "^6.5.0", "gunshi": "^0.29.5", - "lockparse": "^0.5.0", + "lockparse": "^0.5.2", "module-replacements": "^3.0.0-beta.7", "module-replacements-codemods": "^1.2.0", "obug": "^2.1.1", @@ -3548,9 +3548,9 @@ } }, "node_modules/lockparse": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/lockparse/-/lockparse-0.5.0.tgz", - "integrity": "sha512-seaI91ZVc4mnEGL+/cEEd5MybTnb86NH3W5lM0Ft7CMCZsLP5z1orWnu8g7YacpiMc5GxU7wIrYLhb6W2DvNWg==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/lockparse/-/lockparse-0.5.2.tgz", + "integrity": "sha512-iu19W86kvrT2MLpvyXoIn/351Rb3PO7v7ljgFRQYj8tGpBRvNO9cZEI8x/7GGN+WN6BbrWpXc5CPrgPUjgX6Iw==", "license": "MIT" }, "node_modules/magic-string": { diff --git a/package.json b/package.json index 9f513ba..38c00bd 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "fast-wrap-ansi": "^0.2.0", "fdir": "^6.5.0", "gunshi": "^0.29.5", - "lockparse": "^0.5.0", + "lockparse": "^0.5.2", "module-replacements": "^3.0.0-beta.7", "module-replacements-codemods": "^1.2.0", "obug": "^2.1.1", From 0b4270cb6ed6a02600fa6c2b2b4bf914985e2e9d Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Tue, 19 May 2026 19:59:37 +0100 Subject: [PATCH 05/10] chore: upgrade enginematch --- package-lock.json | 10 +++++----- src/analyze/replacements.ts | 12 +++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 187bcf1..6e9500f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2479,13 +2479,13 @@ "license": "ISC" }, "node_modules/enginematch": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/enginematch/-/enginematch-0.1.3.tgz", - "integrity": "sha512-ne6o7A3ThYUqes9FpRs1NpEw1G0GOPCVHCeNiTZKQfvi1EibqhDJTUbVJmxRpdHfblnRXODCYe4wS4BHi+IRrw==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/enginematch/-/enginematch-0.1.4.tgz", + "integrity": "sha512-zqyDSYd9DP14gfZXxddcPcbXwuHqyygPXHRt/LBLvrn7HyklWJp7QXe8eyf+SGE6i0qNHCBuLIMPv21FpVYe5A==", "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", - "semver": "^7.7.3" + "browserslist": "^4.28.2", + "semver": "^7.8.0" }, "engines": { "node": ">=18.0.0" diff --git a/src/analyze/replacements.ts b/src/analyze/replacements.ts index db23f90..a3eab02 100644 --- a/src/analyze/replacements.ts +++ b/src/analyze/replacements.ts @@ -4,10 +4,12 @@ import type { EngineConstraint, KnownUrl } from 'module-replacements'; -// enginematch@0.1.3 npm package `main` points at missing `lib/main.js`; use published entry under lib/src (see https://www.npmjs.com/package/enginematch). -import type {PackageJson} from 'enginematch/lib/src/main.js'; -import {satisfies} from 'enginematch/lib/src/main.js'; -import type {ReportPluginResult, AnalysisContext} from '../types.js'; +import {type PackageJson, satisfies} from 'enginematch'; +import type { + ReportPluginResult, + AnalysisContext, + PackageJsonLike +} from '../types.js'; import type {ResolvedRuntimeTarget} from '../targets/runtime-target.js'; import {fixableReplacements} from '../commands/fixable-replacements.js'; import {getPackageJson} from '../utils/package-json.js'; @@ -36,7 +38,7 @@ function getNodejsMinVersion(engines?: EngineConstraint[]): string | undefined { /** `PackageJson` for [enginematch](https://github.com/43081j/enginematch): effective browserslist from resolver precedence, then manifest. */ function toEngineMatchPackageJson( - packageJson: NonNullable>>, + packageJson: PackageJsonLike, resolved: ResolvedRuntimeTarget ): PackageJson { return { From b03e83739a48aba2a877ba5a4b584e74bf7c9e00 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Tue, 19 May 2026 20:37:20 +0100 Subject: [PATCH 06/10] feat: remove new flags Greatly simplifies the logic by removing the two new flags and only inferring via enginematch. --- package-lock.json | 1 - package.json | 1 - src/analyze/replacements.ts | 28 +-- src/analyze/report.ts | 21 +-- src/commands/analyze.meta.ts | 10 - src/commands/analyze.ts | 23 +-- src/index.ts | 11 -- src/targets/resolve-runtime-target.ts | 126 ------------- src/targets/runtime-target.ts | 47 ----- src/test/__snapshots__/cli.test.ts.snap | 2 - src/test/analyze/core-js.test.ts | 14 +- src/test/analyze/dependencies.test.ts | 7 +- .../analyze/web-features-codemods.test.ts | 14 +- src/test/custom-manifests.test.ts | 4 +- src/test/duplicate-dependencies.test.ts | 18 +- src/test/plugin-runner.test.ts | 22 +-- src/test/resolve-runtime-target.test.ts | 175 ------------------ src/test/utils.ts | 16 -- src/types.ts | 7 - 19 files changed, 19 insertions(+), 528 deletions(-) delete mode 100644 src/targets/resolve-runtime-target.ts delete mode 100644 src/targets/runtime-target.ts delete mode 100644 src/test/resolve-runtime-target.test.ts diff --git a/package-lock.json b/package-lock.json index 6e9500f..4e10e29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@clack/prompts": "^1.4.0", "@e18e/web-features-codemods": "^0.2.0", "@publint/pack": "^0.1.4", - "browserslist": "^4.28.2", "core-js-compat": "^3.48.0", "enginematch": "^0.1.3", "fast-wrap-ansi": "^0.2.0", diff --git a/package.json b/package.json index 0e72bab..f7fb181 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "@clack/prompts": "^1.4.0", "@e18e/web-features-codemods": "^0.2.0", "@publint/pack": "^0.1.4", - "browserslist": "^4.28.2", "core-js-compat": "^3.48.0", "enginematch": "^0.1.3", "fast-wrap-ansi": "^0.2.0", diff --git a/src/analyze/replacements.ts b/src/analyze/replacements.ts index a3eab02..6101ac8 100644 --- a/src/analyze/replacements.ts +++ b/src/analyze/replacements.ts @@ -5,12 +5,7 @@ import type { KnownUrl } from 'module-replacements'; import {type PackageJson, satisfies} from 'enginematch'; -import type { - ReportPluginResult, - AnalysisContext, - PackageJsonLike -} from '../types.js'; -import type {ResolvedRuntimeTarget} from '../targets/runtime-target.js'; +import type {ReportPluginResult, AnalysisContext} from '../types.js'; import {fixableReplacements} from '../commands/fixable-replacements.js'; import {getPackageJson} from '../utils/package-json.js'; import {getManifestForCategories} from '../categories.js'; @@ -32,21 +27,10 @@ export function resolveUrl(url: KnownUrl): string { } } -function getNodejsMinVersion(engines?: EngineConstraint[]): string | undefined { +function getNodeJSMinVersion(engines?: EngineConstraint[]): string | undefined { return engines?.find((e) => e.engine === 'nodejs')?.minVersion; } -/** `PackageJson` for [enginematch](https://github.com/43081j/enginematch): effective browserslist from resolver precedence, then manifest. */ -function toEngineMatchPackageJson( - packageJson: PackageJsonLike, - resolved: ResolvedRuntimeTarget -): PackageJson { - return { - engines: packageJson.engines as Record | undefined, - browserslist: resolved.browserslistQueries ?? packageJson.browserslist - }; -} - function findFirstCompatibleReplacement( replacementIds: string[], defs: Record, @@ -137,10 +121,6 @@ export async function runReplacements( const fixableByMigrate = new Set(fixableReplacements.map((r) => r.from)); const enginesNode = packageJson.engines?.node; - const pkgForEngines = toEngineMatchPackageJson( - packageJson, - context.resolvedRuntimeTarget - ); for (const name of Object.keys(packageJson.dependencies)) { const mapping = allMappings[name]; @@ -151,7 +131,7 @@ export async function runReplacements( const firstCompatible = findFirstCompatibleReplacement( mapping.replacements, allReplacementDefs, - pkgForEngines, + packageJson as PackageJson, context.root ); if (!firstCompatible) { @@ -170,7 +150,7 @@ export async function runReplacements( message = `Module "${name}" can be replaced with inline native syntax. ${firstCompatible.description}.`; break; case 'native': { - const nodeVersion = getNodejsMinVersion(firstCompatible.engines); + const nodeVersion = getNodeJSMinVersion(firstCompatible.engines); const requires = nodeVersion && !enginesNode ? ` Required Node >= ${nodeVersion}.` diff --git a/src/analyze/report.ts b/src/analyze/report.ts index 1e3837c..37e631e 100644 --- a/src/analyze/report.ts +++ b/src/analyze/report.ts @@ -18,10 +18,6 @@ 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'; -import { - resolveRuntimeTarget, - formatResolvedRuntimeTargetSummary -} from '../targets/resolve-runtime-target.js'; const plugins: ReportPlugin[] = [ runPublint, @@ -97,13 +93,6 @@ export async function report(options: Options) { extraStats: [] }; - const resolvedRuntimeTarget = resolveRuntimeTarget({ - root, - packageFile, - runtime: options?.runtime, - browserslistQuery: options?.browserslistQuery - }); - const context: AnalysisContext = { fs: fileSystem, root, @@ -111,18 +100,10 @@ export async function report(options: Options) { lockfile: parsedLock, stats, messages, - options, - resolvedRuntimeTarget + options }; await runPlugins(context, plugins); - stats.extraStats ??= []; - stats.extraStats.push({ - name: 'analyzeTarget', - label: 'Analyze target', - value: formatResolvedRuntimeTargetSummary(resolvedRuntimeTarget) - }); - const info = await computeInfo(fileSystem); return {info, messages, stats}; diff --git a/src/commands/analyze.meta.ts b/src/commands/analyze.meta.ts index f94b391..6881afb 100644 --- a/src/commands/analyze.meta.ts +++ b/src/commands/analyze.meta.ts @@ -45,16 +45,6 @@ export const meta = { multiple: true, description: 'Glob pattern(s) for source files to scan for imports (e.g. "src/**/*.ts"). Defaults to scanning all JS/TS files from the project root.' - }, - runtime: { - type: 'string', - description: - 'Target runtime for replacement engine matching: any, browser, nodejs, deno, bun, cloudflare. Default: inferred (browser when Browserslist is present, else nodejs).' - }, - 'browserslist-query': { - type: 'string', - description: - 'Override Browserslist targets (e.g. "baseline widely available"). Overrides project Browserslist config; see https://web.dev/articles/use-baseline-with-browserslist' } } } as const; diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index f98c07f..09ad0f5 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -8,7 +8,6 @@ import {enableDebug} from '../logger.js'; import {wrapAnsi} from 'fast-wrap-ansi'; import {parseCategories} from '../categories.js'; import type {Message} from '../types.js'; -import {parseTargetRuntime} from '../targets/runtime-target.js'; function formatBytes(bytes: number) { const units = ['B', 'KB', 'MB', 'GB']; @@ -80,20 +79,6 @@ export async function run(ctx: CommandContext) { process.exit(1); } - let parsedRuntime: ReturnType; - try { - parsedRuntime = parseTargetRuntime(ctx.values.runtime); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const descriptiveMessage = `Invalid --runtime: ${message}`; - if (jsonOutput) { - process.stderr.write(`Error: ${descriptiveMessage}\n`); - } else { - prompts.cancel(descriptiveMessage); - } - process.exit(1); - } - // Path can be a directory (analyze project) if (providedPath) { let stat: Stats | null; @@ -117,18 +102,12 @@ export async function run(ctx: CommandContext) { const customManifests = ctx.values['manifest']; const srcDirs = ctx.values['src']; - const browserslistQuery = ctx.values['browserslist-query']; const {stats, messages} = await report({ root, manifest: customManifests, src: srcDirs, - categories: parsedCategories, - runtime: parsedRuntime, - browserslistQuery: - typeof browserslistQuery === 'string' && browserslistQuery.trim() !== '' - ? browserslistQuery.trim() - : undefined + categories: parsedCategories }); const thresholdRank = FAIL_THRESHOLD_RANK[logLevel] ?? 0; diff --git a/src/index.ts b/src/index.ts index a30ef79..716539b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,17 +3,6 @@ import type {PackageModuleType} from './compute-type.js'; export type {Message, Options, PackageModuleType, Stat}; -export type { - ResolvedRuntimeTarget, - RuntimePrimarySource, - TargetRuntime -} from './targets/runtime-target.js'; -export {parseTargetRuntime, TARGET_RUNTIMES} from './targets/runtime-target.js'; -export { - resolveRuntimeTarget, - formatResolvedRuntimeTargetSummary -} from './targets/resolve-runtime-target.js'; - export {report} from './analyze/report.js'; // Core modules - reusable logic for external tools diff --git a/src/targets/resolve-runtime-target.ts b/src/targets/resolve-runtime-target.ts deleted file mode 100644 index 5bdf820..0000000 --- a/src/targets/resolve-runtime-target.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Direct `browserslist` dependency: `loadConfig` here supplies effective query - * strings for `ResolvedRuntimeTarget` (and the analyze summary). Replacement - * checks use `enginematch` (which also depends on `browserslist`). Keep this - * import until `core-js` / compat tooling consumes the same resolved queries - * and we can reassess dropping the direct dep. - */ -import browserslist from 'browserslist'; -import type {PackageJsonLike} from '../types.js'; -import type { - ResolvedRuntimeTarget, - RuntimePrimarySource, - TargetRuntime -} from './runtime-target.js'; - -export interface ResolveRuntimeTargetInput { - root: string; - packageFile: PackageJsonLike; - /** CLI override; wins over project Browserslist config. */ - browserslistQuery?: string; - /** CLI explicit runtime; if omitted, inferred from resolution path. */ - runtime?: TargetRuntime; -} - -function normalizeBrowserslistQueries( - value: string | undefined -): string[] | undefined { - if (value === undefined) { - return undefined; - } - const trimmed = value.trim(); - if (trimmed === '') { - return undefined; - } - return [trimmed]; -} - -function queriesFromProject(root: string): string[] | undefined { - const loaded = browserslist.loadConfig({path: root}); - if (!loaded || loaded.length === 0) { - return undefined; - } - return [...loaded]; -} - -function inferRuntime( - explicit: TargetRuntime | undefined, - hasBrowserslistQueries: boolean -): TargetRuntime { - if (explicit !== undefined) { - return explicit; - } - if (hasBrowserslistQueries) { - return 'browser'; - } - return 'nodejs'; -} - -/** - * Resolves effective analysis targets with precedence: - * CLI `--browserslist-query` > project Browserslist > `engines.node` > default. - */ -export function resolveRuntimeTarget( - input: ResolveRuntimeTargetInput -): ResolvedRuntimeTarget { - const { - root, - packageFile, - browserslistQuery: cliQuery, - runtime: runtimeCli - } = input; - - const nodeRange = packageFile.engines?.node; - - let primarySource: RuntimePrimarySource = 'default'; - let browserslistQueries: string[] | undefined; - - const cliNormalized = normalizeBrowserslistQueries(cliQuery); - if (cliNormalized) { - primarySource = 'cli-browserslist'; - browserslistQueries = cliNormalized; - } else { - const fromProject = queriesFromProject(root); - if (fromProject) { - primarySource = 'project-browserslist'; - browserslistQueries = fromProject; - } else if (nodeRange) { - primarySource = 'engines-node'; - } - } - - const runtime = inferRuntime( - runtimeCli, - browserslistQueries !== undefined && browserslistQueries.length > 0 - ); - - return { - runtime, - primarySource, - browserslistQueries, - nodeRange - }; -} - -/** One-line summary for CLI / `stats.extraStats` (Analyze target row). */ -export function formatResolvedRuntimeTargetSummary( - t: ResolvedRuntimeTarget -): string { - const queries = t.browserslistQueries?.join(', '); - switch (t.primarySource) { - case 'cli-browserslist': - return queries - ? `${t.runtime} (CLI Browserslist: ${queries})` - : `${t.runtime} (CLI Browserslist)`; - case 'project-browserslist': - return queries - ? `${t.runtime} (Browserslist: ${queries})` - : `${t.runtime} (Browserslist)`; - case 'engines-node': - return t.nodeRange - ? `${t.runtime} (engines.node: ${t.nodeRange})` - : `${t.runtime} (engines.node)`; - default: - return `${t.runtime} (default — no Browserslist or engines.node)`; - } -} diff --git a/src/targets/runtime-target.ts b/src/targets/runtime-target.ts deleted file mode 100644 index cf63b44..0000000 --- a/src/targets/runtime-target.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Runtime dimension for replacement engine matching. - */ -export const TARGET_RUNTIMES = [ - 'any', - 'browser', - 'nodejs', - 'deno', - 'bun', - 'cloudflare' -] as const; - -export type TargetRuntime = (typeof TARGET_RUNTIMES)[number]; - -const RUNTIME_SET = new Set(TARGET_RUNTIMES); - -export type RuntimePrimarySource = - | 'cli-browserslist' - | 'project-browserslist' - | 'engines-node' - | 'default'; - -export interface ResolvedRuntimeTarget { - /** Effective runtime for `engines_match_runtime`-style checks. */ - runtime: TargetRuntime; - /** Highest-precedence source that supplied browserlist-style targets. */ - primarySource: RuntimePrimarySource; - /** Queries passed to tools that accept Browserslist (e.g. core-js-compat). */ - browserslistQueries: string[] | undefined; - /** `package.json#engines.node` when present (always from manifest). */ - nodeRange: string | undefined; -} - -export function parseTargetRuntime( - value: string | undefined -): TargetRuntime | undefined { - if (value === undefined || value === '') { - return undefined; - } - const trimmed = value.trim(); - if (!RUNTIME_SET.has(trimmed)) { - throw new Error( - `Invalid --runtime "${value}". Valid values: ${TARGET_RUNTIMES.join(', ')}` - ); - } - return trimmed as TargetRuntime; -} diff --git a/src/test/__snapshots__/cli.test.ts.snap b/src/test/__snapshots__/cli.test.ts.snap index c170da7..bd6e3f4 100644 --- a/src/test/__snapshots__/cli.test.ts.snap +++ b/src/test/__snapshots__/cli.test.ts.snap @@ -11,7 +11,6 @@ exports[`CLI > should display package report 1`] = ` │ Install Size 53.0 B │ Dependencies 1 (1 production, 0 development) │ Duplicate Dependency Count 0 -│ Analyze target nodejs (default — no Browserslist or engines.node) │ ● Results: │ @@ -34,7 +33,6 @@ exports[`CLI > should run successfully with default options 1`] = ` │ Install Size 53.0 B │ Dependencies 1 (1 production, 0 development) │ Duplicate Dependency Count 0 -│ Analyze target nodejs (default — no Browserslist or engines.node) │ ● Results: │ diff --git a/src/test/analyze/core-js.test.ts b/src/test/analyze/core-js.test.ts index 893fb26..5b8ce7c 100644 --- a/src/test/analyze/core-js.test.ts +++ b/src/test/analyze/core-js.test.ts @@ -4,11 +4,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import {runCoreJsAnalysis} from '../../analyze/core-js.js'; import {LocalFileSystem} from '../../local-file-system.js'; -import { - createTempDir, - cleanupTempDir, - testResolvedRuntimeTarget -} from '../utils.js'; +import {createTempDir, cleanupTempDir} from '../utils.js'; import type {AnalysisContext, PackageJsonLike} from '../../types.js'; const cjsRequire = createRequire(import.meta.url); @@ -31,7 +27,6 @@ function makeContext( overrides: Partial = {} ): AnalysisContext { const { - resolvedRuntimeTarget: rtOverride, packageFile: pkgOverride, options: optionsOverride, fs: fsOverride, @@ -47,12 +42,6 @@ function makeContext( version: '1.0.0' }) as PackageJsonLike; const root = rootOverride ?? tempDir; - const resolvedRuntimeTarget = - rtOverride ?? - testResolvedRuntimeTarget(root, packageFile, { - runtime: optionsOverride?.runtime, - browserslistQuery: optionsOverride?.browserslistQuery - }); return { fs: fsOverride ?? new LocalFileSystem(root), @@ -78,7 +67,6 @@ function makeContext( }, packageFile, options: optionsOverride, - resolvedRuntimeTarget, ...rest }; } diff --git a/src/test/analyze/dependencies.test.ts b/src/test/analyze/dependencies.test.ts index dfefe34..3616734 100644 --- a/src/test/analyze/dependencies.test.ts +++ b/src/test/analyze/dependencies.test.ts @@ -6,7 +6,6 @@ import { cleanupTempDir, createTestPackage, createTestPackageWithDependencies, - testResolvedRuntimeTarget, type TestPackage } from '../utils.js'; import type {AnalysisContext} from '../../types.js'; @@ -49,11 +48,7 @@ describe('analyzeDependencies (local)', () => { packageFile: { name: 'test-package', version: '1.0.0' - }, - resolvedRuntimeTarget: testResolvedRuntimeTarget(tempDir, { - name: 'test-package', - version: '1.0.0' - }) + } }; }); diff --git a/src/test/analyze/web-features-codemods.test.ts b/src/test/analyze/web-features-codemods.test.ts index ba2f884..568027b 100644 --- a/src/test/analyze/web-features-codemods.test.ts +++ b/src/test/analyze/web-features-codemods.test.ts @@ -3,11 +3,7 @@ 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, - testResolvedRuntimeTarget -} from '../utils.js'; +import {createTempDir, cleanupTempDir} from '../utils.js'; import type {AnalysisContext, PackageJsonLike} from '../../types.js'; function makeContext( @@ -15,7 +11,6 @@ function makeContext( overrides: Partial = {} ): AnalysisContext { const { - resolvedRuntimeTarget: rtOverride, packageFile: pkgOverride, options: optionsOverride, fs: fsOverride, @@ -31,12 +26,6 @@ function makeContext( version: '1.0.0' }) as PackageJsonLike; const root = rootOverride ?? tempDir; - const resolvedRuntimeTarget = - rtOverride ?? - testResolvedRuntimeTarget(root, packageFile, { - runtime: optionsOverride?.runtime, - browserslistQuery: optionsOverride?.browserslistQuery - }); return { fs: fsOverride ?? new LocalFileSystem(root), @@ -62,7 +51,6 @@ function makeContext( }, packageFile, options: optionsOverride, - resolvedRuntimeTarget, ...rest }; } diff --git a/src/test/custom-manifests.test.ts b/src/test/custom-manifests.test.ts index 1c80203..b125c47 100644 --- a/src/test/custom-manifests.test.ts +++ b/src/test/custom-manifests.test.ts @@ -2,7 +2,6 @@ import {describe, it, expect, afterEach, vi, beforeEach} from 'vitest'; import {runReplacements} from '../analyze/replacements.js'; import {LocalFileSystem} from '../local-file-system.js'; import type {AnalysisContext} from '../types.js'; -import {testResolvedRuntimeTarget} from './utils.js'; import {join} from 'node:path'; import {fileURLToPath} from 'node:url'; import {dirname} from 'node:path'; @@ -42,8 +41,7 @@ describe('Custom Manifests', () => { peerDependencies: [] } }, - packageFile: pkg, - resolvedRuntimeTarget: testResolvedRuntimeTarget(testDir, pkg) + packageFile: pkg }; }); diff --git a/src/test/duplicate-dependencies.test.ts b/src/test/duplicate-dependencies.test.ts index d5ef9a0..37c6a7d 100644 --- a/src/test/duplicate-dependencies.test.ts +++ b/src/test/duplicate-dependencies.test.ts @@ -1,10 +1,6 @@ import {describe, it, expect, beforeEach, afterEach} from 'vitest'; import {LocalFileSystem} from '../local-file-system.js'; -import { - createTempDir, - cleanupTempDir, - testResolvedRuntimeTarget -} from './utils.js'; +import {createTempDir, cleanupTempDir} from './utils.js'; import type {AnalysisContext} from '../types.js'; import {runDuplicateDependencyAnalysis} from '../analyze/duplicate-dependencies.js'; import {ParsedDependency} from 'lockparse'; @@ -108,11 +104,7 @@ describe('Duplicate Dependency Detection', () => { packageFile: { name: 'test-package', version: '1.0.0' - }, - resolvedRuntimeTarget: testResolvedRuntimeTarget(tempDir, { - name: 'test-package', - version: '1.0.0' - }) + } }; const stats = await runDuplicateDependencyAnalysis(context); @@ -183,11 +175,7 @@ describe('Duplicate Dependency Detection', () => { packageFile: { name: 'test-package', version: '1.0.0' - }, - resolvedRuntimeTarget: testResolvedRuntimeTarget(tempDir, { - name: 'test-package', - version: '1.0.0' - }) + } }; const stats = await runDuplicateDependencyAnalysis(context); diff --git a/src/test/plugin-runner.test.ts b/src/test/plugin-runner.test.ts index 892ba3a..7c68c39 100644 --- a/src/test/plugin-runner.test.ts +++ b/src/test/plugin-runner.test.ts @@ -2,7 +2,6 @@ import {describe, it, expect} from 'vitest'; import {runPlugins} from '../plugin-runner.js'; import type {FileSystem} from '../file-system.js'; import type {ReportPlugin, Stats, Message, AnalysisContext} from '../types.js'; -import {testResolvedRuntimeTarget} from './utils.js'; const fsMock: FileSystem = { getRootDir: async () => '/', @@ -14,9 +13,6 @@ const fsMock: FileSystem = { const depCounts = {production: 0, development: 0}; -const testPkg = {name: 'test-package', version: '1.0.0' as const}; -const pluginRunnerResolved = testResolvedRuntimeTarget('.', testPkg); - describe('runPlugins', () => { it('should aggregate messages and merge stats with extraStats de-dup', async () => { const pluginA: ReportPlugin = async () => ({ @@ -73,8 +69,7 @@ describe('runPlugins', () => { packageFile: { name: 'test-package', version: '1.0.0' - }, - resolvedRuntimeTarget: pluginRunnerResolved + } }; await runPlugins(context, [pluginA, pluginB, pluginC]); @@ -117,8 +112,7 @@ describe('runPlugins', () => { packageFile: { name: 'test-package', version: '1.0.0' - }, - resolvedRuntimeTarget: pluginRunnerResolved + } }; const noop: ReportPlugin = async () => ({messages: []}); @@ -165,8 +159,7 @@ describe('runPlugins', () => { packageFile: { name: 'test-package', version: '1.0.0' - }, - resolvedRuntimeTarget: pluginRunnerResolved + } }; const boom: ReportPlugin = async () => { @@ -208,8 +201,7 @@ describe('runPlugins', () => { packageFile: { name: 'test-package', version: '1.0.0' - }, - resolvedRuntimeTarget: pluginRunnerResolved + } }; await runPlugins(context, [onlyMsgs]); @@ -257,8 +249,7 @@ describe('runPlugins', () => { packageFile: { name: 'test-package', version: '1.0.0' - }, - resolvedRuntimeTarget: pluginRunnerResolved + } }; await runPlugins(context, [plugin]); @@ -295,8 +286,7 @@ describe('runPlugins', () => { packageFile: { name: 'test-package', version: '1.0.0' - }, - resolvedRuntimeTarget: pluginRunnerResolved + } }; await runPlugins(context, []); diff --git a/src/test/resolve-runtime-target.test.ts b/src/test/resolve-runtime-target.test.ts deleted file mode 100644 index 4c7742e..0000000 --- a/src/test/resolve-runtime-target.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import {describe, it, expect, beforeEach, afterEach} from 'vitest'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import {createTempDir, cleanupTempDir} from './utils.js'; -import { - resolveRuntimeTarget, - formatResolvedRuntimeTargetSummary -} from '../targets/resolve-runtime-target.js'; -import {parseTargetRuntime} from '../targets/runtime-target.js'; - -describe('resolveRuntimeTarget', () => { - let tempDir: string; - - beforeEach(async () => { - tempDir = await createTempDir(); - }); - - afterEach(async () => { - await cleanupTempDir(tempDir); - }); - - it('uses default when no engines, browserslist, or CLI override', async () => { - await fs.writeFile( - path.join(tempDir, 'package.json'), - JSON.stringify({name: 'p', version: '1.0.0'}) - ); - - const r = resolveRuntimeTarget({ - root: tempDir, - packageFile: {name: 'p', version: '1.0.0'} - }); - - expect(r.primarySource).toBe('default'); - expect(r.runtime).toBe('nodejs'); - expect(r.browserslistQueries).toBeUndefined(); - expect(r.nodeRange).toBeUndefined(); - }); - - it('uses engines.node when no browserslist config', async () => { - const pkg = { - name: 'p', - version: '1.0.0', - engines: {node: '>=18'} - }; - await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); - - const r = resolveRuntimeTarget({ - root: tempDir, - packageFile: pkg - }); - - expect(r.primarySource).toBe('engines-node'); - expect(r.runtime).toBe('nodejs'); - expect(r.nodeRange).toBe('>=18'); - expect(r.browserslistQueries).toBeUndefined(); - }); - - it('loads browserslist from package.json and infers browser runtime', async () => { - const pkg = { - name: 'p', - version: '1.0.0', - browserslist: ['defaults'] - }; - await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); - - const r = resolveRuntimeTarget({ - root: tempDir, - packageFile: pkg - }); - - expect(r.primarySource).toBe('project-browserslist'); - expect(r.runtime).toBe('browser'); - expect(r.browserslistQueries).toBeDefined(); - expect(r.browserslistQueries?.length).toBeGreaterThan(0); - }); - - it('CLI browserslist query wins over project config', async () => { - const pkg = { - name: 'p', - version: '1.0.0', - browserslist: ['defaults'] - }; - await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); - - const r = resolveRuntimeTarget({ - root: tempDir, - packageFile: pkg, - browserslistQuery: 'baseline widely available' - }); - - expect(r.primarySource).toBe('cli-browserslist'); - expect(r.browserslistQueries).toEqual(['baseline widely available']); - expect(r.runtime).toBe('browser'); - }); - - it('respects explicit CLI runtime over inference', async () => { - const pkg = {name: 'p', version: '1.0.0', browserslist: ['defaults']}; - await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify(pkg)); - - const r = resolveRuntimeTarget({ - root: tempDir, - packageFile: pkg, - runtime: 'nodejs' - }); - - expect(r.primarySource).toBe('project-browserslist'); - expect(r.runtime).toBe('nodejs'); - }); -}); - -describe('formatResolvedRuntimeTargetSummary', () => { - it('formats default and engines-node cases', () => { - expect( - formatResolvedRuntimeTargetSummary({ - runtime: 'nodejs', - primarySource: 'default', - browserslistQueries: undefined, - nodeRange: undefined - }) - ).toContain('default'); - - expect( - formatResolvedRuntimeTargetSummary({ - runtime: 'nodejs', - primarySource: 'engines-node', - browserslistQueries: undefined, - nodeRange: '>=20' - }) - ).toContain('engines.node'); - expect( - formatResolvedRuntimeTargetSummary({ - runtime: 'nodejs', - primarySource: 'engines-node', - browserslistQueries: undefined, - nodeRange: '>=20' - }) - ).toContain('>=20'); - }); - - it('formats browserslist cases', () => { - expect( - formatResolvedRuntimeTargetSummary({ - runtime: 'browser', - primarySource: 'cli-browserslist', - browserslistQueries: ['baseline widely available'], - nodeRange: undefined - }) - ).toMatch(/CLI Browserslist/); - - expect( - formatResolvedRuntimeTargetSummary({ - runtime: 'browser', - primarySource: 'project-browserslist', - browserslistQueries: ['defaults'], - nodeRange: undefined - }) - ).toMatch(/Browserslist/); - }); -}); - -describe('parseTargetRuntime', () => { - it('returns undefined for empty input', () => { - expect(parseTargetRuntime(undefined)).toBeUndefined(); - expect(parseTargetRuntime('')).toBeUndefined(); - }); - - it('parses valid runtimes', () => { - expect(parseTargetRuntime('browser')).toBe('browser'); - expect(parseTargetRuntime('nodejs')).toBe('nodejs'); - }); - - it('throws on invalid runtime', () => { - expect(() => parseTargetRuntime('nope')).toThrow(/Invalid --runtime/); - }); -}); diff --git a/src/test/utils.ts b/src/test/utils.ts index 7770bf0..62bcbc8 100644 --- a/src/test/utils.ts +++ b/src/test/utils.ts @@ -1,9 +1,6 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; -import type {PackageJsonLike} from '../types.js'; -import type {TargetRuntime} from '../targets/runtime-target.js'; -import {resolveRuntimeTarget} from '../targets/resolve-runtime-target.js'; export interface TestPackage { name: string; @@ -100,16 +97,3 @@ export async function createTestPackageWithDependencies( await createTestPackage(depDir, dep); } } - -export function testResolvedRuntimeTarget( - root: string, - packageFile: PackageJsonLike, - overrides?: {runtime?: TargetRuntime; browserslistQuery?: string} -) { - return resolveRuntimeTarget({ - root, - packageFile, - runtime: overrides?.runtime, - browserslistQuery: overrides?.browserslistQuery - }); -} diff --git a/src/types.ts b/src/types.ts index 5aa5f9a..af4a9ad 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,18 +2,12 @@ import type {FileSystem} from './file-system.js'; import type {Codemod, CodemodOptions} from 'module-replacements-codemods'; import type {ParsedLockFile} from 'lockparse'; import type {ParsedCategories} from './categories.js'; -import type { - ResolvedRuntimeTarget, - TargetRuntime -} from './targets/runtime-target.js'; export interface Options { root?: string; manifest?: string[]; src?: string[]; categories?: ParsedCategories; - runtime?: TargetRuntime; - browserslistQuery?: string; } export interface StatLike { @@ -79,5 +73,4 @@ export interface AnalysisContext { packageFile: PackageJsonLike; stats: Stats; messages: Message[]; - resolvedRuntimeTarget: ResolvedRuntimeTarget; } From 13fca8afc4adc664bbe9837aaa62f5d86de7b18a Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Tue, 19 May 2026 21:16:31 +0100 Subject: [PATCH 07/10] chore: bump enginematch & add tests --- package-lock.json | 6 +- src/test/analyze/replacements.test.ts | 190 ++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 src/test/analyze/replacements.test.ts diff --git a/package-lock.json b/package-lock.json index 4e10e29..53d0224 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2478,9 +2478,9 @@ "license": "ISC" }, "node_modules/enginematch": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/enginematch/-/enginematch-0.1.4.tgz", - "integrity": "sha512-zqyDSYd9DP14gfZXxddcPcbXwuHqyygPXHRt/LBLvrn7HyklWJp7QXe8eyf+SGE6i0qNHCBuLIMPv21FpVYe5A==", + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/enginematch/-/enginematch-0.1.5.tgz", + "integrity": "sha512-Q1a8Kqxg9LPw4Rr5OV5ggO+XazMYcM+tndr5upq4dJ2F1FNyNhmaQLAUYc/gr09F/vJ28r0k/Tk2CwehdERhrw==", "license": "MIT", "dependencies": { "browserslist": "^4.28.2", diff --git a/src/test/analyze/replacements.test.ts b/src/test/analyze/replacements.test.ts new file mode 100644 index 0000000..8e4fcdd --- /dev/null +++ b/src/test/analyze/replacements.test.ts @@ -0,0 +1,190 @@ +import {describe, it, expect, beforeEach, afterEach} from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {runReplacements} from '../../analyze/replacements.js'; +import {LocalFileSystem} from '../../local-file-system.js'; +import {createTempDir, cleanupTempDir} from '../utils.js'; +import type {AnalysisContext, PackageJsonLike} from '../../types.js'; + +const MANIFEST = { + mappings: { + 'legacy-pkg': { + type: 'module', + moduleName: 'legacy-pkg', + replacements: ['legacy-native'] + }, + 'old-browser-pkg': { + type: 'module', + moduleName: 'old-browser-pkg', + replacements: ['modern-browser-native'] + } + }, + replacements: { + 'legacy-native': { + id: 'legacy-native', + type: 'simple', + description: 'use the native equivalent', + engines: [{engine: 'nodejs', minVersion: '20.0.0'}] + }, + 'modern-browser-native': { + id: 'modern-browser-native', + type: 'simple', + description: 'use the native browser API', + engines: [{engine: 'chrome', minVersion: '100.0.0'}] + } + } +}; + +async function writeManifest(root: string): Promise { + const manifestPath = path.join(root, 'manifest.json'); + await fs.writeFile(manifestPath, JSON.stringify(MANIFEST)); + return manifestPath; +} + +async function setupContext( + root: string, + packageFile: PackageJsonLike, + manifestPath: string +): Promise { + await fs.writeFile( + path.join(root, 'package.json'), + JSON.stringify(packageFile) + ); + return makeContext(root, packageFile, manifestPath); +} + +function makeContext( + root: string, + packageFile: PackageJsonLike, + manifestPath: string +): AnalysisContext { + return { + fs: new LocalFileSystem(root), + root, + messages: [], + stats: { + name: packageFile.name, + version: packageFile.version, + dependencyCount: {production: 0, development: 0}, + extraStats: [] + }, + lockfile: { + type: 'npm', + packages: [], + root: { + name: packageFile.name, + version: packageFile.version, + dependencies: [], + devDependencies: [], + optionalDependencies: [], + peerDependencies: [] + } + }, + packageFile, + options: {manifest: [manifestPath]} + }; +} + +describe('runReplacements engine filtering', () => { + let tempDir: string; + let manifestPath: string; + + beforeEach(async () => { + tempDir = await createTempDir(); + manifestPath = await writeManifest(tempDir); + }); + + afterEach(async () => { + await cleanupTempDir(tempDir); + }); + + it('emits a replacement when engines.node satisfies the requirement', async () => { + const pkg: PackageJsonLike = { + name: 'test-pkg', + version: '1.0.0', + dependencies: {'legacy-pkg': '1.0.0'}, + engines: {node: '>=20'} + }; + const result = await runReplacements( + await setupContext(tempDir, pkg, manifestPath) + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0]?.message).toContain('legacy-pkg'); + }); + + it('filters out a replacement when engines.node does not satisfy the requirement', async () => { + const pkg: PackageJsonLike = { + name: 'test-pkg', + version: '1.0.0', + dependencies: {'legacy-pkg': '1.0.0'}, + engines: {node: '>=16'} + }; + const result = await runReplacements( + await setupContext(tempDir, pkg, manifestPath) + ); + + expect(result.messages).toEqual([]); + }); + + it('emits a replacement when no engines are declared (constraint trivially satisfied)', async () => { + const pkg: PackageJsonLike = { + name: 'test-pkg', + version: '1.0.0', + dependencies: {'legacy-pkg': '1.0.0'} + }; + const result = await runReplacements( + await setupContext(tempDir, pkg, manifestPath) + ); + + expect(result.messages).toHaveLength(1); + }); + + it('discovers .browserslistrc from cwd to filter a replacement', async () => { + await fs.writeFile( + path.join(tempDir, '.browserslistrc'), + 'chrome >= 110\n' + ); + + const pkg: PackageJsonLike = { + name: 'test-pkg', + version: '1.0.0', + dependencies: {'old-browser-pkg': '1.0.0'} + }; + const result = await runReplacements( + await setupContext(tempDir, pkg, manifestPath) + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0]?.message).toContain('old-browser-pkg'); + }); + + it('filters via .browserslistrc when the resolved browser version is too low', async () => { + await fs.writeFile(path.join(tempDir, '.browserslistrc'), 'chrome >= 90\n'); + + const pkg: PackageJsonLike = { + name: 'test-pkg', + version: '1.0.0', + dependencies: {'old-browser-pkg': '1.0.0'} + }; + const result = await runReplacements( + await setupContext(tempDir, pkg, manifestPath) + ); + + expect(result.messages).toEqual([]); + }); + + it('uses package.json browserslist field to filter a replacement', async () => { + const pkg: PackageJsonLike = { + name: 'test-pkg', + version: '1.0.0', + dependencies: {'old-browser-pkg': '1.0.0'}, + browserslist: ['chrome >= 90'] + }; + const result = await runReplacements( + await setupContext(tempDir, pkg, manifestPath) + ); + + expect(result.messages).toEqual([]); + }); +}); From 64fdad190c325d6b6ef15500ba23a5db5a8ac76b Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Tue, 19 May 2026 21:20:44 +0100 Subject: [PATCH 08/10] chore: drop old type --- src/test/analyze/replacements.test.ts | 4 ++-- src/types.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/test/analyze/replacements.test.ts b/src/test/analyze/replacements.test.ts index 8e4fcdd..491ef79 100644 --- a/src/test/analyze/replacements.test.ts +++ b/src/test/analyze/replacements.test.ts @@ -175,12 +175,12 @@ describe('runReplacements engine filtering', () => { }); it('uses package.json browserslist field to filter a replacement', async () => { - const pkg: PackageJsonLike = { + const pkg = { name: 'test-pkg', version: '1.0.0', dependencies: {'old-browser-pkg': '1.0.0'}, browserslist: ['chrome >= 90'] - }; + } as PackageJsonLike; const result = await runReplacements( await setupContext(tempDir, pkg, manifestPath) ); diff --git a/src/types.ts b/src/types.ts index af4a9ad..a39af0c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,7 +46,6 @@ export interface PackageJsonLike { node?: string; [engineName: string]: string | undefined; }; - browserslist?: string | string[] | Record; } export interface Replacement { From 9d523c82b3b8e7654341397643db4216b8b7c71f Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Tue, 19 May 2026 21:24:21 +0100 Subject: [PATCH 09/10] chore: redundant dep --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index f7fb181..1488eb7 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,6 @@ "obug": "^2.1.1", "package-manager-detector": "^1.6.0", "publint": "^0.3.21", - "core-js-compat": "^3.48.0", "semver": "^7.8.0", "tinyglobby": "^0.2.16" }, From d07ca9a90dd6bd87a4cb5e50d3c47f76b3ad91db Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Tue, 19 May 2026 21:26:46 +0100 Subject: [PATCH 10/10] chore: revert test files --- src/test/analyze/core-js.test.ts | 37 ++++++------------- .../analyze/web-features-codemods.test.ts | 37 ++++++------------- 2 files changed, 22 insertions(+), 52 deletions(-) diff --git a/src/test/analyze/core-js.test.ts b/src/test/analyze/core-js.test.ts index 5b8ce7c..799a5ea 100644 --- a/src/test/analyze/core-js.test.ts +++ b/src/test/analyze/core-js.test.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import {runCoreJsAnalysis} from '../../analyze/core-js.js'; import {LocalFileSystem} from '../../local-file-system.js'; import {createTempDir, cleanupTempDir} from '../utils.js'; -import type {AnalysisContext, PackageJsonLike} from '../../types.js'; +import type {AnalysisContext} from '../../types.js'; const cjsRequire = createRequire(import.meta.url); const {compat} = cjsRequire('core-js-compat') as { @@ -26,34 +26,17 @@ function makeContext( tempDir: string, overrides: Partial = {} ): AnalysisContext { - const { - packageFile: pkgOverride, - options: optionsOverride, - fs: fsOverride, - root: rootOverride, - messages: messagesOverride, - stats: statsOverride, - lockfile: lockfileOverride, - ...rest - } = overrides; - - const packageFile = (pkgOverride ?? { - name: 'test-package', - version: '1.0.0' - }) as PackageJsonLike; - const root = rootOverride ?? tempDir; - return { - fs: fsOverride ?? new LocalFileSystem(root), - root, - messages: messagesOverride ?? [], - stats: statsOverride ?? { + fs: new LocalFileSystem(tempDir), + root: tempDir, + messages: [], + stats: { name: 'test-package', version: '1.0.0', dependencyCount: {production: 0, development: 0}, extraStats: [] }, - lockfile: lockfileOverride ?? { + lockfile: { type: 'npm', packages: [], root: { @@ -65,9 +48,11 @@ function makeContext( peerDependencies: [] } }, - packageFile, - options: optionsOverride, - ...rest + packageFile: { + name: 'test-package', + version: '1.0.0' + }, + ...overrides }; } diff --git a/src/test/analyze/web-features-codemods.test.ts b/src/test/analyze/web-features-codemods.test.ts index 568027b..b54e0c8 100644 --- a/src/test/analyze/web-features-codemods.test.ts +++ b/src/test/analyze/web-features-codemods.test.ts @@ -4,40 +4,23 @@ 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, PackageJsonLike} from '../../types.js'; +import type {AnalysisContext} from '../../types.js'; function makeContext( tempDir: string, overrides: Partial = {} ): AnalysisContext { - const { - packageFile: pkgOverride, - options: optionsOverride, - fs: fsOverride, - root: rootOverride, - messages: messagesOverride, - stats: statsOverride, - lockfile: lockfileOverride, - ...rest - } = overrides; - - const packageFile = (pkgOverride ?? { - name: 'test-package', - version: '1.0.0' - }) as PackageJsonLike; - const root = rootOverride ?? tempDir; - return { - fs: fsOverride ?? new LocalFileSystem(root), - root, - messages: messagesOverride ?? [], - stats: statsOverride ?? { + fs: new LocalFileSystem(tempDir), + root: tempDir, + messages: [], + stats: { name: 'test-package', version: '1.0.0', dependencyCount: {production: 0, development: 0}, extraStats: [] }, - lockfile: lockfileOverride ?? { + lockfile: { type: 'npm', packages: [], root: { @@ -49,9 +32,11 @@ function makeContext( peerDependencies: [] } }, - packageFile, - options: optionsOverride, - ...rest + packageFile: { + name: 'test-package', + version: '1.0.0' + }, + ...overrides }; }