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
4 changes: 2 additions & 2 deletions .claude/rules/detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ Non-obvious implementation details for each detection feature. These are NOT dis
- **JSDoc `import()` type references**: JSDoc comments matching `import('./path').Member` inside `@param`, `@returns`, `@type`, `@typedef`, `@callback`, or other tag contexts are extracted as type-only imports. Supports single/double quotes, bare specifiers, parent-relative paths, union annotations with multiple `import()` expressions per comment, and nested member access (first segment wins). Side-effect-only `import('./types')` without `.Member` is recorded as `SideEffect` to keep the file reachable. Prevents false `unused-export` reports for types referenced only from JSDoc annotations in untyped JS files. Only `/** */` block comments are scanned (single-star `/* */` is not JSDoc). See issue #105 (till's comment).
- **`typeof import('./path').X` in TypeScript type positions**: `TSImportType` AST nodes are extracted as type-only `ImportInfo` entries. A flat qualifier (`typeof import('./x').foo`) credits a `Named("foo")` import; a nested qualifier (`typeof import('./x').A.B`) walks to the leftmost identifier and credits `Named("A")`; no qualifier and the indexed-access form (`typeof import('./x')['default']`) record a `SideEffect` import so the target file stays reachable. Fires inside any type position, including the bodies of `declare global { ... }` and `declare module 'pkg' { ... }` ambient declarations produced by unplugin-auto-import (`auto-imports.d.ts`) and unplugin-vue-components (`components.d.ts`). Pairs with the graph-level promotion of every `.d.ts` / `.d.mts` / `.d.cts` file to an overall reachability root (`ModuleGraph::build_with_reachability_roots` augments `entry_point_ids` with declaration files), so the new edges propagate through BFS even when the d.ts file is not in the user's `entry` config. Side effect: `is_entry_point()` returns `true` for declaration files, which auto-skips them in `unused-exports` / `unused-types` detection (preventing false positives on module-augmentation interfaces TypeScript consumes via global type resolution); pass `--include-entry-exports` to re-enable. Runtime / test reachability is unchanged because declaration files have no runtime side effects. See issues #396 and #397.
- **`new URL('./', import.meta.url)` directory specifiers skipped**: the worker / asset detector for `new URL(literal, import.meta.url)` admits relative specifiers (`./`, `../` prefixes) but rejects directory-only forms (`./`, `../`, `./foo/`). Directory URLs do not resolve to a module, so reporting them as `unresolved-imports` was a false positive on the canonical ESM `__dirname` idiom (`fileURLToPath(new URL('./', import.meta.url))`). File-pointing specifiers (`./worker.js`, `./assets/foo.svg`) are unaffected. See issue #399.
- **Vue/Svelte SFC**: handles `>` in quoted attributes like `generic="T extends Foo<Bar>"`, `<script src="...">` external script support, HTML comment filtering. Script bodies are parsed as extracted fragments whose spans are remapped back to original `.vue` / `.svelte` byte offsets before module info is finalized; `<script src>` and style `src` references carry the `src` value span when available. Template-visible import tracking: imports used only in markup (`{formatDate(x)}`, `utils.formatDate()`) are credited as used, preventing false unused-import/export reports. Vue credits only `<script setup>` bindings; Svelte excludes `context="module"` scripts from template visibility. Namespace member access in templates (`utils.formatDate`) tracked as member usage. Svelte 5 `$derived(new Class())` / `$derived.by(() => new Class())` bindings propagate template member access to the derived class instance, while `$effect(...)` and `$inspect(...)` are ordinary script calls whose referenced imports are credited by the JS visitor. See issue #445.
- **Vue/Svelte SFC**: handles `>` in quoted attributes like `generic="T extends Foo<Bar>"`, Vue `<script src="...">` external script support, Svelte script `src` metadata parsing, and HTML comment filtering. Script bodies are parsed as extracted fragments whose spans are remapped back to original `.vue` / `.svelte` byte offsets before module info is finalized; Vue `<script src>` and style `src` references carry the `src` value span when available. Svelte markup-level `<script src>` references do not create graph edges because Svelte treats them as runtime HTML. Template-visible import tracking: imports used only in markup (`{formatDate(x)}`, `utils.formatDate()`) are credited as used, preventing false unused-import/export reports. Vue credits only `<script setup>` bindings; Svelte excludes `context="module"` scripts from template visibility. Namespace member access in templates (`utils.formatDate`) tracked as member usage. Svelte 5 `$derived(new Class())` / `$derived.by(() => new Class())` bindings propagate template member access to the derived class instance, while `$effect(...)` and `$inspect(...)` are ordinary script calls whose referenced imports are credited by the JS visitor. See issues #445 and #835.
- **CSS Modules class extraction skips `@layer` and `@import` preludes**: `extract_css_module_exports` runs `CSS_AT_RULE_PRELUDE_RE = @(?:layer|import)\b[^;{]*` over the source after string and `url()` stripping and before the class regex, so dot-separated cascade-layer names (`@layer foo.bar { ... }`, `@layer foo.bar, foo.baz;`) and parenthesised layer references on `@import` (`@import url("x.css") layer(theme.button)`) never reach the class scan. Block bodies are preserved because the strip stops at the opening `{`. The allowlist is intentionally narrow: `@scope (.foo) to (.bar) { ... }` keeps its existing behavior because that prelude IS a selector list and `.foo` / `.bar` are real class references. `@media`, `@supports`, `@keyframes`, `@-webkit-keyframes`, and every other at-rule are also untouched because their preludes do not contain dotted identifiers that the class regex would otherwise capture. Bumps `CACHE_VERSION` because pre-fix cache entries carry phantom exports on `.module.css` files using cascade-layer syntax. See issues #540 and #549.

- **CSS Modules class extraction records real source positions**: every strip pass (block comments, SCSS line comments, quoted strings plus `url(...)`, the `@layer` / `@import` prelude strip) replaces matches with ASCII spaces of equal byte length instead of collapsing the range, so `CSS_CLASS_RE` capture offsets in the masked buffer index 1:1 back into the original source. `extract_css_module_exports` emits `Span::new(start, end)` pointing at the bare class identifier (no leading dot) on each `ExportInfo`, matching how oxc spans identifiers elsewhere. CSS/SCSS `@import`, `@use`, `@forward`, and `@plugin` imports also carry spans for the authored specifier when one exists; implicit `tailwindcss` imports synthesized from `@apply` / `@tailwind` intentionally remain unpositioned. Downstream `compute_line_offsets` resolves the real declaration line and column, so LSP "go to source", hover anchors, SARIF regions, and `--diff-file` line-overlap classification all land on the actual selector or directive rather than line:1 col:0. The function signature carries `is_scss: bool` so the SCSS-line-comment strip only fires for `.module.scss`. See issues #549 and #445.
- **Vue/Svelte SFC `<style>` blocks**: `<style lang="scss">` and `<style lang="sass">` block bodies are scanned for `@import` / `@use` / `@forward` / Tailwind CSS `@plugin` directives via the same regexes used for plain `.scss` files (issue #195 Case B). Plain `<style>` (no `lang`) and `<style lang="css">` are scanned for `@import` and `@plugin`. `<style src="./theme.scss">` external style references are extracted symmetrically with `<script src>` and emit a `SideEffect` import on the SFC. Each style-context import is tagged with `from_style: true` on `ImportInfo` so the resolver routes through the SCSS fallback chain (CSS-extension probe, partial convention `_filename`, include paths, node_modules) BEFORE the standard JS resolver. Without that routing, `./Foo` from a `Foo.vue` style block would resolve to the SFC itself (`.vue` precedes `.scss` in the resolver's extension list); the CSS-restricted probe selects the sibling `Foo.scss` instead. Languages other than CSS / SCSS / Sass (`postcss`, `less`, `stylus`) only have their `<style src>` reference seeded; their body syntax differs (e.g., Less `@import (reference)` modifier) and is out of scope.
- **Vue/Svelte SFC `<style>` blocks**: `<style lang="scss">` and `<style lang="sass">` block bodies are scanned for `@import` / `@use` / `@forward` / Tailwind CSS `@plugin` directives via the same regexes used for plain `.scss` files (issue #195 Case B). Plain `<style>` (no `lang`) and `<style lang="css">` are scanned for `@import` and `@plugin`. `<style src="./theme.scss">` external style references emit a `SideEffect` import on the SFC. Each style-context import is tagged with `from_style: true` on `ImportInfo` so the resolver routes through the SCSS fallback chain (CSS-extension probe, partial convention `_filename`, include paths, node_modules) BEFORE the standard JS resolver. Without that routing, `./Foo` from a `Foo.vue` style block would resolve to the SFC itself (`.vue` precedes `.scss` in the resolver's extension list); the CSS-restricted probe selects the sibling `Foo.scss` instead. Languages other than CSS / SCSS / Sass (`postcss`, `less`, `stylus`) only have their `<style src>` reference seeded; their body syntax differs (e.g., Less `@import (reference)` modifier) and is out of scope.
- **Glimmer component files**: `.gts` and `.gjs` files are discovered as source files. `.gts` parses with TypeScript source semantics and `.gjs` parses with JavaScript module semantics. `<template>...</template>` blocks are stripped before Oxc parsing while preserving byte offsets and line numbers, so imports before/around templates are still extracted and resolved through the same tsconfig path-alias path as `.ts` importers. Two replacement strategies are used so the post-strip source stays syntactically valid in both canonical GTS contexts: class-body templates (`class X { <template>...</template> }`) blank to spaces, leaving an empty class body; module-level template expressions (`const X = <template>...</template>`) and other expression-position templates (function argument, ternary branch, decorator-call argument) are replaced with a byte-length-preserving parenthesized template literal `` (`...`) ``. Without the expression-context branch, a file mixing both shapes parses to `const x = ;`, oxc bails, and every import in the file is dropped. Expression position is detected by the previous non-whitespace byte being one of `=`, `,`, `(`, `?`, `:`, plus an identifier-byte lookback that matches the expression-prefix keyword set `{default, return, throw, yield, await, new}` so the canonical template-only-component shape `export default <template>...</template>` (no `const` wrapper) does not blank to `export default ;`. User identifiers ending in a keyword suffix (`mydefault`, `$return`) walk back the FULL identifier and do not match. See issues #270, #375, #379.
- **Namespace destructuring**: `const { a, b } = ns` → member accesses. Rest patterns (`const { foo, ...rest } = ns`) → conservative whole-object use. Works with static/dynamic imports and require.
- **Node `child_process.fork()` entrypoints**: module-scope `fork()` calls emit `DynamicImportInfo` entries only when the callee is proven to come from `node:child_process` / `child_process`. Supported provenance forms are `import { fork }`, `import * as childProcess`, `const { fork } = require(...)`, and `const childProcess = require(...)`. Supported targets are local string/template literals without expressions, `new URL('./runner.js', import.meta.url)` bindings, and `path.resolve(filename, '../runner.js')` where `filename` is bound to `fileURLToPath(import.meta.url)` and `path` is from `node:path` / `path`. Unresolved identifiers, non-local specifiers, computed templates, nested shadowed calls, and unrelated `fork` functions are ignored. See issue #638.
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

- **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).)
- **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).)
Expand Down
18 changes: 18 additions & 0 deletions crates/core/tests/integration_test/sfc_parsing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,24 @@ fn sveltekit_generated_types_not_unresolved() {
);
}

#[test]
fn sveltekit_head_script_src_not_unresolved() {
let root = fixture_path("issue-835-svelte-script-src");
let config = create_config(root);
let results = fallow_core::analyze(&config).expect("analysis should succeed");

let unresolved_specs: Vec<&str> = results
.unresolved_imports
.iter()
.map(|u| u.import.specifier.as_str())
.collect();

assert!(
!unresolved_specs.contains(&"/some-lib.min.js"),
"SvelteKit markup script src should not be unresolved: {unresolved_specs:?}"
);
}

#[test]
fn sveltekit_workspace_types_not_unresolved() {
let root = fixture_path("workspace-sveltekit");
Expand Down
10 changes: 5 additions & 5 deletions crates/extract/src/asset_url.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
//!
//! Used by parsers that emit side-effect imports from user-authored asset
//! references: Angular `@Component({ templateUrl, styleUrl })`, HTML
//! `<script src>` / `<link href>`, and Vue/Svelte `<script src>`.
//! `<script src>` / `<link href>`, Vue `<script src>`, and SFC `<style src>`.
//!
//! Browsers, Vite, Parcel, Angular's compiler, and Vue/Svelte's SFC tooling
//! all resolve these references relative to the document or component file
//! whether or not they start with `./`. Fallow's downstream specifier
//! classifier, however, treats any string not starting with `.`, `/`, or
//! Browsers, Vite, Parcel, Angular's compiler, Vue external scripts, and
//! SFC style loaders all resolve these references relative to the document or
//! component file whether or not they start with `./`. Fallow's downstream
//! specifier classifier, however, treats any string not starting with `.`, `/`, or
//! containing `://` as a bare npm package specifier, so bare filenames like
//! `'app.component.html'` or `'app.js'` are misclassified as unlisted
//! dependencies. Prepending `./` at extraction time aligns the emitted
Expand Down
7 changes: 6 additions & 1 deletion crates/extract/src/cache/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,12 @@ use crate::MemberKind;
/// Bumped to 106 for `fallow security`: JS/TS extraction now stores file-level
/// directives (`"use client"`, `"use server"`) in the parse cache so client
/// boundary detection does not depend on stale cached module info.
pub(super) const CACHE_VERSION: u32 = 106;
///
/// Bumped to 107 for issue #835: Svelte `<script src>` references no longer
/// 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;

/// Duplication token cache version. Bump when duplicate tokenization,
/// normalization, or the on-disk token cache schema changes.
Expand Down
8 changes: 6 additions & 2 deletions crates/extract/src/sfc.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
//! Vue/Svelte Single File Component (SFC) script and style extraction.
//!
//! Extracts `<script>` block content from `.vue` and `.svelte` files using regex,
//! handling `lang`, `src`, and `generic` attributes, and filtering HTML comments.
//! handling `lang`, `src` metadata, and `generic` attributes, and filtering
//! HTML comments. Vue external script references are emitted as graph edges;
//! Svelte markup-level script `src` references are treated as runtime HTML.
//! Also extracts `<style>` block sources (`@import` / `@use` / `@forward` /
//! `@plugin` and `<style src="...">`) so referenced CSS / SCSS files become
//! reachable from the component, preventing false `unused-files` reports on
Expand Down Expand Up @@ -335,7 +337,9 @@ fn merge_script_into_module(
template_visible_bound_targets: &mut FxHashMap<String, String>,
need_complexity: bool,
) {
if let Some(src) = &script.src {
if kind == SfcKind::Vue
&& let Some(src) = &script.src
{
add_script_src_import(combined, src, script.src_span);
}

Expand Down
Loading
Loading