Skip to content

Commit c7ad12f

Browse files
fix(workspace): attribute files to nearest package.json, not glob-matched intermediate dirs
Fixes #842
1 parent 5efa81c commit c7ad12f

2 files changed

Lines changed: 143 additions & 5 deletions

File tree

crates/core/src/analyze/unused_deps.rs

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -647,28 +647,90 @@ pub fn find_test_only_dependencies(
647647
}
648648

649649
/// Check whether a package is listed in root deps or in the workspace that owns `file_path`.
650+
///
651+
/// The owning workspace is the nearest ancestor directory tracked in `ws_dep_map`.
652+
/// When a file lives inside a tracked workspace but also inside a nested directory
653+
/// that has its own `package.json` (e.g. `packages/themes/my-theme/package.json`
654+
/// discovered outside the glob-matched workspace set), that nested manifest is
655+
/// probed first. This prevents the intermediate glob-matched workspace
656+
/// (`packages/themes`) from masking the actual owning package's declared deps.
650657
pub fn is_package_listed_for_file(
651658
file_path: &Path,
652659
package_name: &str,
653660
root_deps: &FxHashSet<String>,
654661
ws_dep_map: &[(PathBuf, FxHashSet<String>)],
655662
) -> bool {
656-
if let Some(ws_deps) = owning_workspace_deps(file_path, ws_dep_map) {
657-
return ws_deps.contains(package_name);
663+
if let Some(ws_root) = owning_workspace_root(file_path, ws_dep_map) {
664+
// Walk from the file's parent up to (but not past) the matched workspace root.
665+
// If a closer package.json exists, use its deps for the check so that nested
666+
// packages not declared as workspace entries are still attributed correctly.
667+
if let Some(deps) = nearest_undeclared_package_json_deps(file_path, ws_root, ws_dep_map) {
668+
return deps.contains(package_name);
669+
}
670+
671+
// Fall back to the matched workspace's declared dep set.
672+
if let Some(ws_deps) = ws_dep_map
673+
.iter()
674+
.find(|(r, _)| r == ws_root)
675+
.map(|(_, d)| d)
676+
{
677+
return ws_deps.contains(package_name);
678+
}
658679
}
659680

660681
root_deps.contains(package_name)
661682
}
662683

663-
fn owning_workspace_deps<'a>(
684+
/// Return the root path of the deepest tracked workspace that is an ancestor of `file_path`.
685+
fn owning_workspace_root<'a>(
664686
file_path: &Path,
665687
ws_dep_map: &'a [(PathBuf, FxHashSet<String>)],
666-
) -> Option<&'a FxHashSet<String>> {
688+
) -> Option<&'a PathBuf> {
667689
ws_dep_map
668690
.iter()
669691
.filter(|(ws_root, _)| file_path.starts_with(ws_root))
670692
.max_by_key(|(ws_root, _)| ws_root.components().count())
671-
.map(|(_, ws_deps)| ws_deps)
693+
.map(|(ws_root, _)| ws_root)
694+
}
695+
696+
/// Walk ancestor directories of `file_path` from the file's parent down to
697+
/// (but not past) `workspace_root`, looking for a `package.json` that is NOT
698+
/// already tracked in `ws_dep_map`.
699+
///
700+
/// Returns the loaded dependency set when such a closer manifest is found.
701+
/// This handles the case where a glob like `./packages/*` discovers
702+
/// `packages/themes` as a workspace, but `packages/themes/my-theme` has its
703+
/// own `package.json` that was not expanded by the glob. The nested manifest
704+
/// is the actual owning package for any file beneath it.
705+
fn nearest_undeclared_package_json_deps(
706+
file_path: &Path,
707+
workspace_root: &Path,
708+
ws_dep_map: &[(PathBuf, FxHashSet<String>)],
709+
) -> Option<FxHashSet<String>> {
710+
let file_dir = file_path.parent()?;
711+
for ancestor in file_dir.ancestors() {
712+
// Stop once we reach or pass the matched workspace root.
713+
if ancestor == workspace_root {
714+
break;
715+
}
716+
if !ancestor.starts_with(workspace_root) {
717+
break;
718+
}
719+
// Skip directories that are already tracked in ws_dep_map; those are
720+
// handled by the normal max-depth lookup.
721+
if ws_dep_map.iter().any(|(r, _)| r == ancestor) {
722+
break;
723+
}
724+
let pkg_path = ancestor.join("package.json");
725+
if let Ok(pkg) = PackageJson::load(&pkg_path) {
726+
let mut deps: FxHashSet<String> = pkg.all_dependency_names().into_iter().collect();
727+
if let Some(name) = pkg.name {
728+
deps.insert(name);
729+
}
730+
return Some(deps);
731+
}
732+
}
733+
None
672734
}
673735

674736
/// Check if a corresponding `@types/<package>` is listed in dependencies.

crates/core/src/analyze/unused_deps_tests/unlisted_deps.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -931,3 +931,79 @@ fn workspace_import_case(
931931
workspaces,
932932
}
933933
}
934+
935+
/// Regression test for issue #842.
936+
///
937+
/// Fixture layout:
938+
/// root/
939+
/// package.json (workspaces: `["./packages/*"]`)
940+
/// packages/
941+
/// themes/
942+
/// package.json (name: "themes", no dep on "chroma-js")
943+
/// my-theme/
944+
/// package.json (name: "@scope/my-theme", dep: "chroma-js")
945+
/// src/
946+
/// index.ts <-- file being checked
947+
///
948+
/// The glob `./packages/*` discovers `packages/themes` as a workspace but does NOT
949+
/// discover `packages/themes/my-theme` (one level too deep). So `ws_dep_map` has
950+
/// `packages/themes` but not `packages/themes/my-theme`.
951+
///
952+
/// Before the fix, `is_package_listed_for_file` attributed `packages/themes/my-theme/src/index.ts`
953+
/// to `packages/themes` and reported "chroma-js" as an unlisted dependency because it is not
954+
/// in `themes/package.json`.
955+
///
956+
/// After the fix, the function walks up from the file and finds
957+
/// `packages/themes/my-theme/package.json`, uses its deps, and correctly reports
958+
/// "chroma-js" as listed.
959+
#[cfg_attr(miri, ignore)]
960+
#[test]
961+
fn is_package_listed_attributes_file_to_nearest_package_json_not_glob_matched_intermediate() {
962+
let tmp = tempfile::tempdir().expect("create temp dir");
963+
let root = tmp.path();
964+
965+
// packages/themes/package.json (intermediate, glob-matched workspace, no chroma-js dep)
966+
let themes_dir = root.join("packages").join("themes");
967+
std::fs::create_dir_all(&themes_dir).expect("create themes dir");
968+
std::fs::write(
969+
themes_dir.join("package.json"),
970+
r#"{"name": "themes", "dependencies": {}}"#,
971+
)
972+
.expect("write themes package.json");
973+
974+
// packages/themes/my-theme/package.json (real nested package, declares chroma-js)
975+
let my_theme_dir = themes_dir.join("my-theme");
976+
std::fs::create_dir_all(my_theme_dir.join("src")).expect("create my-theme/src dir");
977+
std::fs::write(
978+
my_theme_dir.join("package.json"),
979+
r#"{"name": "@scope/my-theme", "dependencies": {"chroma-js": "^2.0.0"}}"#,
980+
)
981+
.expect("write my-theme package.json");
982+
983+
// The source file that imports chroma-js.
984+
let source_file = my_theme_dir.join("src").join("index.ts");
985+
std::fs::write(&source_file, "import chroma from 'chroma-js';").expect("write source file");
986+
987+
// ws_dep_map mirrors what find_unlisted_dependencies builds when the glob
988+
// `./packages/*` discovers `packages/themes` but not `packages/themes/my-theme`.
989+
let mut themes_deps: FxHashSet<String> = FxHashSet::default();
990+
themes_deps.insert("themes".to_string()); // no chroma-js
991+
let ws_dep_map: Vec<(PathBuf, FxHashSet<String>)> = vec![(themes_dir, themes_deps)];
992+
993+
let root_deps: FxHashSet<String> = FxHashSet::default();
994+
995+
// chroma-js is declared in my-theme/package.json but NOT in themes/package.json.
996+
// Without the fix, this would return false (false unlisted-dependency).
997+
// With the fix, it should return true.
998+
assert!(
999+
is_package_listed_for_file(&source_file, "chroma-js", &root_deps, &ws_dep_map),
1000+
"dep declared in a nested package.json below the glob-matched intermediate workspace \
1001+
must be found; file should be attributed to the nearest ancestor package.json"
1002+
);
1003+
1004+
// A dep that is truly unlisted (not in any package.json) should still be flagged.
1005+
assert!(
1006+
!is_package_listed_for_file(&source_file, "lodash", &root_deps, &ws_dep_map),
1007+
"dep not declared in any ancestor package.json should still be reported as unlisted"
1008+
);
1009+
}

0 commit comments

Comments
 (0)