Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/rules/detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>()` 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 <SameClassName>()`, `extract_class_members` flags the matching `MemberInfo` with `is_instance_returning_static`. The visitor records every `const <local> = <ID>.<method>()` 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 `<TParam, Option<Constraint>>` 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<TClient extends BaseClient>` 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 `<local>.<member>` 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 (<local> instanceof <Class>)` guard populates `binding_target_names` with `<local> -> <Class>` so method calls on the narrowed local inside (or after) the guard body (`<local>.<member>()`) 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<MyFixtures>({ ... })` 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<MyFixtures>(...)`), 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<T>(...)` 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 `<local>.<method>` 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<T>(...)` 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<T>(...); }`, `const appTest = () => base.extend<T>(...)`, and `const appTest = function () { return base.extend<T>(...); }`. The body must be a single `return <call>` 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:<fixture>` per type binding. Same-file helper chains (`function appTest() { return setupTestFixture(); } function setupTestFixture() { return base.extend<T>(...); }`) 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<Enum, T>` mark all enum members as used via whole-object use.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Class>` 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 `<script src>` tags no longer surface as unresolved imports.** Svelte treats tags such as `<svelte:head><script src="/some-lib.min.js" async></script></svelte:head>` 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 `use<Name>Store` 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).)
Expand Down
2 changes: 2 additions & 0 deletions crates/core/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> = 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:?}"
);
}
Loading
Loading