From cf99619f4706f8751e0816a403b338f61f88272a Mon Sep 17 00:00:00 2001 From: Bart Waardenburg Date: Sun, 12 Jul 2026 21:45:18 +0200 Subject: [PATCH] fix(extract): scope this.* binding keys per enclosing class `binding_target_names` is module-flat, so two classes in one module that declare a same-named field collided on the `this.` key via last-write-wins: `constructor(private dep: DepA)` in one class and `readonly dep = new DepB()` in another both wrote `this.dep`, so every `this.dep.*` access in the module resolved against the surviving target only, and the losing class's dep members were falsely reported as unused-class-member cross-module. This is the collision behind the issue's privParam row (order-dependent: reversing the class declarations flipped which class's members were flagged). Qualify `this.`-rooted binding keys and receiver spellings with an internal per-class scope id during the walk (`this@.`), pushed and popped alongside the class-super / class-context stacks in `visit_class`. Both the insert side and the access / whole-object / iteration-receiver read sides are qualified consistently, so the longest-prefix resolution and the typed property-hop expansion keep working within one class scope. The qualifier is an extraction-only disambiguator: `strip_this_scope_qualifiers` runs last in `finalize_resolution_phase`, after every resolution pass, and rewrites every `this@.` spelling back to `this.` across `member_accesses` and `whole_object_uses` before any spelling reaches `ModuleInfo`, so no persisted spelling and no downstream consumer (core self-access `== "this"`, heritage `!= "this"`, unused-component-output `this.`, SFC template `starts_with("this.")`) ever sees it. Bare `this` (the per-file self-access key) and module-level `this` are never qualified. Correcting last-write-wins removes accidental credit, so a member that exists on both colliding classes but is called on only one can surface as a new true-positive finding. Validated on ten real-world projects: one such finding (`NextNodeServer.revalidate` in next.js, where a `this.server` getter on an unrelated class had been borrowing a sibling class's `server: NextNodeServer` field binding) and zero non-member drift. CACHE_VERSION 233 -> 234 (the emitted member_accesses change for modules with same-named fields across classes; warm 233 caches keep the collision). Fixes #1821 --- crates/core/tests/integration_test.rs | 2 + .../issue_1821_per_class_this_scoping.rs | 53 +++++++ crates/extract/src/cache/types.rs | 9 +- crates/extract/src/visitor/mod.rs | 90 ++++++++++- crates/extract/src/visitor/tests.rs | 149 ++++++++++++++++++ crates/extract/src/visitor/visit_impl.rs | 57 ++++--- .../package.json | 5 + .../src/consumers.ts | 40 +++++ .../src/dep.ts | 23 +++ .../src/index.ts | 12 ++ 10 files changed, 420 insertions(+), 20 deletions(-) create mode 100644 crates/core/tests/integration_test/issue_1821_per_class_this_scoping.rs create mode 100644 tests/fixtures/issue-1821-per-class-this-scoping/package.json create mode 100644 tests/fixtures/issue-1821-per-class-this-scoping/src/consumers.ts create mode 100644 tests/fixtures/issue-1821-per-class-this-scoping/src/dep.ts create mode 100644 tests/fixtures/issue-1821-per-class-this-scoping/src/index.ts diff --git a/crates/core/tests/integration_test.rs b/crates/core/tests/integration_test.rs index db9e00f9f..ad32ca141 100644 --- a/crates/core/tests/integration_test.rs +++ b/crates/core/tests/integration_test.rs @@ -219,6 +219,8 @@ mod issue_1640_commit_and_tag_version_updater; mod issue_1785_typed_property_hop; #[path = "integration_test/issue_1788_local_class_options.rs"] mod issue_1788_local_class_options; +#[path = "integration_test/issue_1821_per_class_this_scoping.rs"] +mod issue_1821_per_class_this_scoping; #[path = "integration_test/issue_1821_private_field_di.rs"] mod issue_1821_private_field_di; #[path = "integration_test/issue_346_static_factory_method.rs"] diff --git a/crates/core/tests/integration_test/issue_1821_per_class_this_scoping.rs b/crates/core/tests/integration_test/issue_1821_per_class_this_scoping.rs new file mode 100644 index 000000000..98def6a4a --- /dev/null +++ b/crates/core/tests/integration_test/issue_1821_per_class_this_scoping.rs @@ -0,0 +1,53 @@ +use super::common::{create_config, fixture_path}; + +fn unused_member_names(root: std::path::PathBuf) -> Vec { + let mut config = create_config(root); + config.rules.unused_class_members = fallow_config::Severity::Error; + let results = fallow_core::analyze(&config).expect("analysis should succeed"); + results + .unused_class_members + .iter() + .map(|m| format!("{}.{}", m.member.parent_name, m.member.member_name)) + .collect() +} + +#[test] +fn per_class_this_scoping_credits_both_colliding_fields_cross_module() { + // Issue #1821 (Fix B): two classes in ONE consumer file both name their + // receiver field `dep` (`constructor(private dep: DepPrivParam)` and + // `readonly dep = new DepPubField()`), and a second pair collides on a + // `#`-private `#dep`. Before per-class scoping the module-flat `this.dep` / + // `this.#dep` binding key collided (last-write-wins), so only one class of + // each pair credited its dep's members and the other's real member was + // falsely reported as `unused-class-member` cross-module. Consumers live in + // a separate file from the dep classes so the file-level self-access map + // cannot mask the bug. Every dep class also carries a genuinely-dead control + // member that must STAY flagged, proving the fix credits precisely per class + // and never over-credits. + let unused = unused_member_names(fixture_path("issue-1821-per-class-this-scoping")); + + for credited in [ + "DepPrivParam.privParam", + "DepPubField.pubField", + "DepHashA.hashA", + "DepHashB.hashB", + ] { + assert!( + !unused.contains(&credited.to_string()), + "{credited} is reached through a same-named `dep` / `#dep` receiver on a \ + sibling class and must be credited per class (issue #1821 Fix B), found: {unused:?}" + ); + } + for control in [ + "DepPrivParam.deadOnPrivParam", + "DepPubField.deadOnPubField", + "DepHashA.deadOnHashA", + "DepHashB.deadOnHashB", + ] { + assert!( + unused.contains(&control.to_string()), + "{control} has no call site and must stay flagged (per-class crediting must \ + not blanket-credit), found: {unused:?}" + ); + } +} diff --git a/crates/extract/src/cache/types.rs b/crates/extract/src/cache/types.rs index 3c23ac2f2..59d94d94a 100644 --- a/crates/extract/src/cache/types.rs +++ b/crates/extract/src/cache/types.rs @@ -791,7 +791,14 @@ use crate::MemberKind; /// iteration variable to the element class, so `for...of` / `.map` / `.forEach` /// member accesses on the item credit the class. Warm 232 caches lack the added /// `member_accesses`, leaving those methods falsely reported as unused. -pub(super) const CACHE_VERSION: u32 = 233; +/// +/// Bumped to 234 for issue #1821 (Fix B): `this.` binding keys are now +/// scoped per enclosing class during extraction, so two classes in one module +/// that declare a same-named field (e.g. `constructor(private dep: DepA)` and +/// `readonly dep = new DepB()`) no longer collide via last-write-wins. The +/// resulting `member_accesses` change for such modules (the previously-losing +/// class's members are now credited); warm 233 caches keep the collision. +pub(super) const CACHE_VERSION: u32 = 234; /// Duplication token cache version. Bump when duplicate tokenization, /// normalization, or the on-disk token cache schema changes. diff --git a/crates/extract/src/visitor/mod.rs b/crates/extract/src/visitor/mod.rs index 8a2bff15e..4edf84db6 100644 --- a/crates/extract/src/visitor/mod.rs +++ b/crates/extract/src/visitor/mod.rs @@ -281,6 +281,19 @@ pub(crate) struct ModuleInfoExtractor { /// `toString` coercion for the quasi itself. in_tagged_template_quasi: bool, pub(crate) class_super_stack: Vec>, + /// Monotonic per-module class-scope id source: incremented each time a + /// class body is entered so every class gets a unique id, paired with + /// `class_scope_stack`. A `this.` binding key is qualified with the + /// enclosing class's id (`this@.`) during the walk so two + /// classes declaring a same-named field do not collide in the module-flat + /// `binding_target_names` map; the qualifier is stripped back to `this.` + /// before ModuleInfo emission. See issue #1821 (Fix B). + class_scope_counter: u32, + /// Stack of active class-scope ids. The top is the enclosing class of the + /// current walk position; `this.` keys and receiver spellings are + /// qualified with it. Empty at module scope, so module-level `this` stays + /// unqualified (behavior unchanged). See issue #1821 (Fix B). + class_scope_stack: Vec, pub(crate) inline_template_findings: Vec, pub(crate) side_effect_registered_class_names: FxHashSet, lit_custom_element_candidates: Vec, @@ -743,6 +756,56 @@ impl ModuleInfoExtractor { .collect() } + /// Build the class-scoped `binding_target_names` key for a `this.` + /// member. Inside a class body the key is qualified with the enclosing + /// class's scope id (`this@.`) so two classes in one module that + /// declare a same-named field do not collide in the module-flat map: without + /// this, last-write-wins credits only the class declared last and falsely + /// reports the other class's members unused. Module-level `this` (no active + /// class scope) keeps the plain `this.` spelling. The qualifier is + /// stripped back to `this.` by `strip_this_scope_qualifiers` before any + /// spelling reaches `ModuleInfo`. See issue #1821 (Fix B). + fn this_member_key(&self, suffix: &str) -> String { + match self.class_scope_stack.last() { + Some(id) => format!("this@{id}.{suffix}"), + None => format!("this.{suffix}"), + } + } + + /// Qualify a `this.`-rooted access / whole-object / iteration-receiver + /// spelling with the enclosing class scope id so it resolves against the + /// same class's qualified `binding_target_names` keys (issue #1821). A no-op + /// for the bare `this` object (single segment, so the per-file self-access + /// credit keyed on `object == "this"` is untouched), any non-`this` + /// spelling, an already-qualified `this@.` spelling, and module-level + /// `this` (no active class scope). Paired with `strip_this_scope_qualifiers` + /// at emission. + fn qualify_this_scope(&self, spelling: &str) -> String { + if let Some(id) = self.class_scope_stack.last() + && let Some(rest) = spelling.strip_prefix("this.") + { + return format!("this@{id}.{rest}"); + } + spelling.to_string() + } + + /// Rewrite every internal `this@.` scope qualifier (issue #1821) back to + /// a plain `this.` across the emitted `member_accesses` and + /// `whole_object_uses`, so no persisted spelling and no downstream consumer + /// (core member self-access `== "this"`, heritage `!= "this"`, + /// `unused_component_output` `this.`, SFC template `starts_with("this.")`) + /// ever sees the qualifier. Called last in `finalize_resolution_phase`, after + /// every resolution pass that relies on the per-class keys, so the strip is + /// invariant across the `into_module_info` and `merge_into` (SFC) paths. + fn strip_this_scope_qualifiers(&mut self) { + for access in &mut self.member_accesses { + strip_this_scope_qualifier(&mut access.object); + } + for whole in &mut self.whole_object_uses { + strip_this_scope_qualifier(whole); + } + } + fn insert_class_binding_target(&mut self, binding: String, target: String) { self.binding_target_names .insert(binding, BindingTarget::Class(target)); @@ -2101,7 +2164,12 @@ impl ModuleInfoExtractor { self.map_local_signature_refs_to_exports(); self.apply_side_effect_registrations(); self.resolve_typed_react_props(); - self.collect_namespace_object_aliases() + let namespace_object_aliases = self.collect_namespace_object_aliases(); + // Last: every resolution pass above relies on the per-class `this@.` + // keys, so the qualifier is stripped only once they have run, before any + // spelling is emitted into `ModuleInfo`. See issue #1821 (Fix B). + self.strip_this_scope_qualifiers(); + namespace_object_aliases } pub(crate) fn into_module_info( @@ -2350,6 +2418,26 @@ pub(super) fn extract_destructured_names(obj_pat: &ObjectPattern<'_>) -> Vec` scope qualifier back to `this` (issue #1821), +/// leaving any non-`this@` spelling untouched. `@` cannot appear in a JS +/// identifier or dotted member path, so the marker is unambiguous, and it is +/// only ever produced for multi-segment `this.` spellings, so a `.` +/// always follows the id. The `else` arm is defensive and unreachable in +/// practice. +fn strip_this_scope_qualifier(spelling: &mut String) { + let Some(rest) = spelling.strip_prefix("this@") else { + return; + }; + if let Some(dot) = rest.find('.') { + let mut rebuilt = String::with_capacity("this".len() + rest.len() - dot); + rebuilt.push_str("this"); + rebuilt.push_str(&rest[dot..]); + *spelling = rebuilt; + } else { + *spelling = "this".to_string(); + } +} + fn try_extract_require<'a, 'b>( init: &'b Expression<'a>, ) -> Option<(&'b CallExpression<'a>, &'b str)> { diff --git a/crates/extract/src/visitor/tests.rs b/crates/extract/src/visitor/tests.rs index 6fb6054e1..9ee8a0135 100644 --- a/crates/extract/src/visitor/tests.rs +++ b/crates/extract/src/visitor/tests.rs @@ -4307,6 +4307,155 @@ fn private_field_without_member_call_records_no_access() { ); } +#[test] +fn per_class_this_scoping_credits_both_colliding_public_fields() { + // Issue #1821 (Fix B): two classes in one module both name their field + // `dep`. Before per-class scoping the module-flat `this.dep` binding key + // collided (last-write-wins), so only the class declared last credited its + // dep's members. Now BOTH `DepA.aMethod` and `DepB.bMethod` are credited. + let info = parse( + r" + import { DepA, DepB } from './dep'; + class ConsumerA { + constructor(private dep: DepA) {} + run() { return this.dep.aMethod(); } + } + class ConsumerB { + readonly dep = new DepB(); + run() { return this.dep.bMethod(); } + } + ", + ); + assert!( + info.member_accesses + .iter() + .any(|a| a.object == "DepA" && a.member == "aMethod"), + "param-property field should credit DepA.aMethod despite the same-named \ + `dep` field on ConsumerB, found: {:?}", + info.member_accesses + ); + assert!( + info.member_accesses + .iter() + .any(|a| a.object == "DepB" && a.member == "bMethod"), + "public field should credit DepB.bMethod despite the same-named `dep` \ + field on ConsumerA, found: {:?}", + info.member_accesses + ); +} + +#[test] +fn per_class_this_scoping_is_declaration_order_independent() { + // Issue #1821 (Fix B): reversing the class declaration order must not flip + // which class's members are credited (the tell that exposed the collision). + let info = parse( + r" + import { DepA, DepB } from './dep'; + class ConsumerB { + readonly dep = new DepB(); + run() { return this.dep.bMethod(); } + } + class ConsumerA { + constructor(private dep: DepA) {} + run() { return this.dep.aMethod(); } + } + ", + ); + assert!( + info.member_accesses + .iter() + .any(|a| a.object == "DepA" && a.member == "aMethod"), + "DepA.aMethod must be credited regardless of declaration order, found: {:?}", + info.member_accesses + ); + assert!( + info.member_accesses + .iter() + .any(|a| a.object == "DepB" && a.member == "bMethod"), + "DepB.bMethod must be credited regardless of declaration order, found: {:?}", + info.member_accesses + ); +} + +#[test] +fn per_class_this_scoping_credits_both_colliding_private_fields() { + // Issue #1821 (Fix B): the same collision through `#`-private fields (the + // Fix A receiver shape). Both classes name the field `#dep`; both dep + // classes' members must be credited. + let info = parse( + r" + import { DepA, DepB } from './dep'; + class ConsumerA { + readonly #dep = new DepA(); + run() { return this.#dep.aMethod(); } + } + class ConsumerB { + readonly #dep = new DepB(); + run() { return this.#dep.bMethod(); } + } + ", + ); + assert!( + info.member_accesses + .iter() + .any(|a| a.object == "DepA" && a.member == "aMethod"), + "private-field DepA.aMethod must be credited despite the same-named \ + `#dep` on ConsumerB, found: {:?}", + info.member_accesses + ); + assert!( + info.member_accesses + .iter() + .any(|a| a.object == "DepB" && a.member == "bMethod"), + "private-field DepB.bMethod must be credited despite the same-named \ + `#dep` on ConsumerA, found: {:?}", + info.member_accesses + ); +} + +#[test] +fn per_class_this_scoping_strips_internal_qualifier_before_emission() { + // Issue #1821 (Fix B): the internal `this@.` per-class qualifier is an + // extraction-only disambiguator and must never reach an emitted spelling. + // Assert no emitted `member_accesses` or `whole_object_uses` object carries + // it, and that the plain `this.dep` receiver spelling survives. + let info = parse( + r" + import { DepA, DepB } from './dep'; + class ConsumerA { + constructor(private dep: DepA) {} + run() { return this.dep.aMethod(); } + } + class ConsumerB { + readonly dep = new DepB(); + run() { + Object.keys(this.dep); + return this.dep.bMethod(); + } + } + ", + ); + assert!( + info.member_accesses.iter().all(|a| !a.object.contains('@')), + "no emitted member-access object may carry the internal per-class \ + qualifier, found: {:?}", + info.member_accesses + ); + assert!( + info.whole_object_uses.iter().all(|w| !w.contains('@')), + "no emitted whole-object use may carry the internal per-class \ + qualifier, found: {:?}", + info.whole_object_uses + ); + assert!( + info.member_accesses + .iter() + .any(|a| a.object == "this.dep" && a.member == "bMethod"), + "the plain `this.dep` receiver spelling must survive the strip, found: {:?}", + info.member_accesses + ); +} + #[test] fn module_exports_object_extracts_keys() { let info = parse("module.exports = { foo: 1, bar: 2 };"); diff --git a/crates/extract/src/visitor/visit_impl.rs b/crates/extract/src/visitor/visit_impl.rs index 110cca130..0c4f6a1a3 100644 --- a/crates/extract/src/visitor/visit_impl.rs +++ b/crates/extract/src/visitor/visit_impl.rs @@ -444,6 +444,9 @@ impl ModuleInfoExtractor { let Some(receiver_name) = static_member_object_name(receiver_expr) else { return; }; + // A `this.()` / `this.` iteration receiver is class-scoped + // so it matches the same class's qualified query-list key (issue #1821). + let receiver_name = self.qualify_this_scope(&receiver_name); let Some(element_type) = self.iterable_element_type_for(&receiver_name) else { return; }; @@ -548,6 +551,9 @@ impl ModuleInfoExtractor { let Some(receiver_name) = static_member_object_name(&stmt.right) else { return; }; + // Class-scope the `for (const x of this.)` receiver so it matches + // the same class's qualified query-list key (issue #1821). + let receiver_name = self.qualify_this_scope(&receiver_name); let Some(element_type) = self.iterable_element_type_for(&receiver_name) else { return; }; @@ -690,7 +696,11 @@ impl ModuleInfoExtractor { self.whole_object_uses .push(ROUTE_LOADER_DATA_OBJECT.to_string()); } - self.whole_object_uses.push(name.to_string()); + // A `this.` reflective use (`Object.keys(this.opts.c)`) is keyed + // per class so it resolves against the same class's binding, then + // stripped back to `this.` before emission (issue #1821). + let qualified = self.qualify_this_scope(name); + self.whole_object_uses.push(qualified); } } @@ -1729,21 +1739,17 @@ impl<'a> ModuleInfoExtractor { && let Expression::Identifier(callee) = &new_expr.callee && !super::helpers::is_builtin_constructor(callee.name.as_str()) { - self.insert_class_binding_target( - format!("this.{member_name}"), - callee.name.to_string(), - ); + let key = self.this_member_key(member_name); + self.insert_class_binding_target(key, callee.name.to_string()); } else if let Expression::Identifier(ident) = &expr.right && let Some(target_name) = self.binding_target_names.get(ident.name.as_str()).cloned() { - self.binding_target_names - .insert(format!("this.{member_name}"), target_name); + let key = self.this_member_key(member_name); + self.binding_target_names.insert(key, target_name); } if let Expression::Identifier(ident) = &expr.right { - self.copy_nested_binding_targets( - ident.name.as_str(), - format!("this.{member_name}").as_str(), - ); + let target = self.this_member_key(member_name); + self.copy_nested_binding_targets(ident.name.as_str(), target.as_str()); } } @@ -1945,7 +1951,8 @@ impl<'a> Visit<'a> for ModuleInfoExtractor { { self.record_typed_binding(id.name.as_str(), type_annotation); if param.accessibility.is_some() { - self.record_typed_binding(format!("this.{}", id.name).as_str(), type_annotation); + let key = self.this_member_key(id.name.as_str()); + self.record_typed_binding(key.as_str(), type_annotation); } } @@ -1975,14 +1982,17 @@ impl<'a> Visit<'a> for ModuleInfoExtractor { ); if let Some(name) = member_key.as_deref() { + // Class-scoped key so a same-named field on a sibling class does not + // collide in the module-flat `binding_target_names` (issue #1821). + let this_key = self.this_member_key(name); if let Some(type_annotation) = prop.type_annotation.as_deref() { - self.record_typed_binding(format!("this.{name}").as_str(), type_annotation); + self.record_typed_binding(this_key.as_str(), type_annotation); if has_angular_plural_query_decorator(&prop.decorators) && let Some(element_type) = extract_query_list_element_type(type_annotation) { self.iterable_element_types - .insert(format!("this.{name}"), element_type); + .insert(this_key.clone(), element_type); } } @@ -1990,19 +2000,19 @@ impl<'a> Visit<'a> for ModuleInfoExtractor { && let Expression::Identifier(callee) = &new_expr.callee && !super::helpers::is_builtin_constructor(callee.name.as_str()) { - self.insert_class_binding_target(format!("this.{name}"), callee.name.to_string()); + self.insert_class_binding_target(this_key.clone(), callee.name.to_string()); } if let Some(Expression::CallExpression(call)) = &prop.value && let Some(type_name) = self.extract_angular_inject_target(call) { - self.insert_class_binding_target(format!("this.{name}"), type_name); + self.insert_class_binding_target(this_key.clone(), type_name); } if let Some(value) = prop.value.as_ref() && let Some(query) = extract_angular_signal_query(value) { - let call_key = format!("this.{name}()"); + let call_key = format!("{this_key}()"); if query.plural { self.iterable_element_types.insert(call_key, query.type_arg); } else { @@ -2610,8 +2620,12 @@ impl<'a> Visit<'a> for ModuleInfoExtractor { member: expr.property.name.to_string(), }); } + // Qualify a `this.` receiver with the enclosing class scope so + // it resolves against the same class's binding key (issue #1821); + // stripped back to `this.` before emission. A bare `this` object and + // any non-`this` receiver pass through unchanged. self.member_accesses.push(MemberAccess { - object: object_name, + object: self.qualify_this_scope(&object_name), member: expr.property.name.to_string(), }); } @@ -2740,7 +2754,14 @@ impl<'a> Visit<'a> for ModuleInfoExtractor { .push(super::helpers::extract_super_class_name(class)); self.class_type_param_constraints .push(super::helpers::collect_class_type_param_constraints(class)); + // Assign this class body a unique scope id so its `this.` binding + // keys are qualified per-class (issue #1821, Fix B). A nested class + // pushes its own id, so `this` inside the inner body binds to the inner + // class. + self.class_scope_counter += 1; + self.class_scope_stack.push(self.class_scope_counter); walk::walk_class(self, class); + self.class_scope_stack.pop(); self.class_type_param_constraints.pop(); self.class_super_stack.pop(); } diff --git a/tests/fixtures/issue-1821-per-class-this-scoping/package.json b/tests/fixtures/issue-1821-per-class-this-scoping/package.json new file mode 100644 index 000000000..4351298d7 --- /dev/null +++ b/tests/fixtures/issue-1821-per-class-this-scoping/package.json @@ -0,0 +1,5 @@ +{ + "name": "issue-1821-per-class-this-scoping", + "private": true, + "main": "src/index.ts" +} diff --git a/tests/fixtures/issue-1821-per-class-this-scoping/src/consumers.ts b/tests/fixtures/issue-1821-per-class-this-scoping/src/consumers.ts new file mode 100644 index 000000000..0f4dce10a --- /dev/null +++ b/tests/fixtures/issue-1821-per-class-this-scoping/src/consumers.ts @@ -0,0 +1,40 @@ +import { DepPrivParam, DepPubField, DepHashA, DepHashB } from './dep'; + +// Two classes in ONE file both name their receiver field `dep`. Before +// per-class scoping the module-flat `this.dep` binding collided +// (last-write-wins), so only the class declared last credited its dep's +// members and the other's real member was falsely reported unused (issue +// #1821, Fix B). +export class ConsumerPrivParam { + constructor(private dep: DepPrivParam) {} + + run(): void { + this.dep.privParam(); + } +} + +export class ConsumerPubField { + readonly dep = new DepPubField(); + + run(): void { + this.dep.pubField(); + } +} + +// The same collision through `#`-private fields: both classes name the field +// `#dep`. +export class ConsumerHashA { + readonly #dep = new DepHashA(); + + run(): void { + this.#dep.hashA(); + } +} + +export class ConsumerHashB { + readonly #dep = new DepHashB(); + + run(): void { + this.#dep.hashB(); + } +} diff --git a/tests/fixtures/issue-1821-per-class-this-scoping/src/dep.ts b/tests/fixtures/issue-1821-per-class-this-scoping/src/dep.ts new file mode 100644 index 000000000..a2a41b848 --- /dev/null +++ b/tests/fixtures/issue-1821-per-class-this-scoping/src/dep.ts @@ -0,0 +1,23 @@ +// Each dep class carries one consumed member and one genuinely-dead control +// member. The control members must STAY flagged, proving the fix credits +// precisely per class and never blanket-credits. + +export class DepPrivParam { + privParam(): void {} + deadOnPrivParam(): void {} +} + +export class DepPubField { + pubField(): void {} + deadOnPubField(): void {} +} + +export class DepHashA { + hashA(): void {} + deadOnHashA(): void {} +} + +export class DepHashB { + hashB(): void {} + deadOnHashB(): void {} +} diff --git a/tests/fixtures/issue-1821-per-class-this-scoping/src/index.ts b/tests/fixtures/issue-1821-per-class-this-scoping/src/index.ts new file mode 100644 index 000000000..cd80c35b0 --- /dev/null +++ b/tests/fixtures/issue-1821-per-class-this-scoping/src/index.ts @@ -0,0 +1,12 @@ +import { DepPrivParam } from './dep'; +import { + ConsumerPrivParam, + ConsumerPubField, + ConsumerHashA, + ConsumerHashB, +} from './consumers'; + +new ConsumerPrivParam(new DepPrivParam()).run(); +new ConsumerPubField().run(); +new ConsumerHashA().run(); +new ConsumerHashB().run();