Skip to content

Commit a0944ea

Browse files
fix(graph): SCSS imports no longer resolve to sibling .tsx files
Closes #245. When a `.scss` / `.sass` file does `@use 'Widget'` (extensionless) and both `Widget.scss` and `Widget.tsx` exist as siblings, fallow now resolves the import to `Widget.scss` per Sass's actual resolution algorithm. The standard `oxc_resolver` extension list contains `.tsx` / `.ts` before `.scss`, so without this guard a bare specifier from a stylesheet was silently picking the JS/TS sibling, creating phantom circular dependencies in standard CSS-modules / Angular `styleUrls` patterns: Helper.scss -> Widget.tsx -> Helper.tsx -> Helper.scss (Helper.scss meant to share Sass variables with Widget.scss but mis-resolved to Widget.tsx instead.) Implementation: in `resolve_specifier`, after the standard resolver returns a hit, reject any path whose extension is a JS/TS-family extension (.tsx, .ts, .mts, .cts, .js, .jsx, .mjs, .cjs) when the importer is `.scss` / `.sass`, and re-route through the SCSS-aware fallback chain (CSS-extension probe, `_filename` partial convention, framework include paths, `node_modules` walk-up). When those also fail, the import is reported as unresolved instead of falling through to JS/TS extensions. SFC `<style>` block imports (`from_style = true`) keep their existing behaviour because their importer extension is `.vue` / `.svelte` rather than `.scss`. Tests: new fixture `scss-bare-import-tsx-collision` exactly mirrors the issue's reproduction (Widget.tsx + Widget.scss siblings, Helper.scss with `@use 'Widget'`, Helper.tsx imports `./Helper.scss`). Integration test asserts zero circular dependencies, zero unresolved imports, and that Widget.scss is reachable. The OLD binary built via `git stash` of the diff produces the exact 3-file phantom cycle the issue documents; the FIXED binary produces zero. The other 9 SCSS integration tests (partial convention, include paths, node_modules resolution, external package SCSS subpaths, SFC `<style>` blocks, Vite preprocessor additionalData) continue to pass.
1 parent ae7207b commit a0944ea

10 files changed

Lines changed: 110 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- **`includeEntryExports` config option, and `--include-entry-exports` is now a global CLI flag.** Set `"includeEntryExports": true` (JSON / JSONC) or `includeEntryExports = true` (TOML) in your fallow config to opt in to entry-file export validation persistently, without passing `--include-entry-exports` on every run. The flag is now accepted in combined mode (`fallow --include-entry-exports`) as well as on `fallow dead-code` and `fallow audit`; previously the bare combined invocation rejected the flag because it was only defined on the `dead-code` subcommand. Thanks [@filipw01](https://github.com/filipw01) for the report. (Closes [#249](https://github.com/fallow-rs/fallow/issues/249))
1313

14+
### Fixed
15+
16+
- **SCSS / Sass `@use 'X'` no longer resolves to a sibling `X.tsx`.** When both `Widget.scss` and `Widget.tsx` exist next to each other and a `.scss` importer does `@use 'Widget'`, fallow now resolves the import to `Widget.scss` per Sass's actual resolution algorithm. The standard module resolver's extension list contains `.tsx` / `.ts` before `.scss`, so without this guard a bare specifier from a stylesheet was silently picking the JS/TS sibling, creating phantom circular dependencies in CSS-modules / Angular `styleUrls` patterns where the `.tsx` component imports its own `.scss` and a sibling `.scss` shares variables/mixins. Stylesheet importers now reject any standard-resolver hit whose extension is a JS/TS-family extension (`.tsx`, `.ts`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, `.cjs`) and re-route through the SCSS-aware fallback chain (CSS-extension probe, `_filename` partial convention, framework include paths, `node_modules` walk-up); when those also fail, the import is reported as unresolved instead of falling through to JS/TS extensions. Thanks [@OmerGronich](https://github.com/OmerGronich) for the precise reproduction and the suggested fix. (Closes [#245](https://github.com/fallow-rs/fallow/issues/245))
17+
1418
## [2.59.0] - 2026-05-01
1519

1620
### Added

crates/core/tests/integration_test/scss_partials.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,51 @@ fn external_package_scss_subpaths_credit_nested_style_dependencies() {
197197
"real unused dependencies should still be reported: {unused_dep_names:?}"
198198
);
199199
}
200+
201+
#[test]
202+
fn scss_bare_import_does_not_collide_with_sibling_tsx() {
203+
// Issue #245: `@use 'Widget'` from a `.scss` file MUST resolve only to
204+
// `Widget.scss` / `_Widget.scss` etc. Sass never sees JS/TS files; a
205+
// sibling `Widget.tsx` is invisible to the Sass resolver. The bug
206+
// manifested as a phantom 3-file circular dependency chain when both a
207+
// `.tsx` component file and its `.scss` style sheet existed alongside.
208+
let root = fixture_path("scss-bare-import-tsx-collision");
209+
let config = create_config(root);
210+
let results = fallow_core::analyze(&config).expect("analysis should succeed");
211+
212+
assert!(
213+
results.circular_dependencies.is_empty(),
214+
"expected no circular dependencies, got: {:?}",
215+
results
216+
.circular_dependencies
217+
.iter()
218+
.map(|c| c
219+
.files
220+
.iter()
221+
.map(|p| p.display().to_string())
222+
.collect::<Vec<_>>())
223+
.collect::<Vec<_>>()
224+
);
225+
226+
let unresolved_specs: Vec<&str> = results
227+
.unresolved_imports
228+
.iter()
229+
.map(|u| u.specifier.as_str())
230+
.collect();
231+
assert!(
232+
unresolved_specs.is_empty(),
233+
"expected no unresolved imports, got: {unresolved_specs:?}"
234+
);
235+
236+
let unused_files: Vec<String> = results
237+
.unused_files
238+
.iter()
239+
.filter_map(|f| f.path.file_name())
240+
.filter_map(|n| n.to_str())
241+
.map(ToString::to_string)
242+
.collect();
243+
assert!(
244+
!unused_files.contains(&"Widget.scss".to_string()),
245+
"Widget.scss must be reachable via Helper.scss `@use 'Widget'`: {unused_files:?}"
246+
);
247+
}

crates/graph/src/resolve/specifier.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,25 @@ fn is_style_file(path: &Path) -> bool {
243243
.is_some_and(|ext| matches!(ext, "css" | "scss" | "sass"))
244244
}
245245

246+
/// Return `true` when the path's extension is a JS/TS-family runtime extension.
247+
///
248+
/// Used to reject standard-resolver hits when the importer is a stylesheet:
249+
/// Sass's resolution algorithm only ever considers `.css` / `.scss` / `.sass`
250+
/// files, so a sibling `.tsx` / `.ts` / `.js` cannot legally satisfy a Sass
251+
/// `@use` / `@import`. The resolver's extension list mixes JS/TS and CSS,
252+
/// so without this guard `@use 'Widget'` from a `.scss` importer would
253+
/// resolve to a sibling `Widget.tsx` whenever both files exist. See #245.
254+
fn is_js_ts_extension(path: &Path) -> bool {
255+
path.extension()
256+
.and_then(|ext| ext.to_str())
257+
.is_some_and(|ext| {
258+
matches!(
259+
ext,
260+
"ts" | "tsx" | "mts" | "cts" | "js" | "jsx" | "mjs" | "cjs"
261+
)
262+
})
263+
}
264+
246265
fn is_node_modules_path(path: &Path) -> bool {
247266
path.components().any(|component| match component {
248267
std::path::Component::Normal(segment) => segment == "node_modules",
@@ -388,6 +407,24 @@ pub(super) fn resolve_specifier(
388407
match resolve_file_with_tsconfig_fallback(ctx, from_file, specifier) {
389408
ResolveFileAttempt::Resolved(resolved) => {
390409
let resolved_path = resolved.path();
410+
// Reject JS/TS hits for stylesheet importers. The standard resolver's
411+
// extension list mixes JS/TS with CSS-family extensions and tries
412+
// `.tsx` / `.ts` before `.scss` / `.sass` / `.css`, so a `@use 'Widget'`
413+
// from a `.scss` file would otherwise resolve to a sibling
414+
// `Widget.tsx` even when `Widget.scss` exists next to it. Sass's
415+
// actual resolution algorithm only considers stylesheets; redirect
416+
// to the SCSS-aware fallback chain (CSS-extension probe, partial
417+
// convention, include paths, node_modules) and short-circuit with
418+
// `Unresolvable` if those also fail. See issue #245.
419+
let is_scss_importer = from_file
420+
.extension()
421+
.is_some_and(|e| e == "scss" || e == "sass");
422+
if is_scss_importer && is_js_ts_extension(resolved_path) {
423+
if let Some(result) = try_scss_fallbacks(ctx, from_file, specifier, from_style) {
424+
return result;
425+
}
426+
return ResolveResult::Unresolvable(specifier.to_string());
427+
}
391428
// Try raw path lookup first (avoids canonicalize syscall in most cases)
392429
if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
393430
return ResolveResult::InternalModule(file_id);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"entry": ["src/main.tsx"]
3+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{ "name": "scss-bare-import-tsx-collision", "private": true, "version": "0.0.1" }
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
@use 'Widget';
2+
3+
.helper { background: blue; }
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import './Helper.scss';
2+
3+
export function Helper() {
4+
return 'helper';
5+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.widget { color: red; }
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import './Widget.scss';
2+
import { Helper } from './Helper';
3+
4+
export function Widget() {
5+
return Helper();
6+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import { Widget } from './Widget';
2+
console.log(Widget());

0 commit comments

Comments
 (0)