diff --git a/.claude/rules/detection.md b/.claude/rules/detection.md index efbb48dff..10761f7b3 100644 --- a/.claude/rules/detection.md +++ b/.claude/rules/detection.md @@ -60,6 +60,7 @@ Non-obvious implementation details for each detection feature. These are NOT dis - **Class instance members**: `const svc = new MyService(); svc.greet()` and `this.service = new MyService(); this.service.doWork()` track the method as used. Chained `this.field` access uses synthetic `"this.field"` keys in `instance_binding_names`. Factory initializers through wrapper calls are also tracked: `const [svc] = useState(() => new MyService())` binds the first array-destructured element as an instance. Typed parameter / property chains through nested object types are also tracked: `constructor(private deps: { foo: FooClass }) { ... } this.deps.foo.foo()` registers `this.deps.foo -> FooClass` via `extract_nested_type_bindings` walking the `TSTypeLiteral`, then `static_member_object_name` records the full dotted-path access (`this.deps.foo`) which resolves through `binding_target_names` so `FooClass.foo` is credited. Same propagation runs through explicit `this.deps = deps` assignments via `copy_nested_binding_targets`, covering the no-accessibility-modifier form. Angular 14+ field-initializer DI (`private readonly inner = inject(InnerService)`) is recognized: `extract_angular_inject_target` reads the call's `inject()` type argument or first identifier argument and registers `this.inner -> InnerService` so `this.inner.member` chains credit `InnerService.member`. The `inject` callee is gated by a named-import check against `@angular/core`, so a same-named `inject` from any other module is intentionally ignored (false positives over false negatives). **Static-factory call results** (issue #346): when a static method's body's last top-level statement is `return new this()` or `return new ()`, `extract_class_members` flags the matching `MemberInfo` with `is_instance_returning_static`. The visitor records every `const = .()` shape into a `FactoryCallCandidate` list, and a finalize step resolves each candidate against this module's `local_class_exports` (same-file: bind `local -> ClassName` directly so `resolve_bound_member_accesses` re-emits accesses against the class) or `imports` (cross-file: bind `local -> FACTORY_CALL_SENTINEL:ID:method`). The analyze-layer `propagate_factory_call_accesses` pass parses the sentinel out of `access.object`, resolves `ID` via `build_local_to_export_keys`, walks `walk_re_export_origins` for barrel chains, and credits `access.member` on the origin class export only when its `MemberInfo` for `method` has `is_instance_returning_static`. Conservative: chained `.build()`, ternary returns, and named-target factories (`Wrapper.getBuilder() { return new MyClass(); }`) stay out of scope. Globals like `Math.floor(...)` match neither a local class nor an import and are dropped without effect. Scope-unaware, false matches produce false negatives, not false positives. See issues #244 (Angular `inject()` field), #246 (Playwright fixtures, see next entry), #346 (static-factory methods). - **Typed instance chains** (issues #386, #388): `ClassHeritageInfo.instance_bindings` are extracted for all classes, not only Angular-decorated classes. The binding set includes non-private typed constructor parameters, typed properties, nested object-type properties, and typed getters, so a non-Angular chain like `factory.service.query()` credits `Service.query` when `factory` is a `Factory` instance and `Factory.service` is declared as `Service`. Reflective whole-object uses such as `Object.keys(factory.service)` propagate through the same typed chain and mark the target class as whole-object used. This covers getter-backed Playwright fixture teardown factories without suppressing genuinely unused service methods. **Generic-constraint substitution** (#388): on `visit_class` enter, the visitor pushes a `>` map collected via `collect_class_type_param_constraints` onto `class_type_param_constraints` (popped on exit). `record_typed_binding` consults the top of the stack: a constrained parameter substitutes its constraint (`class BaseService` with `constructor(client: TClient)` registers `this.client -> BaseClient`); an unconstrained parameter drops the binding (no resolvable class). The substitution flows through `extract_nested_type_bindings` so nested object-type properties typed as a generic parameter also resolve correctly. `extract_class_instance_bindings` applies the same substitution when populating `ClassHeritageInfo.instance_bindings`. - **Typed destructure bindings** (issue #752): a destructured `let`/`const` binding or destructured function parameter that carries a type annotation populates `binding_target_names` so subsequent `.` accesses resolve onto the referenced class. Covers the Svelte typed-prop shape `let { resultState }: Props = $props()` where `interface Props { resultState: ResultState }`, the inline-literal form `let { resultState }: { resultState: ResultState } = ...`, object type-alias references, rename forms (`let { resultState: rs }: Props = ...` binds `rs`), and destructured params (`function render({ resultState }: Props) { resultState.pin() }`). Interface / object-type-alias property types are collected at module scope (`collect_object_type_property_types`) and resolved in the `resolve_typed_destructure_bindings` finalize step so the type may be declared after the binding (interfaces hoist); inline literals resolve in place during the walk. Only bare `TSTypeReference`-to-single-name property types are recorded (`extract_type_annotation_name`); union / array / generic-wrapped property types, nested destructures with dotted keys, and rest elements do not resolve (conservative, no false negatives in the over-crediting direction). The finalize step runs first in `into_module_info` / `merge_into` and is also called in `sfc.rs::merge_script_into_module` before the template-visible `binding_target_names()` read so the Svelte/Vue template scanner credits markup member access (`{#if resultState.labelMessage}`, `bind:value={resultState.labelInput}`, `onclick={() => resultState.pin(...)}`). Resolution is type-driven, so it is framework-agnostic (plain TS, Vue, Svelte). Cross-file crediting works through type-only imports because `build_local_to_export_keys` includes resolved type-only imports. Bumps the extract `CACHE_VERSION` (the resulting `member_accesses` change). See issue #752. +- **`instanceof` narrowing member credit** (issue #845): an `if ( instanceof )` guard populates `binding_target_names` with ` -> ` so method calls on the narrowed local inside (or after) the guard body (`.()`) resolve onto the class via the same `resolve_bound_member_accesses` path as typed-destructure bindings. Recorded in `visit_if_statement` via `collect_instanceof_narrowings`, which walks the test condition, recurses through `&&`-chained `LogicalExpression`s and `ParenthesizedExpression`s, and collects only simple `Identifier instanceof Identifier` pairs (complex left-hand expressions and member-expression class operands are skipped conservatively). Insertion uses `entry(local).or_insert(class)`, so a pre-existing binding for the same local (a real instance binding, typed-destructure target, etc.) wins over the narrowing. Bindings are module-scoped rather than strictly block-scoped, which can credit a member outside the guard's true narrowed region: this over-credits in the false-negative direction only (a member that is genuinely unused but shares a local name with an instanceof guard could be missed), never the false-positive direction, matching fallow's conservative posture. Genuinely-unused members on the same class still report. Bumps the extract `CACHE_VERSION` (107 to 108) because the resulting `member_accesses` change, structurally identical to the #752 typed-destructure bump. See issue #845. - **Fluent-builder chain credit** (issue #387): static-builder chains like `EventBuilder.create().setX(1).setY(2).build()` credit each chained method on the originating class. `MemberInfo.is_self_returning` flags instance methods whose declared return type matches the enclosing class name OR whose body's last statement is `return this`. `is_instance_returning_static` widened to also cover static methods whose declared return type matches the class name (covers `static createWithDefaults(): EventBuilder { return chain; }` where the body is too complex to inspect syntactically). At extract time the visitor records one `FLUENT_CHAIN_SENTINEL` `MemberAccess` per call expression chained off a previous call, encoded as `{sentinel}{root_local}:{root_method}:{chain_prefix}` with `member` set to the method being called now; `chain_prefix` is the comma-separated list of intermediate methods walked since the root (empty when the credited member is the first chained call). The analyze-layer `propagate_fluent_chain_accesses` pass resolves the root through each consumer's `local_to_export_keys`, walks each origin via `walk_re_export_origins`, validates the root method has `is_instance_returning_static` and every intermediate chain step is `is_self_returning`, then credits `access.member`. Chains stop at non-self-returning methods, so `.build().toString()` correctly does NOT credit `toString` on the builder. Conservative on mismatch: imports that turn out not to name a class drop silently. Cache version bumped 80 to 81. **Constructor roots** (issue #605): the same chain credit covers chains rooted at a `new` expression (`new OptionBuilder().addDefault(x).addFromCli(y).build()`), the shape two public adoption PRs had to suppress inline (everr#144, graphql-markdown#2949). `static_member_object_name` gains a `NewExpression` arm returning the bare `Identifier` callee name UNCONDITIONALLY (no builtin-name guard: distinguishing a global `new Map()` from a user `class Map {}` is the analyze layer's job, where a global resolves to no user export and drops while a user class named `URL` / `Request` / `Map` is credited; guarding on `is_builtin_constructor` here would silently drop the access for such user classes and re-introduce the false positive), so a direct `new Repo(client).search(data)` and the FIRST method off any constructor chain are credited as plain `Class.member` accesses through the existing `local_to_export_keys` path. Downstream chained methods carry a distinct `FLUENT_CHAIN_NEW_SENTINEL` encoded as `{sentinel}{class}:{chain}` (no root method, since a constructor always returns a `class` instance); the analyze-layer `propagate_fluent_chain_new_accesses` pass resolves `class`, walks re-export origins, and requires every `chain` step to be an `is_self_returning` `ClassMethod` before crediting `member`. The #387 self-returning safety check still applies (a member reached after a non-self-returning step stays flagged), and genuinely-unused members on the constructed class are still reported. Member-expression constructor callees (`new ns.Builder()`) are out of scope. Cache version bumped 98 to 99. - **Playwright POM fixtures**: typed `base.extend({ ... })` definitions are correlated with their callback-side member uses so POM class methods consumed only through fixtures are credited. The visitor records two synthetic-object-prefix `MemberAccess` flavors keyed by the test's local name: `__fallow_playwright_fixture_def__:test:fixture` (member = the declared POM type name) and `__fallow_playwright_fixture_use__:test:fixture` (member = the accessed method name). Definitions accept the fixture map as a named type alias (`type MyFixtures = { adminPage: AdminPage }; base.extend(...)`), an inline type literal (`base.extend<{ adminPage: AdminPage }>(...)`), an intersection of either, or any parenthesized form; alias bodies are pre-collected into `playwright_fixture_types` so a referenced alias resolves to the same `(fixture, type_name)` pairs as an inline literal. The `base.extend` callee is gated against an `@playwright/test` named-import (`base` aliased from `test`), so `someOther.extend(...)` is ignored. Use-side collection runs at every call expression: when the callee root identifier resolves to a Playwright test object, the first arrow / function argument's object-pattern parameter (`{ adminPage }`, with rename support `{ adminPage: alias }`) seeds a per-call `fixture_by_local` map, and a sub-visit over the callback body emits one use-sentinel per `.` static-member access. Nested fixture types (`{ pages: { adminPage: AdminPage; userPage: UserPage } }`) and nested usage are both supported: type traversal recurses through `TSTypeLiteral` / aliased `TSTypeReference` properties under a dotted path so the def-side sentinel keys become `pages.adminPage`, etc. Use-side handles two consumption styles with the same dotted-path key: nested destructure (`{ pages: { adminPage } }` → `adminPage.method()`) maps the local to its full path, and chained access on a top-level destructure (`{ pages }` → `pages.adminPage.method()`) walks the static-member chain and concatenates the leftmost local's resolved path with the remaining segments. The matched chain is not re-walked, so intermediate member exprs do not emit spurious `(pages, adminPage)` accesses. See issue #268. **Helper-function fixtures** (issue #491): when `base.extend(...)` is returned from a helper instead of bound directly to a `const`, the same sentinel correlation runs keyed by the helper's name. Three declarator shapes capture the helper: `function appTest() { return base.extend(...); }`, `const appTest = () => base.extend(...)`, and `const appTest = function () { return base.extend(...); }`. The body must be a single `return ` statement (multi-statement bodies and conditional returns stay out of scope to keep matching conservative). A finalize-time pass (`ModuleInfoExtractor::resolve_playwright_factory_call_definitions`) gates each captured helper on `base` resolving to a `test` named import from `@playwright/test` and emits the def-sentinel `__fallow_playwright_fixture_def__:appTest:` per type binding. Same-file helper chains (`function appTest() { return setupTestFixture(); } function setupTestFixture() { return base.extend(...); }`) propagate via a capped fixed-point pass over `(caller, callee)` aliases captured at the same site, so the outer name inherits the inner helper's bindings. Cross-file chains are intentionally out of scope: the propagation is per-module. Use-side recognition extends `playwright_test_callee_name` with a `CallExpression` arm that recurses into the inner call's callee, so `appTest()(...)` and `appTest(opts)(...)` extract the helper name on the def side. Safe because def sentinels gate the analyzer; use sentinels for unmatched helper names produce no credit. The analyzer (`propagate_playwright_fixture_accesses` in `unused_members.rs`) walks both sentinel flavors, resolves the test identifier through each importing file's `local_to_export_keys` so cross-file imports / aliased imports (`import { test as fixtureTest } from './fixtures'`) match the same export key on both sides, follows re-export chains for the POM type via `walk_re_export_origins`, and credits the accessed method into `accessed_members` on the defining file's class export. Runs BEFORE `propagate_accesses_through_re_exports` so barrel-re-exported POMs are also covered. - **Type-level member access**: `TSQualifiedName` (e.g., `type X = Status.Active`) tracked as member access. Mapped type constraints (`{ [K in Enum]: ... }`, `{ [K in keyof typeof Enum]: ... }`) and `Record` mark all enum members as used via whole-object use. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5696a0f34..0356bd308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **A method called on a value narrowed by `instanceof` is now credited as a use of that class's member.** Code such as `if (e instanceof BaseException) { e.getMessage(); }` previously left `BaseException.getMessage` reported as an `unused-class-member`, because the call was attributed to the local `e` rather than the narrowed class. Fallow now records `instanceof ` guards (including `&&`-chained guards) so method calls on the narrowed local inside the body are attributed to the class. Genuinely-unused members on the same class still report. Files are re-extracted on upgrade (cache version bump). (Closes [#845](https://github.com/fallow-rs/fallow/issues/845).) - **Svelte markup `` as runtime HTML, not bundled SFC script modules. Fallow now keeps parsing the `src` metadata for span fidelity but no longer emits synthetic imports for Svelte `script src` references, so root-relative browser assets do not become false `unresolved-imports`. Vue external scripts keep their existing graph edges. Thanks [@codingthat](https://github.com/codingthat) for the report. (Closes [#835](https://github.com/fallow-rs/fallow/issues/835).) - **Pinia stores auto-imported by `@pinia/nuxt` are now tracked in the module graph.** Fallow now activates a Pinia plugin on `@pinia/nuxt`, scans direct store files in `stores/` and Nuxt 4 `app/stores/`, and maps value exports named `useStore` to the file that provides them. Pages that call `useUserStore()` without an import now keep `stores/user.ts` reachable and credit the matching export, while sibling helper exports can still report as unused. Nested stores and custom `storesDirs` remain out of scope for the default detector. (Closes [#740](https://github.com/fallow-rs/fallow/issues/740).) - **TanStack Router `routeFileIgnorePattern` warnings now call out JavaScript regex compatibility instead of treating every unsupported pattern as a user typo.** TanStack accepts JavaScript regular expressions, while fallow validates route ignore patterns with Rust's regex engine before matching route file segments. Patterns that use JavaScript-only syntax such as lookahead are still ignored during analysis so the run can continue, but the warning now names `routeFileIgnorePattern`, points at the source config file when known, and explains that the syntax is unsupported by fallow's Rust regex engine. (Refs [#513](https://github.com/fallow-rs/fallow/issues/513).) diff --git a/crates/core/tests/integration_test.rs b/crates/core/tests/integration_test.rs index ae4882d98..5ce3b35c0 100644 --- a/crates/core/tests/integration_test.rs +++ b/crates/core/tests/integration_test.rs @@ -133,6 +133,8 @@ mod issue_753_oxlint_cli_tooling; mod issue_758_danger_no_dep; #[path = "integration_test/issue_772_workspace_plugin_merge.rs"] mod issue_772_workspace_plugin_merge; +#[path = "integration_test/issue_845_instanceof_narrowing.rs"] +mod issue_845_instanceof_narrowing; #[path = "integration_test/lit_custom_element.rs"] mod lit_custom_element; #[path = "integration_test/scoped_used_class_members.rs"] diff --git a/crates/core/tests/integration_test/issue_845_instanceof_narrowing.rs b/crates/core/tests/integration_test/issue_845_instanceof_narrowing.rs new file mode 100644 index 000000000..4c2de1110 --- /dev/null +++ b/crates/core/tests/integration_test/issue_845_instanceof_narrowing.rs @@ -0,0 +1,27 @@ +use crate::common::{create_config, fixture_path}; + +/// Issue #845: a method called on a value narrowed by `if (e instanceof +/// BaseException)` is a real use of `BaseException.getMessage` and must not be +/// reported as an unused class member, while genuinely-unused members on the +/// same class keep reporting. +#[test] +fn instanceof_narrowed_method_call_credits_class_member() { + let root = fixture_path("issue-845-instanceof-narrowing"); + let config = create_config(root); + let results = fallow_core::analyze(&config).expect("analysis should succeed"); + + let unused_members: Vec = results + .unused_class_members + .iter() + .map(|m| format!("{}.{}", m.member.parent_name, m.member.member_name)) + .collect(); + + assert!( + !unused_members.contains(&"BaseException.getMessage".to_string()), + "method reached via `instanceof` narrowing must be credited: {unused_members:?}" + ); + assert!( + unused_members.contains(&"BaseException.unusedHelper".to_string()), + "a genuinely-unused member on the same class must still report: {unused_members:?}" + ); +} diff --git a/crates/extract/src/cache/types.rs b/crates/extract/src/cache/types.rs index 167bd9b70..a207d16ed 100644 --- a/crates/extract/src/cache/types.rs +++ b/crates/extract/src/cache/types.rs @@ -110,7 +110,16 @@ use crate::MemberKind; /// emit synthetic imports because they are runtime markup, not bundled SFC /// script modules. Pre-fix entries can carry stale root-relative imports that /// surface as false `unresolved-imports`. -pub(super) const CACHE_VERSION: u32 = 107; +/// +/// Bumped to 108 for issue #845: `if (x instanceof ClassName)` guards now +/// populate `binding_target_names`, so method calls on the narrowed local +/// inside the body (`x.getMessage()`) are credited as uses of +/// `ClassName.getMessage`. This changes the `member_accesses` emitted for any +/// instanceof-bearing file (structurally identical to the #752 typed-destructure +/// change above). Pre-fix cache entries lack the additional member accesses, so +/// the narrowed class members surface as false `unused-class-member` until the +/// file is re-extracted. +pub(super) const CACHE_VERSION: u32 = 108; /// Duplication token cache version. Bump when duplicate tokenization, /// normalization, or the on-disk token cache schema changes. diff --git a/crates/extract/src/tests/js_ts/member_access.rs b/crates/extract/src/tests/js_ts/member_access.rs index c999bc3af..4d6e3ab95 100644 --- a/crates/extract/src/tests/js_ts/member_access.rs +++ b/crates/extract/src/tests/js_ts/member_access.rs @@ -140,3 +140,68 @@ fn non_namespace_destructuring_not_captured() { "Should not capture destructuring of non-namespace variables" ); } + +/// Regression test for issue #845: a method call on a value narrowed by +/// `if (x instanceof ClassName)` must be credited as a use of +/// `ClassName.method`, preventing a false `unused-class-member` finding. +#[test] +fn instanceof_narrowed_method_call_is_credited_as_class_member_use() { + let info = parse_source( + r" +import { BaseException } from './exceptions'; +function handle(e) { + if (e instanceof BaseException) { + e.getMessage(); + } +} +", + ); + let has_access = info + .member_accesses + .iter() + .any(|a| a.object == "BaseException" && a.member == "getMessage"); + assert!( + has_access, + "e.getMessage() inside `if (e instanceof BaseException)` must be \ + credited as BaseException.getMessage; got member_accesses = {:?}", + info.member_accesses, + ); +} + +/// Regression test for issue #845: `&&`-chained instanceof guards must all +/// contribute narrowings so each narrowed local's method calls are credited. +#[test] +fn instanceof_narrowing_through_logical_and_chain() { + let info = parse_source( + r" +import { FooError } from './foo'; +import { BarError } from './bar'; +function handle(a, b) { + if (a instanceof FooError && b instanceof BarError) { + a.getFooMessage(); + b.getBarMessage(); + } +} +", + ); + let has_foo = info + .member_accesses + .iter() + .any(|a| a.object == "FooError" && a.member == "getFooMessage"); + let has_bar = info + .member_accesses + .iter() + .any(|a| a.object == "BarError" && a.member == "getBarMessage"); + assert!( + has_foo, + "a.getFooMessage() inside `if (a instanceof FooError && ...)` must be \ + credited as FooError.getFooMessage; got member_accesses = {:?}", + info.member_accesses, + ); + assert!( + has_bar, + "b.getBarMessage() inside `if (... && b instanceof BarError)` must be \ + credited as BarError.getBarMessage; got member_accesses = {:?}", + info.member_accesses, + ); +} diff --git a/crates/extract/src/visitor/visit_impl.rs b/crates/extract/src/visitor/visit_impl.rs index 4ed726b92..a81f751c0 100644 --- a/crates/extract/src/visitor/visit_impl.rs +++ b/crates/extract/src/visitor/visit_impl.rs @@ -2474,6 +2474,22 @@ impl<'a> Visit<'a> for ModuleInfoExtractor { walk::walk_for_in_statement(self, stmt); } + fn visit_if_statement(&mut self, stmt: &IfStatement<'a>) { + // Record `x instanceof ClassName` narrowings from the test condition so + // that method calls on `x` inside the body (e.g. `x.getMessage()`) are + // credited as uses of `ClassName.getMessage`, preventing false + // unused-class-member findings. The bindings are module-scoped (not + // strictly block-scoped), which is conservative: it may credit accesses + // outside the guard, but that produces at most false negatives, not false + // positives. + let mut narrowings = Vec::new(); + collect_instanceof_narrowings(&stmt.test, &mut narrowings); + for (local, class_name) in narrowings { + self.binding_target_names.entry(local).or_insert(class_name); + } + walk::walk_if_statement(self, stmt); + } + fn visit_spread_element(&mut self, elem: &SpreadElement<'a>) { if let Expression::Identifier(ident) = &elem.argument { self.whole_object_uses.push(ident.name.to_string()); @@ -2649,6 +2665,31 @@ fn is_html_tagged_template(tag: &Expression<'_>) -> bool { matches!(tag, Expression::Identifier(id) if id.name == "html") } +/// Collect `(local_name, class_name)` pairs from an `instanceof` guard expression. +/// +/// Recurses through `&&`-chained conditions so `a instanceof A && b instanceof B` +/// yields both pairs. Only simple identifier left-hand sides (`x instanceof Cls`) +/// are collected; complex left-hand expressions are skipped conservatively. +fn collect_instanceof_narrowings<'a>(expr: &'a Expression<'a>, out: &mut Vec<(String, String)>) { + match expr { + Expression::BinaryExpression(bin) if bin.operator == BinaryOperator::Instanceof => { + if let Expression::Identifier(left) = &bin.left + && let Expression::Identifier(right) = &bin.right + { + out.push((left.name.to_string(), right.name.to_string())); + } + } + Expression::LogicalExpression(logical) if logical.operator == LogicalOperator::And => { + collect_instanceof_narrowings(&logical.left, out); + collect_instanceof_narrowings(&logical.right, out); + } + Expression::ParenthesizedExpression(paren) => { + collect_instanceof_narrowings(&paren.expression, out); + } + _ => {} + } +} + impl ModuleInfoExtractor { /// Push an HTML-template-sourced asset reference onto `imports`, mirroring /// the HTML parser's remote-url, normalization, and `SideEffect` pipeline. diff --git a/scripts/agent-files.sha256 b/scripts/agent-files.sha256 index 56d7f55ff..8cd1e96bf 100644 --- a/scripts/agent-files.sha256 +++ b/scripts/agent-files.sha256 @@ -12,7 +12,7 @@ b40014209809c5a25ce8d4f8b434a77a1fb6d9deb78b9059badd098d39062c13 .claude/agents 7f64808f1721c30f92123e2d363e9b7b5c782b3155571d23a66a58b2b3a9bf26 .claude/rules/cli-crate.md ba291bbb73bed92aa102c43eb6360f1107e1d86af443d3249b800f4292900c07 .claude/rules/code-quality.md 80d9fea77af6652946d6eefe4f30ed35c1df8dcbabeb9d8f47f4308019caddc6 .claude/rules/core-crate.md -d13800a6d81fc4058b2d5c3a4110a146822a4e4851ea888c99d8ba8c69e2ee72 .claude/rules/detection.md +3d68d6587f64cfe95f831ff1a8f0f37227f009414349e6409fcc99368eb74fb4 .claude/rules/detection.md 4d34b7f8589ea48e012346d294947867ddaa78fb74263707f6b8951817f357db .claude/rules/extract-crate.md d550b4a609495732c98006423b168437d8b6c435ab7ed360adf15b8a4346fc71 .claude/rules/graph-crate.md 0279743bfb1c788af2f0273ff2dd6bc9c255743813d0213798f7bd4c71002ab9 .claude/rules/lsp-server.md diff --git a/tests/fixtures/issue-845-instanceof-narrowing/package.json b/tests/fixtures/issue-845-instanceof-narrowing/package.json new file mode 100644 index 000000000..0d81a6c79 --- /dev/null +++ b/tests/fixtures/issue-845-instanceof-narrowing/package.json @@ -0,0 +1,4 @@ +{ + "name": "issue-845-instanceof-narrowing-fixture", + "main": "src/app.ts" +} diff --git a/tests/fixtures/issue-845-instanceof-narrowing/src/app.ts b/tests/fixtures/issue-845-instanceof-narrowing/src/app.ts new file mode 100644 index 000000000..14dc7de11 --- /dev/null +++ b/tests/fixtures/issue-845-instanceof-narrowing/src/app.ts @@ -0,0 +1,9 @@ +import { BaseException } from "./exceptions"; + +// `getMessage` is only ever reached through the instanceof-narrowed local `e`. +export function handle(e: unknown): string | undefined { + if (e instanceof BaseException) { + return e.getMessage(); + } + return undefined; +} diff --git a/tests/fixtures/issue-845-instanceof-narrowing/src/exceptions.ts b/tests/fixtures/issue-845-instanceof-narrowing/src/exceptions.ts new file mode 100644 index 000000000..aa897628c --- /dev/null +++ b/tests/fixtures/issue-845-instanceof-narrowing/src/exceptions.ts @@ -0,0 +1,9 @@ +export class BaseException { + // Used only via `e instanceof BaseException` narrowing in app.ts: must be credited. + getMessage(): string { + return "base"; + } + + // Never called anywhere: must still report as an unused class member. + unusedHelper(): void {} +}