Skip to content
Merged
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
12 changes: 7 additions & 5 deletions src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ fn find_affected_internal(
"Source file not in analyzer.files, using root fallback: {:?}",
file_path
);
let owning_packages = project_index.get_package_names_by_path(file_path);
let owning_packages = project_index.get_owning_packages_by_path(file_path);
for pkg in &owning_packages {
debug!(
"File {:?} belongs to package '{}' (via root fallback)",
Expand Down Expand Up @@ -218,8 +218,10 @@ fn find_affected_internal(
)
.collect();

// Add all packages that own this file (multiple projects can share the same sourceRoot)
let owning_packages = project_index.get_package_names_by_path(file_path);
// Add all packages that own this file (multiple projects can share the same sourceRoot).
// Uses the unfiltered lookup — a directly changed file always belongs to its project
// regardless of tsconfig excludes (spec files, stories, config files all count).
let owning_packages = project_index.get_owning_packages_by_path(file_path);
for pkg in &owning_packages {
debug!("File {:?} belongs to package '{}'", file_path, pkg);
affected_packages.insert(pkg.clone());
Expand Down Expand Up @@ -316,8 +318,8 @@ fn find_affected_internal(
for asset_file in &asset_files {
let asset_path = &asset_file.file_path;

// Mark all owning projects as affected (multiple projects can share the same sourceRoot)
let owning_packages = project_index.get_package_names_by_path(asset_path);
// Mark all owning projects as affected — uses unfiltered lookup (direct change).
let owning_packages = project_index.get_owning_packages_by_path(asset_path);
for pkg in &owning_packages {
debug!("Asset {:?} belongs to package '{}'", asset_path, pkg);
affected_packages.insert(pkg.clone());
Expand Down
150 changes: 129 additions & 21 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,21 @@ impl ProjectIndex {
/// back to root entries for files that live inside a project's root but outside its
/// sourceRoot (e.g. config files like project.json, jest.config.js).
pub fn get_package_names_by_path(&self, file_path: &Path) -> Vec<String> {
self.resolve_packages(file_path, false)
}

/// Like `get_package_names_by_path` but skips tsconfig exclude filtering.
///
/// Use for **direct changes**: a file that was modified in the diff always
/// belongs to its project regardless of whether tsconfig compiles it (spec
/// files, stories, config files all count). The filtered variant should
/// only be used for reference traversal where cascade through excluded
/// files is undesirable.
pub fn get_owning_packages_by_path(&self, file_path: &Path) -> Vec<String> {
self.resolve_packages(file_path, true)
}

fn resolve_packages(&self, file_path: &Path, skip_excludes: bool) -> Vec<String> {
let mut result = Vec::new();
// Fast path: no root entries means every project has root == sourceRoot,
// so there's no fallback to run — skip the hashset allocation entirely.
Expand All @@ -107,13 +122,15 @@ impl ProjectIndex {
for (root, names) in &self.entries {
if file_path.starts_with(root) {
for name in names {
if let Some(excl) = self.excludes.get(name) {
if excl.is_excluded(file_path) {
debug!(
"File {:?} excluded by tsconfig for project '{}'",
file_path, name
);
continue;
if !skip_excludes {
if let Some(excl) = self.excludes.get(name) {
if excl.is_excluded(file_path) {
debug!(
"File {:?} excluded by tsconfig for project '{}'",
file_path, name
);
continue;
}
}
}
result.push(name.clone());
Expand All @@ -124,32 +141,30 @@ impl ProjectIndex {
}

// Track which projects were already considered via sourceRoot (even if excluded
// by tsconfig) so that the root fallback doesn't re-add them. Borrow &str from
// self.entries — no allocation needed.
// by tsconfig) so that the root fallback doesn't re-add them.
let mut seen_via_source_root: FxHashSet<&str> = FxHashSet::default();
// Primary: match against sourceRoot (with tsconfig exclude filtering)
for (root, names) in &self.entries {
if file_path.starts_with(root) {
for name in names {
seen_via_source_root.insert(name.as_str());
if let Some(excl) = self.excludes.get(name) {
if excl.is_excluded(file_path) {
debug!(
"File {:?} excluded by tsconfig for project '{}'",
file_path, name
);
continue;
if !skip_excludes {
if let Some(excl) = self.excludes.get(name) {
if excl.is_excluded(file_path) {
debug!(
"File {:?} excluded by tsconfig for project '{}'",
file_path, name
);
continue;
}
}
}
result.push(name.clone());
}
}
}
// Fallback: match against project root for projects not already matched via
// sourceRoot. This handles files inside a project's root but outside its
// sourceRoot (e.g. config files). Also handles nested projects where the
// parent's sourceRoot is a prefix but the child was never checked.
// tsconfig excludes are not applied here — config files should always count.
// sourceRoot. tsconfig excludes are not applied here — config files should
// always count.
for (root, names) in &self.root_entries {
if file_path.starts_with(root) {
for name in names {
Expand Down Expand Up @@ -370,6 +385,99 @@ mod tests {
);
}

#[test]
fn test_get_owning_packages_skips_tsconfig_excludes() {
let tmp = tempfile::TempDir::new().unwrap();
let cwd = tmp.path();

let lib_dir = cwd.join("libs/ui-widgets");
std::fs::create_dir_all(&lib_dir).unwrap();
std::fs::write(
lib_dir.join("tsconfig.lib.json"),
r#"{ "exclude": ["**/*.spec.ts", "**/*.stories.tsx"] }"#,
)
.unwrap();

let projects = vec![Project {
name: "ui-widgets".to_string(),
root: "libs/ui-widgets".into(),
source_root: "libs/ui-widgets/src".into(),
ts_config: Some(lib_dir.join("tsconfig.lib.json")),
implicit_dependencies: vec![],
targets: vec![],
}];

let index = ProjectIndex::new(&projects, cwd);

// get_package_names_by_path excludes spec/stories (for reference traversal)
assert!(
index
.get_package_names_by_path(Path::new("libs/ui-widgets/src/utils.spec.ts"))
.is_empty(),
"filtered method should exclude spec files"
);

// get_owning_packages_by_path includes them (for direct changes)
assert_eq!(
index.get_owning_packages_by_path(Path::new("libs/ui-widgets/src/utils.spec.ts")),
vec!["ui-widgets"],
"direct-change method should include spec files"
);
assert_eq!(
index.get_owning_packages_by_path(Path::new("libs/ui-widgets/src/Grid.stories.tsx")),
vec!["ui-widgets"],
"direct-change method should include stories files"
);
assert_eq!(
index.get_owning_packages_by_path(Path::new("libs/ui-widgets/src/index.ts")),
vec!["ui-widgets"],
"normal files work with both methods"
);
}

#[test]
fn test_get_owning_packages_fast_path_no_root_entries() {
// When root == sourceRoot, root_entries is empty and resolve_packages
// takes the fast path (no hashset allocation). This test exercises that
// branch with tsconfig excludes active.
let tmp = tempfile::TempDir::new().unwrap();
let cwd = tmp.path();

let lib_dir = cwd.join("libs/simple");
std::fs::create_dir_all(&lib_dir).unwrap();
std::fs::write(
lib_dir.join("tsconfig.lib.json"),
r#"{ "exclude": ["**/*.spec.ts"] }"#,
)
.unwrap();

let projects = vec![Project {
name: "simple".to_string(),
root: "libs/simple".into(),
source_root: "libs/simple".into(),
ts_config: Some(lib_dir.join("tsconfig.lib.json")),
implicit_dependencies: vec![],
targets: vec![],
}];

let index = ProjectIndex::new(&projects, cwd);

// Filtered: spec excluded
assert!(
index
.get_package_names_by_path(Path::new("libs/simple/utils.spec.ts"))
.is_empty(),
"fast path: filtered method should exclude spec files"
);

// Unfiltered: spec included
assert_eq!(
index.get_owning_packages_by_path(Path::new("libs/simple/utils.spec.ts")),
vec!["simple"],
"fast path: direct-change method should include spec files"
);
}

#[test]
fn test_project_index_root_fallback() {
let tmp = tempfile::TempDir::new().unwrap();
Expand Down
75 changes: 75 additions & 0 deletions tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3397,6 +3397,81 @@ fn test_workspace_root_project_not_over_attributed() {
);
}

#[test]
fn test_spec_file_change_affects_owning_project() {
// lib-a has sourceRoot = "libs/lib-a/src" and its tsconfig.lib.json
// excludes *.spec.ts. A direct change to a spec file must still mark
// lib-a as affected — tsconfig excludes define compilation scope, not
// project ownership.
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();

git_in(root, &["init", "-q"]);
git_in(root, &["config", "user.email", "test@example.com"]);
git_in(root, &["config", "user.name", "Test"]);
git_in(root, &["branch", "-M", "main"]);

fs::write(root.join("nx.json"), r#"{}"#).unwrap();

fs::create_dir_all(root.join("libs/lib-a/src")).unwrap();
fs::write(
root.join("libs/lib-a/project.json"),
r#"{ "name": "lib-a", "sourceRoot": "libs/lib-a/src" }"#,
)
.unwrap();
fs::write(
root.join("libs/lib-a/tsconfig.lib.json"),
r#"{ "exclude": ["**/*.spec.ts", "**/*.stories.tsx"] }"#,
)
.unwrap();
fs::write(
root.join("libs/lib-a/src/index.ts"),
"export const a = 1;\n",
)
.unwrap();
fs::write(
root.join("libs/lib-a/src/utils.spec.ts"),
"import { a } from './index';\n",
)
.unwrap();

git_in(root, &["add", "."]);
git_in(root, &["commit", "-q", "-m", "init"]);
git_in(root, &["checkout", "-q", "-b", "test-branch"]);

// Change the spec file
fs::write(
root.join("libs/lib-a/src/utils.spec.ts"),
"import { a } from './index';\n// changed\n",
)
.unwrap();
git_in(root, &["add", "."]);
git_in(root, &["commit", "-q", "-m", "change spec"]);

let projects = domino::workspace::discover_projects(root).unwrap();
let config = TrueAffectedConfig {
cwd: root.to_path_buf(),
base: "main".to_string(),
head: None,
root_ts_config: None,
projects,
include: vec![],
ignored_paths: vec![],
lockfile_strategy: LockfileStrategy::None,
};

let profiler = Arc::new(Profiler::new(false));
let affected = find_affected(config, profiler)
.expect("find_affected failed")
.affected_projects;

assert_eq!(
affected,
vec!["lib-a".to_string()],
"lib-a should be affected even though the changed spec file is tsconfig-excluded"
);
}

#[test]
fn test_head_flag_commit_to_commit_diff() {
let branch = TestBranch::new("test-head-flag");
Expand Down
Loading