-
Notifications
You must be signed in to change notification settings - Fork 4.7k
lint(src/js): flag duplicate property reads between an if condition and its body #32522
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Jarred-Sumner
merged 18 commits into
main
from
claude/farm/f089f901/oxlint-no-duplicate-nullish-property-access
Jun 20, 2026
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
c865659
lint(src/js): add oxlint rule flagging duplicate property reads after…
robobun f0d2eef
lint(src/js): fix existing duplicate-nullish-property-access sites an…
robobun 32e1b5c
test: loosen stderr assertion and fix stale comment
robobun d091f2f
lint(src/js): fix duplicate conditional property reads, part 1 (129/363)
robobun 82c55c0
lint(src/js): fix duplicate conditional property reads, part 2 (178/363)
robobun ca4d257
lint(src/js): fix duplicate conditional property reads, part 3 (readl…
robobun de23d22
lint(src/js): fix duplicate conditional property reads in url.ts and …
robobun 71ca40a
lint(src/js): fix duplicate conditional property reads in https.ts an…
robobun 7aa02aa
lint(src/js): fix duplicate conditional property reads in tls.ts
robobun 739b900
lint(src/js): fix duplicate conditional property reads in child_proce…
robobun 77d4b4e
lint(src/js): fix duplicate conditional property reads in net.ts (363…
robobun fe3aa1c
test: update oxlint plugin test for broader rule
robobun 86b4abc
streams/destroy: defer stream.req read until the branch that needs it
robobun 5ef85f0
lint(src/js): preserve original property-access timing at short-circu…
robobun f9ef3de
lint(src/js): apply inline-assign uniformly at remaining hoisted sites
robobun 49d0e22
sql/shared: defer options.filename / options.url reads to their origi…
robobun eaa3fa8
test: pin oxlint as a devDependency and run it from node_modules
robobun 07ca12b
test: skip oxlint plugin test under ASAN and when oxlint isn't installed
robobun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,221 @@ | ||
| // Custom oxlint rules for Bun's built-in JavaScript (src/js/**). | ||
| // | ||
| // Registered via `jsPlugins` in oxlint.json. Rules are written against | ||
| // oxlint's ESTree-compatible AST (see the `oxlint/plugins-dev` type | ||
| // definitions). Run with `bun run lint`. | ||
|
|
||
| /** | ||
| * Return a textual key for a simple static member expression chain made of | ||
| * identifiers and `this`, e.g. `options.foo` or `this.a.b`. Returns `null` | ||
| * for anything else (computed access, calls, optional chaining, literals). | ||
| */ | ||
| function memberExpressionKey(node) { | ||
| if (!node || node.type !== "MemberExpression" || node.computed || node.optional) { | ||
| return null; | ||
| } | ||
| const { object, property } = node; | ||
| if (!property || property.type !== "Identifier") { | ||
| return null; | ||
| } | ||
| let base; | ||
| if (object.type === "Identifier") { | ||
| base = object.name; | ||
| } else if (object.type === "ThisExpression") { | ||
| base = "this"; | ||
| } else if (object.type === "MemberExpression") { | ||
| base = memberExpressionKey(object); | ||
| if (base === null) return null; | ||
| } else { | ||
| return null; | ||
| } | ||
| return base + "." + property.name; | ||
| } | ||
|
|
||
| /** | ||
| * True if `node` is the target of an assignment (simple or compound), an | ||
| * update expression, or a `delete`. None of these can be replaced by a read | ||
| * of a cached local. | ||
| */ | ||
| function isWriteTarget(node) { | ||
| const parent = node.parent; | ||
| if (!parent) return false; | ||
| if (parent.type === "AssignmentExpression" && parent.left === node) return true; | ||
| if (parent.type === "UpdateExpression" && parent.argument === node) return true; | ||
| if (parent.type === "UnaryExpression" && parent.operator === "delete" && parent.argument === node) return true; | ||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * True if `node` is the callee of a call/new/tagged-template. Caching a | ||
| * method in a local loses the receiver, so `obj.fn()` in the body is not | ||
| * something a simple `const fn = obj.fn` can replace. | ||
| */ | ||
| function isCallee(node) { | ||
| const parent = node.parent; | ||
| if (!parent) return false; | ||
| if ((parent.type === "CallExpression" || parent.type === "NewExpression") && parent.callee === node) return true; | ||
| if (parent.type === "TaggedTemplateExpression" && parent.tag === node) return true; | ||
| return false; | ||
| } | ||
|
|
||
| function skipKey(k) { | ||
| return k === "parent" || k === "type" || k === "loc" || k === "range" || k === "start" || k === "end"; | ||
| } | ||
|
|
||
| /** | ||
| * Collect every simple static member-expression read inside the `if` test. | ||
| * Only the outermost chain is recorded (`a.b.c`, not also `a.b`). Callees and | ||
| * write targets are ignored: `if (obj.fn())` reads `obj.fn` but the value | ||
| * itself isn't something a local can reuse. | ||
| * | ||
| * A member expression that appears as the right-hand side of an assignment | ||
| * (`(local = obj.prop)`) is recorded in `cached` instead of `out`: that is | ||
| * the inline cache pattern this rule recommends, so a fallback | ||
| * `local ?? obj.prop` read in the body should not be flagged. | ||
| */ | ||
| function collectTestMembers(node, out, cached) { | ||
| if (!node || typeof node !== "object") return; | ||
| switch (node.type) { | ||
| case "FunctionDeclaration": | ||
| case "FunctionExpression": | ||
| case "ArrowFunctionExpression": | ||
| case "ClassDeclaration": | ||
| case "ClassExpression": | ||
| return; | ||
| case "MemberExpression": | ||
| if (!isCallee(node) && !isWriteTarget(node)) { | ||
| const key = memberExpressionKey(node); | ||
| if (key !== null) { | ||
| const parent = node.parent; | ||
| if (parent && parent.type === "AssignmentExpression" && parent.operator === "=" && parent.right === node) { | ||
| cached.add(key); | ||
| } else if (!out.has(key)) { | ||
| out.set(key, node); | ||
| } | ||
| return; | ||
| } | ||
| } | ||
| break; | ||
| } | ||
| for (const k in node) { | ||
| if (skipKey(k)) continue; | ||
| const v = node[k]; | ||
| if (Array.isArray(v)) { | ||
| for (const child of v) { | ||
| if (child && typeof child === "object") collectTestMembers(child, out, cached); | ||
| } | ||
| } else if (v && typeof v === "object" && typeof v.type === "string") { | ||
| collectTestMembers(v, out, cached); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const READ = 1; | ||
| const WRITE = 2; | ||
| const CALLED = 4; | ||
|
|
||
| /** | ||
| * Walk `node` collecting read/write/called flags for the static member | ||
| * expression identified by `key`. Does not descend into nested functions or | ||
| * classes: those run later with a different scope, so caching at the `if` | ||
| * wouldn't help (and the value may legitimately differ by then). | ||
| */ | ||
| function memberAccessFlags(node, key) { | ||
| if (!node || typeof node !== "object") return 0; | ||
| let flags = 0; | ||
| switch (node.type) { | ||
| case "FunctionDeclaration": | ||
| case "FunctionExpression": | ||
| case "ArrowFunctionExpression": | ||
| case "ClassDeclaration": | ||
| case "ClassExpression": | ||
| return 0; | ||
| case "MemberExpression": | ||
| if (memberExpressionKey(node) === key) { | ||
| if (isWriteTarget(node)) { | ||
| // Compound assignments (`+=`, `&&=`) and `++`/`--` also read the | ||
| // previous value, but the suggested refactor still can't | ||
| // eliminate the write-back, so treat them purely as writes here. | ||
| flags |= WRITE; | ||
| } else if (isCallee(node)) { | ||
| flags |= CALLED; | ||
| } else { | ||
| flags |= READ; | ||
| } | ||
| } | ||
| break; | ||
| } | ||
| for (const k in node) { | ||
| if (skipKey(k)) continue; | ||
| const v = node[k]; | ||
| if (Array.isArray(v)) { | ||
| for (const child of v) { | ||
| if (child && typeof child === "object") flags |= memberAccessFlags(child, key); | ||
| } | ||
| } else if (v && typeof v === "object" && typeof v.type === "string") { | ||
| flags |= memberAccessFlags(v, key); | ||
| } | ||
| } | ||
| return flags; | ||
| } | ||
|
|
||
| const noDuplicateConditionalPropertyAccess = { | ||
| meta: { | ||
| type: "suggestion", | ||
| docs: { | ||
| description: | ||
| "Disallow reading the same property in an `if` condition and again in its body. " + | ||
| "Destructure or cache the property in a local first so the getter runs once.", | ||
| }, | ||
| messages: { | ||
| duplicate: | ||
| "`{{expr}}` is read in the `if` condition and again in the body. " + | ||
| "Read it into a local first (e.g. `const { {{prop}} } = {{base}}`) so the property is only accessed once.", | ||
| }, | ||
| schema: [], | ||
| }, | ||
| create(context) { | ||
| return { | ||
| IfStatement(node) { | ||
| const members = new Map(); | ||
| const cached = new Set(); | ||
| collectTestMembers(node.test, members, cached); | ||
| // A property already cached via `(local = obj.prop)` in the | ||
| // condition is the pattern this rule recommends; don't flag it. | ||
| for (const key of cached) members.delete(key); | ||
| if (members.size === 0) return; | ||
|
|
||
| for (const [key, member] of members) { | ||
| const flags = memberAccessFlags(node.consequent, key); | ||
| // If the body writes to the same property, caching it in a local | ||
| // would change semantics (later reads would see the stale value). | ||
| if (flags & WRITE) continue; | ||
| // If the body calls it as a method, caching it in a local loses | ||
| // the receiver; the simple refactor doesn't apply. | ||
| if (flags & CALLED) continue; | ||
| if (!(flags & READ)) continue; | ||
|
|
||
| const dot = key.lastIndexOf("."); | ||
| context.report({ | ||
| node: member, | ||
| messageId: "duplicate", | ||
| data: { | ||
| expr: key, | ||
| prop: key.slice(dot + 1), | ||
| base: key.slice(0, dot), | ||
| }, | ||
| }); | ||
| } | ||
| }, | ||
| }; | ||
| }, | ||
| }; | ||
|
|
||
| export default { | ||
| meta: { | ||
| name: "bun", | ||
| }, | ||
| rules: { | ||
| "no-duplicate-conditional-property-access": noDuplicateConditionalPropertyAccess, | ||
| }, | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.