Skip to content

fix(javascript): honor package exports before sibling files - #1762

Open
shoyann wants to merge 4 commits into
morluto:mainfrom
shoyann:fix/package-exports-before-sibling-files
Open

shoyann wants to merge 4 commits into
morluto:mainfrom
shoyann:fix/package-exports-before-sibling-files

Conversation

@shoyann

@shoyann shoyann commented Oct 11, 2026 •

Copy link
Copy Markdown

Summary

Fixes #1761.

JavaScript analysis can resolve require("fixture") or import ... from "fixture" to node_modules/fixture.js even when fixture/package.json exports a different file or rejects the request. Consult package exports at the appropriate lookup stage while preserving Node's root metadata semantics, CommonJS legacy lookup and package self-reference precedence.

Problem and expected behavior

With both node_modules/fixture.js and a package declaring "exports": "./actual.cjs", Node selects node_modules/fixture/actual.cjs; REA previously reported the sibling as resolved. Blocked, missing, and invalid exports also incorrectly produced successful sibling edges.

The Node resolution algorithm checks eligible package exports before file and directory fallback. Absent/null exports and ignored boolean/numeric root values retain CommonJS file-first behavior. Ordinary CommonJS dependency lookup excludes literal % from exports package names, so an outside require("fi%xture") selects node_modules/fi%xture.js. A matching self-reference from inside that named package instead uses its exports before dependency lookup, even when the name contains %.

Change and scope

Normalize package metadata once when reading it: retain string/object root exports and leave nested target values intact for validation. Read only same-container metadata. Before CommonJS dependency lookup, check the source's nearest package scope for an exact-name or subpath self-reference; stop at the first package metadata or a node_modules boundary. Self-reference exports share the existing target selection and refusal handling.

For ordinary dependency lookup, apply Node's CommonJS exports-name eligibility before metadata can preempt a sibling file; compare the matched name with the parsed package name to handle scoped names correctly. Ineligible names retain legacy file/directory lookup, including main fallback and subpaths whose parent package metadata is invalid. Explicit ESM imports reject percent signs anywhere in the package name before metadata or sibling lookup. Percent-containing subpaths of eligible packages still use exports. Preserve exact target selection, refusal outcomes, directory lookup, and source provenance. ESM self-resolution is outside this change.

Contract and boundary impact

  • Semantic owner and earliest changed stage: application-layer JavaScript artifact path resolution, before graph construction.
  • CLI and MCP/tool-catalog contract: no schema or tool changes; existing analysis results name the correct target or unresolved outcome.
  • Provider, bridge, target-format, or platform compatibility: static JavaScript artifact inventories; no provider or bridge changes.
  • Evidence, artifact, provenance, or reconstruction contract: corrected module edges retain the caller's specifier, source digest, and source range.
  • Process execution, authorization, cleanup, or containment impact: production remains an in-memory inventory lookup; same-container isolation remains enforced. Only trusted test fixtures execute through the Node oracle.
  • Generated metadata, package, or installation impact: none.

Evidence and regression coverage

  • Added real Node import/require comparisons for selected conditional exports, blocked roots, missing targets, and invalid targets in the presence of sibling files.
  • Preserved CommonJS absent/null exports behavior and foreign-container metadata isolation.
  • Added real Node comparisons for percent-containing unscoped names, scoped names and scope names, legacy main fallback, subpaths with malformed parent metadata, and percent subpaths of eligible packages.
  • Added Node comparisons for ignored boolean/numeric root exports, invalid nested scalar targets, CommonJS self roots/subpaths, blocked self exports, nested dependency precedence and nearest-scope boundaries.
  • Added Node comparisons rejecting percent-containing ESM package names, including scoped names, while retaining percent subpaths of valid packages. The public CLI/MCP journey verifies the rejected ESM edge and its source identity alongside CommonJS behavior.
  • Extended the compiled CLI and stdio MCP journey with scoped, blocked, scalar-root and percent-containing packages plus an internal self-reference. Source digest and source-range assertions distinguish the outside reference from the package's own caller.
  • All 84 focused tests pass, including regression cases confirmed to fail before the corresponding fix.
  • Representative results: a scoped package resolves to node_modules/@scope/selected/actual.cjs; a blocked root returns resolved_path: null, resolution_status: "external"; an outside require("fi%xture") resolves to node_modules/fi%xture.js, while the package's own reference selects node_modules/fi%xture/actual.cjs.
  • Remaining proof gaps: native Windows was not run. Complete Node module-loader compatibility is not claimed.

For evidence-bearing changes:

  • Observed, derived, and inferred claims remain distinguishable.
  • Artifact identity, source provenance, and failed attempts remain preserved.
  • Unsupported, incomplete, unavailable, or uncertain outcomes remain visible.

Validation performed

  • npm run test:focused -- tests/boundary/javascript/packageConditionalExports.test.ts tests/acceptance/applications/packageSubpathsCli.test.ts — 84 passed.
  • npm run test:local -- src/application/javascript/*.test.ts — 17 files, 274 passed.
  • npm run check — typecheck, lint, formatting, knip, metadata, module boundaries, and architecture guards passed; lint reported 728 warnings and zero errors.
  • Pre-commit formatting/lint and pre-push npm run check:fast — passed.
  • git diff --check and independent correctness/security review — passed.

Executed on Ubuntu/WSL x64 with pinned Node 24.18.0 and npm 11.16.0.

Compatibility, safety, and release

  • Breaking changes or migration steps: none.
  • Real Hopper/Ghidra, browser, or OS coverage: Linux Node oracle, public CLI, and stdio MCP ran. This static resolver change does not use a native analysis provider or browser.
  • Package or release metadata impact: none.
  • Security, privacy, process, or containment review: no new execution or I/O in production; container checks and input/evidence identity are preserved.

Review checklist

  • This PR addresses a concrete problem.
  • The PR has one focused outcome and a conventional title.
  • Related issue is linked.
  • Tests cover changed observable behavior and meaningful refusal paths.
  • No owning docs, contract, or generated metadata changes are required.
  • User-visible CLI/MCP changes include representative output.
  • Final diff checked for secrets, unrelated cleanup, and unsupported claims.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-11T09:20:28.590900Z 2168576 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1879b07a0b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +208 to +209
if (metadata.kind !== "absent")
return resolvePackageSpecifier(input, candidate, subpath, metadata);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve sibling lookup for percent-containing package names

When a CommonJS bare specifier contains a literal % (for example, require("fi%xture")) and both node_modules/fi%xture.js and node_modules/fi%xture/package.json exist, Node skips package-exports resolution because its CJS exports matcher excludes %, then selects the sibling file. These specifiers pass the existing admission checks, so this unconditional metadata return instead selects or rejects according to package.json, producing a graph result that disagrees with the runtime; gate metadata preemption with the same exports-package-name eligibility before bypassing file-first lookup.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 949bce39ea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

while (true) {
const candidate = posix.join(directory, "node_modules", packageName);
const metadata = readPackageMetadata(input, candidate);
if (packageExportsEligible(input) && metadata.kind !== "absent")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat ignored scalar exports as absent before preemption

When node_modules/example/package.json has a scalar root "exports": false (or a number) beside node_modules/example.js, Node 24 ignores that root value and continues to the sibling file. readPackageMetadata leaves the scalar intact, so this new early return passes it to selectExportTarget, reports rejected, and never executes the file-first block below; normalize package metadata with Node's field semantics before deciding whether exports preempts fallback.

AGENTS.md reference: AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

): CandidateResolution => {
const metadata = readPackageMetadata(input, directory);
if (metadata.kind === "unavailable")
const exportsEligible = packageExportsEligible(input);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor exports for percent-named package self-references

When analyzing node_modules/fi%xture/run.cjs inside a package such as { "name": "fi%xture", "exports": "./actual.cjs", "main": "./legacy.cjs" }, require("fi%xture") takes Node's package self-resolution path, which consults its own exports without the ordinary CommonJS exports-name regex. Reusing packageExportsEligible here therefore suppresses the valid export and falls through to main/index, so Node selects actual.cjs while the graph selects legacy.cjs; retain source-package self handling before applying this gate.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 08a140201d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

};

const packageExportsEligible = (input: ResolveArtifactPathInput): boolean => {
if (input.moduleKind !== "require") return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject percent signs in ESM package names

When an import bare specifier has % in its package-name segment (for example, import("fi%xture")), Node 24 rejects it with ERR_INVALID_MODULE_SPECIFIER before looking at package.json. Returning true for every non-CommonJS request lets the new metadata-preemption path resolve node_modules/fi%xture/actual.cjs (including when a same-named sibling file is present), so the analyzed graph records a module edge that cannot exist at runtime; only percent signs in the subpath of an otherwise valid package name should remain eligible.

Useful? React with 👍 / 👎.

@N0zoM1z0 N0zoM1z0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review of 21685762c03c5d174aa07d9412d45244b42bf866, integrated locally with main e90acaea674f5f7f9f17ea472104afdc1422ada6 on an isolated branch.

I reproduced the four wrong edges on fresh main 41544fb952aef84fcf6ff5d9a5284cf2b2985053 using a 12-file, 436-byte target. Native Node 24.18.0 resolution selected the exported file, refused the blocked root, selected the package self export, and rejected the percent-containing ESM package name. Compiled public CLI and real stdio SDK MCP both disagreed before this change and agreed after it. Entire Evidence envelopes were authenticated and normalized CLI/MCP results matched; caller specifiers, source digests and ranges were checked. Targets were not executed.

Local validation passed: 22 files / 376 tests (274 application tests, 82 package-export boundary cases, four public CLI/MCP acceptance tests, and 14 cancellation cases), single-threaded compile and typecheck, lint/module boundaries, changed-file formatting and architecture guards. Flameox 0.2.9 retained complete test output and the native V8 launcher profile. That profile does not establish child-worker CPU or allocation behavior. Native Windows and complete Node loader compatibility were not independently verified.

The CI shard-2 failure is in the unrelated process-capture cleanup journey (localToolContracts.test.ts): an environment ownership-token read returned EACCES for a live candidate, producing cleanup_incomplete. Package acceptance tests in that job passed. I am investigating this separately and waiting for all CI checks; I have not merged this PR.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] A sibling node_modules file overrides package exports during JavaScript analysis

2 participants