Skip to content

Commit 674bfd5

Browse files
authored
Merge branch 'main' into shew/schema-json-generation
2 parents 16a912e + df49709 commit 674bfd5

File tree

14 files changed

+417
-64
lines changed

14 files changed

+417
-64
lines changed

crates/turborepo-scope/src/filter.rs

Lines changed: 312 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,27 +47,61 @@ impl PackageInference {
4747
pkg_inference_path
4848
);
4949
let full_inference_path = turbo_root.resolve(pkg_inference_path);
50+
51+
// Track the best matching package (the one whose path is the longest prefix
52+
// of the inference path, i.e., the most specific package containing our
53+
// current directory)
54+
let mut best_match: Option<(String, AnchoredSystemPathBuf)> = None;
55+
let mut found_package_below = false;
56+
5057
for (workspace_name, workspace_entry) in pkg_graph.packages() {
5158
let pkg_path = turbo_root.resolve(workspace_entry.package_path());
59+
60+
// Check if the inference path is inside this package (pkg_path is a prefix of
61+
// full_inference_path)
5262
let inferred_path_is_below = pkg_path.contains(&full_inference_path);
63+
5364
// We skip over the root package as the inferred path will always be below it
5465
if inferred_path_is_below && (&pkg_path as &AbsoluteSystemPath) != turbo_root {
55-
// set both. The user might have set a parent directory filter,
56-
// in which case we *should* fail to find any packages, but we should
57-
// do so in a consistent manner
58-
return Self {
59-
package_name: Some(workspace_name.to_string()),
60-
directory_root: workspace_entry.package_path().to_owned(),
66+
// This package contains the inference path. Track it if it's a better
67+
// (longer/more specific) match than what we've found so far.
68+
let pkg_path_len = workspace_entry.package_path().as_str().len();
69+
let is_better_match = match &best_match {
70+
None => true,
71+
Some((_, existing_path)) => pkg_path_len > existing_path.as_str().len(),
6172
};
73+
74+
if is_better_match {
75+
best_match = Some((
76+
workspace_name.to_string(),
77+
workspace_entry.package_path().to_owned(),
78+
));
79+
}
6280
}
81+
82+
// Check if this package is below the inference path (full_inference_path is a
83+
// prefix of pkg_path)
6384
let inferred_path_is_between_root_and_pkg = full_inference_path.contains(&pkg_path);
6485
if inferred_path_is_between_root_and_pkg {
65-
// we've found *some* package below our inference directory. We can stop now and
66-
// conclude that we're looking for all packages in a
67-
// subdirectory
68-
break;
86+
// We've found *some* package below our inference directory
87+
found_package_below = true;
6988
}
7089
}
90+
91+
// If we found a package that contains our inference path, use it
92+
if let Some((package_name, directory_root)) = best_match {
93+
return Self {
94+
package_name: Some(package_name),
95+
directory_root,
96+
};
97+
}
98+
99+
// If we found packages below the inference path, or no packages matched at all,
100+
// use the inference path as the directory root
101+
if found_package_below {
102+
// We're in a directory that contains packages
103+
}
104+
71105
Self {
72106
package_name: None,
73107
directory_root: pkg_inference_path.to_owned(),
@@ -718,7 +752,10 @@ pub enum ResolutionError {
718752

719753
#[cfg(test)]
720754
mod test {
721-
use std::collections::{HashMap, HashSet};
755+
use std::{
756+
collections::{HashMap, HashSet},
757+
str::FromStr,
758+
};
722759

723760
use pretty_assertions::assert_eq;
724761
use tempfile::TempDir;
@@ -1390,4 +1427,268 @@ mod test {
13901427
.expect("unsupported range"))
13911428
}
13921429
}
1430+
1431+
/// Creates a package graph for testing PackageInference::calculate
1432+
fn make_package_graph(
1433+
package_paths: &[&str],
1434+
) -> (TempDir, &'static AbsoluteSystemPathBuf, PackageGraph) {
1435+
let temp_folder = tempfile::tempdir().unwrap();
1436+
let turbo_root = Box::leak(Box::new(
1437+
AbsoluteSystemPathBuf::new(temp_folder.path().as_os_str().to_str().unwrap()).unwrap(),
1438+
));
1439+
1440+
let package_jsons = package_paths
1441+
.iter()
1442+
.map(|package_path| {
1443+
let (_, name) = get_name(package_path);
1444+
(
1445+
turbo_root.join_unix_path(
1446+
RelativeUnixPathBuf::new(format!("{package_path}/package.json")).unwrap(),
1447+
),
1448+
PackageJson {
1449+
name: Some(Spanned::new(name.to_string())),
1450+
..Default::default()
1451+
},
1452+
)
1453+
})
1454+
.collect::<HashMap<_, _>>();
1455+
1456+
for package_dir in package_jsons.keys() {
1457+
package_dir.ensure_dir().unwrap();
1458+
}
1459+
1460+
let graph = {
1461+
let rt = tokio::runtime::Builder::new_current_thread()
1462+
.enable_all()
1463+
.build()
1464+
.unwrap();
1465+
rt.block_on(
1466+
PackageGraph::builder(turbo_root, Default::default())
1467+
.with_package_discovery(MockDiscovery)
1468+
.with_package_jsons(Some(package_jsons))
1469+
.build(),
1470+
)
1471+
.unwrap()
1472+
};
1473+
1474+
(temp_folder, turbo_root, graph)
1475+
}
1476+
1477+
/// Test that PackageInference::calculate is deterministic when invoked from
1478+
/// a directory that contains nested packages (regression test for
1479+
/// GitHub issue #11428).
1480+
///
1481+
/// The bug was that HashMap iteration order is non-deterministic, and the
1482+
/// previous implementation would return early when it found ANY package
1483+
/// below the inference path, rather than finding the most specific one.
1484+
#[test]
1485+
fn test_package_inference_deterministic_with_nested_packages() {
1486+
// Simulate the structure from the issue:
1487+
// apps/onprem/ <- invocation directory
1488+
// apps/onprem/backend/ <- nested package
1489+
// apps/onprem/web/ <- nested package
1490+
// packages/shared/ <- unrelated package
1491+
let (_tempdir, turbo_root, graph) =
1492+
make_package_graph(&["apps/onprem/backend", "apps/onprem/web", "packages/shared"]);
1493+
1494+
// Invoke from apps/onprem (a directory containing packages)
1495+
let inference_path = AnchoredSystemPathBuf::try_from("apps/onprem").unwrap();
1496+
1497+
// Run the calculation multiple times to verify determinism
1498+
// (with the bug, different runs could give different results)
1499+
for _ in 0..10 {
1500+
let inference = PackageInference::calculate(turbo_root, &inference_path, &graph);
1501+
1502+
// We should NOT infer a specific package - we're in a directory that contains
1503+
// packages, not inside a specific package
1504+
assert!(
1505+
inference.package_name.is_none(),
1506+
"Expected no package to be inferred when running from a directory containing \
1507+
packages, but got {:?}",
1508+
inference.package_name
1509+
);
1510+
1511+
// The directory root should be the inference path itself
1512+
assert_eq!(
1513+
inference.directory_root, inference_path,
1514+
"Expected directory_root to be the inference path"
1515+
);
1516+
}
1517+
}
1518+
1519+
/// Test that PackageInference::calculate correctly identifies when we're
1520+
/// inside a specific package (at or below the package root).
1521+
#[test]
1522+
fn test_package_inference_inside_package() {
1523+
let (_tempdir, turbo_root, graph) =
1524+
make_package_graph(&["apps/onprem/backend", "apps/onprem/web", "packages/shared"]);
1525+
1526+
// Invoke from inside apps/onprem/backend
1527+
let inference_path = AnchoredSystemPathBuf::try_from("apps/onprem/backend").unwrap();
1528+
let inference = PackageInference::calculate(turbo_root, &inference_path, &graph);
1529+
1530+
assert_eq!(
1531+
inference.package_name,
1532+
Some("backend".to_string()),
1533+
"Expected to infer 'backend' package"
1534+
);
1535+
assert_eq!(
1536+
inference.directory_root,
1537+
AnchoredSystemPathBuf::try_from("apps/onprem/backend").unwrap()
1538+
);
1539+
1540+
// Invoke from a subdirectory inside apps/onprem/backend/src
1541+
let inference_path = AnchoredSystemPathBuf::try_from("apps/onprem/backend/src").unwrap();
1542+
let inference = PackageInference::calculate(turbo_root, &inference_path, &graph);
1543+
1544+
assert_eq!(
1545+
inference.package_name,
1546+
Some("backend".to_string()),
1547+
"Expected to infer 'backend' package from subdirectory"
1548+
);
1549+
assert_eq!(
1550+
inference.directory_root,
1551+
AnchoredSystemPathBuf::try_from("apps/onprem/backend").unwrap()
1552+
);
1553+
}
1554+
1555+
/// Test that PackageInference selects the most specific (deepest) package
1556+
/// when packages are nested.
1557+
#[test]
1558+
fn test_package_inference_selects_deepest_package() {
1559+
// Create a structure with nested packages
1560+
let (_tempdir, turbo_root, graph) = make_package_graph(&[
1561+
"apps", // shallow package
1562+
"apps/web", // deeper package inside apps
1563+
"apps/web/ui", // even deeper package
1564+
"packages/shared",
1565+
]);
1566+
1567+
// When invoked from apps/web/ui/src, should infer apps/web/ui (the deepest)
1568+
let inference_path = AnchoredSystemPathBuf::try_from("apps/web/ui/src").unwrap();
1569+
let inference = PackageInference::calculate(turbo_root, &inference_path, &graph);
1570+
1571+
assert_eq!(
1572+
inference.package_name,
1573+
Some("ui".to_string()),
1574+
"Expected to infer the deepest package 'ui'"
1575+
);
1576+
assert_eq!(
1577+
inference.directory_root,
1578+
AnchoredSystemPathBuf::try_from("apps/web/ui").unwrap()
1579+
);
1580+
1581+
// When invoked from apps/web/src (outside ui), should infer apps/web
1582+
let inference_path = AnchoredSystemPathBuf::try_from("apps/web/src").unwrap();
1583+
let inference = PackageInference::calculate(turbo_root, &inference_path, &graph);
1584+
1585+
assert_eq!(
1586+
inference.package_name,
1587+
Some("web".to_string()),
1588+
"Expected to infer 'web' package"
1589+
);
1590+
assert_eq!(
1591+
inference.directory_root,
1592+
AnchoredSystemPathBuf::try_from("apps/web").unwrap()
1593+
);
1594+
}
1595+
1596+
/// End-to-end test for GitHub issue #11428: running `turbo -F "{./*}^..."
1597+
/// build` from a directory containing packages should select those
1598+
/// packages' dependencies.
1599+
///
1600+
/// This test verifies that running from apps/onprem with filter {./*}^...
1601+
/// correctly selects packages under apps/onprem/* and their dependencies,
1602+
/// rather than randomly returning 0 packages.
1603+
#[test]
1604+
fn test_issue_11428_filter_from_directory_with_nested_packages() {
1605+
// Create the structure from the issue:
1606+
// apps/onprem/backend depends on packages/shared
1607+
// apps/onprem/web depends on packages/shared
1608+
let (_tempdir, resolver) = make_project(
1609+
&[
1610+
("apps/onprem/backend", "packages/shared"),
1611+
("apps/onprem/web", "packages/shared"),
1612+
],
1613+
&[],
1614+
// Simulate running from apps/onprem
1615+
Some(PackageInference {
1616+
package_name: None,
1617+
directory_root: AnchoredSystemPathBuf::try_from("apps/onprem").unwrap(),
1618+
}),
1619+
TestChangeDetector::new(&[]),
1620+
);
1621+
1622+
// The filter {./*}^... means:
1623+
// - {./*} = packages matching "./*" relative to inference directory
1624+
// (apps/onprem/*)
1625+
// - ^... = exclude self, include dependencies
1626+
//
1627+
// So this should select: packages/shared (dependency of backend and web)
1628+
// but NOT backend or web themselves (^ excludes self)
1629+
let selector = TargetSelector::from_str("{./*}^...").unwrap();
1630+
let packages = resolver.get_filtered_packages(vec![selector]).unwrap();
1631+
1632+
// We should get the dependencies of apps/onprem/* packages
1633+
// which is packages/shared
1634+
assert!(
1635+
!packages.is_empty(),
1636+
"Expected to find packages, but got none. This is the bug from issue #11428!"
1637+
);
1638+
1639+
assert!(
1640+
packages.contains_key(&PackageName::from("shared")),
1641+
"Expected 'shared' to be selected as a dependency. Got: {:?}",
1642+
packages.keys().collect::<Vec<_>>()
1643+
);
1644+
1645+
// backend and web should NOT be included (^ excludes self)
1646+
assert!(
1647+
!packages.contains_key(&PackageName::from("backend")),
1648+
"backend should be excluded due to ^ in filter"
1649+
);
1650+
assert!(
1651+
!packages.contains_key(&PackageName::from("web")),
1652+
"web should be excluded due to ^ in filter"
1653+
);
1654+
}
1655+
1656+
/// Test that running from a directory containing packages with filter {./*}
1657+
/// (without dependency traversal) selects those packages directly.
1658+
#[test]
1659+
fn test_filter_from_directory_selects_child_packages() {
1660+
let (_tempdir, resolver) = make_project(
1661+
&[
1662+
("apps/onprem/backend", "packages/shared"),
1663+
("apps/onprem/web", "packages/shared"),
1664+
],
1665+
&[],
1666+
Some(PackageInference {
1667+
package_name: None,
1668+
directory_root: AnchoredSystemPathBuf::try_from("apps/onprem").unwrap(),
1669+
}),
1670+
TestChangeDetector::new(&[]),
1671+
);
1672+
1673+
// {./*} without ^... should select the packages themselves
1674+
let selector = TargetSelector::from_str("{./*}").unwrap();
1675+
let packages = resolver.get_filtered_packages(vec![selector]).unwrap();
1676+
1677+
assert!(
1678+
packages.contains_key(&PackageName::from("backend")),
1679+
"Expected 'backend' to be selected. Got: {:?}",
1680+
packages.keys().collect::<Vec<_>>()
1681+
);
1682+
assert!(
1683+
packages.contains_key(&PackageName::from("web")),
1684+
"Expected 'web' to be selected. Got: {:?}",
1685+
packages.keys().collect::<Vec<_>>()
1686+
);
1687+
1688+
// shared should NOT be selected (it's not under apps/onprem/*)
1689+
assert!(
1690+
!packages.contains_key(&PackageName::from("shared")),
1691+
"shared should not be selected as it's not under apps/onprem/*"
1692+
);
1693+
}
13931694
}

0 commit comments

Comments
 (0)