What happened?
When an SCSS file uses a bare module specifier like @use 'Widget'; (Sass's "I'll figure out the extension" form), and there is both a Widget.tsx and a Widget.scss next to the importer, fallow resolves the import to Widget.tsx instead of Widget.scss.
This is incorrect: Sass's resolver only ever looks for Widget.scss / _Widget.scss / Widget.sass / _Widget.sass. The TypeScript file is invisible to Sass; it has no .scss content and Dart Sass / node-sass would error at build time if asked to import it. The Angular CLI, Nx, Vite, and webpack sass-loader all behave the same way.
The mis-resolution creates phantom circular dependencies when:
Widget.tsx imports ./Widget.scss (standard CSS-modules / Angular styleUrls pattern).
Widget.tsx imports ./Helper (a sibling component).
Helper.tsx imports ./Helper.scss.
Helper.scss does @use 'Widget'; — meant to share Sass variables/mixins from Widget.scss.
Fallow's graph then contains:
Widget.tsx → Widget.scss ✓ (correct)
Widget.tsx → Helper.tsx ✓ (correct)
Helper.tsx → Helper.scss ✓ (correct)
Helper.scss → Widget.tsx ❌ (should be → Widget.scss)
… which produces a 3-file cycle Helper.scss → Widget.tsx → Helper.tsx → Helper.scss that does not exist in reality.
Reproduction
-
Create an empty directory and cd into it.
-
Create a minimal .fallowrc.json:
-
Create package.json:
{ "name": "demo", "private": true, "version": "0.0.1" }
-
Create src/Widget.tsx:
import './Widget.scss';
import { Helper } from './Helper';
export function Widget() {
return Helper();
}
-
Create src/Widget.scss:
-
Create src/Helper.tsx:
import './Helper.scss';
export function Helper() {
return 'helper';
}
-
Create src/Helper.scss — bare @use of the sibling Sass file (the bug trigger):
@use 'Widget';
.helper { background: blue; }
-
Create src/main.tsx:
import { Widget } from './Widget';
console.log(Widget());
-
Run fallow:
fallow dead-code --format json --quiet | jq '{cnt: (.circular_dependencies | length), cycles: .circular_dependencies}'
Expected behavior
{ "cnt": 0, "cycles": [] }
@use 'Widget'; from a .scss importer must resolve only to *.scss / *.sass / _*.scss / _*.sass candidates per the Sass resolution algorithm. Widget.tsx is not a Sass file and must not be considered.
The control case verifies this: replace @use 'Widget'; with @use './Widget.scss'; (extension-explicit) and re-run — fallow correctly reports cnt: 0. The two forms are equivalent in Sass, so they should be classified identically.
Actual behavior
{
"cnt": 1,
"cycles": [
{
"files": ["src/Helper.scss", "src/Widget.tsx", "src/Helper.tsx"],
"length": 3,
"line": 1,
"col": 0,
"actions": [
{
"type": "refactor-cycle",
"auto_fixable": false,
"description": "Extract shared logic into a separate module to break the cycle",
"note": "Circular imports can cause initialization issues and make code harder to reason about"
},
...
]
}
]
}
The cycle includes Widget.tsx, which Helper.scss cannot possibly reach via Sass's resolution algorithm.
Suggested fix
The SCSS-specific resolver in crates/graph/src/resolve/specifier.rs:197 (fn try_scss_fallbacks) calls a chain of three SCSS-aware resolvers (partial fallback, framework include-paths, node_modules), but if none of those produce a hit, the outer resolve_specifier continues into the generic alias / source-extension fallback chain — which is where Widget.tsx is matched.
A minimal fix: when the importer's extension is .scss / .sass, the resolver should return ResolveResult::Unresolved after exhausting the SCSS-specific fallbacks, instead of falling through to the JS/TS extension list. Equivalently, the generic source-extension fallback (try_source_fallback and the inline extensions retry in resolve_specifier) should consult is_style_file(from_file) and skip JS/TS extensions when the importer is a stylesheet.
(Note: Sass has its own pkg: import scheme for cross-language resolution. Even there, the targets are *.scss only, never *.tsx.)
Fallow version
2.56.0
Operating system
macOS
Configuration
`.fallowrc.json` shown above. There is no per-file or per-extension override for the resolver; the only way to silence the resulting cycle is `rules.circular-dependency: "off"` globally (which hides every cycle, real and phantom).
What happened?
When an SCSS file uses a bare module specifier like
@use 'Widget';(Sass's "I'll figure out the extension" form), and there is both aWidget.tsxand aWidget.scssnext to the importer, fallow resolves the import toWidget.tsxinstead ofWidget.scss.This is incorrect: Sass's resolver only ever looks for
Widget.scss/_Widget.scss/Widget.sass/_Widget.sass. The TypeScript file is invisible to Sass; it has no.scsscontent and Dart Sass /node-sasswould error at build time if asked to import it. The Angular CLI, Nx, Vite, and webpacksass-loaderall behave the same way.The mis-resolution creates phantom circular dependencies when:
Widget.tsximports./Widget.scss(standard CSS-modules / AngularstyleUrlspattern).Widget.tsximports./Helper(a sibling component).Helper.tsximports./Helper.scss.Helper.scssdoes@use 'Widget';— meant to share Sass variables/mixins fromWidget.scss.Fallow's graph then contains:
Widget.tsx → Widget.scss✓ (correct)Widget.tsx → Helper.tsx✓ (correct)Helper.tsx → Helper.scss✓ (correct)Helper.scss → Widget.tsx❌ (should be→ Widget.scss)… which produces a 3-file cycle
Helper.scss → Widget.tsx → Helper.tsx → Helper.scssthat does not exist in reality.Reproduction
Create an empty directory and
cdinto it.Create a minimal
.fallowrc.json:{ "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json", "entry": ["src/main.tsx"] }Create
package.json:{ "name": "demo", "private": true, "version": "0.0.1" }Create
src/Widget.tsx:Create
src/Widget.scss:Create
src/Helper.tsx:Create
src/Helper.scss— bare@useof the sibling Sass file (the bug trigger):Create
src/main.tsx:Run fallow:
Expected behavior
{ "cnt": 0, "cycles": [] }@use 'Widget';from a.scssimporter must resolve only to*.scss/*.sass/_*.scss/_*.sasscandidates per the Sass resolution algorithm.Widget.tsxis not a Sass file and must not be considered.The control case verifies this: replace
@use 'Widget';with@use './Widget.scss';(extension-explicit) and re-run — fallow correctly reportscnt: 0. The two forms are equivalent in Sass, so they should be classified identically.Actual behavior
{ "cnt": 1, "cycles": [ { "files": ["src/Helper.scss", "src/Widget.tsx", "src/Helper.tsx"], "length": 3, "line": 1, "col": 0, "actions": [ { "type": "refactor-cycle", "auto_fixable": false, "description": "Extract shared logic into a separate module to break the cycle", "note": "Circular imports can cause initialization issues and make code harder to reason about" }, ... ] } ] }The cycle includes
Widget.tsx, whichHelper.scsscannot possibly reach via Sass's resolution algorithm.Suggested fix
The SCSS-specific resolver in
crates/graph/src/resolve/specifier.rs:197(fn try_scss_fallbacks) calls a chain of three SCSS-aware resolvers (partial fallback, framework include-paths, node_modules), but if none of those produce a hit, the outerresolve_specifiercontinues into the generic alias / source-extension fallback chain — which is whereWidget.tsxis matched.A minimal fix: when the importer's extension is
.scss/.sass, the resolver should returnResolveResult::Unresolvedafter exhausting the SCSS-specific fallbacks, instead of falling through to the JS/TS extension list. Equivalently, the generic source-extension fallback (try_source_fallbackand the inlineextensionsretry inresolve_specifier) should consultis_style_file(from_file)and skip JS/TS extensions when the importer is a stylesheet.(Note: Sass has its own
pkg:import scheme for cross-language resolution. Even there, the targets are*.scssonly, never*.tsx.)Fallow version
2.56.0
Operating system
macOS
Configuration
`.fallowrc.json` shown above. There is no per-file or per-extension override for the resolver; the only way to silence the resulting cycle is `rules.circular-dependency: "off"` globally (which hides every cycle, real and phantom).