diff --git a/package-lock.json b/package-lock.json index ab9655c..a1916ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,11 +12,13 @@ "@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", + "enginematch": "^0.1.3", "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", @@ -2240,9 +2242,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 +2276,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 +2295,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 +2315,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,11 +2473,24 @@ } }, "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/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", @@ -3533,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 04d15b9..38c00bd 100644 --- a/package.json +++ b/package.json @@ -48,18 +48,19 @@ "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", + "enginematch": "^0.1.3", "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", "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/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/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..5bdf820 --- /dev/null +++ b/src/targets/resolve-runtime-target.ts @@ -0,0 +1,126 @@ +/** + * 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 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..893fb26 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(root), + 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..ba2f884 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(root), + 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; }