lint(src/js): flag duplicate property reads between an if condition and its body#32522
Conversation
… a null check
Adds a custom oxlint jsPlugin with one rule,
bun/no-duplicate-nullish-property-access, enabled for src/js/** via an
override in oxlint.json.
The rule flags the pattern
if (obj.prop != null) {
x = f(obj.prop);
}
where obj.prop is read once in the condition and again inside the body.
In src/js these objects are often user-supplied, so each access may
trigger a getter or Proxy trap. The suggested fix is to read the
property into a local (for example via destructuring) and test the
local instead.
Matched when:
- the test is a BinaryExpression with operator != or !== against
null, undefined, or void 0
- the compared expression is a simple static member chain
(identifiers / this only, no computed or optional access)
- the consequent reads the same member expression again, ignoring
occurrences inside nested functions or classes
Skipped when the consequent writes to the same member (assignment
target, compound assignment, or update expression), since caching the
value in a local would change semantics for any read after the write.
Existing instances (61 across 17 files, plus those in http2.ts which is
already excluded from oxlint) are suppressed with inline oxlint-disable
comments so the rule blocks new code without a behaviour change in this
commit. Runs of three or more adjacent violations use a block-level
disable/enable pair.
test/internal/oxlint-plugin-bun.test.ts exercises the rule on fixtures
and asserts that 'bun run lint' stays clean on src/js.
|
Updated 5:01 AM PT - Jun 20th, 2026
✅ @robobun, your commit 07ca12bb24d320b9b7813b9024a2ef25c8d14963 passed in 🧪 To try this PR locally: bunx bun-pr 32522That installs a local version of the PR into your bun-32522 --bun |
|
Found 4 issues this PR may fix:
🤖 Generated with Claude Code |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughIntroduces a custom oxlint plugin ( ChangesProperty caching refactor and oxlint rule
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
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 `@oxlint.json`:
- Around line 90-93: The oxlint.json file contains JavaScript-style // comments
which are invalid in strict JSON syntax. Remove all // comments from the file
(including the multi-line comment block above
"bun/no-duplicate-nullish-property-access" and others at lines 20, 21, 23-24) or
convert them to multi-line /* */ comment blocks if the contextual information
needs to be preserved. Ensure the resulting file is valid JSON that can be
parsed by tooling and editor integrations.
🪄 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: af9a931e-0b00-4530-aebd-b2bf31afe0c8
📒 Files selected for processing (20)
oxlint.jsonscripts/oxlint-plugin-bun.jssrc/js/builtins/ReadableByteStreamInternals.tssrc/js/internal/fs/cp-sync.tssrc/js/internal/sql/errors.tssrc/js/internal/streams/iter/consumers.tssrc/js/internal/util/inspect.jssrc/js/internal/webstreams_adapters.tssrc/js/node/_http_agent.tssrc/js/node/_http_client.tssrc/js/node/_http_server.tssrc/js/node/child_process.tssrc/js/node/dns.tssrc/js/node/https.tssrc/js/node/net.tssrc/js/node/perf_hooks.tssrc/js/node/tls.tssrc/js/node/wasi.tssrc/js/thirdparty/ws.jstest/internal/oxlint-plugin-bun.test.ts
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Put the plugins in a better folder.
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Remove all the disable liens and fix the underlying cause.
…d move plugin - Move the plugin to scripts/oxlint-plugins/bun.js and update oxlint.json and the test accordingly. - Replace every oxlint-disable comment with a real fix: each property that was read once in an 'if (obj.prop != null)' test and again in the body is now read into a local (via destructuring or a const) and the local is used for both the check and the subsequent reads. - Treat 'delete obj.prop' as a write in the plugin so it does not produce a false positive, and update the test fixture to cover it.
|
Addressed in f0d2eef:
|
Avoid exact-empty stderr match (ASAN/debug builds may emit benign warnings) and update the describe comment to reflect that existing sites are fixed rather than suppressed.
There was a problem hiding this comment.
All my earlier feedback has been addressed and I found no further issues, but the 17-file refactor across hot src/js paths (http server Expect handling, tls, net, child_process, the wasi .$call receiver change) is broad enough that a human should give it a once-over before merge.
Extended reasoning...
Overview
This PR introduces a custom oxlint plugin (scripts/oxlint-plugins/bun.js) implementing bun/no-duplicate-nullish-property-access, wires it into oxlint.json for src/js/**, and refactors 61 sites across 17 files in src/js (http, https, tls, net, child_process, dns, wasi, perf_hooks, streams, inspect, ws, ReadableByteStream builtins, etc.) to read each nullish-checked property into a local before the if. A new test file exercises the rule against fixtures and asserts src/js lints clean.
Security risks
None identified. The lint rule is build-time tooling. The runtime refactors are read-once caching of option/property values; they reduce, not increase, exposure to user-controlled getter side effects.
Level of scrutiny
Medium-high. Each individual edit is mechanical, but in aggregate the PR touches production-critical Node.js compatibility code: the HTTP server Expect: header dispatch was re-nested in _http_server.ts, wasi.ts switched this.getStdin() / this.sleep(ms) to cached fn.$call(this, ...) (a deliberate receiver-preserving change, but still a semantic shift), and perf_hooks.ts now eagerly destructures both entryTypes and type where previously type was only read on the fallback path. None of these look wrong, but they are the kind of subtle equivalence a human should confirm.
Other factors
- I previously flagged the missing
deletehandling inisWriteTarget, a stale test comment, and an exact-emptystderrassertion; all three were fixed in f0d2eef and 32e1b5c and the threads are resolved. - The bug-hunting pass on the latest revision found nothing.
- Test coverage for the rule itself is solid (positive/negative/write/delete/suppression/message + an end-to-end clean-lint check).
- Given the breadth across
src/jsruntime modules, autonomous approval isn't appropriate even though I have no specific concerns left.
Broadens the rule to any member expression read in an if condition and again in the body (not just nullish checks), skipping bodies that write to or call the same property. Renames the rule to bun/no-duplicate-conditional-property-access. Fixes the first 129 sites across the smaller files by caching the property in a local before the check.
…ine + stragglers)
Rename to no-duplicate-conditional-property-access and add fixtures for the new match shapes (truthy check, numeric comparison, typeof, method-call exclusion).
|
Broadened the rule per discussion: it now matches any static property access that appears in an It skips when the body writes to the property (assignment, compound assignment, All 363 existing instances across 62 files in |
There was a problem hiding this comment.
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/js/internal/streams/destroy.ts`:
- Around line 311-318: The code unconditionally reads stream.req at the start of
the destroyer function, which can trigger userland getters or Proxy traps that
may have side effects or throw errors, even on code paths that never use the req
variable. Move the const req = stream.req assignment to only execute immediately
before the else if (isRequest(req)) branch where req is actually needed, rather
than at the beginning of the function. This ensures req is only accessed when
necessary for the specific cleanup path being executed.
🪄 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: 3ebff335-c841-4722-972d-912d60f6ac20
📒 Files selected for processing (65)
oxlint.jsonscripts/oxlint-plugins/bun.jssrc/js/builtins/BundlerPlugin.tssrc/js/builtins/JSBufferConstructor.tssrc/js/builtins/JSBufferPrototype.tssrc/js/builtins/ReadableStreamInternals.tssrc/js/eval/feedback.tssrc/js/internal/assert/assertion_error.tssrc/js/internal/cluster/Worker.tssrc/js/internal/cluster/child.tssrc/js/internal/debugger.tssrc/js/internal/errors.tssrc/js/internal/fifo.tssrc/js/internal/fs/glob.tssrc/js/internal/fs/streams.tssrc/js/internal/fs/watch.tssrc/js/internal/linkedlist.tssrc/js/internal/primordials.jssrc/js/internal/sql/shared.tssrc/js/internal/sql/sqlite.tssrc/js/internal/streams/compose.tssrc/js/internal/streams/destroy.tssrc/js/internal/streams/duplex.tssrc/js/internal/streams/end-of-stream.tssrc/js/internal/streams/iter/broadcast.tssrc/js/internal/streams/iter/consumers.tssrc/js/internal/streams/iter/pull.tssrc/js/internal/streams/iter/push.tssrc/js/internal/streams/iter/transform.tssrc/js/internal/streams/iter/utils.tssrc/js/internal/streams/legacy.tssrc/js/internal/streams/native-readable.tssrc/js/internal/streams/readable.tssrc/js/internal/streams/transform.tssrc/js/internal/streams/utils.tssrc/js/internal/streams/writable.tssrc/js/internal/url.tssrc/js/internal/util/inspect.jssrc/js/internal/webstreams_adapters.tssrc/js/node/_http2_upgrade.tssrc/js/node/_http_agent.tssrc/js/node/_http_client.tssrc/js/node/_http_common.tssrc/js/node/_http_outgoing.tssrc/js/node/_http_server.tssrc/js/node/_tls_common.tssrc/js/node/assert.tssrc/js/node/child_process.tssrc/js/node/dns.tssrc/js/node/fs.promises.tssrc/js/node/fs.tssrc/js/node/https.tssrc/js/node/net.tssrc/js/node/querystring.tssrc/js/node/readline.tssrc/js/node/tls.tssrc/js/node/url.tssrc/js/node/util.tssrc/js/node/vm.tssrc/js/node/wasi.tssrc/js/node/worker_threads.tssrc/js/node/zlib.tssrc/js/thirdparty/node-fetch.tssrc/js/thirdparty/ws.jstest/internal/oxlint-plugin-bun.test.ts
Avoids invoking a user getter on stream.req for the isServerRequest and isRequest(stream) paths, preserving the original access timing.
…it and else-if sites Audited every refactored site for hoists that moved a property read above a guard (short-circuit &&/||, else-if, or throwing validation) that previously prevented it. For those, the local is now declared with 'let' and assigned inline at the original first-read position via '(local = obj.prop)', with a 'local ?? obj.prop' fallback where the body is reachable through the short-circuit arm that skips the assignment. Multi-property destructures that ran ahead of a throwing validator are split back into per-property reads at their original positions (consumers.ts, perf_hooks.ts, tls.ts InternalSecureContext). Hoists that read from objects known to be plain internal copies (spread-merged option bags, validated cp options, Url instances we constructed, internal arrays) are kept: every property was already accessed on every path, so only intra-block ordering differs. The rule now recognises '(local = obj.prop)' inside the condition as the cache pattern it recommends and does not flag the fallback read.
|
Audited every refactored site for reads that I'd moved above a guard that previously gated them. Applied the declare-then-inline-assign pattern so the first read stays at its original position: let x;
if (guard && (x = obj.prop)) {
use(x);
}For Changed (read moved back to original position): BundlerPlugin, feedback.ts, glob.ts, sqlite.ts, duplex.ts, consumers.ts, perf_hooks.ts, tls.ts InternalSecureContext, net.ts (12 sites), child_process.ts (3), _http_outgoing.ts (3), _http2_upgrade.ts, webstreams_adapters.ts, internal/url.ts, assert.ts, readline.ts, node/url.ts (4). Kept as eager reads with justification:
The rule now recognises |
There was a problem hiding this comment.
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/js/internal/url.ts`:
- Around line 17-21: The conditional assignment pattern in line 19 only assigns
url.password to the password variable when username is falsy, leaving password
undefined when username is truthy. Then line 20 uses the fallback expression
password ?? url.password which re-reads the url.password property unnecessarily.
Fix this by eagerly assigning both username and password from the url object
before the conditional check (extract them as const variables at the start),
then use the conditional only to decide whether to set options.auth, so the auth
assignment on line 20 only references the cached password variable without a
fallback property access.
🪄 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: 8e1bfef1-2510-4b69-bc99-5be6f4197e8c
📒 Files selected for processing (21)
scripts/oxlint-plugins/bun.jssrc/js/builtins/BundlerPlugin.tssrc/js/eval/feedback.tssrc/js/internal/fs/glob.tssrc/js/internal/sql/sqlite.tssrc/js/internal/streams/destroy.tssrc/js/internal/streams/duplex.tssrc/js/internal/streams/iter/consumers.tssrc/js/internal/url.tssrc/js/internal/webstreams_adapters.tssrc/js/node/_http2_upgrade.tssrc/js/node/_http_client.tssrc/js/node/_http_outgoing.tssrc/js/node/assert.tssrc/js/node/child_process.tssrc/js/node/net.tssrc/js/node/perf_hooks.tssrc/js/node/readline.tssrc/js/node/tls.tssrc/js/node/url.tstest/internal/oxlint-plugin-bun.test.ts
Converts the remaining hoists I previously kept as eager reads to the
declare-then-inline-assign pattern so the first property access stays
at its original position:
legacy.ts prependListener (emitter._events / [event])
readable.ts fromList state.length (else-if)
inspect.js formatProperty desc.get (else-if), reduceToSingleString
output.length (after &&)
JSBufferPrototype.ts read{Int,UInt}{LE,BE} this.length (after offset>=0)
_http_outgoing.ts _send this._header (after !_headerSent)
_http_client.ts socketPath (after opts.path)
zlib.ts processCallback self._chunkSize (after ||)
fs.promises.ts write chunk.byteLength (after bytesRemaining>=0)
iter/pull.ts batch.length (after hasWritev/hasWritevSync)
Where the body is reachable through the short-circuit arm that skips
the assignment, the local is populated via 'local ??= obj.prop' at the
original body read site.
|
Pushed f9ef3de: converted the remaining hoists to declare-then-inline-assign so the first read stays at the original position. Specifically the ones called out ( The cases I'd described as "already inside the else block" actually are (glob GLOBSTAR, destroy.ts req, end-of-stream signal, inspect keysLength, _http_server expectHeader, url relativeSearch, vm descriptor.value, querystring encodeURIComponent): each one's The remaining |
There was a problem hiding this comment.
I didn't find any bugs, but this PR refactors ~363 property-access sites across 62 files in src/js — including net, tls, the HTTP client/server, streams, and child_process — and several of the transforms (eager destructuring, inline-assign-with-fallback) subtly change property read ordering/timing relative to the original; that's too much production-critical surface area for me to sign off on without a human spot-checking the trickier sites.
Extended reasoning...
Overview
This PR introduces a custom oxlint rule (bun/no-duplicate-conditional-property-access) and applies it to all of src/js. The rule flags reading the same static property in an if condition and again in its body. To make src/js clean under the rule, 363 sites across 62 files were refactored to cache the property in a local. The plugin itself (~220 lines) and its test suite are straightforward; the bulk of the diff is the refactors.
Security risks
None introduced directly. The refactored code touches TLS handshake/verification (tls.ts, net.ts, _http2_upgrade.ts), certificate parsing (_tls_common.ts), and HTTP auth header construction (_http_client.ts), but each change is a read-once → cache transform rather than a logic change. The risk is regression, not new attack surface.
Level of scrutiny
High. While each individual edit is mechanically simple, the PR uses three distinct transform patterns with different semantics:
- Eager
const x = obj.propbefore theif— moves the read earlier; benign when nothing between the original and new read site can throw or short-circuit, but the author had to walk this back at several sites already (see thedestroy.ts/stream.reqthread). - Eager destructuring of many properties at once (e.g.
https.Agent.getNamenow destructures 22 keys up front;Readable/Writable/Duplexconstructors destructure all hooks;sql/errors.tsdestructures 17 fields). This changes the order in which getters fire and reads properties that the original sometimes never touched. let x; if (a || (x = obj.prop)) { use(x ?? obj.prop) }— the fallback re-read is intentional access-timing preservation, but it's non-obvious enough that CodeRabbit misread it once.
The author audited and fixed hoists-past-guards in two follow-up commits, but with 363 sites in core Node-compat code (net/tls/http/streams/child_process/readline/url), a human should at least skim the higher-risk files. The sql/shared.ts change also rewrites "filename" in options && options.filename into a hoisted ternary that reads options.filename/options.url before the adapter branch that previously gated which one was touched.
Other factors
- CI on the latest commit shows 3 musl build failures and 2 test failures — unclear if related.
- All my prior inline comments and CodeRabbit's were addressed and resolved.
- The bug-hunting system found nothing on this revision.
- No CODEOWNERS hits, but
src/js/node/**andsrc/js/internal/streams/**are de-facto owned areas.
…nal branch The hoisted ternaries read both properties before the adapter branch that previously gated which one was touched. Move each read back into the branch that uses it via inline assignment.
|
Pushed 49d0e22: moved Build 63589 failures on the previous commit were unrelated:
270 lanes passed including debian-13-x64-asan and all linux/windows test lanes. |
Makes the plugin test hermetic (no npm registry fetch on a cold bunx cache) and version-locks it to the 1.70.0 jsPlugins API the plugin is written against. Also drops 'bunx' from the lint script so it uses the locked install.
oxlint's prebuilt NAPI binding aborts when loaded by the ASAN build (exit 134); the rule is still enforced in the Lint JavaScript workflow with a release bun. Also skip when node_modules/oxlint is absent so a fresh checkout without 'bun install' doesn't fail.
There was a problem hiding this comment.
No bugs found on this revision, but with 363 refactored sites across 62 core runtime files (net/tls/http/streams/readline/child_process) — several of which needed access-timing fixes during review — this one's worth a human pass.
Extended reasoning...
Overview
This PR adds a custom oxlint rule (bun/no-duplicate-conditional-property-access) via a new jsPlugins entry, pins oxlint@1.70.0 as a devDependency, and refactors 363 call sites across 62 files in src/js/ to read properties into locals before re-using them in if bodies. Touched modules include node:net, node:tls, node:http (client/server/agent/outgoing), node:https, node:child_process, node:readline, node:url, streams internals, util.inspect, wasi, and several builtins (JSBufferPrototype, ReadableStreamInternals).
Security risks
Several refactors sit in security-sensitive paths: TLS handshake/verification (tls.ts, net.ts _rejectUnauthorized handling, _http2_upgrade.ts mutual-TLS), https.Agent.getName (session-cache keying), and auth header construction (internal/url.ts, _http_client.ts). The transformations are mechanical and I didn't spot a case where a cached value could diverge from a guard in a way that bypasses a check, but the _rejectUnauthorized sites now use the (x = obj.prop) / x ?? obj.prop short-circuit pattern which is subtle enough to merit a second pair of eyes.
Level of scrutiny
High. The pattern being applied is simple, but it's applied at scale to hot/critical runtime code, and the PR's own history shows the refactor is error-prone: earlier revisions hoisted reads past guards (destroy.ts stream.req, sql/shared.ts adapter branches) and needed a dedicated audit pass plus the inline-assign-with-fallback pattern to preserve original access timing. A few sites also change call semantics (wasi.ts getStdin/sleep now go through .(this, ...)).
Other factors
All four of my prior inline comments were addressed (delete-as-write, stale test comment, exact-empty stderr assertion, unpinned bun x oxlint). The bug-hunting system found nothing on the latest revision. CI on eaa3fa8 shows two x64-musl build failures and an ASAN test failure for the new test (now skipped under ASAN), which the author should confirm are infra-only before merge.
Adds a custom oxlint rule,
bun/no-duplicate-conditional-property-access, enabled forsrc/js/**only.What it flags
Any simple static property access that appears in an
ifcondition and is read again in the body:In
src/jsthe object is often user-supplied, so each access may hit a getter or Proxy trap. The rule suggests reading the property into a local first:How
Implemented as a
jsPluginsentry inoxlint.jsonpointing atscripts/oxlint-plugins/bun.js. oxlint 1.70.0 does not shipno-restricted-syntax, and an ESQuery selector cannot express "the same member expression appears in the test and the body" anyway.Match conditions:
thisonly; no computed access, no optional chaining) appears anywhere in theiftest, other than as a callee or assignment targetThe rule does not fire when the consequent:
++/--, ordelete): caching it in a local would change semantics for reads after the writeobj.fn()): caching loses the receiverExisting violations
All 363 existing instances across 62 files in
src/jsare fixed in this PR by reading the property into a local before theif(via destructuring or aconst).http2.tsis already excluded from oxlint and is unchanged.Tests
test/internal/oxlint-plugin-bun.test.tsruns oxlint against fixtures covering each match/skip condition and assertsbun run lintis clean onsrc/js.