Skip to content

Bun.plugin: propagate getter exceptions from loader: "object" exports - #33793

Closed
robobun wants to merge 5 commits into
mainfrom
claude/994afa7d/plugin-object-loader-getter-throw
Closed

robobun wants to merge 5 commits into
mainfrom
claude/994afa7d/plugin-object-loader-getter-throw

Conversation

@robobun

@robobun robobun commented Jul 8, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

const sentinel = new Error("GETTER-THROW");
const exportsObj = { before: 1 };
Object.defineProperty(exportsObj, "boom", { enumerable: true, get() { throw sentinel; } });
exportsObj.after = 2;

Bun.plugin({ name: "g", setup(b) { b.module("g:m", () => ({ exports: exportsObj, loader: "object" })); } });
const ns = await import("g:m");   // does NOT reject
console.log(Object.keys(ns), ns.boom);
// [ "after", "before", "boom" ] undefined

The import resolves, the boom export exists, and its value is undefined. Nothing is surfaced to the importer or the plugin. The same object handed to a plain JS spread ({ ...exportsObj }) throws, and a throwing Proxy ownKeys trap on the same object already rejects the import; only the per-property Get exception was being dropped.

Cause

generateObjectModuleSourceCode in src/jsc/modules/ObjectModule.cpp iterates the exports object's own property names and calls object->get(globalObject, entry) for each. If the Get threw, it cleared the exception and stored jsUndefined():

auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm);
JSValue value = object->get(globalObject, entry);
if (scope.exception()) [[unlikely]] {
    (void)scope.tryClearException();
    value = jsUndefined();
}
exportValues.append(value);

The sibling generateObjectModuleSourceCodeForJSON in the same file and generateInternalModuleSourceCode in ModuleLoader.cpp both use RETURN_IF_EXCEPTION after the same get call, and this function already uses RETURN_IF_EXCEPTION after getOwnPropertyNames.

Fix

Replace the clear-and-default with RETURN_IF_EXCEPTION(throwScope, void()), so the getter's exception propagates and the import rejects with it.

The mock.module() ESM-override path in src/jsc/bindings/BunPlugin.cpp had the same swallow with a comment reading "consistent with regular esm handling code" (i.e. the code above). That copy is updated to propagate as well so the two paths stay consistent.

The analogous swallow in JSCommonJSModule.cpp is left untouched: it is documented as matching Node.js behaviour for CJS named-export enumeration and is a different code path.

Verification

USE_SYSTEM_BUN=1 bun test test/js/bun/plugin/plugins.test.ts -t "propagates exceptions from export property getters"
  -> fail: "import resolved boom=undefined" / "require resolved boom=undefined"

bun bd test test/js/bun/plugin/plugins.test.ts -t "propagates exceptions from export property getters"
  -> pass: "import rejected true GETTER-THROW" / "require rejected true GETTER-THROW"

bun bd test test/js/bun/plugin/plugins.test.ts        # 35 pass, 1 todo
bun bd test test/js/bun/test/mock/mock-module.test.ts #  9 pass, 1 todo

[review] gate passed · iteration 2 · 5 files touched

fails on main (without fix)
ASAN without fix: 2 failed, 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/plugin/plugins.test.ts test/js/bun/test/mock/mock-module.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (0152a8640)

test/js/bun/plugin/plugins.test.ts:
If bundling, conditions should include development or production. If not bundling, conditions or NODE_ENV should include development or production. See https://www.npmjs.com/package/esm-env for tips on setting conditions in popular bundlers and runtimes.
(pass) require > SSRs `<h1>Hello world!</h1>` with Svelte [1096.54ms]
(pass) require > beep:boop returns 42 [8.83ms]
(pass) require > object module works [7.76ms]
(pass) module > throws with require() [12.81ms]
(pass) module > async module works with async import [26.55ms]
(pass) module > sync module module works with require() [4.45ms]
(pass) module > sync module module works with require.res
... (truncated)

release without fix: 2 skipped
bun test v1.4.0-canary.1 (0152a8640)

test/js/bun/plugin/plugins.test.ts:
If bundling, conditions should include development or production. If not bundling, conditions or NODE_ENV should include development or production. See https://www.npmjs.com/package/esm-env for tips on setting conditions in popular bundlers and runtimes.
(pass) require > SSRs `<h1>Hello world!</h1>` with Svelte [34.19ms]
(pass) require > beep:boop returns 42 [0.19ms]
(pass) require > object module works [0.11ms]
(pass) module > throws with require() [0.18ms]
(pass) module > async module works with async import [2.81ms]
(pass) module > sync module module works with require() [0.08ms]
(pass) module > sync module module works with require.resolve() [0.07ms]
(pass) module > sync module module works with import [0.08ms]
(pass) module > modules are overridable [0.22ms]
(pass) dynamic import > SSRs `<h1>Hello world!</h1>` with Svelte [0.13ms]
(pass) dynamic import > beep:boop returns 42 [0.07ms]
(pass) dynamic import > async:onLoad returns 42 [1.44ms]
(pass) dynamic import > async object loader returns 42 [1.27ms]
(pass) import statement > SSRs `<h1>Hello world!</h1>` with Svelte [2.06ms]
(todo) erro
... (truncated)
passes on PR (with fix)
ASAN with fix: 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/plugin/plugins.test.ts test/js/bun/test/mock/mock-module.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (0152a8640)

test/js/bun/plugin/plugins.test.ts:
If bundling, conditions should include development or production. If not bundling, conditions or NODE_ENV should include development or production. See https://www.npmjs.com/package/esm-env for tips on setting conditions in popular bundlers and runtimes.
(pass) require > SSRs `<h1>Hello world!</h1>` with Svelte [1106.36ms]
(pass) require > beep:boop returns 42 [8.89ms]
(pass) require > object module works [8.17ms]
(pass) module > throws with require() [12.61ms]
(pass) module > async module works with async import [23.57ms]
(pass) module > sync module module works with require() [4.35ms]
(pass) module > sync module module works with require.res
... (truncated)

release with fix: 2 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 710ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/9] cxx obj/unified/UnifiedSource-src_jsc_modules-0.cpp.o
[2/9] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-3.cpp.o
[3/9] cxx obj/unified/UnifiedSource-src_jsc_bindings-0.cpp.o
[4/9] gen generated_host_exports.rs
generated_host_exports.rs: 90 exports (host=3, lazy=10, generic=77, rust=0); 254 extern-C blocks audited
[5/9] gen cpp.rs (cppbind)
[5/9] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

info: checking for self-update (current version: 1.29.0)
  nightly-2026-05-06-x86_64-unkno
... (truncated)
diff hotspot
src/jsc/bindings/BunPlugin.cpp                     | 22 ++++++++----
 src/jsc/modules/ObjectModule.cpp                   |  6 +---
 test/js/bun/plugin/plugins.test.ts                 | 42 ++++++++++++++++++++++
 .../test/mock/mock-module-getter-throw-fixture.ts  |  2 ++
 test/js/bun/test/mock/mock-module.test.ts          | 18 ++++++++++
 5 files changed, 78 insertions(+), 12 deletions(-)

gate history · 1 passed · 1 rejected · iteration 2

evidence per changed file
file                                                      reads  edits  tests
src/jsc/bindings/BunPlugin.cpp                                3      5      0
src/jsc/modules/ObjectModule.cpp                              1      1      0
test/js/bun/plugin/plugins.test.ts                            5      2      0
…st/js/bun/test/mock/mock-module-getter-throw-fixture.ts      0      2      0
test/js/bun/test/mock/mock-module.test.ts                     2      3      0

@github-actions github-actions Bot added the claude label Jul 8, 2026
@robobun

robobun commented Jul 8, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:02 PM PT - Jul 8th, 2026

❌ @robobun, your commit 0152a86 has 1 failures in Build #70781 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33793

That installs a local version of the PR into your bun-33793 executable, so you can run:

bun-33793 --bun

@coderabbitai

coderabbitai Bot commented Jul 8, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR changes export-value retrieval in BunPlugin.cpp and ObjectModule.cpp to propagate thrown getter exceptions immediately instead of substituting undefined. New tests verify the behavior for object-loaded modules and mock.module.

Changes

Throwing getter propagation

Layer / File(s) Summary
Core exception propagation in module export handling
src/jsc/bindings/BunPlugin.cpp, src/jsc/modules/ObjectModule.cpp
mock(module, fn) export overriding now collects export values in a two-phase pass with immediate exception propagation; generateObjectModuleSourceCode now aborts on the first thrown property read instead of clearing the exception and using jsUndefined().
Tests for throwing-getter behavior
test/js/bun/plugin/plugins.test.ts, test/js/bun/test/mock/mock-module.test.ts, test/js/bun/test/mock/mock-module-getter-throw-fixture.ts
A new concurrent plugin test checks import() and require() reject with the same sentinel error for object-loaded modules with throwing getters; a new mock.module test and fixture verify the throw is propagated and the original fixture exports remain unchanged.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly describes the main change: propagating getter exceptions for Bun.plugin object-loader exports.
Description check ✅ Passed It explains the change and includes verification steps, though it uses custom sections instead of the template headings.

Comment @coderabbitai help to get the list of available commands.

Comment thread src/jsc/bindings/BunPlugin.cpp
Comment thread src/jsc/bindings/BunPlugin.cpp

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/BunPlugin.cpp`:
- Around line 662-674: The export override path in BunPlugin.cpp delays the
`MarkedArgumentBuffer::hasOverflowed()` check until after the loop that reads
`object->get(...)`, unlike the fail-fast pattern used elsewhere such as
`OnResolve::run`. Move the overflow check immediately after
`values.ensureCapacity(names.size())` in the
`moduleNamespaceObject->overrideExportValue` flow, and keep the existing
post-loop check so `object->get` is never invoked if reserving the buffer
already failed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4505aafc-437e-40f9-95fa-693f17239602

📥 Commits

Reviewing files that changed from the base of the PR and between 4924862 and f7ae1c9.

📒 Files selected for processing (5)
  • src/jsc/bindings/BunPlugin.cpp
  • src/jsc/modules/ObjectModule.cpp
  • test/js/bun/plugin/plugins.test.ts
  • test/js/bun/test/mock/mock-module-getter-throw-fixture.ts
  • test/js/bun/test/mock/mock-module.test.ts

Comment thread src/jsc/bindings/BunPlugin.cpp

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Both earlier review threads are addressed and I don't see any remaining issues, but this changes user-visible behavior (imports/mocks that previously resolved with undefined now throw) and restructures a GC-sensitive loop in JSMock__jsModuleMock, so it's worth a quick maintainer glance before merging.

Extended reasoning...

Overview

Two native changes plus tests:

  • src/jsc/modules/ObjectModule.cpp: in generateObjectModuleSourceCode, replaces the per-property tryClearException() + jsUndefined() fallback with RETURN_IF_EXCEPTION(throwScope, void()), matching the sibling generateObjectModuleSourceCodeForJSON in the same file.
  • src/jsc/bindings/BunPlugin.cpp: the mock.module() ESM-override loop is restructured into two phases — collect all export values into a MarkedArgumentBuffer (with RETURN_IF_EXCEPTION per get and hasOverflowed() checks matching OnResolve::run), then apply overrideExportValue — so a throwing getter surfaces before the live namespace is mutated.
  • New tests in plugins.test.ts (spawned subprocess covering import() and require() through build.module + loader: "object") and mock-module.test.ts (two-export fixture proving the namespace is left untouched on throw).

Security risks

None identified. No auth/crypto/permissions surface; input is a user-supplied JS object whose getters are already being invoked today — the change only stops swallowing their exceptions. The MarkedArgumentBuffer roots collected values across the read loop, and overflow is checked both after ensureCapacity and after the append loop.

Level of scrutiny

Medium. The ObjectModule.cpp hunk is a mechanical 4→1 line swap onto an existing throwScope and mirrors adjacent code exactly. The BunPlugin.cpp hunk is larger (~20 lines) and sits in JSC bindings where GC rooting and exception-scope discipline matter, but it copies the ensureCapacity/hasOverflowed pattern already used elsewhere in the same file. The main reason for a human look is that this is a behavior change: Bun.plugin loader: "object" imports and mock.module() calls that previously succeeded (with undefined for the throwing export) will now reject/throw. The removed comment ("consistent with regular esm handling code") suggests the swallow was once deliberate, and while the PR argues convincingly that propagation is correct and consistent with sibling paths, that product call is worth a maintainer confirming.

Other factors

All three prior review threads (my two and CodeRabbit's overflow-ordering nit) were addressed in follow-up commits and are resolved. The bug-hunting pass found nothing on the current revision. Test coverage now exercises both changed code paths and the atomicity guarantee. The analogous swallow in JSCommonJSModule.cpp is explicitly and correctly left alone (documented Node-compat behavior).

robobun and others added 5 commits July 9, 2026 01:36
When a runtime plugin returns { exports, loader: "object" } and one of
the enumerable properties on the exports object has a getter that
throws, the exception was being cleared and the export was silently set
to undefined. The import resolved successfully with the wrong value and
nothing surfaced to the importer or the plugin.

generateObjectModuleSourceCode now propagates the exception via
RETURN_IF_EXCEPTION, matching what generateObjectModuleSourceCodeForJSON
and generateInternalModuleSourceCode already do for the same per-property
Get, and what this function already did for a throwing ownKeys trap.

The mock.module() ESM override path in BunPlugin.cpp carried a copy of
the same swallow with a comment pointing at the code above; it now
propagates as well.
The BunPlugin.cpp change is only reachable via mock.module() on a
specifier that is already linked in the ESM registry. The plugins.test.ts
case goes through build.module() and never touches that loop, so add a
dedicated test that imports a fixture at module scope and then mocks it
with a factory whose getter throws.
Previously a throwing getter on a later property would leave the module
namespace with earlier properties already overridden. Collect every
value into a MarkedArgumentBuffer first, then apply the overrides in a
second pass so a getter exception leaves the live namespace unchanged.

The test fixture now exports two bindings and the mock supplies a plain
value for the first so the untouched-namespace assertion would fail on a
torn state.
Matches the sibling pattern a few hundred lines below in OnResolve::run.
@robobun
robobun force-pushed the claude/994afa7d/plugin-object-loader-getter-throw branch from eb6e064 to 0152a86 Compare July 9, 2026 01:38

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks correct and both earlier concerns are addressed, but this changes user-visible error semantics for loader: "object" / mock.module() (silently-undefined exports now reject the import) in the module-loading path — worth a human confirming that's the intended contract.

Extended reasoning...

Overview

Two C++ changes in JSC bindings plus tests:

  • src/jsc/modules/ObjectModule.cpp: generateObjectModuleSourceCode now propagates getter exceptions via RETURN_IF_EXCEPTION(throwScope, void()) instead of clearing them and substituting jsUndefined(). This aligns it with the sibling generateObjectModuleSourceCodeForJSON immediately below in the same file.
  • src/jsc/bindings/BunPlugin.cpp: the ESM-override loop in JSMock__jsModuleMock was reworked into a two-phase read-then-write using a MarkedArgumentBuffer (with ensureCapacity + hasOverflowed checks matching OnResolve::run in the same file), so a throwing getter surfaces before any live-namespace mutation.
  • Tests cover both entry points: Bun.plugin build.module() via a spawned subprocess in plugins.test.ts, and mock.module() via a two-export fixture in mock-module.test.ts that would fail on a torn state.

Both of my earlier inline concerns (missing coverage for the mock.module path; torn-namespace on partial failure) were addressed in follow-up commits, and the CodeRabbit fail-fast overflow-check nit was applied.

Security risks

None identified. No untrusted-input parsing, no auth/crypto/permissions surface. The change tightens error handling rather than loosening it.

Level of scrutiny

Medium-high. The mechanical change is small and matches established sibling patterns, but it lives in the module-loader / plugin path (generateObjectModuleSourceCode backs loader: "object" for all runtime plugins) and it is a user-visible behavior change: exports objects with throwing getters that previously produced undefined silently will now reject the import. The PR description explicitly leaves the analogous CJS swallow in JSCommonJSModule.cpp untouched as intentional Node-compat, which is a design distinction a maintainer should ratify.

Other factors

  • No bugs surfaced by the bug-hunting pass on the current revision.
  • Tests are well-constructed (subprocess isolation, sentinel identity check, atomicity assertion on a two-binding fixture) and were verified to fail on stock bun / pass on the branch per the author's replies.
  • The BunPlugin.cpp hunk grew from a one-line swap into ~20 lines of new MarkedArgumentBuffer + overflow-handling logic during review; while it mirrors an existing pattern in the file, it's enough new C++ in a JSC-bindings hot area that I'd rather a human confirm than auto-approve.

@robobun

robobun commented Jul 9, 2026 •

Copy link
Copy Markdown
Collaborator Author

Diff is green: the robobun/evidence gate passes (tests fail without the fix and pass with it on both ASAN and release), and plugins.test.ts / mock-module.test.ts pass on every lane that ran.

The red on build #70781 is unrelated to this change:

  • darwin-14-aarch64-test-bun: one shard expired waiting for an agent; the shard that did run failed on test/js/web/websocket/autobahn.test.ts because the autobahn docker image is linux/amd64 and the host is arm64 (exec format error).
  • Flaky-retry annotation (Windows): update_interactive_install.test.ts, bun-write.test.js, bake/dev-and-prod.test.ts.

None of these touch the plugin or mock.module paths this PR changes. Ready for review.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Checked whether #37026 made this redundant. It did not: #37026 handles a throwing getter on the exports property of the plugin result (ModuleLoader.cpp), while this PR handles throwing getters on the properties of the exports object itself (ObjectModule.cpp and the mock.module() path in BunPlugin.cpp).

On current main (165dc9f), both tests from this PR still fail with a debug build:

  • plugins.test.ts: import resolved boom=undefined / require resolved boom=undefined
  • mock-module.test.ts: mock.module(...) does not throw

For contrast, #37026's scenario (getter on the result's exports property) does reject on main. generateObjectModuleSourceCode on main still clears the exception and exports undefined (it now also bails out on a termination exception, which is why the ObjectModule.cpp hunk here conflicts). Still a valid change; it needs a rebase.

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Cross-checked this PR against #39804, which touches the same two files. Result: #39804 covers part of this PR, so this one stays open for a rebase after #39804 lands.

Covered by #39804:

  • generateObjectModuleSourceCode in ObjectModule.cpp propagates the getter exception. The plugins.test.ts case from this PR passes on a debug build of Propagate JS exceptions instead of clearing them where the caller is fallible #39804 (9c2f5045d6), for both import() and require().
  • The mock.module() loop in BunPlugin.cpp propagates the exception. The toThrow(sentinel) assertion in the mock-module.test.ts case passes there too.

Not covered by #39804:

Rebase plan once #39804 is in: drop the ObjectModule.cpp hunk, keep the two-phase loop in BunPlugin.cpp and both tests.

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Both hunks of this PR landed on main in #39804 (commit 025570f). generateObjectModuleSourceCode propagates the getter exception, and mock.module() reads every export before it overrides any. The earlier note above about #39804 missing the read-before-write loop applied to an older commit of that PR. The merged commit has it.

I applied the test additions from this PR to main at 40ef811 with no src/ change. Both cases pass on a debug build (plugins.test.ts: the import and require case, mock-module.test.ts: the throw and the untouched namespace). #39804 also added its own mock.module() tests for this.

The one piece that did not land is the plugins.test.ts case, since #39804 tested the mock.module() entry point only. #39998 adds that coverage.

Closing as superseded.

@robobun robobun closed this Aug 21, 2026
Jarred-Sumner pushed a commit that referenced this pull request Aug 22, 2026
)

### Problem
- #39804 (commit 025570f) changed `generateObjectModuleSourceCode`
(`src/jsc/modules/ObjectModule.cpp:25`). A throwing getter on the
exports object of a `loader: "object"` result now fails the import.
Before, the loader exported `undefined` for it.
- #39804 added tests for the `mock.module()` entry point only. Its
description lists this change under "no repro", but a plugin reaches it:
bun `1.4.0-canary.1+6e906e468` (before #39804) prints `boom=undefined`
for the cases below.
- This replaces #33793. Its code change landed through #39804. Its
plugin test did not.

### Fix
- Test only. Adds `describe("object loader with a throwing getter on an
export")` to `test/js/bun/plugin/plugins.test.ts`, next to the #37026
block for a throwing getter on the result's `exports` property.
- Three cases: `import()` and `require()` of a `build.module()` result,
and `import()` of a `build.onLoad()` result. Each checks that the caught
error is the object the getter threw. The getter sits between two plain
exports.
- Verified: all three fail with `USE_SYSTEM_BUN=1`
(`1.4.0-canary.1+6e906e468`) and pass with a debug build of main at
40ef811. The full file passes there (45 tests).

### Background
- A `loader: "object"` result becomes a synthetic module.
`ModuleLoader.cpp:442` passes its `exports` object to
`generateObjectModuleSourceCode`, which reads each own enumerable
property once into the namespace. `require()` takes the same path after
the `__esModule` check at `ModuleLoader.cpp:422`.
- `mock.module()` of a module that is not loaded yet uses the same
generator. That is the entry point #39804 tests.
- #37026 covers the layer above: a getter on the `exports` property of
the result object (`ModuleLoader.cpp:155`).

<details><summary>Notes</summary>

- Output on the old binary: `imported boom=undefined` for the two
`import()` cases and `required boom=undefined` for the `require()` case.
- #33793's test ran `import()` and `require()` in one subprocess. This
version mirrors the shape of the #37026 block: one subprocess per entry
point, exact `stdout`, empty `stderr`, exit code 0.
- #33793's `mock.module()` test is not carried over. #39804 added two
tests for that entry point in
`test/js/bun/test/mock/mock-module.test.ts` (the import failure and the
untouched namespace). Both of #33793's test cases pass on a debug build
of main with no `src/` change. That is the basis for closing #33793.
- Commands: `bun bd test test/js/bun/plugin/plugins.test.ts` (45 pass)
and `USE_SYSTEM_BUN=1 bun test test/js/bun/plugin/plugins.test.ts -t
"throwing getter on an export"` (3 fail).
</details>
Jarred-Sumner pushed a commit that referenced this pull request Aug 22, 2026
### Problem
- GitHub closes only the first reference after a keyword, so "Fixes #1,
#2" leaves #2 open. "Supersedes #3" links nothing, and no reference
closes a pull request.
- The last 1000 merged PRs name 274 such references. PR #32292 is open
although merged #36135 says "Supersedes #32292".

### Fix
- `.github/workflows/close-linked-issues.yml` runs on
`pull_request_target` `closed` (a merge into the default branch of
`oven-sh/bun`) and on `workflow_dispatch` with a PR number and
`dry_run`. Everything is inline in one `actions/github-script` step,
with no checkout.
- Each open target is closed as `completed` with the comment "Closed as
completed by #N." or "Superseded by #N.". Closed or missing targets, the
PR itself and other repositories are skipped.
- The parser has no regex. A closing keyword (close, fix, resolve,
supersede, replace, any tense) must lead the reference, alone or in a
list. A negated, hedged or noun keyword, or one whose subject is another
reference, does not count ("may fix", "the rm fix #1", "#100 supersedes
#1").
- Verified: `test/internal/close-linked-issues.test.ts` (333 cases) runs
the YAML's script against fake `github`, `context` and `core`. Also the
1000-PR parse (Notes).

### Background
- GitHub's own keywords are close, fix and resolve (-s, -ed). Each links
one reference, and only a merge into the default branch closes it.
- `pull_request_target` runs in the base repository with a write token,
also for fork PRs. That is safe only when no PR-controlled code runs.
Here the description is the only PR input, parsed as text.

<details><summary>Notes</summary>

A close through the API does not create the "closed this in #N" timeline
link that GitHub makes for its own closes. The comment carries the PR
number instead.

How the parser was calibrated. I pulled the descriptions of the last
1000 merged PRs and listed every line with a keyword next to a
reference. The keyword families, list shapes and reference forms in the
script are the ones that appear there. A reference is `#1`,
`owner/repo#1`, an issue or pull URL (bare or in `<>`), or a markdown
link. Four lines would have been wrong with a plain
keyword-then-reference rule, and each led to a rule:

- "the open `rm` fix #37521" (#38379): "fix" as a noun. Base forms (fix,
close, resolve, supersede, replace) count only at the start of a
sentence or line, or after will, should, does, and, and a few similar
words. "to" is not one of them ("unable to fix #1", "how to fix #1").
- "May also fix #12318 / #10046, untested" (#38242): hedged. may, might,
could, would, partially and the negations disqualify the keyword,
looking past adverbs such as "also".
- "Supersedes the closed #26040" (#36289) and "a comment on closed
#35351" (#35365): "closed" as an adjective. A determiner or preposition
before the keyword disqualifies it.
- "supersedes #33130's optimisation" (#35843): a number that continues
into a word is not a reference.

Review added: a reference before the keyword is the subject ("#100
supersedes #1"), also through "which" or "that" ("reverts #100, which
fixed #1") and across a removed span ("#100 ~~also~~ fixes #1"). A hedge
two words before the keyword disqualifies it ("hopefully this fixes #1",
"could this fix #1?"). A clause that starts with if, when, once, until
or unless is not a statement. The tokenizer keeps a line break as a
token so that "Fixes #1" on one line and "Fixes #2" on the next stay two
statements. Code spans, fences, indented code, blockquotes, HTML
comments and strikethrough are skipped. The block stripping follows
CommonMark for fences (also inside a blockquote), indented code,
blockquotes with lazy continuation, setext underlines and HTML comments,
and GFM for `~~` flanking.

Result over the 1000 descriptions: 274 distinct references in 135 PRs. I
checked the current state of all of them through GraphQL. All but one
are closed (202 issues completed, 5 duplicates, 66 pull requests). The
one open target is PR #32292, superseded by merged #36135. No open
target is a false positive. Every review change kept this result.

Patterns that are deliberately not handled: a bulleted list under
"Closes:" on its own line (not seen in the sample), references separated
by whitespace only ("#1 #2"), "fix for #1", and GH-1 style references. A
`?` after the list is not treated as a question. The block parser tracks
no list containers, so a second paragraph of a list item indented by
four spaces is read as an indented code block and skipped. A removed
span or inline comment reads as one word, so "Fixes <!-- n --> #1" finds
nothing.

The test suite covers: the phrases above, stopping at the right place in
real sentences, CRLF descriptions, URLs with fragments or a `/files`
suffix, case-insensitive `Owner/Repo#1`, the fake API where a lookup, an
update or a comment fails, the `dry_run` input, an invalid `pr_number`
input, an unmerged PR, a PR merged into a non-default branch, the merge
event body against a later edit, and a description with no closing
statement.

The first revision of this PR checked out the repository and ran
`scripts/close-linked-issues.ts`. Jarred asked for no checkout and no
script file, so the script moved inline into the workflow and the test
now reads it out of the YAML.
</details>

<!-- robobun:evidence:begin -->

---

**[stamp-90s]** gate passed · iteration 9 · 2 files touched

<details><summary>passes on PR (with fix)</summary>

```console
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/internal/close-linked-issues.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/internal/close-linked-issues.test.ts
bun test v1.4.1 (4448a2e)

test/internal/close-linked-issues.test.ts:
(pass) finds "Fixes #39852" [176.21ms]
(pass) finds "Closes #31772. Fixes #31771." [22.28ms]
(pass) finds "- Fixes #39930" [12.28ms]
(pass) finds "Fixes: #30429" [10.46ms]
(pass) finds "FIXES #1" [7.86ms]
(pass) finds "(Fixes #1)" [8.97ms]
(pass) finds "**Fixes #1**" [10.20ms]
(pass) finds "__Fixes #1__" [9.83ms]
(pass) finds "_Fixes #1_" [11.25ms]
(pass) finds "Fixes **#1**" [9.72ms]
(pass) finds "**Fixes** #1" [7.13ms]
(pass) finds "**Fixes:** #1" [8.11ms]
(pass) finds "Fixes #1 and **#2**" [11.47ms]
(pass) finds "Fixes **#1**, **#2**" [9.13ms]
(pass) finds "## Why (fixes #13771, closes #30543)" [16.08ms]
(pass) finds "Closes #11418" [19.46ms]
(pass) finds "Resolves #1. Resolved #2. Resolve #3." [12.09ms]
(pass) finds "Fixes #34055, #30327, #24394, #20816, #32403, #11898, #10056." [17.11ms]
(pass) finds "Fixes #18192 and #31675 as a consequence" [10.45ms]
(pass) finds "Fixes #1, #2, and #3" [10.96ms]
(pass) finds "Fixes #1 & #2" [7.63ms]
(pass) finds "Closes #33280,  Closes #32864 and Closes #29696 (the timer in #32949 is orthogonal)" [20.29ms]
(pass) finds "Closes #33182 and #32947 on top of current main (which already has #36304 for catalogs)." [16.12ms]
(pass) finds "Fixes #1,\n#2" [7.76ms]
(pass) finds "Fixes #1, #2,\nand #3" [9.27ms]
(pass) finds "Fixes #1\nand #2" [8.57ms]
(pass) finds "Fixes #1\n& #2" [6.80ms]
(pass) finds "Fixes #1 and\n#2" [7.31ms]
(pass) finds "Supersedes #39908 (same change, moved from a fork branch)" [13.21ms]
(pass) finds "Supersedes #38778 and #38391. Carries the entry point arm of #35053." [14.43ms]
(pass) finds "Supersedes #39193 and keeps its three tests." [11.48ms]
(pass) finds "This supersedes #33306 and #32803. Their tests are kept here." [13.73ms]
(pass) finds "- This replaces #33793. Its 
... (truncated)
Exit: 0
```

</details>

<details><summary>diff hotspot</summary>

```
.github/workflows/close-linked-issues.yml | 950 ++++++++++++++++++++++++++++++
 test/internal/close-linked-issues.test.ts | 598 +++++++++++++++++++
 2 files changed, 1548 insertions(+)
```

</details>

**gate history** · 29 passed · 0 rejected · iteration 9

<details><summary>evidence per changed file</summary>

```
file                                       reads  edits  tests
.github/workflows/close-linked-issues.yml      6     12      0
test/internal/close-linked-issues.test.ts      3     11      0
```

</details>

<!-- robobun:evidence:end -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant