mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-06 17:34:25 +00:00
feat(semantic): add MemberWriteTarget flag to ReferenceFlags (#20772)
## Summary
Add a new `MemberWriteTarget` bit to `ReferenceFlags` ~~Store member write target references in a separate `FxHashSet<ReferenceId>` on `Semantic` rather than adding a flag to `ReferenceFlags`.~~
This marks identifier references read as the object of a member expression in a simple assignment target position (e.g., `A` in `A.foo = 1`).
- Set during `visit_member_expression` when flags indicate write-only context (simple `=`, not compound `+=` or update `++`)
- Stored in `Semantic::member_write_references` (`FxHashSet<ReferenceId>`)
- Queryable via `Semantic::is_member_write_reference(reference_id)` and `Semantic::member_write_references()`
- Does not affect existing `ReferenceFlags` — no changes to the shared bitflags type
## Motivation
Distinguishing "reads that exist only to serve a property write" from "real reads" is broadly useful:
### Minifier: dead code elimination of property-write-only symbols
```js
function A() {}
A.from = () => {} // A's only reference is a member write target
// → both can be eliminated when property_write_side_effects: false
```
Without this info, the minifier sees `A` as "referenced" and cannot remove the function declaration. With it, the minifier knows all of `A`'s reads are property-write targets and can safely drop them. (See stacked PR #20773 — will be updated to use the new API in a follow-up)
### Linter: `no_unused_vars` improvement
```js
const config = {};
config.debug = true;
config.verbose = false;
// config is never read — only mutated via property writes
```
Currently `config` is considered "used" because `config.debug = ...` creates a read reference. With member write target tracking, `no_unused_vars` could detect that `config`'s only reads are property-write targets and report it as unused.
### Linter: simplify `no_param_reassign` property detection
`no_param_reassign` has a complex `is_modifying_property()` function that manually walks the ancestor chain to detect property modifications like `param.foo = 1`. With member write target tracking, this can be checked directly on the reference.
### Rolldown: replace `MemberExprIsWrite` traversal state
Rolldown already maintains its own [`MemberExprIsWrite` flag](https://github.com/rolldown/rolldown/blob/eec7d7320cc991e1efd5ed8264d02c6889a223c3/crates/rolldown/src/ast_scanner/mod.rs#L130-L131) as local traversal state in the AST scanner to [detect member expressions in write context](https://github.com/rolldown/rolldown/blob/eec7d7320cc991e1efd5ed8264d02c6889a223c3/crates/rolldown/src/ast_scanner/impl_visit.rs#L55-L73). Having this on `Semantic` means Rolldown can consume it directly instead of re-detecting it during scanning. There's also a [TODO in Rolldown's treeshake options](https://github.com/rolldown/rolldown/blob/main/crates/rolldown_common/src/inner_bundler_options/types/treeshake.rs#L255-L259) to wire `property_write_side_effects` to oxc_minifier — our stacked PR #20773 provides that.
### General: escape analysis for local objects
Any analysis that needs to know "is this object only mutated locally, never passed elsewhere?" benefits from knowing which reads are purely for property writes vs. reads that pass the value to other functions.
## Test plan
- [x] Semantic snapshot tests updated
- [x] Conformance snapshots updated
- [x] Clippy clean
🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
1 parent
af7bff0c12
commit
3cfe8ede95
6 files changed
+42
-10
No files matched your search
@@ -2012,7 +2012,15 @@ impl<'a> Visit<'a> for SemanticBuilder<'a> {
|
||||
fn visit_member_expression(&mut self, it: &MemberExpression<'a>) {
|
||||
// A.B = 1;
|
||||
// ^^^ Can't treat A as a Write reference since it's A's property(B) that changes.
|
||||
self.current_reference_flags -= ReferenceFlags::Write;
|
||||
// For write-only references (simple `=` assignment), mark as MemberWriteTarget
|
||||
// so the minifier can identify property-write-only references.
|
||||
// Compound assignments (`+=`) and update expressions (`++`) have Read|Write flags,
|
||||
// so `is_write_only()` is false and they correctly skip this branch.
|
||||
if self.current_reference_flags.is_write_only() {
|
||||
self.current_reference_flags = ReferenceFlags::Read | ReferenceFlags::MemberWriteTarget;
|
||||
} else {
|
||||
self.current_reference_flags -= ReferenceFlags::Write;
|
||||
}
|
||||
|
||||
match it {
|
||||
MemberExpression::ComputedMemberExpression(it) => {
|
||||
@@ -2021,6 +2029,12 @@ impl<'a> Visit<'a> for SemanticBuilder<'a> {
|
||||
MemberExpression::StaticMemberExpression(it) => self.visit_static_member_expression(it),
|
||||
MemberExpression::PrivateFieldExpression(it) => self.visit_private_field_expression(it),
|
||||
}
|
||||
|
||||
// Clear any unconsumed flags to prevent leaking to sibling AST nodes.
|
||||
// When the object is `this` or a call expression (not an IdentifierReference),
|
||||
// the flags set above aren't consumed by `resolve_reference_usages`,
|
||||
// and would incorrectly propagate to the next visited identifier (e.g., the RHS).
|
||||
self.current_reference_flags = ReferenceFlags::empty();
|
||||
}
|
||||
|
||||
fn visit_simple_assignment_target(&mut self, it: &SimpleAssignmentTarget<'a>) {
|
||||
|
||||
@@ -51,21 +51,21 @@ input_file: crates/oxc_semantic/tests/fixtures/oxc/ts/assignments/left-hand.ts
|
||||
"scope_id": 0
|
||||
},
|
||||
{
|
||||
"flags": "ReferenceFlags(Read)",
|
||||
"flags": "ReferenceFlags(Read | MemberWriteTarget)",
|
||||
"id": 5,
|
||||
"name": "Foo",
|
||||
"node_id": 42,
|
||||
"scope_id": 0
|
||||
},
|
||||
{
|
||||
"flags": "ReferenceFlags(Read)",
|
||||
"flags": "ReferenceFlags(Read | MemberWriteTarget)",
|
||||
"id": 6,
|
||||
"name": "Foo",
|
||||
"node_id": 49,
|
||||
"scope_id": 0
|
||||
},
|
||||
{
|
||||
"flags": "ReferenceFlags(Read)",
|
||||
"flags": "ReferenceFlags(Read | MemberWriteTarget)",
|
||||
"id": 7,
|
||||
"name": "Foo",
|
||||
"node_id": 57,
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ input_file: crates/oxc_semantic/tests/fixtures/typescript-eslint/destructuring/a
|
||||
"node": "VariableDeclarator(obj)",
|
||||
"references": [
|
||||
{
|
||||
"flags": "ReferenceFlags(Read)",
|
||||
"flags": "ReferenceFlags(Read | MemberWriteTarget)",
|
||||
"id": 0,
|
||||
"name": "obj",
|
||||
"node_id": 14,
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ input_file: crates/oxc_semantic/tests/fixtures/typescript-eslint/destructuring/o
|
||||
"node": "VariableDeclarator(obj)",
|
||||
"references": [
|
||||
{
|
||||
"flags": "ReferenceFlags(Read)",
|
||||
"flags": "ReferenceFlags(Read | MemberWriteTarget)",
|
||||
"id": 2,
|
||||
"name": "obj",
|
||||
"node_id": 20,
|
||||
|
||||
@@ -117,6 +117,16 @@ bitflags! {
|
||||
/// a type parameter that might shadow it, since type parameters cannot
|
||||
/// have member access.
|
||||
const Namespace = 1 << 4;
|
||||
/// The identifier is read as the object of a member expression in an
|
||||
/// assignment target position. For example, `A` in `A.foo = 1`.
|
||||
///
|
||||
/// This flag is always combined with [`Read`] (since the identifier is
|
||||
/// read to access the property). It helps the minifier determine if a
|
||||
/// symbol's only reads are property-write targets, enabling dead code
|
||||
/// elimination of patterns like `function A() {} A.foo = 1;`.
|
||||
///
|
||||
/// [`Read`]: ReferenceFlags::Read
|
||||
const MemberWriteTarget = 1 << 5;
|
||||
/// The symbol being referenced is a value.
|
||||
///
|
||||
/// Note that this does not necessarily indicate the reference is used
|
||||
@@ -164,7 +174,7 @@ impl ReferenceFlags {
|
||||
/// The identifier is only written to. It is not read from in this reference.
|
||||
#[inline]
|
||||
pub const fn is_write_only(self) -> bool {
|
||||
!self.contains(Self::Read)
|
||||
self.intersects(Self::Write) && !self.contains(Self::Read)
|
||||
}
|
||||
|
||||
/// The identifier is both read from and written to, e.g `a += 1`.
|
||||
@@ -173,6 +183,13 @@ impl ReferenceFlags {
|
||||
self.contains(Self::Read | Self::Write)
|
||||
}
|
||||
|
||||
/// The identifier is read only as the object of a member expression
|
||||
/// in an assignment target position (e.g., `A` in `A.foo = 1`).
|
||||
#[inline]
|
||||
pub const fn is_member_write_target(self) -> bool {
|
||||
self.intersects(Self::MemberWriteTarget)
|
||||
}
|
||||
|
||||
/// Checks if the reference is a value being used in a type context.
|
||||
#[inline]
|
||||
pub fn is_value_as_type(self) -> bool {
|
||||
|
||||
@@ -101,7 +101,7 @@ use oxc_diagnostics::OxcDiagnostic;
|
||||
use oxc_semantic::{Scoping, SemanticBuilder};
|
||||
use oxc_span::CompactStr;
|
||||
use oxc_syntax::{
|
||||
reference::ReferenceId,
|
||||
reference::{ReferenceFlags, ReferenceId},
|
||||
scope::{ScopeFlags, ScopeId},
|
||||
symbol::SymbolId,
|
||||
};
|
||||
@@ -457,9 +457,10 @@ impl PostTransformChecker<'_, '_> {
|
||||
);
|
||||
}
|
||||
|
||||
// Check flags match
|
||||
// Check flags match.
|
||||
// Ignore `MemberWriteTarget` flag - it's set by `SemanticBuilder` but not by the transformer.
|
||||
let flags = self.get_pair(reference_ids, |scoping, reference_id| {
|
||||
scoping.get_reference(reference_id).flags()
|
||||
scoping.get_reference(reference_id).flags() - ReferenceFlags::MemberWriteTarget
|
||||
});
|
||||
if flags.is_mismatch() {
|
||||
self.errors.push_mismatch(
|
||||
|
||||
Reference in new issue
Block a user