Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 36 additions & 21 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
59 changes: 26 additions & 33 deletions src/analyze/replacements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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<Awaited<ReturnType<typeof getPackageJson>>>,
resolved: ResolvedRuntimeTarget
): PackageJson {
return {
engines: packageJson.engines as Record<string, string> | undefined,
browserslist: resolved.browserslistQueries ?? packageJson.browserslist
};
}

function findFirstCompatibleReplacement(
replacementIds: string[],
defs: Record<string, ModuleReplacement>,
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;
}
}
Expand Down Expand Up @@ -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];
Expand All @@ -157,7 +149,8 @@ export async function runReplacements(
const firstCompatible = findFirstCompatibleReplacement(
mapping.replacements,
allReplacementDefs,
enginesNode
pkgForEngines,
context.root
);
if (!firstCompatible) {
continue;
Expand All @@ -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}.`
Expand Down
21 changes: 20 additions & 1 deletion src/analyze/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -93,17 +97,32 @@ export async function report(options: Options) {
extraStats: []
};

const resolvedRuntimeTarget = resolveRuntimeTarget({
root,
packageFile,
runtime: options?.runtime,
browserslistQuery: options?.browserslistQuery
});

const context: AnalysisContext = {
fs: fileSystem,
root,
packageFile,
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};
Expand Down
10 changes: 10 additions & 0 deletions src/commands/analyze.meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
23 changes: 22 additions & 1 deletion src/commands/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -79,6 +80,20 @@ export async function run(ctx: CommandContext<typeof meta>) {
process.exit(1);
}

let parsedRuntime: ReturnType<typeof parseTargetRuntime>;
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;
Expand All @@ -102,12 +117,18 @@ export async function run(ctx: CommandContext<typeof meta>) {

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;
Expand Down
11 changes: 11 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading