fix(javascript): honor package exports condition order (#591)

* fix(javascript): honor package exports condition order

Signed-off-by: Rudy Celekli <[email protected]>

* fix(javascript): resolve exports against Node's full condition set

The declared-order fix selected targets against {default, import|require},
but Node's active set for the resolver that owns installed packages also
includes "node" and "node-addons". Ordering only means something against the
full active set, so a package declaring {"node", "import"} resolved to the
wrong target, and a package declaring only {"import"} reported no target
where Node resolves ./node.cjs.

Nested condition objects and fallback arrays are also valid Node targets.
They are now flattened in Node's own order: a condition object stops at its
first active key, so a null or unmatched nested target ends the search, while
an array skips the entries it cannot resolve and concatenates the rest. Node
does not fall through a target whose file is absent, so the first flattened
target stands and its absence is reported rather than skipped.

A valid exports map that matches no active condition is reported as external
with the declared condition names, and one that selects an unsupported nested
or array target no longer borrows the "not valid package JSON" reason.

The differential test installs a real package and resolves the same bare
specifier through Node and through REA, mirroring file presence between the
filesystem and the inventory. 18 of the 25 cases fail without this change.

* style: apply Prettier to the conditional exports differential test

---------

Signed-off-by: Rudy Celekli <[email protected]>
Co-authored-by: morluto <[email protected]>
This commit is contained in:
Rudy Mizrahi Celekliandmorluto authored and GitHub committed 2026-10-06 01:03:18 +08:00
1 parent ffbde92f9a
commit 82895959d1
2 files changed
+302 -7

No files matched your search

@@ -319,6 +319,14 @@ const resolveCandidate = (
`Directory package metadata ${packagePath} is not valid package JSON.`,
],
};
if (main.status === "unmatched")
return {
resolvedPath: null,
status: "external",
limitations: [
`Directory package metadata ${packagePath} declares no exports target for the active conditions; it declares ${main.declared.join(", ")}.`,
],
};
if (packageChain.has(packagePath)) {
return {
resolvedPath: null,
@@ -401,7 +409,8 @@ const packageEntry = (
readonly source: "legacy" | "exports";
}
| { readonly status: "missing" }
| { readonly status: "invalid" } => {
| { readonly status: "invalid" }
| { readonly status: "unmatched"; readonly declared: readonly string[] } => {
try {
const value: unknown = JSON.parse(text);
if (typeof value !== "object" || value === null)
@@ -429,9 +438,7 @@ const packageEntry = (
const packageExport = (
value: unknown,
moduleKind: ResolveArtifactPathInput["moduleKind"],
):
| { readonly status: "value"; readonly value: string }
| { readonly status: "invalid" } => {
): PackageExportOutcome => {
if (typeof value === "string") return packagePathValue(value);
if (typeof value !== "object" || value === null || Array.isArray(value))
return { status: "invalid" };
@@ -439,11 +446,71 @@ const packageExport = (
if (typeof root === "string") return packagePathValue(root);
if (typeof root !== "object" || root === null || Array.isArray(root))
return { status: "invalid" };
const condition =
Reflect.get(root, moduleKind ?? "default") ?? Reflect.get(root, "default");
return packagePathValue(condition);
const flattened = exportTargets(root, packageExportConditions(moduleKind));
if (flattened.kind === "invalid") return { status: "invalid" };
const first = flattened.values[0];
return first === undefined
? { status: "unmatched", declared: Object.keys(root) }
: { status: "value", value: first };
};
type ExportTargets =
| { readonly kind: "targets"; readonly values: readonly string[] }
| { readonly kind: "invalid" };
/**
* Flatten one exports target into the ordered strings Node would consider.
* A condition object stops at its first active key, so a null or unmatched
* nested target ends the search rather than falling through to a later key. An
* array instead skips the entries it cannot resolve and concatenates the rest.
*/
const exportTargets = (
value: unknown,
conditions: ReadonlySet<string>,
): ExportTargets => {
if (typeof value === "string")
return value.length > 0
? { kind: "targets", values: [value] }
: { kind: "invalid" };
if (value === null) return { kind: "targets", values: [] };
if (Array.isArray(value)) {
const values: string[] = [];
for (const entry of value) {
const nested = exportTargets(entry, conditions);
if (nested.kind === "invalid") return nested;
values.push(...nested.values);
}
return { kind: "targets", values };
}
if (typeof value !== "object") return { kind: "invalid" };
for (const [condition, target] of Object.entries(value)) {
if (!conditions.has(condition)) continue;
return exportTargets(target, conditions);
}
return { kind: "targets", values: [] };
};
/**
* Node selects an exports target by walking the declared keys in order and
* taking the first whose condition is active for the calling resolver.
* "default" is always active, and "node" plus "node-addons" are active for the
* built-in resolver that owns installed-package imports and requires.
*/
const packageExportConditions = (
moduleKind: ResolveArtifactPathInput["moduleKind"],
): ReadonlySet<string> =>
new Set([
"node",
"node-addons",
...(moduleKind === undefined ? [] : [moduleKind]),
"default",
]);
type PackageExportOutcome =
| { readonly status: "value"; readonly value: string }
| { readonly status: "invalid" }
| { readonly status: "unmatched"; readonly declared: readonly string[] };
const packagePathValue = (
value: unknown,
):