Skip to content
Closed
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 @@ -56,6 +56,7 @@ Non-obvious implementation details for each detection feature. These are NOT dis
- **Indirect alias definitions (imported identifier / spread)**: the shared alias extractor (`config_parser::extract_config_aliases[_kinded]`, used by the Vite `resolve.alias`, Webpack `resolve.alias`, Nuxt `alias`, SvelteKit `kit.alias`, AdonisJS `aliases`, TypeScript `compilerOptions.paths`, and Vitest `test.alias` consumers) now recovers aliases when the value is not an inline literal. An identifier resolves to a local `const NAME = [...] | {...}` or one hop through a relative import (`import { sharedAliases } from "./vite.shared.js"`; the sibling is read and parsed for `export const NAME`, `export default`, or a re-exported local `const NAME`). Array spread elements (`[...a, ...b]`) and object spread properties (`{ ...a, "@": "./src" }`) are expanded, each spread source resolved recursively. This fixes the common DRY pattern of sharing one alias array across `vite.config` / `vitest.config` / Storybook via an imported module, which previously dropped every alias and cascaded aliased imports into false `unresolved-import` / `unused-file` / `unused-export` findings. Bounded by an identifier-hop cap plus a per-file visited set (import cycles terminate); bare-package imports of an alias module, regex `find` keys, and dynamically-built arrays stay out of scope (not statically evaluable). See issue #811.
- **Unused import bindings**: via `oxc_semantic` scope-aware symbol analysis. Dead imports don't count as references, improving unused-export precision.
- **TypeScript overload dedup**: `export function foo(): void; export function foo(x: string): string;` treated as single export
- **`declare` ambient class properties excluded from members**: `extract_class_members` skips a `PropertyDefinition` whose `declare` flag is set (`declare readonly __input?: I`), alongside the existing `private` / `protected` accessibility skip. A `declare` field is a type-only ambient annotation that emits no JavaScript and cannot be value-referenced, so it can never be a real `unused-class-member`; extracting it produced false findings on classes that annotate injected or framework-provided state with `declare`. Class methods and non-`declare` fields on the same class are still extracted and reported when genuinely unused. Bumps the extract `CACHE_VERSION` because the persisted `CachedExport.members` shape changes (phantom `declare` members drop). See issue #839.
- **Import-then-reexport pattern**: `import { X } from './a'; export { X };` is detected as a re-export (semantically equivalent to `export { X } from './a';`) instead of a local export. Without this, the bottom export collides with the original in duplicate-export detection and is never reached by re-export chain propagation. Handles renames on either side (`import { X as Foo }; export { Foo }` or `import { X }; export { X as Y }`), default imports (re-exported with `imported_name: "default"`), and mixed local + re-export in one statement. Source-order-independent (PR #427): `export { X }; import { X } from './a';` (export before import) is also recognized via a pending-specifier finalize step that resolves the `export { X }` against this module's imports and local declarations after the full AST walk. A local declaration named `X` in the same module shadows the import and keeps the export local. Class-inheritance and interface-implementer propagation in `find_unused_members` walks `walk_re_export_origins` for both the parent and the interface key, so `extends BaseShape` / `implements RenderableShape` resolved through a barrel re-exports the parent's `this.*` accesses to the actual defining file. Namespace imports (`import * as ns; export { ns }`) stay as local exports.
- **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`.
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

- **`declare` ambient class properties are no longer reported as unused class members.** A TypeScript class field declared with `declare` (`declare readonly __input?: I`) is a type-only annotation that emits no JavaScript and cannot be value-referenced, so it can never be a real `unused-class-member`. Fallow previously extracted these ambient properties as ordinary members, so a class that only annotated injected or framework-provided state with `declare` surfaced false unused-member findings. Ambient `declare` property definitions are now skipped during class-member extraction, while normal methods and fields on the same class are still reported when genuinely unused. Thanks [@codingthat](https://github.com/codingthat) for the report. (Closes [#839](https://github.com/fallow-rs/fallow/issues/839).)
- **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
8 changes: 7 additions & 1 deletion crates/extract/src/cache/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,13 @@ 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 #839: `declare` ambient class properties are no
/// longer extracted as class members because they emit no JS and cannot be
/// value-referenced. Pre-fix cache entries carry those phantom members, so they
/// surface as false `unused-class-member` findings 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.
Expand Down
27 changes: 27 additions & 0 deletions crates/extract/src/tests/js_ts/classes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1286,3 +1286,30 @@ fn factory_call_candidate_with_unknown_object_is_dropped() {
info.member_accesses
);
}

/// Regression test for issue #839: `declare` ambient class properties must not
/// be extracted as class members. They emit no JS and cannot be value-referenced,
/// so including them causes false unused-class-member findings.
#[test]
fn declare_ambient_property_excluded_from_class_members() {
let info = parse_source(
r"export class MyComponent<I> {
declare readonly __input?: I;
processInput() { return this.__input; }
}",
);
assert_eq!(info.exports.len(), 1);
let names: Vec<&str> = info.exports[0]
.members
.iter()
.map(|m| m.name.as_str())
.collect();
assert!(
!names.contains(&"__input"),
"`declare` ambient property must not appear in extracted members; got {names:?}",
);
assert!(
names.contains(&"processInput"),
"normal method must still be extracted; got {names:?}",
);
}
1 change: 1 addition & 0 deletions crates/extract/src/visitor/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ pub fn extract_class_members(class: &Class<'_>, is_angular_class: bool) -> Vec<M
}
ClassElement::PropertyDefinition(prop) => {
if let Some(name) = prop.key.static_name()
&& !prop.declare
&& !matches!(
prop.accessibility,
Some(TSAccessibility::Private | oxc_ast::ast::TSAccessibility::Protected)
Expand Down
2 changes: 1 addition & 1 deletion scripts/agent-files.sha256
Original file line number Diff line number Diff line change
Expand Up @@ -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
7aeeb1c990d785e1cab5bb781ad772950c414987bdf4ba0e11139e9bfbb45aeb .claude/rules/detection.md
4d34b7f8589ea48e012346d294947867ddaa78fb74263707f6b8951817f357db .claude/rules/extract-crate.md
d550b4a609495732c98006423b168437d8b6c435ab7ed360adf15b8a4346fc71 .claude/rules/graph-crate.md
0279743bfb1c788af2f0273ff2dd6bc9c255743813d0213798f7bd4c71002ab9 .claude/rules/lsp-server.md
Expand Down
Loading