Repository navigation
Capture esbuild's resolver metadata for resolution parity - #65
ChristopherTuring wants to merge 8 commits into
Conversation
…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
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
…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
|
@codex review Addressed the concurrent-resolution finding in 02a71e4 (answered on its thread). Generated by Claude Code |
There was a problem hiding this comment.
💡 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".
…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
|
@codex review Addressed the pluginData-key finding in 1a73899 (answered on its thread). Generated by Claude Code |
There was a problem hiding this comment.
💡 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".
…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
|
@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 |
There was a problem hiding this comment.
💡 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".
… 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
|
@codex review Addressed the suffixed-load finding in d2c2a85 (answered on its thread). Generated by Claude Code |
There was a problem hiding this comment.
💡 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".
…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
|
@codex review Addressed the pluginData-load finding in ac0e9d0 (answered on its thread). Generated by Claude Code |
There was a problem hiding this comment.
💡 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".
…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
|
@codex review Answered the downstream-loader finding on its thread: confirmed, but Generated by Claude Code |
There was a problem hiding this comment.
💡 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".
…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
|
@codex review Addressed the pluginData-context finding in cc10d38 (answered on its thread). Generated by Claude Code |
There was a problem hiding this comment.
💡 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' |
There was a problem hiding this comment.
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 👍 / 👎.
Summary
At capture, StasisEsbuild now produces the same output as plain esbuild for files whose module type or JSX settings come from
package.jsonortsconfig.json.esbuild attaches some metadata only to resolutions its own resolver makes, never to a path a plugin returns:
package.json"type", which controls node-mode CommonJS interop (__toESM(x, 1))tsconfig.json's JSX and TypeScript settings"main"fallback for a package's"module"pathThe plugin returned
build.resolve()'s result for every import, so all of this was lost. A.jsfile in a"type": "module"package lost Node's default-import semantics for CommonJS, and JSX ignoredtsconfig.json(React.createElementinstead of the configured factory).Changes
Capture declines its resolutions. The
file-namespaceonResolvehook callsbuild.resolve(), records the edge and the target's kind, then returnsundefined. esbuild resolves the import again with its own resolver, to the same file, and attaches the metadata.onLoadreads, attests and serves the file as before. Since StasisEsbuild's ownpluginDatano longer reachesonLoad, 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 thefilenamespace with the arguments esbuild passed, so plugins after StasisEsbuild answer it as they will once the import is declined. Before, it ran in a privatestasisnamespace: anamespace: '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
onResolveargument recognizes it, with the importer'spluginDatacompared by identity.#resolvingmapspluginData, then the key, to the in-flight resolution's promise.onLoadfinds no record, it waits for the in-flight resolutions; none of them waits on a load.pluginDatadiffers 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
browserfield disables can't be declined. esbuild would load its empty module in thefilenamespace with the sameonLoadarguments as a normal import of that file, and abrowsermap 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 underabsPaths. A disabled bare name with no file (e.g.fs) is still declined.What
onLoadclaims at capture. Only modules of recorded files that StasisEsbuild's own declined resolutions produce:pluginData: capture keeps each recorded resolution'spluginDataper file (#dataTargets), as set by a plugin after StasisEsbuild. A load carryingpluginDatais claimed only if the data deep-equals one of those.pluginData: a load withpluginDatais another plugin's module, and is left to it.Another plugin's module of a recorded file therefore reaches the plugin meant to load it. Examples: a
?rawmodule, or one a plugin before StasisEsbuild tags withpluginData.Known limits
.jsfile in a"type": "module"package therefore replays without node-mode interop. This is noted in the load hook.pluginDataalone fails the build instead. This is noted in the code.pluginDataits own resolver set;mainbehaves the same. A loader that must transform files has to run before StasisEsbuild. This is noted atonLoad.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.jsentry 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.jsonjsxFactory/jsxFragmentFactoryare applied; the output runs.esbuild-plugin-after: anamespace: 'file'alias plugin after StasisEsbuild that returnspluginData. 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:?rawmodule of a recorded file, and one tagged withpluginDataby a plugin before StasisEsbuild, both reach the plugin that loads them;pluginDatamatches no recorded resolution of its file fails closed.tests/esbuild-run.helper.jsgainsSTASIS_TEST_ESBUILD_PLUGINS_BEFORE/_AFTERto load extra plugins around StasisEsbuild.pnpm testandpnpm lintpass.🤖 Generated with Claude Code
https://claude.ai/code/session_019kyiMA9rcDeNWR7m6uY9XD