From 72d36cb3274444ead717e73f6505735bfaa5fae9 Mon Sep 17 00:00:00 2001 From: Bart Waardenburg Date: Mon, 1 Jun 2026 20:05:01 +0200 Subject: [PATCH 1/2] chore: open issue 740 implementation branch From 0458fe9677ec2822fbcb933c26ae5aa1eb4f8488 Mon Sep 17 00:00:00 2001 From: Bart Waardenburg Date: Mon, 1 Jun 2026 20:19:36 +0200 Subject: [PATCH 2/2] fix: resolve Pinia store auto-imports --- .claude/rules/detection.md | 2 +- CHANGELOG.md | 1 + README.md | 4 +- crates/core/src/plugins/mod.rs | 1 + crates/core/src/plugins/pinia.rs | 225 ++++++++++++++++++ crates/core/src/plugins/registry/builtin.rs | 7 +- crates/core/tests/integration_test.rs | 2 + .../issue_740_pinia_store_auto_imports.rs | 97 ++++++++ scripts/agent-files.sha256 | 2 +- .../package.json | 9 + .../pages/index.vue | 7 + .../stores/user.ts | 3 + .../app/stores/settings.ts | 3 + .../nuxt.config.ts | 3 + .../package.json | 10 + .../pages/index.vue | 9 + .../stores/admin/user.ts | 3 + .../stores/user.ts | 7 + 18 files changed, 388 insertions(+), 7 deletions(-) create mode 100644 crates/core/src/plugins/pinia.rs create mode 100644 crates/core/tests/integration_test/issue_740_pinia_store_auto_imports.rs create mode 100644 tests/fixtures/nuxt-pinia-store-auto-imports-disabled/package.json create mode 100644 tests/fixtures/nuxt-pinia-store-auto-imports-disabled/pages/index.vue create mode 100644 tests/fixtures/nuxt-pinia-store-auto-imports-disabled/stores/user.ts create mode 100644 tests/fixtures/nuxt-pinia-store-auto-imports/app/stores/settings.ts create mode 100644 tests/fixtures/nuxt-pinia-store-auto-imports/nuxt.config.ts create mode 100644 tests/fixtures/nuxt-pinia-store-auto-imports/package.json create mode 100644 tests/fixtures/nuxt-pinia-store-auto-imports/pages/index.vue create mode 100644 tests/fixtures/nuxt-pinia-store-auto-imports/stores/admin/user.ts create mode 100644 tests/fixtures/nuxt-pinia-store-auto-imports/stores/user.ts diff --git a/.claude/rules/detection.md b/.claude/rules/detection.md index 272e0eaeb..3b43b97be 100644 --- a/.claude/rules/detection.md +++ b/.claude/rules/detection.md @@ -98,7 +98,7 @@ Non-obvious implementation details for each detection feature. These are NOT dis - **`export *` chain propagation**: multi-level barrel file chains fully resolved for transitive usage tracking. The fixpoint loop in `crates/graph/src/graph/re_exports/mod.rs::resolve_re_export_chains` terminates naturally via the monotone dedup-by-`from_file` check in `propagate.rs`; a defensive safety backstop bounded by `re_export_info.len() + 1` iterations guards against future regressions. Chain depth is unbounded in practice (the old `max_iterations = 20` cap silently truncated barrels with deeper chains; removed for #442). Re-export cycles are detected up front via iterative Tarjan SCC over the (barrel, source) edge subgraph; cycles surface BOTH as one `tracing::warn!` per cycle naming the member paths (RUST_LOG=warn discoverability, unchanged from #442) AND as a user-visible `re-export-cycle` finding on `AnalysisResults.re_export_cycles` (#515). Self-re-exports (a barrel re-exporting from itself, often a rename leftover) carry `kind: "self-loop"`; multi-node SCCs carry `kind: "multi-node"`. The finding is file-scoped (no per-edge line/col anchor); suppression via `// fallow-ignore-file re-export-cycle` (or aliases `re-export-cycles` / `reexport-cycle` / `reexport-cycles`) on any member file drops the cycle from results, and per-file `overrides.rules.re-export-cycle` is a no-op because the cycle spans multiple files (resolver emits a load-time warn pointing at the working escape hatch). Synthetic bridge stubs created during multi-hop star propagation inherit `is_type_only` from the triggering re-export edge so multi-hop `export type *` chains tag the stub correctly; when both a type-only and a value star edge reach the same source for the same name, a `synthetic_stubs` side set tracks the stub identity so the value edge can downgrade `is_type_only: false`. Real `export type Foo` declarations on the source are never in the side set and stay type-only. Type-only re-export cycles (`export type * from './b'` paired symmetrically) still surface as findings, because chain propagation through the loop is the same no-op as for value cycles. See `decisions/005-re-export-chain-resolution.md`. - **`export * as Foo from './bar'` namespace re-export propagation**: a barrel that namespace-re-exports a module records a `ReExportEdge { imported_name: "*", exported_name: "Foo" }` plus a synthesised stub `ExportSymbol` named `Foo` on the barrel. The Phase 2 narrowing path only handles `import * as ns` (direct namespace imports), and Phase 4's named-re-export propagation looks for a source export literally named `"*"` (never matches), so a consumer using the named import form (`import { Foo } from './barrel'; Foo.X`) would otherwise leave every export of `./bar` unreferenced. Phase 2c (`graph::namespace_re_exports::propagate_namespace_re_exports`) closes the gap: it iterates re-export edges with `imported_name == "*" && exported_name != "*"`, enumerates the reachable `(barrel_file, exported_name_at_barrel)` pairs via a forward BFS over named re-export edges (so multi-hop chains like `outer_barrel.Foo -> inner_barrel.Foo -> source` match), and for every consumer that imports a reachable name records each `.` access. Credits are applied with `mark_member_exports_referenced` plus `create_synthetic_exports_for_star_re_exports` on the namespace target file, mirroring `narrow_namespace_references`'s two-step shape so star barrels on the target (`./source/index.ts` doing `export * from './impl'`) get a synthetic stub the Phase 4 chain resolver carries through. Whole-object uses (`Object.values(Foo)`, spread, destructure-with-rest) credit every target export; entry-point barrels exposing the namespace externally also credit every target export, mirroring `propagate_entry_point_star`. Consumer bindings reported as unused (`unused_import_bindings`) are skipped. The walker intentionally does NOT follow `export * as ns from` edges forward at non-seed barrels because the original identifier is hidden behind `ns.` rather than exposed directly. See issue #324. - **CSS Modules**: default imports (`import styles from '...'`) resolve `styles.className` to named exports via graph-level narrowing. Spread/`Object.values` handled conservatively. -- **Convention auto-import resolution (Nuxt components and scripts)**: frameworks that expose components by filesystem convention (`` resolving to `components/Card001.vue` with no `import`) are modeled by a first-class `Plugin::auto_imports(&self, root) -> Vec` trait method. The architecture splits across two layers so the per-file extraction cache stays correct: (1) **capture** at extraction. The Vue SFC template scanner (`crates/extract/src/sfc_template/shared.rs::mark_binding_used`) records every PascalCase tag that matches no import and no local binding into `ModuleInfo.auto_import_candidates` (content-local, so it round-trips through the bitcode cache; native lowercase HTML elements and locals are skipped, kebab tags are normalized to their Pascal form). JS/TS and SFC script parsing also records unresolved value-reference identifiers such as `useCounter()` and `formatPrice()`, including files with zero explicit imports; locally declared, explicitly imported, and type-only names are not captured. (2) **resolve** at graph-build, never cached: `crates/graph/src/resolve/mod.rs::synthesize_auto_import_edges` runs after `apply_specifier_upgrades`, builds a `name -> FileId` table from the active plugins' `auto_imports` rules (mapped via the live file index), skips known JS/Web/Vue/Nuxt built-ins, and pushes a synthetic `ResolvedImport` for each captured name that matches. Name collisions over-credit every match (so `Comments.client.vue` + `Comments.server.vue`, both ``, or same-name composables in two convention dirs, stay reachable). The Nuxt plugin (`crates/core/src/plugins/nuxt.rs`) scans `components/` and `app/components/`, deriving the directory-prefixed PascalCase name with Nuxt's prefix-overlap dedup (`base/foo/Button.vue` -> `BaseFooButton`, `foo/Foo.vue` -> `Foo`, `base/BaseButton.vue` -> `BaseButton`), pre-expanding the implicit `Lazy`-prefixed variant, and stripping `.client` / `.server` / `.global` suffixes so paired files share one name. It scans top-level `composables/`, `app/composables/`, `utils/`, and `app/utils/`, plus recursive `shared/utils/` and `shared/types/`, emitting named-export rules and default-export rules keyed by the derived basename. Synthesis is unconditional and additive. The opt-in `autoImports: true` config field gates ONLY entry-pattern removal: component fallbacks are dropped unless a root/workspace `nuxt.config` declares `components:`, and composable/util fallbacks are dropped unless it declares `imports:`. Pinia stores (#740) remain separate; bumps the extract `CACHE_VERSION` because `auto_import_candidates` content changes. See issues #704 and #739. +- **Convention auto-import resolution (Nuxt components, scripts, and Pinia stores)**: frameworks that expose components or script bindings by filesystem convention (`` resolving to `components/Card001.vue`, or `useUserStore()` resolving to `stores/user.ts`, with no `import`) are modeled by a first-class `Plugin::auto_imports(&self, root) -> Vec` trait method. The architecture splits across two layers so the per-file extraction cache stays correct: (1) **capture** at extraction. The Vue SFC template scanner (`crates/extract/src/sfc_template/shared.rs::mark_binding_used`) records every PascalCase tag that matches no import and no local binding into `ModuleInfo.auto_import_candidates` (content-local, so it round-trips through the bitcode cache; native lowercase HTML elements and locals are skipped, kebab tags are normalized to their Pascal form). JS/TS and SFC script parsing also records unresolved value-reference identifiers such as `useCounter()`, `formatPrice()`, and `useUserStore()`, including files with zero explicit imports; locally declared, explicitly imported, and type-only names are not captured. (2) **resolve** at graph-build, never cached: `crates/graph/src/resolve/mod.rs::synthesize_auto_import_edges` runs after `apply_specifier_upgrades`, builds a `name -> FileId` table from the active plugins' `auto_imports` rules (mapped via the live file index), skips known JS/Web/Vue/Nuxt built-ins, and pushes a synthetic `ResolvedImport` for each captured name that matches. Name collisions over-credit every match (so `Comments.client.vue` + `Comments.server.vue`, both ``, same-name composables in two convention dirs, or same-name stores in two supported dirs, stay reachable). The Nuxt plugin (`crates/core/src/plugins/nuxt.rs`) scans `components/` and `app/components/`, deriving the directory-prefixed PascalCase name with Nuxt's prefix-overlap dedup (`base/foo/Button.vue` -> `BaseFooButton`, `foo/Foo.vue` -> `Foo`, `base/BaseButton.vue` -> `BaseButton`), pre-expanding the implicit `Lazy`-prefixed variant, and stripping `.client` / `.server` / `.global` suffixes so paired files share one name. It scans top-level `composables/`, `app/composables/`, `utils/`, and `app/utils/`, plus recursive `shared/utils/` and `shared/types/`, emitting named-export rules and default-export rules keyed by the derived basename. The Pinia plugin (`crates/core/src/plugins/pinia.rs`) activates on `@pinia/nuxt` and scans direct files in `stores/` and `app/stores/`, emitting named-export rules only for value exports that match `useStore`; nested stores and custom `storesDirs` remain out of scope. Synthesis is unconditional and additive. The opt-in `autoImports: true` config field gates ONLY Nuxt entry-pattern removal: component fallbacks are dropped unless a root/workspace `nuxt.config` declares `components:`, and composable/util fallbacks are dropped unless it declares `imports:`. Pinia adds no entry-pattern fallback and does not require an extract `CACHE_VERSION` bump because it reuses existing script-level candidate capture. See issues #704, #739, and #740. - **Nuxt `@nuxt/content` config entry**: `@nuxt/content` v3 reads a root `content.config.ts` (`export default defineContentConfig({...})`) at build time; nothing in app source imports it, so it surfaced as `unused-file` plus `unused-export`. When `@nuxt/content` appears in the nuxt.config `modules:` array, `crates/core/src/plugins/nuxt.rs::resolve_config` credits `content.config.{ts,js,mts,mjs,cts,cjs}` as a default-export entry (entry pattern + `default` used-export rule), resolved relative to the nuxt.config directory via `normalize_config_path` so nested/monorepo configs credit the sibling `content.config` rather than a root one. Gated on module registration, not mere package.json presence: an installed-but-unregistered `@nuxt/content` leaves a genuinely-orphan `content.config` correctly flagged. Scoped to the first-party `@nuxt/content` only; third-party Nuxt module config files are deliberately not hardcoded (declined in #138). No `CACHE_VERSION` bump (`resolve_config` is analysis-time, not extraction). See issue #792. ## Analysis-level diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c39ae970..b771f47d7 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 +- **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).) - **Nuxt composables and utils referenced only through script auto-imports are now tracked in the module graph.** Fallow now records unresolved value identifiers in JS/TS and Vue/Svelte script blocks, then resolves Nuxt convention exports from top-level `composables/`, `app/composables/`, `utils/`, and `app/utils/`, plus recursive `shared/utils/` and `shared/types/`, during graph build. This keeps files like `composables/useCounter.ts`, `utils/format-price.ts`, and named exports from `composables/index.ts` reachable when a page calls `useCounter()` or `formatPrice()` without an import. Local declarations, explicit imports, type-only references, and known JS/Web/Vue/Nuxt built-ins do not synthesize edges. With `autoImports: true`, component entry-pattern removal remains guarded by `components:` config, while composable/util pattern removal is separately guarded by `imports:` config. (Closes [#739](https://github.com/fallow-rs/fallow/issues/739).) - **`fallow health` now surfaces CRAP coverage-source consistency in JSON and lets teams tune the secondary CRAP refactor band.** CRAP source precedence is explicit (`template` inheritance first, then Istanbul data including unmatched files, then static estimates), health JSON emits `summary.coverage_source_consistency` and grouped health emits `groups[].coverage_source_consistency` whenever CRAP findings carry source data, and `health.crapRefactorBand` configures the old fixed 5-point window that adds a secondary `refactor-function` action to near-threshold CRAP-only findings. This closes the remaining JSON/config contract work from issue #474 after the human-output clarification below. (Closes [#474](https://github.com/fallow-rs/fallow/issues/474).) diff --git a/README.md b/README.md index 79f200f99..fa169471b 100644 --- a/README.md +++ b/README.md @@ -751,11 +751,11 @@ See the [full configuration reference](https://docs.fallow.tools/configuration/o ## Framework plugins -117 built-in plugins detect entry points, convention exports, config-defined aliases, and template-visible usage for your framework automatically. +118 built-in plugins detect entry points, convention exports, config-defined aliases, and template-visible usage for your framework automatically. | Category | Plugins | |---|---| -| **Frameworks** | Next.js, Nuxt, Remix, Qwik, SvelteKit, Gatsby, Astro, Angular, NestJS, AdonisJS, Contentlayer, Fumadocs, Lit, Obsidian, Ember, Expo, Expo Router, Electron, and more | +| **Frameworks** | Next.js, Nuxt, Pinia, Remix, Qwik, SvelteKit, Gatsby, Astro, Angular, NestJS, AdonisJS, Contentlayer, Fumadocs, Lit, Obsidian, Ember, Expo, Expo Router, Electron, and more | | **Bundlers** | Vite, Webpack, Rspack, Rsbuild, Rollup, Rolldown, Tsup, Tsdown, pkg-utils, Parcel | | **Testing** | Vitest, Jest, Playwright, Cypress, Storybook, Stryker, Mocha, Ava, tap, tsd | | **CI/CD & Release** | Danger, Commitlint, Commitizen, Semantic Release | diff --git a/crates/core/src/plugins/mod.rs b/crates/core/src/plugins/mod.rs index cddd83f5d..ca3bc4627 100644 --- a/crates/core/src/plugins/mod.rs +++ b/crates/core/src/plugins/mod.rs @@ -1168,6 +1168,7 @@ mod opennext_cloudflare; mod oxlint; mod pandacss; mod parcel; +mod pinia; mod pkg_utils; mod playwright; mod plop; diff --git a/crates/core/src/plugins/pinia.rs b/crates/core/src/plugins/pinia.rs new file mode 100644 index 000000000..bea9eef21 --- /dev/null +++ b/crates/core/src/plugins/pinia.rs @@ -0,0 +1,225 @@ +//! Pinia Nuxt store auto-import plugin. + +use std::path::{Path, PathBuf}; + +use fallow_config::{AutoImportKind, AutoImportRule}; +use fallow_types::discover::FileId; +use fallow_types::extract::ExportName; + +use super::Plugin; + +const ENABLERS: &[&str] = &["@pinia/nuxt"]; +const STORE_DIRS: &[&str] = &["stores", "app/stores"]; +const STORE_EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx", "mts", "cts", "mjs", "cjs"]; + +pub struct PiniaPlugin; + +impl Plugin for PiniaPlugin { + fn name(&self) -> &'static str { + "pinia" + } + + fn enablers(&self) -> &'static [&'static str] { + ENABLERS + } + + fn auto_imports(&self, root: &Path) -> Vec { + let mut rules = Vec::new(); + for dir in STORE_DIRS { + let base = root.join(dir); + if base.is_dir() { + collect_store_auto_imports(&base, &mut rules); + } + } + rules + } +} + +fn collect_store_auto_imports(dir: &Path, rules: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() || !has_store_extension(&path) { + continue; + } + let Ok(source) = std::fs::read_to_string(&path) else { + continue; + }; + let module = fallow_extract::parse_source_to_module(FileId(0), &path, &source, 0, false); + for export in module.exports { + if export.is_type_only { + continue; + } + if let ExportName::Named(name) = export.name + && is_store_export_name(&name) + { + push_store_rule(rules, name, path.clone()); + } + } + } +} + +fn has_store_extension(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + STORE_EXTENSIONS + .iter() + .any(|candidate| ext.eq_ignore_ascii_case(candidate)) + }) +} + +fn is_store_export_name(name: &str) -> bool { + name.starts_with("use") && name.ends_with("Store") && name.len() > "useStore".len() +} + +fn push_store_rule(rules: &mut Vec, name: String, source: PathBuf) { + if rules.iter().any(|rule| { + rule.name == name && rule.source == source && rule.kind == AutoImportKind::Named + }) { + return; + } + rules.push(AutoImportRule { + name, + source, + kind: AutoImportKind::Named, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_file(root: &Path, relative: &str, source: &str) { + let path = root.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create parent dir"); + } + std::fs::write(path, source).expect("write fixture file"); + } + + fn has_rule(rules: &[AutoImportRule], name: &str, relative: &str, root: &Path) -> bool { + let source = root.join(relative); + rules.iter().any(|rule| { + rule.name == name && rule.source == source && rule.kind == AutoImportKind::Named + }) + } + + #[test] + fn enabler_is_pinia_nuxt() { + let plugin = PiniaPlugin; + assert_eq!(plugin.enablers(), &["@pinia/nuxt"]); + } + + #[test] + fn is_enabled_with_pinia_nuxt_dep() { + let plugin = PiniaPlugin; + let deps = vec!["@pinia/nuxt".to_string()]; + assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project"))); + } + + #[test] + fn is_not_enabled_without_pinia_nuxt_dep() { + let plugin = PiniaPlugin; + let deps = vec!["pinia".to_string(), "nuxt".to_string()]; + assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project"))); + } + + #[test] + fn auto_imports_emit_named_store_exports() { + let tmp = tempfile::tempdir().expect("temp dir"); + let root = tmp.path(); + write_file( + root, + "stores/user.ts", + r#" + export const useUserStore = defineStore("user", () => ({})); + export const unusedStoreHelper = () => null; + "#, + ); + + let rules = PiniaPlugin.auto_imports(root); + + assert!(has_rule(&rules, "useUserStore", "stores/user.ts", root)); + assert!( + !has_rule(&rules, "unusedStoreHelper", "stores/user.ts", root), + "non-store exports must not become Pinia auto-import providers" + ); + } + + #[test] + fn auto_imports_scan_app_stores() { + let tmp = tempfile::tempdir().expect("temp dir"); + let root = tmp.path(); + write_file( + root, + "app/stores/settings.ts", + r#" + export const useSettingsStore = defineStore("settings", () => ({})); + "#, + ); + + let rules = PiniaPlugin.auto_imports(root); + + assert!(has_rule( + &rules, + "useSettingsStore", + "app/stores/settings.ts", + root + )); + } + + #[test] + fn auto_imports_ignore_type_only_and_non_store_exports() { + let tmp = tempfile::tempdir().expect("temp dir"); + let root = tmp.path(); + write_file( + root, + "stores/user.ts", + r" + export type useTypeStore = { id: string }; + export interface useInterfaceStore { id: string } + export const useUser = () => null; + export const useStore = () => null; + ", + ); + + let rules = PiniaPlugin.auto_imports(root); + + assert!(rules.is_empty(), "no export should match useStore"); + } + + #[test] + fn auto_imports_do_not_recurse_into_nested_stores() { + let tmp = tempfile::tempdir().expect("temp dir"); + let root = tmp.path(); + write_file( + root, + "stores/admin/user.ts", + r#" + export const useAdminStore = defineStore("admin", () => ({})); + "#, + ); + + let rules = PiniaPlugin.auto_imports(root); + + assert!( + !has_rule(&rules, "useAdminStore", "stores/admin/user.ts", root), + "default Pinia storesDirs only scan direct store files" + ); + } + + #[test] + fn auto_imports_empty_without_store_dirs() { + let tmp = tempfile::tempdir().expect("temp dir"); + + let rules = PiniaPlugin.auto_imports(tmp.path()); + + assert!(rules.is_empty()); + } +} diff --git a/crates/core/src/plugins/registry/builtin.rs b/crates/core/src/plugins/registry/builtin.rs index 5ef45c820..06eef742f 100644 --- a/crates/core/src/plugins/registry/builtin.rs +++ b/crates/core/src/plugins/registry/builtin.rs @@ -21,9 +21,9 @@ use super::super::{ nitro::NitroPlugin, nodemon::NodemonPlugin, nuxt::NuxtPlugin, nx::NxPlugin, nyc::NycPlugin, obsidian::ObsidianPlugin, openapi_ts::OpenapiTsPlugin, opencode::OpenCodePlugin, opennext_cloudflare::OpenNextCloudflarePlugin, oxlint::OxlintPlugin, pandacss::PandaCssPlugin, - parcel::ParcelPlugin, pkg_utils::PkgUtilsPlugin, playwright::PlaywrightPlugin, - plop::PlopPlugin, pm2::Pm2Plugin, pnpm::PnpmPlugin, postcss::PostCssPlugin, - prettier::PrettierPlugin, prisma::PrismaPlugin, qwik::QwikPlugin, + parcel::ParcelPlugin, pinia::PiniaPlugin, pkg_utils::PkgUtilsPlugin, + playwright::PlaywrightPlugin, plop::PlopPlugin, pm2::Pm2Plugin, pnpm::PnpmPlugin, + postcss::PostCssPlugin, prettier::PrettierPlugin, prisma::PrismaPlugin, qwik::QwikPlugin, react_native::ReactNativePlugin, react_router::ReactRouterPlugin, redwoodsdk::RedwoodSdkPlugin, relay::RelayPlugin, remark::RemarkPlugin, remix::RemixPlugin, rolldown::RolldownPlugin, rollup::RollupPlugin, rsbuild::RsbuildPlugin, rspack::RspackPlugin, rspress::RspressPlugin, @@ -45,6 +45,7 @@ pub fn create_builtin_plugins() -> Vec> { vec![ Box::new(NextJsPlugin), Box::new(NuxtPlugin), + Box::new(PiniaPlugin), Box::new(RemixPlugin), Box::new(AstroPlugin), Box::new(BrowserExtensionPlugin), diff --git a/crates/core/tests/integration_test.rs b/crates/core/tests/integration_test.rs index a8dce085a..ae4882d98 100644 --- a/crates/core/tests/integration_test.rs +++ b/crates/core/tests/integration_test.rs @@ -225,6 +225,8 @@ mod issue_609_velite; mod issue_704_auto_import_components; #[path = "integration_test/issue_739_script_auto_imports.rs"] mod issue_739_script_auto_imports; +#[path = "integration_test/issue_740_pinia_store_auto_imports.rs"] +mod issue_740_pinia_store_auto_imports; #[path = "integration_test/issue_744_tsdown_config.rs"] mod issue_744_tsdown_config; #[path = "integration_test/pkg_utils_plugin.rs"] diff --git a/crates/core/tests/integration_test/issue_740_pinia_store_auto_imports.rs b/crates/core/tests/integration_test/issue_740_pinia_store_auto_imports.rs new file mode 100644 index 000000000..1eebc766b --- /dev/null +++ b/crates/core/tests/integration_test/issue_740_pinia_store_auto_imports.rs @@ -0,0 +1,97 @@ +//! Issue #740: Pinia store auto-import resolution for Nuxt. + +use std::path::Path; + +use super::common::{create_config, fixture_path}; +use fallow_types::results::AnalysisResults; + +fn normalize_path(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +fn unused_file_paths(results: &AnalysisResults, root: &Path) -> Vec { + results + .unused_files + .iter() + .map(|finding| normalize_path(root, &finding.file.path)) + .collect() +} + +fn unused_exports(results: &AnalysisResults, root: &Path) -> Vec<(String, String)> { + results + .unused_exports + .iter() + .map(|finding| { + ( + normalize_path(root, &finding.export.path), + finding.export.export_name.clone(), + ) + }) + .collect() +} + +#[test] +fn pinia_store_auto_imports_keep_direct_store_files_reachable() { + let root = fixture_path("nuxt-pinia-store-auto-imports"); + let config = create_config(root.clone()); + + let results = fallow_core::analyze(&config).expect("analysis should succeed"); + let unused = unused_file_paths(&results, &root); + + for reachable in ["stores/user.ts", "app/stores/settings.ts"] { + assert!( + !unused.contains(&reachable.to_string()), + "{reachable} should be reachable via Pinia store auto-imports: {unused:?}" + ); + } + + assert!( + unused.contains(&"stores/admin/user.ts".to_string()), + "nested stores should stay outside default Pinia storesDirs: {unused:?}" + ); +} + +#[test] +fn include_entry_exports_credits_pinia_store_exports() { + let root = fixture_path("nuxt-pinia-store-auto-imports"); + let mut config = create_config(root.clone()); + config.include_entry_exports = true; + + let results = fallow_core::analyze(&config).expect("analysis should succeed"); + let unused = unused_exports(&results, &root); + + for used in [ + ("stores/user.ts", "useUserStore"), + ("app/stores/settings.ts", "useSettingsStore"), + ] { + assert!( + !unused.contains(&(used.0.to_string(), used.1.to_string())), + "{used:?} should be credited by synthesized Pinia import edges: {unused:?}" + ); + } + + assert!( + unused.contains(&( + "stores/user.ts".to_string(), + "unusedStoreHelper".to_string() + )), + "unreferenced sibling exports should still report: {unused:?}" + ); +} + +#[test] +fn package_without_pinia_nuxt_does_not_credit_store_files() { + let root = fixture_path("nuxt-pinia-store-auto-imports-disabled"); + let config = create_config(root.clone()); + + let results = fallow_core::analyze(&config).expect("analysis should succeed"); + let unused = unused_file_paths(&results, &root); + + assert!( + unused.contains(&"stores/user.ts".to_string()), + "Pinia store auto-import rules should activate only on @pinia/nuxt: {unused:?}" + ); +} diff --git a/scripts/agent-files.sha256 b/scripts/agent-files.sha256 index 45f984af0..649020965 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 -3e0ebd2c95ae273de689770bb06939fa94d291169fbdf836e0425f886662ac90 .claude/rules/detection.md +8e0bbf429afe2c6eb4d4cfa94084f6bf6c50164f22246c01d6334ea872b17496 .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/nuxt-pinia-store-auto-imports-disabled/package.json b/tests/fixtures/nuxt-pinia-store-auto-imports-disabled/package.json new file mode 100644 index 000000000..7c4bf31f3 --- /dev/null +++ b/tests/fixtures/nuxt-pinia-store-auto-imports-disabled/package.json @@ -0,0 +1,9 @@ +{ + "name": "nuxt-pinia-store-auto-imports-disabled", + "private": true, + "dependencies": { + "nuxt": "^4.0.0", + "pinia": "^3.0.0", + "vue": "^3.0.0" + } +} diff --git a/tests/fixtures/nuxt-pinia-store-auto-imports-disabled/pages/index.vue b/tests/fixtures/nuxt-pinia-store-auto-imports-disabled/pages/index.vue new file mode 100644 index 000000000..8473becea --- /dev/null +++ b/tests/fixtures/nuxt-pinia-store-auto-imports-disabled/pages/index.vue @@ -0,0 +1,7 @@ + + + diff --git a/tests/fixtures/nuxt-pinia-store-auto-imports-disabled/stores/user.ts b/tests/fixtures/nuxt-pinia-store-auto-imports-disabled/stores/user.ts new file mode 100644 index 000000000..e76b4c49f --- /dev/null +++ b/tests/fixtures/nuxt-pinia-store-auto-imports-disabled/stores/user.ts @@ -0,0 +1,3 @@ +export const useUserStore = defineStore('user', () => ({ + name: 'Ada', +})); diff --git a/tests/fixtures/nuxt-pinia-store-auto-imports/app/stores/settings.ts b/tests/fixtures/nuxt-pinia-store-auto-imports/app/stores/settings.ts new file mode 100644 index 000000000..0adcb5c52 --- /dev/null +++ b/tests/fixtures/nuxt-pinia-store-auto-imports/app/stores/settings.ts @@ -0,0 +1,3 @@ +export const useSettingsStore = defineStore('settings', () => ({ + theme: 'dark', +})); diff --git a/tests/fixtures/nuxt-pinia-store-auto-imports/nuxt.config.ts b/tests/fixtures/nuxt-pinia-store-auto-imports/nuxt.config.ts new file mode 100644 index 000000000..a169333d4 --- /dev/null +++ b/tests/fixtures/nuxt-pinia-store-auto-imports/nuxt.config.ts @@ -0,0 +1,3 @@ +export default defineNuxtConfig({ + modules: ['@pinia/nuxt'], +}); diff --git a/tests/fixtures/nuxt-pinia-store-auto-imports/package.json b/tests/fixtures/nuxt-pinia-store-auto-imports/package.json new file mode 100644 index 000000000..d89d204f5 --- /dev/null +++ b/tests/fixtures/nuxt-pinia-store-auto-imports/package.json @@ -0,0 +1,10 @@ +{ + "name": "nuxt-pinia-store-auto-imports", + "private": true, + "dependencies": { + "@pinia/nuxt": "^0.11.0", + "nuxt": "^4.0.0", + "pinia": "^3.0.0", + "vue": "^3.0.0" + } +} diff --git a/tests/fixtures/nuxt-pinia-store-auto-imports/pages/index.vue b/tests/fixtures/nuxt-pinia-store-auto-imports/pages/index.vue new file mode 100644 index 000000000..84f271777 --- /dev/null +++ b/tests/fixtures/nuxt-pinia-store-auto-imports/pages/index.vue @@ -0,0 +1,9 @@ + + + diff --git a/tests/fixtures/nuxt-pinia-store-auto-imports/stores/admin/user.ts b/tests/fixtures/nuxt-pinia-store-auto-imports/stores/admin/user.ts new file mode 100644 index 000000000..06c559d07 --- /dev/null +++ b/tests/fixtures/nuxt-pinia-store-auto-imports/stores/admin/user.ts @@ -0,0 +1,3 @@ +export const useAdminStore = defineStore('admin', () => ({ + name: 'Grace', +})); diff --git a/tests/fixtures/nuxt-pinia-store-auto-imports/stores/user.ts b/tests/fixtures/nuxt-pinia-store-auto-imports/stores/user.ts new file mode 100644 index 000000000..b340784a5 --- /dev/null +++ b/tests/fixtures/nuxt-pinia-store-auto-imports/stores/user.ts @@ -0,0 +1,7 @@ +export const useUserStore = defineStore('user', () => ({ + name: 'Ada', +})); + +export const unusedStoreHelper = () => null; + +export type UseUserStoreType = ReturnType;