Skip to content

Capture esbuild's resolver metadata for resolution parity - #65

Open
ChristopherTuring wants to merge 8 commits into
mainfrom
claude/gallant-cray-ctc5kn
Open

ChristopherTuring wants to merge 8 commits into
mainfrom
claude/gallant-cray-ctc5kn

Conversation

@ChristopherTuring

@ChristopherTuring ChristopherTuring commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

At capture, StasisEsbuild now produces the same output as plain esbuild for files whose module type or JSX settings come from package.json or tsconfig.json.

esbuild attaches some metadata only to resolutions its own resolver makes, never to a path a plugin returns:

  • the enclosing package.json "type", which controls node-mode CommonJS interop (__toESM(x, 1))
  • the nearest tsconfig.json's JSX and TypeScript settings
  • the "main" fallback for a package's "module" path

The plugin returned build.resolve()'s result for every import, so all of this was lost. A .js file in a "type": "module" package lost Node's default-import semantics for CommonJS, and JSX ignored tsconfig.json (React.createElement instead of the configured factory).

Changes

Capture declines its resolutions. The file-namespace onResolve hook calls build.resolve(), records the edge and the target's kind, then returns undefined. esbuild resolves the import again with its own resolver, to the same file, and attaches the metadata. onLoad reads, attests and serves the file as before. Since StasisEsbuild's own pluginData no longer reaches onLoad, it finds the file's kind and entry status by path (#targets, #entries).

The inner build.resolve() sees the same plugin chain as esbuild's own pass. It runs in the file namespace with the arguments esbuild passed, so plugins after StasisEsbuild answer it as they will once the import is declined. Before, it ran in a private stasis namespace: a namespace: 'file' plugin after StasisEsbuild was skipped there, and the file it resolved was bundled but never attested.

Re-entry guard. The inner call re-enters the hook; a key of every onResolve argument recognizes it, with the importer's pluginData compared by identity.

  • #resolving maps pluginData, then the key, to the in-flight resolution's promise.
  • esbuild loads one file twice when it's imported both with and without a suffix, so an identical import from the other copy can resolve at the same moment. It's indistinguishable from the nested call and also declines. When onLoad finds no record, it waits for the in-flight resolutions; none of them waits on a load.
  • Copies whose pluginData differs resolve separately. If they resolve one import to two files, the conflicting edge fails the build: the lockfile records one target per import.

Disabled imports. A file a browser field disables can't be declined. esbuild would load its empty module in the file namespace with the same onLoad arguments as a normal import of that file, and a browser map disables a bare name only for its own package's importers. So the plugin serves the empty module from a (disabled) namespace that esbuild prints exactly like its own (disabled):<path> modules, including under absPaths. A disabled bare name with no file (e.g. fs) is still declined.

What onLoad claims at capture. Only modules of recorded files that StasisEsbuild's own declined resolutions produce:

  • Suffix: a load with one is never claimed; capture refuses suffixes at resolve time.
  • pluginData: capture keeps each recorded resolution's pluginData per file (#dataTargets), as set by a plugin after StasisEsbuild. A load carrying pluginData is claimed only if the data deep-equals one of those.
  • File recorded without pluginData: a load with pluginData is another plugin's module, and is left to it.
  • Data equal to none of the recorded values: the load is either another plugin's module or a later plugin answering differently. Capture can't tell which, so the build fails.

Another plugin's module of a recorded file therefore reaches the plugin meant to load it. Examples: a ?raw module, or one a plugin before StasisEsbuild tags with pluginData.

Known limits

  • bundle=load doesn't get the metadata. It serves paths from the bundle's import map, which esbuild never resolved, and the files may not be on disk. A .js file in a "type": "module" package therefore replays without node-mode interop. This is noted in the load hook.
  • Plugins after StasisEsbuild must be deterministic. They now run in both the inner lookup and esbuild's own pass. One that answers identical arguments with a different file can make esbuild bundle a file other than the one recorded, unattested; bundle=load then fails on the recorded one. esbuild can't tell a plugin that declined where the import ended up. A different answer in pluginData alone fails the build instead. This is noted in the code.
  • A loader after StasisEsbuild never sees a file capture claims (pre-existing). Capture serves the bytes it attests, so a later plugin can't load or transform that file, even with pluginData its own resolver set; main behaves the same. A loader that must transform files has to run before StasisEsbuild. This is noted at onLoad.
  • Glob imports are not attested (pre-existing). esbuild resolves them (import(`./locale/${x}.js`)) without calling plugins, so the matched files are bundled but never recorded. Left for a follow-up.

Tests

New tests in tests/esbuild.test.js; each compares capture output byte for byte with a plain esbuild build:

  • esbuild-node-mode: a .js entry of a "type": "module" package importing CommonJS with __esModule. The output contains __toESM(require_cjs_pkg(), 1), running it matches Node, and both files are attested.
  • esbuild-tsconfig: tsconfig.json jsxFactory/jsxFragmentFactory are applied; the output runs.
  • esbuild-plugin-after: a namespace: 'file' alias plugin after StasisEsbuild that returns pluginData. Its file is attested with its edge, and bundle=load replays it from the bundle alone.
  • esbuild-browser-map (extra entry): one package disabled for one importer and imported normally by another; both edges replay at bundle=load.
  • esbuild-duplicate-importer: an import resolving twice at once still has its file attested; two copies of one importer resolving one import to two files fail closed.
  • esbuild-raw-suffix:
    • a ?raw module of a recorded file, and one tagged with pluginData by a plugin before StasisEsbuild, both reach the plugin that loads them;
    • a load whose pluginData matches no recorded resolution of its file fails closed.

tests/esbuild-run.helper.js gains STASIS_TEST_ESBUILD_PLUGINS_BEFORE / _AFTER to load extra plugins around StasisEsbuild. pnpm test and pnpm lint pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_019kyiMA9rcDeNWR7m6uY9XD

…tadata

esbuild attaches some metadata only to a resolution its own resolver made,
never to a plugin's result: the enclosing package.json "type" (node-mode
CommonJS interop, `__toESM(x, 1)`), the nearest tsconfig's JSX and TS
settings, and the "main" fallback of a "module" path. Capture returned
build.resolve()'s result for every import, so a .js file in a "type":
"module" package lost node mode and JSX ignored tsconfig.json.

Capture now records the edge, then declines; esbuild resolves the import
again to the same file, and onLoad attests and serves it, finding its kind
and entry status by path instead of pluginData.

- build.resolve() runs in the 'file' namespace with the importer's own
  arguments (an in-flight key stops it re-entering this hook), so plugins
  after StasisEsbuild answer as they will once it declines. Before, a
  `namespace: 'file'` plugin after it resolved files that were bundled
  but never attested.
- A disabled file can't be declined: esbuild would load its empty module
  in the 'file' namespace with the same onLoad arguments as a real import
  of the same file. Serve it from a '(disabled)' namespace that esbuild
  prints exactly like its own disabled modules.
- bundle=load still serves the bundle's paths, which esbuild never
  resolved, so it can't reproduce this metadata (documented gap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kyiMA9rcDeNWR7m6uY9XD
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 7, 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-08T02:21:32.319904Z cc10d38 Manual request
🔒 Security Review ✅ Completed 2026-10-07T09:56:14.853449Z e97fa40 PR opened
ℹ️ 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: e97fa40b00

ℹ️ 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 thread stasis-plugins/src/esbuild.js Outdated
…esolving

Capture's onResolve tells its own nested build.resolve() apart by an
in-flight key of the import's arguments. An identical import resolving at
the same time -- from a file esbuild loads twice, as with and without a
suffix -- matches that key too, so it declined without recording its file,
and the file could reach onLoad before the first resolution recorded it:
bundled, never attested.

Keep each in-flight resolution's promise, and on a miss onLoad waits for
the ones in flight (none waits on a load) and looks again: the identical
resolution records the same file. A pluginData marker would tell the
nested call apart instead, but it would also change the pluginData the
plugins after StasisEsbuild see in that lookup, so it could resolve
differently from esbuild's own pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kyiMA9rcDeNWR7m6uY9XD

Copy link
Copy Markdown
Collaborator Author

@codex review

Addressed the concurrent-resolution finding in 02a71e4 (answered on its thread).


Generated by Claude Code

@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: 02a71e49d7

ℹ️ 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 thread stasis-plugins/src/esbuild.js
…a too

The key that recognizes capture's own nested build.resolve() left out the
importer's pluginData. Two copies of one file carrying different pluginData
then shared a key, so the second copy's import declined as if it were the
nested call -- though a plugin after StasisEsbuild could resolve it to a
different file, which esbuild bundled without it being recorded or attested.

Key in-flight resolutions by the importer's pluginData as well, by
identity: esbuild hands the nested call the very object passed to it, so it
still matches, while copies with different data resolve as their own
imports. If they resolve one import to two files, the conflicting edge now
fails the build, as one import of a file can record only one target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kyiMA9rcDeNWR7m6uY9XD

Copy link
Copy Markdown
Collaborator Author

@codex review

Addressed the pluginData-key finding in 1a73899 (answered on its thread).


Generated by Claude Code

@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: 1a73899c39

ℹ️ 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 thread stasis-plugins/src/esbuild.js
…deterministic

Capture's build.resolve() and esbuild's own pass after the decline each
call the plugins after StasisEsbuild. One that answers identical arguments
differently can make esbuild bundle a file other than the one recorded,
unattested; bundle=load then fails on the recorded one. esbuild can't tell
a plugin that declines where the import was finally resolved, so say so
where the assumption is made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kyiMA9rcDeNWR7m6uY9XD

Copy link
Copy Markdown
Collaborator Author

@codex review

Answered the stateful-resolver finding on its thread: confirmed, documented as an assumption in 77b91c3 rather than detected (no sound, proportionate way to see esbuild's final resolution after declining).


Generated by Claude Code

@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: 77b91c3b8c

ℹ️ 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 thread stasis-plugins/src/esbuild.js Outdated
… them

Capture's onLoad found a file's kind by path alone, so a module of a
recorded file that another plugin resolved -- `./a.js?raw` with its suffix
kept, for a plugin after StasisEsbuild to load as text -- was claimed and
served as the JS file, and that loader never ran. Before this branch the
load carried the other plugin's pluginData and was skipped.

An import capture declines never loads with a suffix (capture refuses one)
or pluginData (esbuild's resolver sets none), so leave any load with either
to esbuild and the plugin that made it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kyiMA9rcDeNWR7m6uY9XD

Copy link
Copy Markdown
Collaborator Author

@codex review

Addressed the suffixed-load finding in d2c2a85 (answered on its thread).


Generated by Claude Code

@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: d2c2a859c5

ℹ️ 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 thread stasis-plugins/src/esbuild.js Outdated
…t it

d2c2a85 left every capture load with pluginData to other plugins, taking
pluginData to come only from a resolver before StasisEsbuild. A resolver
after it can return a file with pluginData too: capture recorded the file
and its edge, but esbuild's own pass loaded it with that pluginData, so the
file was bundled unattested.

Record which targets were resolved with pluginData. A load with pluginData
is capture's only for one of those; for a file recorded without any, the
pluginData came from a plugin before StasisEsbuild, and its module is left
to that plugin. The alias fixture now returns pluginData as resolver
metadata, and a new test covers a plugin before StasisEsbuild tagging a
recorded file for a later loader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kyiMA9rcDeNWR7m6uY9XD

Copy link
Copy Markdown
Collaborator Author

@codex review

Addressed the pluginData-load finding in ac0e9d0 (answered on its thread).


Generated by Claude Code

@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: ac0e9d0ad5

ℹ️ 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 thread stasis-plugins/src/esbuild.js Outdated
…ture claims

Capture serves the bytes it attests for every file it recorded, so a
plugin after StasisEsbuild can't load or transform those files, even when
its own resolver tagged them with pluginData for it. This predates the
decline change (main served them too); say so where onLoad claims them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kyiMA9rcDeNWR7m6uY9XD

Copy link
Copy Markdown
Collaborator Author

@codex review

Answered the downstream-loader finding on its thread: confirmed, but main behaves the same and serving the attested bytes is the plugin's purpose; documented in 58965d4.


Generated by Claude Code

@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: 58965d4f24

ℹ️ 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 thread stasis-plugins/src/esbuild.js Outdated
…d resolution

ac0e9d0 marked a path once any plugin after StasisEsbuild resolved it with
pluginData, and then claimed every load of that path carrying pluginData.
A module of the same file another plugin tagged -- `@text/a` for a later
text loader -- was served as the JS file, unlike plain esbuild.

Record each resolution's pluginData per target, and claim a load carrying
pluginData only when it deep-equals one of them: a plugin answering the
same arguments the same way returns equal data on esbuild's own pass. If
it equals none, the load is another plugin's module or a later plugin
answering differently; capture can't tell which, so the build fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kyiMA9rcDeNWR7m6uY9XD

Copy link
Copy Markdown
Collaborator Author

@codex review

Addressed the pluginData-context finding in cc10d38 (answered on its thread).


Generated by Claude Code

@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: cc10d3862d

ℹ️ 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".

if (pluginData === undefined) return this.#targets.get(path)
const data = this.#dataTargets.get(path)
if (data === undefined) return undefined
return data.some((d) => isDeepStrictEqual(d, pluginData)) ? this.#targets.get(path) : 'mismatch'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Track pluginData provenance instead of deep equality

When an upstream resolver and a downstream resolver attach structurally equal pluginData to different modules backed by the same path, this treats the upstream load as one Stasis recorded even though only the downstream resolution was observed. For example, in the new two-tags scenario, if both resolvers independently return { text: true }, the upstream @text/a load passes this equality check, Stasis serves raw JavaScript, and the intended text loader is bypassed instead of the ambiguity failing closed. This is fresh evidence after cc10d38: value equality still conflates unrelated plugin contexts, so the match needs provenance/identity that survives the repeated resolution or must reject this ambiguous case.

Useful? React with 👍 / 👎.

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.

2 participants