From b6692e6078f7b5a47b1456ab0e0dd49646ce3aed Mon Sep 17 00:00:00 2001 From: Bart Waardenburg Date: Mon, 1 Jun 2026 23:08:58 +0200 Subject: [PATCH 1/3] fix(plugins): credit NestJS framework-dispatched lifecycle/handler methods as used Fixes #843 --- crates/core/src/plugins/nestjs.rs | 243 +++++++++++++++++++++++++++++- 1 file changed, 237 insertions(+), 6 deletions(-) diff --git a/crates/core/src/plugins/nestjs.rs b/crates/core/src/plugins/nestjs.rs index 41e5fd825..5f38f469d 100644 --- a/crates/core/src/plugins/nestjs.rs +++ b/crates/core/src/plugins/nestjs.rs @@ -2,6 +2,12 @@ //! //! Detects `NestJS` projects and marks module, controller, service, guard, //! interceptor, pipe, filter, middleware, gateway, and resolver files as entry points. +//! +//! Also credits the framework-dispatched lifecycle and handler methods so they +//! are not reported as `unused-class-member`. Each rule is scoped to the +//! relevant Nest interface so unrelated classes are unaffected. + +use fallow_config::{ScopedUsedClassMemberRule, UsedClassMemberRule}; use super::Plugin; @@ -38,10 +44,235 @@ const TOOLING_DEPENDENCIES: &[&str] = &[ "reflect-metadata", ]; -define_plugin! { - struct NestJsPlugin => "nestjs", - enablers: ENABLERS, - entry_patterns: ENTRY_PATTERNS, - always_used: ALWAYS_USED, - tooling_dependencies: TOOLING_DEPENDENCIES, +/// `NestModule.configure()` called by the framework when wiring middleware. +const NEST_MODULE_MEMBERS: &[&str] = &["configure"]; + +/// General module lifecycle hooks from `OnModuleInit`, `OnModuleDestroy`, +/// `OnApplicationBootstrap`, `BeforeApplicationShutdown`, and +/// `OnApplicationShutdown`. These are invoked reflectively by the Nest +/// lifecycle manager regardless of which class implements them. +const MODULE_LIFECYCLE_MEMBERS: &[&str] = &[ + "onModuleInit", + "onModuleDestroy", + "onApplicationBootstrap", + "beforeApplicationShutdown", + "onApplicationShutdown", +]; + +/// `CanActivate.canActivate()` the guard dispatch method. +const GUARD_MEMBERS: &[&str] = &["canActivate"]; + +/// `NestInterceptor.intercept()` the interceptor dispatch method. +const INTERCEPTOR_MEMBERS: &[&str] = &["intercept"]; + +/// `PipeTransform.transform()` the pipe dispatch method. +const PIPE_MEMBERS: &[&str] = &["transform"]; + +/// `ExceptionFilter.catch()` the exception-filter dispatch method. +const FILTER_MEMBERS: &[&str] = &["catch"]; + +/// `NestMiddleware.use()` the middleware dispatch method. +const MIDDLEWARE_MEMBERS: &[&str] = &["use"]; + +fn implements_rule(iface: &str, members: &[&str]) -> UsedClassMemberRule { + UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule { + extends: None, + implements: Some(iface.to_string()), + members: members.iter().map(|s| (*s).to_string()).collect(), + }) +} + +pub struct NestJsPlugin; + +impl Plugin for NestJsPlugin { + fn name(&self) -> &'static str { + "nestjs" + } + + fn enablers(&self) -> &'static [&'static str] { + ENABLERS + } + + fn entry_patterns(&self) -> &'static [&'static str] { + ENTRY_PATTERNS + } + + fn always_used(&self) -> &'static [&'static str] { + ALWAYS_USED + } + + fn tooling_dependencies(&self) -> &'static [&'static str] { + TOOLING_DEPENDENCIES + } + + fn used_class_member_rules(&self) -> Vec { + vec![ + // NestModule: middleware configuration + implements_rule("NestModule", NEST_MODULE_MEMBERS), + // Lifecycle hooks: all five interfaces share the same method names; + // scope each individually so a class that only implements one of them + // still gets the right credit. + implements_rule("OnModuleInit", MODULE_LIFECYCLE_MEMBERS), + implements_rule("OnModuleDestroy", MODULE_LIFECYCLE_MEMBERS), + implements_rule("OnApplicationBootstrap", MODULE_LIFECYCLE_MEMBERS), + implements_rule("BeforeApplicationShutdown", MODULE_LIFECYCLE_MEMBERS), + implements_rule("OnApplicationShutdown", MODULE_LIFECYCLE_MEMBERS), + // Handler dispatch methods + implements_rule("CanActivate", GUARD_MEMBERS), + implements_rule("NestInterceptor", INTERCEPTOR_MEMBERS), + implements_rule("PipeTransform", PIPE_MEMBERS), + implements_rule("ExceptionFilter", FILTER_MEMBERS), + implements_rule("NestMiddleware", MIDDLEWARE_MEMBERS), + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enablers_contain_nestjs_core() { + assert!(NestJsPlugin.enablers().contains(&"@nestjs/core")); + } + + #[test] + fn tooling_dependencies_cover_common_nest_packages() { + let deps = NestJsPlugin.tooling_dependencies(); + assert!(deps.contains(&"@nestjs/core")); + assert!(deps.contains(&"@nestjs/common")); + assert!(deps.contains(&"@nestjs/cli")); + assert!(deps.contains(&"reflect-metadata")); + } + + /// `configure` on a class that implements `NestModule` must be credited as + /// used. A genuinely-unused method on the same class (`unusedHelper`) must + /// NOT be credited. + #[test] + fn configure_on_nest_module_is_credited_as_used() { + let rules = NestJsPlugin.used_class_member_rules(); + + // Simulate a class: `class AppModule implements NestModule` + let implemented = vec!["NestModule".to_string()]; + let super_class: Option<&str> = None; + + let configure_credited = rules.iter().any(|r| match r { + UsedClassMemberRule::Scoped(s) => { + s.matches_heritage(super_class, &implemented) + && s.members.iter().any(|m| m == "configure") + } + UsedClassMemberRule::Name(name) => name == "configure", + }); + assert!( + configure_credited, + "`configure` must be credited as used for a class implementing NestModule" + ); + + // `unusedHelper` must not be credited by any rule matched for NestModule + let unused_helper_credited = rules.iter().any(|r| match r { + UsedClassMemberRule::Scoped(s) => { + s.matches_heritage(super_class, &implemented) + && s.members.iter().any(|m| m == "unusedHelper") + } + UsedClassMemberRule::Name(name) => name == "unusedHelper", + }); + assert!( + !unused_helper_credited, + "`unusedHelper` must NOT be credited as used: it is a genuinely unused method" + ); + } + + #[test] + fn lifecycle_hooks_are_credited_for_implementing_classes() { + let rules = NestJsPlugin.used_class_member_rules(); + + for iface in [ + "OnModuleInit", + "OnModuleDestroy", + "OnApplicationBootstrap", + "BeforeApplicationShutdown", + "OnApplicationShutdown", + ] { + let implemented = vec![iface.to_string()]; + for method in [ + "onModuleInit", + "onModuleDestroy", + "onApplicationBootstrap", + "beforeApplicationShutdown", + "onApplicationShutdown", + ] { + let credited = rules.iter().any(|r| match r { + UsedClassMemberRule::Scoped(s) => { + s.matches_heritage(None, &implemented) + && s.members.iter().any(|m| m == method) + } + UsedClassMemberRule::Name(_) => false, + }); + assert!( + credited, + "`{method}` must be credited for a class implementing `{iface}`" + ); + } + } + } + + #[test] + fn handler_dispatch_methods_are_credited() { + let rules = NestJsPlugin.used_class_member_rules(); + let cases: &[(&str, &str)] = &[ + ("CanActivate", "canActivate"), + ("NestInterceptor", "intercept"), + ("PipeTransform", "transform"), + ("ExceptionFilter", "catch"), + ("NestMiddleware", "use"), + ]; + for (iface, method) in cases { + let implemented = vec![(*iface).to_string()]; + let credited = rules.iter().any(|r| match r { + UsedClassMemberRule::Scoped(s) => { + s.matches_heritage(None, &implemented) && s.members.iter().any(|m| m == *method) + } + UsedClassMemberRule::Name(_) => false, + }); + assert!( + credited, + "`{method}` must be credited for a class implementing `{iface}`" + ); + } + } + + #[test] + fn unrelated_class_gets_no_lifecycle_credit() { + let rules = NestJsPlugin.used_class_member_rules(); + // A plain service class that extends nothing and implements nothing + let no_implements: Vec = vec![]; + for r in &rules { + let UsedClassMemberRule::Scoped(s) = r else { + continue; + }; + assert!( + !s.matches_heritage(None, &no_implements), + "rule {:?} must not match a class with no heritage", + s + ); + } + } + + #[test] + fn rules_are_all_implements_scoped_not_extends() { + let rules = NestJsPlugin.used_class_member_rules(); + for r in &rules { + let UsedClassMemberRule::Scoped(s) = r else { + continue; + }; + assert!( + s.extends.is_none(), + "NestJS rules should use `implements`, not `extends`; found: {s:?}" + ); + assert!( + s.implements.is_some(), + "every scoped NestJS rule must have an `implements` constraint; found: {s:?}" + ); + } + } } From e5c09d86f73994c5975ff96e63df181357d46a02 Mon Sep 17 00:00:00 2001 From: Bart Waardenburg Date: Tue, 2 Jun 2026 00:00:57 +0200 Subject: [PATCH 2/3] review: address fallow-review findings (#843) - Fix clippy uninlined_format_args in nestjs test - Add real end-to-end regression test + fixture (fails before / passes after) - Document detection behavior in detection.md and CHANGELOG --- .claude/rules/detection.md | 1 + CHANGELOG.md | 1 + crates/core/src/plugins/nestjs.rs | 3 +- crates/core/tests/integration_test.rs | 3 ++ .../issue_843_nestjs_lifecycle.rs | 54 +++++++++++++++++++ .../issue-843-nestjs-lifecycle/package.json | 9 ++++ .../issue-843-nestjs-lifecycle/src/main.ts | 10 ++++ .../src/nest-app-module.ts | 39 ++++++++++++++ .../src/nest-auth-guard.ts | 18 +++++++ .../src/plain-service.ts | 11 ++++ 10 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 crates/core/tests/integration_test/issue_843_nestjs_lifecycle.rs create mode 100644 tests/fixtures/issue-843-nestjs-lifecycle/package.json create mode 100644 tests/fixtures/issue-843-nestjs-lifecycle/src/main.ts create mode 100644 tests/fixtures/issue-843-nestjs-lifecycle/src/nest-app-module.ts create mode 100644 tests/fixtures/issue-843-nestjs-lifecycle/src/nest-auth-guard.ts create mode 100644 tests/fixtures/issue-843-nestjs-lifecycle/src/plain-service.ts diff --git a/.claude/rules/detection.md b/.claude/rules/detection.md index efbb48dff..c49e76490 100644 --- a/.claude/rules/detection.md +++ b/.claude/rules/detection.md @@ -111,6 +111,7 @@ Non-obvious implementation details for each detection feature. These are NOT dis - **Entry-point public API class members**: class methods/properties on classes exposed through non-private package entry points are not reported as `unused-class-member` by default. The detector requires public-API provenance from an actual non-private package entry point re-export (`main`, root `exports`, subpath `exports`), from a barrel inside the transitive `export *` closure rooted at one, or from `src/**/index.*` source subpath indexes when the package has no `exports` map. Do not key this on `ReferenceKind::ReExport` alone: internal barrels also create re-export references and must keep member-level detection. Named, renamed, default-as-named, package subpath exports, source-first root `index.js` fallbacks, exportless source subpath indexes, and multi-hop star barrels are covered. The skip is class-member-only; enum member behavior is unchanged, private app packages do not get this skip, and reachable internal classes imported by an entry point but not re-exported still report unused public members. See issue #643. - **Configurable class member allowlist**: the built-in Angular/React lifecycle check (see `predicates::lifecycle`) is extended at analysis time with two user-controlled sources: (1) top-level `FallowConfig.used_class_members`, (2) `Plugin::used_class_members()` / `ExternalPluginDef.used_class_members` on active plugins. Both sources feed `AggregatedPluginResult.used_class_members`, which `analyze/mod.rs` merges with the config list into a single `FxHashSet<&str>` passed to `find_unused_members`. Intended for third-party interface patterns where a library calls consumer methods reflectively (ag-Grid's `agInit`/`refresh`, Web Components' `connectedCallback`). The allowlist is scoped to class members — matching enum member names are still flagged. Member strings containing `*` or `?` compile as `globset::Glob` matchers, supporting `"*"` (every member on a heritage-matching class), `"enter*"` / `"*Handler"` (prefix / suffix), and `"on*Event"` (combined). Globs run alongside exact-name matches and respect any `extends` / `implements` heritage clause on a scoped rule. Glob patterns matching zero members emit a single `WARN` per pattern at the end of the run via `ClassMemberAllowlist::warn_unmatched_patterns`, surfacing dead allowlist entries; exact-name entries do not warn. See issues #98, #254. - **Lexical custom-node lifecycle members**: the `lexical` plugin (enabler: `lexical` or any `@lexical/`-scoped package) credits the methods Lexical calls reflectively on custom nodes, so classes that `extends DecoratorNode` / `ElementNode` / `TextNode` do not surface their framework-invoked methods as `unused-class-member`. Implemented via heritage-scoped `UsedClassMemberRule::Scoped` rules in `crates/core/src/plugins/lexical.rs`, mirroring the `lit` / `ember` plugins. The shared lifecycle / serialization / DOM-reconciliation set (`getType`, `clone`, `importJSON`, `importDOM`, `exportJSON`, `exportDOM`, `createDOM`, `updateDOM`, `updateFromJSON`, `getTextContent`) is credited on all three bases; `isInline` is additionally credited on `ElementNode` and `DecoratorNode` only (TextNode is inherently inline and has no `isInline()` hook); `decorate` is credited on `DecoratorNode` only. The per-base layering means a genuinely-dead `isInline` on a TextNode subclass or `decorate` on an ElementNode subclass still surfaces. Method names verified against the Lexical custom-node docs (lexical.dev/docs/concepts/nodes). Heritage matching is on the direct superclass name, so an intermediate base between the user class and the Lexical base is not covered; custom nodes extend a Lexical base directly in practice. Non-lifecycle methods on a node subclass are still reported. No `tooling_dependencies()` is declared because Lexical packages are directly-imported runtime deps. Replaces the manual scoped `usedClassMembers` allowlist users hand-write today. See issue #628. +- **NestJS framework-dispatched lifecycle / handler members**: the `nestjs` plugin (enabler: `@nestjs/core`) credits the methods Nest invokes reflectively on consumer classes, so they are not reported as `unused-class-member`. Implemented via heritage-scoped `UsedClassMemberRule::Scoped` rules in `crates/core/src/plugins/nestjs.rs` (one rule per Nest interface, all `implements`-scoped, never `extends`), mirroring the `lit` / `lexical` / `ember` plugins. Handler dispatch methods are scoped to their single interface: `configure` (`NestModule`), `canActivate` (`CanActivate`), `intercept` (`NestInterceptor`), `transform` (`PipeTransform`), `catch` (`ExceptionFilter`), `use` (`NestMiddleware`). The five lifecycle interfaces (`OnModuleInit`, `OnModuleDestroy`, `OnApplicationBootstrap`, `BeforeApplicationShutdown`, `OnApplicationShutdown`) each credit ALL five lifecycle method names (`onModuleInit`, `onModuleDestroy`, `onApplicationBootstrap`, `beforeApplicationShutdown`, `onApplicationShutdown`) rather than only the one method the implemented interface declares. This is intentional and more correct: Nest's lifecycle manager dispatches by duck-typed method presence, so a class that implements one lifecycle interface but also defines a sibling hook method has that sibling invoked at runtime too. Heritage matching is on `implements`, so an ordinary class that implements none of these interfaces still surfaces a genuinely-unused `transform` / `configure` / lifecycle method, and non-lifecycle methods on a Nest class are still reported. No `CACHE_VERSION` bump: the rules are interpreted at analyze time against existing `implements` heritage data and never enter the extraction cache. See issue #843. - **Native Error subclass `name` members**: a class member named `name` declared on a class whose heritage reaches a native ECMAScript error constructor (`Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, `URIError`, `AggregateError`) is treated as runtime-used and never reported as `unused-class-member`. Custom error classes set `name` so logs, serializers, and `err.name === "..."` discrimination expose the error type, none of which is a static member access fallow can see. Implemented as a built-in heritage-scoped suppression in `crates/core/src/analyze/unused_members.rs` (`is_error_subclass_runtime_member`), mirroring the existing native-Custom-Elements built-in (`is_native_custom_element_lifecycle_method`, `super_class == Some("HTMLElement")`); no plugin or dependency gate because the bases are JavaScript globals. `build_error_subclass_export_keys` seeds on classes that directly extend a native error name, then walks `extends` chains downward via `build_parent_to_children`, so a transitive `class ApiError extends DomainError` where `DomainError extends Error` (resolved through imports, local exports, and re-export origins) is also covered. The rule is heritage-scoped: an ordinary `class Person { name = "x" }` still reports an unused `name`, and members other than `name` on error subclasses (`message` / `stack` / `cause` are inherited and rarely re-declared; arbitrary helper methods) still report. Seeding is by direct super-class name, matching the HTMLElement built-in, so a local class shadowing a native error name is treated as a native error base (pathological in practice). Same-file NON-exported intermediate base classes are not resolved, the same limitation `build_parent_to_children` carries for all heritage-aware member crediting. No `CACHE_VERSION` bump: only analyze-time interpretation of the existing `class_heritage` / `super_class` extraction data changes. See issue #620. - **Cross-package namespace re-export through object literal**: `import * as foo from './bar'; export const API = { foo }` in a barrel followed by `import { API } from '@scope/lib'; API.foo.bar` in another package credits `bar` on `./bar.ts`. The visitor records per-module `NamespaceObjectAlias` entries `{ via_export_name, suffix, namespace_local }` at finalize time (covering shorthand `{ foo }` and nested forms `{ motionNet: { adEngine } }`); both `into_module_info` and the SFC `merge_into` paths populate them. The graph layer threads them onto `ResolvedModule` and a Phase 2b pass (after reference population, before reachability) walks each consumer's `member_accesses` for `object == "."`, looks up the namespace import's target file via the module's resolved imports filtered to `ImportedName::Namespace`, and runs the `mark_member_exports_referenced` + `create_synthetic_exports_for_star_re_exports` pair from `narrowing.rs` so star-barrel namespace targets (`./foo/index.ts` doing `export * from './bar'`) get a synthetic stub export the Phase 4 chain resolver can carry to the real defining file. Default-export aliases match against `ImportedName::Default`. **Consumer-side multi-hop** (issue #310): the alias-defining file may sit behind 1-3 named-re-export barrels (`pkg/index.ts` -> `pkg/methods/index.ts` -> `pkg/methods/methods.ts`); the matcher does a forward BFS over re-export edges starting from `(alias_file, via_export_name)` to enumerate every `(barrel_file, exported_name_at_barrel)` pair through which the alias is reachable, then matches consumer imports against the full set. Plain `export *` propagates names unchanged; `export { A as B }` propagates the renamed `B`; `export * as ns` is skipped at non-seed barrels (its source identifiers are hidden behind `ns.`, not exposed directly). **Target-side multi-hop** (issue #328): the namespace target itself may chain through `export * as N from './source'` namespace re-exports (`barrel.ts` does `export * as inner from './leaf'`; consumer accesses `API.foo.inner.X`). After the initial Phase 2b credit lands on a name namespace-re-exported on the target, the matcher walks chained `export * as from './source'` edges on the alias-target side and propagates the consumer's deeper accesses (`..`) onto the underlying source recursively via `ChainWalkCtx`. Cycle-protected by a per-access visited set keyed on module index. Bumps the extract `CACHE_VERSION` because the new `namespace_object_aliases` field changes serialized module state. See issue #303 (cross-package case) building on issue #269 (same-file case which `resolve_bound_member_accesses` already handled by re-emitting the access against the namespace local); #310 (consumer-side multi-hop); #328 (target-side namespace-re-export chain). - **Cross-package member access through barrels**: `import { Foo } from '@scope/lib'` followed by `Foo.bar` correctly credits `bar` on the originating file even when `@scope/lib`'s entry resolves to a barrel that re-exports `Foo` from a sibling module. Phase 4 chain resolution synthesizes a stub `ExportSymbol` on the barrel for reference tracking, so without further propagation the access map keys at the barrel and the detection loop's origin-keyed lookup misses where `members` are populated. `find_unused_members` walks each access key through `ReExportEdge` chains (named re-exports, renamed re-exports, and `export *` fan-out when no named edge matches) to every defining-site export and copies the access set. Cycle-protected. `export * as ns from './mod'` edges are intentionally skipped because two-level `ns.Foo.member` access isn't tracked at extract time. Same propagation runs for `whole_object_used_exports` (`Object.values(Foo)` on a re-exported enum). Covers both `unused_enum_members` and `unused_class_members`. See issue #178. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5696a0f34..82665fe5d 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 +- **NestJS lifecycle and handler methods are no longer reported as unused class members.** The `nestjs` plugin now credits the methods Nest invokes reflectively on consumer classes: `configure` on `NestModule`, the guard/interceptor/pipe/filter/middleware dispatch methods (`canActivate`, `intercept`, `transform`, `catch`, `use`) on their respective interfaces, and the five module lifecycle hooks (`onModuleInit`, `onModuleDestroy`, `onApplicationBootstrap`, `beforeApplicationShutdown`, `onApplicationShutdown`). Each rule is heritage-scoped to its Nest interface via `implements`, so an ordinary class that implements none of these interfaces still reports a genuinely unused method, and non-lifecycle methods on a Nest class are still flagged. A class that implements one lifecycle interface also has its sibling lifecycle hooks credited, matching Nest's duck-typed lifecycle dispatch. (Closes [#843](https://github.com/fallow-rs/fallow/issues/843).) - **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/src/plugins/nestjs.rs b/crates/core/src/plugins/nestjs.rs index 5f38f469d..845d2420c 100644 --- a/crates/core/src/plugins/nestjs.rs +++ b/crates/core/src/plugins/nestjs.rs @@ -252,8 +252,7 @@ mod tests { }; assert!( !s.matches_heritage(None, &no_implements), - "rule {:?} must not match a class with no heritage", - s + "rule {s:?} must not match a class with no heritage" ); } } diff --git a/crates/core/tests/integration_test.rs b/crates/core/tests/integration_test.rs index ae4882d98..3193a4737 100644 --- a/crates/core/tests/integration_test.rs +++ b/crates/core/tests/integration_test.rs @@ -219,6 +219,9 @@ mod ember_classic; #[path = "integration_test/issue_620_error_subclass_name.rs"] mod issue_620_error_subclass_name; +#[path = "integration_test/issue_843_nestjs_lifecycle.rs"] +mod issue_843_nestjs_lifecycle; + #[path = "integration_test/issue_609_velite.rs"] mod issue_609_velite; #[path = "integration_test/issue_704_auto_import_components.rs"] diff --git a/crates/core/tests/integration_test/issue_843_nestjs_lifecycle.rs b/crates/core/tests/integration_test/issue_843_nestjs_lifecycle.rs new file mode 100644 index 000000000..217dcbb63 --- /dev/null +++ b/crates/core/tests/integration_test/issue_843_nestjs_lifecycle.rs @@ -0,0 +1,54 @@ +use crate::common::{create_config, fixture_path}; + +/// End-to-end regression for issue #843: the `nestjs` plugin must credit +/// framework-dispatched lifecycle and handler methods so they do not surface +/// as `unused-class-member`, while genuinely unused methods (and same-named +/// methods on classes that do NOT implement a Nest interface) still report. +#[test] +fn nestjs_lifecycle_and_handler_methods_credited_but_genuinely_unused_reported() { + let root = fixture_path("issue-843-nestjs-lifecycle"); + let config = create_config(root); + let results = fallow_core::analyze(&config).expect("analysis should succeed"); + + let unused: Vec = results + .unused_class_members + .iter() + .map(|m| format!("{}.{}", m.member.parent_name, m.member.member_name)) + .collect(); + + // NestModule + lifecycle hooks on a class implementing the interfaces: + // none of these may be flagged. + for credited in [ + "AppModule.configure", + "AppModule.onModuleInit", + "AppModule.onModuleDestroy", + // Sibling lifecycle hook NOT in the implements clause: still credited + // because Nest dispatches by duck-typed method presence. + "AppModule.onApplicationBootstrap", + // Guard dispatch method. + "AuthGuard.canActivate", + ] { + assert!( + !unused.contains(&credited.to_string()), + "`{credited}` is framework-dispatched and must be credited, found unused: {unused:?}" + ); + } + + // Genuinely unused helpers on Nest classes must still report. + assert!( + unused.contains(&"AppModule.unusedModuleHelper".to_string()), + "unused non-lifecycle method on a Nest module should still report, found: {unused:?}" + ); + assert!( + unused.contains(&"AuthGuard.unusedGuardHelper".to_string()), + "unused non-lifecycle method on a Nest guard should still report, found: {unused:?}" + ); + + // Heritage scoping: a plain class that implements NONE of the Nest + // interfaces must STILL report its lifecycle-named method, proving the + // rules are `implements`-scoped and not global. + assert!( + unused.contains(&"PlainService.onModuleInit".to_string()), + "`onModuleInit` on a class implementing no lifecycle interface must still report, found: {unused:?}" + ); +} diff --git a/tests/fixtures/issue-843-nestjs-lifecycle/package.json b/tests/fixtures/issue-843-nestjs-lifecycle/package.json new file mode 100644 index 000000000..b0b8cbfff --- /dev/null +++ b/tests/fixtures/issue-843-nestjs-lifecycle/package.json @@ -0,0 +1,9 @@ +{ + "name": "issue-843-nestjs-lifecycle-fixture", + "main": "src/main.ts", + "dependencies": { + "@nestjs/core": "^10.0.0", + "@nestjs/common": "^10.0.0", + "reflect-metadata": "^0.2.0" + } +} diff --git a/tests/fixtures/issue-843-nestjs-lifecycle/src/main.ts b/tests/fixtures/issue-843-nestjs-lifecycle/src/main.ts new file mode 100644 index 000000000..4f9cb052b --- /dev/null +++ b/tests/fixtures/issue-843-nestjs-lifecycle/src/main.ts @@ -0,0 +1,10 @@ +import { AppModule } from './nest-app-module'; +import { AuthGuard } from './nest-auth-guard'; +import { PlainService } from './plain-service'; + +// Reference the classes so they are reachable exports; their members are not +// statically called, which is exactly the framework-dispatch scenario. +const modules = [AppModule, AuthGuard, PlainService]; +for (const ctor of modules) { + new ctor(); +} diff --git a/tests/fixtures/issue-843-nestjs-lifecycle/src/nest-app-module.ts b/tests/fixtures/issue-843-nestjs-lifecycle/src/nest-app-module.ts new file mode 100644 index 000000000..845fe8314 --- /dev/null +++ b/tests/fixtures/issue-843-nestjs-lifecycle/src/nest-app-module.ts @@ -0,0 +1,39 @@ +import { + Module, + type NestModule, + type MiddlewareConsumer, + type OnModuleInit, + type OnModuleDestroy, +} from '@nestjs/common'; + +// Implements NestModule (configure) and two lifecycle interfaces. +// configure / onModuleInit / onModuleDestroy are framework-dispatched and +// must NOT surface as unused-class-member. +@Module({}) +export class AppModule + implements NestModule, OnModuleInit, OnModuleDestroy +{ + configure(consumer: MiddlewareConsumer): void { + void consumer; + } + + onModuleInit(): void { + // framework-invoked lifecycle hook + } + + // Sibling lifecycle hook the class does NOT declare in its `implements` + // clause. Nest dispatches by duck-typed presence, so this must also be + // credited (the all-five crediting behavior). + onApplicationBootstrap(): void { + // framework-invoked lifecycle hook + } + + onModuleDestroy(): void { + // framework-invoked lifecycle hook + } + + unusedModuleHelper(): string { + // Genuinely unused; not a lifecycle name and not called anywhere. + return 'never called'; + } +} diff --git a/tests/fixtures/issue-843-nestjs-lifecycle/src/nest-auth-guard.ts b/tests/fixtures/issue-843-nestjs-lifecycle/src/nest-auth-guard.ts new file mode 100644 index 000000000..9bb585bee --- /dev/null +++ b/tests/fixtures/issue-843-nestjs-lifecycle/src/nest-auth-guard.ts @@ -0,0 +1,18 @@ +import { + Injectable, + type CanActivate, + type ExecutionContext, +} from '@nestjs/common'; + +// canActivate is the guard dispatch method (CanActivate). Must NOT be flagged. +@Injectable() +export class AuthGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + void context; + return true; + } + + unusedGuardHelper(): void { + // Genuinely unused; should still be reported. + } +} diff --git a/tests/fixtures/issue-843-nestjs-lifecycle/src/plain-service.ts b/tests/fixtures/issue-843-nestjs-lifecycle/src/plain-service.ts new file mode 100644 index 000000000..8bc4c5c07 --- /dev/null +++ b/tests/fixtures/issue-843-nestjs-lifecycle/src/plain-service.ts @@ -0,0 +1,11 @@ +// An ordinary class that implements NONE of the Nest interfaces. It declares a +// method with a Nest lifecycle hook name (`onModuleInit`). Because the heritage +// scoping requires `implements OnModuleInit`, this must STILL be reported as +// unused-class-member, proving the lifecycle rules are `implements`-scoped and +// not global. (`onModuleInit` is not a built-in Angular lifecycle name, so the +// only thing that could credit it here is the heritage-scoped Nest rule.) +export class PlainService { + onModuleInit(): void { + // Not actually a Nest provider; no `implements OnModuleInit`. + } +} From d062c3731466a1de47ea65a7434fadaa106df70f Mon Sep 17 00:00:00 2001 From: Bart Waardenburg Date: Tue, 2 Jun 2026 09:40:29 +0200 Subject: [PATCH 3/3] chore: re-bless agent-file manifest for #843 --- scripts/agent-files.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/agent-files.sha256 b/scripts/agent-files.sha256 index 56d7f55ff..2ae28d635 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 +9dba25c122c49f1db86b9507c75b7f54a68a127be899a2e55c277f87ab826d97 .claude/rules/detection.md 4d34b7f8589ea48e012346d294947867ddaa78fb74263707f6b8951817f357db .claude/rules/extract-crate.md d550b4a609495732c98006423b168437d8b6c435ab7ed360adf15b8a4346fc71 .claude/rules/graph-crate.md 0279743bfb1c788af2f0273ff2dd6bc9c255743813d0213798f7bd4c71002ab9 .claude/rules/lsp-server.md