diff --git a/.claude/rules/detection.md b/.claude/rules/detection.md index efbb48dff..a3a726962 100644 --- a/.claude/rules/detection.md +++ b/.claude/rules/detection.md @@ -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()` 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`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5696a0f34..cb3e094a9 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 +- **`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 `` 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/extract/src/cache/types.rs b/crates/extract/src/cache/types.rs index 167bd9b70..d6af4853e 100644 --- a/crates/extract/src/cache/types.rs +++ b/crates/extract/src/cache/types.rs @@ -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. diff --git a/crates/extract/src/tests/js_ts/classes.rs b/crates/extract/src/tests/js_ts/classes.rs index 7f9c058f6..7f600170f 100644 --- a/crates/extract/src/tests/js_ts/classes.rs +++ b/crates/extract/src/tests/js_ts/classes.rs @@ -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 { + 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:?}", + ); +} diff --git a/crates/extract/src/visitor/helpers.rs b/crates/extract/src/visitor/helpers.rs index 2f238c3a2..ab165e9ab 100644 --- a/crates/extract/src/visitor/helpers.rs +++ b/crates/extract/src/visitor/helpers.rs @@ -440,6 +440,7 @@ pub fn extract_class_members(class: &Class<'_>, is_angular_class: bool) -> Vec { if let Some(name) = prop.key.static_name() + && !prop.declare && !matches!( prop.accessibility, Some(TSAccessibility::Private | oxc_ast::ast::TSAccessibility::Protected) diff --git a/scripts/agent-files.sha256 b/scripts/agent-files.sha256 index 56d7f55ff..db69662e9 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 +7aeeb1c990d785e1cab5bb781ad772950c414987bdf4ba0e11139e9bfbb45aeb .claude/rules/detection.md 4d34b7f8589ea48e012346d294947867ddaa78fb74263707f6b8951817f357db .claude/rules/extract-crate.md d550b4a609495732c98006423b168437d8b6c435ab7ed360adf15b8a4346fc71 .claude/rules/graph-crate.md 0279743bfb1c788af2f0273ff2dd6bc9c255743813d0213798f7bd4c71002ab9 .claude/rules/lsp-server.md