diff --git a/src/core.rs b/src/core.rs index da77bc7..52b48dd 100644 --- a/src/core.rs +++ b/src/core.rs @@ -7,12 +7,28 @@ use crate::types::{ TrueAffectedConfig, }; use crate::utils; +use regex::Regex; use rustc_hash::{FxHashMap, FxHashSet}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use tracing::debug; +/// Default include pattern: matches test/spec files (.spec.ts, .test.tsx, etc.) +const DEFAULT_INCLUDE_PATTERN: &str = r"\.(spec|test)\.(ts|js)x?$"; + +fn is_ignored_path(file_path: &Path, ignored_paths: &[String]) -> bool { + if ignored_paths.is_empty() { + return false; + } + + let file_str = file_path.to_string_lossy(); + ignored_paths + .iter() + .filter(|ignored| !ignored.trim().is_empty()) + .any(|ignored| file_str.contains(ignored)) +} + /// Mutable state for tracking affected symbols during analysis struct AffectedState<'a> { affected_packages: &'a mut FxHashSet, @@ -69,6 +85,45 @@ fn find_affected_internal( let mut affected_packages = FxHashSet::default(); let mut project_causes: FxHashMap> = FxHashMap::default(); + // Step 4b: Process included file patterns (e.g., test files) + // Files matching include patterns directly mark their owning project as affected, + // matching traf's changedIncludedFilesPackages behavior. + let include_patterns = if config.include.is_empty() { + vec![DEFAULT_INCLUDE_PATTERN.to_string()] + } else if config.include.len() == 1 && config.include[0].trim().is_empty() { + vec![] + } else { + config.include.clone() + }; + + let compiled_patterns: Vec = include_patterns + .iter() + .filter_map(|p| match Regex::new(p) { + Ok(re) => Some(re), + Err(e) => { + debug!("Invalid include pattern '{}': {}", p, e); + None + } + }) + .collect(); + + for changed_file in &changed_files { + if is_ignored_path(&changed_file.file_path, &config.ignored_paths) { + continue; + } + let file_str = changed_file.file_path.to_string_lossy(); + if compiled_patterns.iter().any(|re| re.is_match(&file_str)) { + if let Some(pkg) = utils::get_package_name_by_path(&changed_file.file_path, &config.projects) + { + debug!( + "Include pattern matched {:?}, adding package '{}'", + changed_file.file_path, pkg + ); + affected_packages.insert(pkg); + } + } + } + // Step 5: Partition changed files into source and non-source let (source_files, asset_files): (Vec<&ChangedFile>, Vec<&ChangedFile>) = changed_files .iter() @@ -680,4 +735,26 @@ mod tests { assert!(affected.contains("lib1")); assert!(affected.contains("app")); // Should be added as implicit dependent } + + #[test] + fn test_default_include_pattern_matches_test_files() { + let re = Regex::new(DEFAULT_INCLUDE_PATTERN).expect("Default pattern should compile"); + + // Should match spec/test files + assert!(re.is_match("proj1/utils.spec.ts")); + assert!(re.is_match("proj1/utils.test.ts")); + assert!(re.is_match("proj1/component.spec.tsx")); + assert!(re.is_match("proj1/component.test.tsx")); + assert!(re.is_match("proj1/helper.spec.js")); + assert!(re.is_match("proj1/helper.test.jsx")); + assert!(re.is_match("tests/validation/roof.step.test.ts")); + + // Should NOT match regular source files + assert!(!re.is_match("proj1/utils.ts")); + assert!(!re.is_match("proj1/component.tsx")); + assert!(!re.is_match("proj1/index.js")); + assert!(!re.is_match("proj1/styles.css")); + assert!(!re.is_match("proj1/data.json")); + assert!(!re.is_match("proj1/test-utils.ts")); // "test" in name but not .test.ts + } } diff --git a/src/types.rs b/src/types.rs index 7aff07c..5d2650b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -95,8 +95,9 @@ pub struct TrueAffectedConfig { pub root_ts_config: Option, /// Projects in the workspace pub projects: Vec, - /// Additional file patterns to include - #[allow(dead_code)] + /// Additional file patterns to include (regex patterns). + /// When empty, defaults to matching test files: `\.(spec|test)\.(ts|js)x?$` + /// Use a single empty string to disable default include patterns. pub include: Vec, /// Paths to ignore #[allow(dead_code)] diff --git a/tests/fixtures/monorepo b/tests/fixtures/monorepo index 0d6eb7a..10ec84a 160000 --- a/tests/fixtures/monorepo +++ b/tests/fixtures/monorepo @@ -1 +1 @@ -Subproject commit 0d6eb7a3c5c9e4e0e19ec9a0cd80e017c55a9c59 +Subproject commit 10ec84a6d723c01d9fbca21e983373b552ef5bd6 diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 9c80743..b94e65d 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1479,3 +1479,113 @@ export function anotherFn() { "proj2 should be affected via asset → constant → export chain" ); } + +/// Helper to get affected projects with custom include patterns +fn get_affected_with_include(include: Vec) -> Vec { + let config = TrueAffectedConfig { + cwd: fixture_path(), + base: "main".to_string(), + root_ts_config: Some(PathBuf::from("tsconfig.json")), + projects: vec![ + Project { + name: "proj1".to_string(), + source_root: PathBuf::from("proj1"), + ts_config: Some(PathBuf::from("proj1/tsconfig.json")), + implicit_dependencies: vec![], + targets: vec![], + }, + Project { + name: "proj2".to_string(), + source_root: PathBuf::from("proj2"), + ts_config: Some(PathBuf::from("proj2/tsconfig.json")), + implicit_dependencies: vec![], + targets: vec![], + }, + Project { + name: "proj3".to_string(), + source_root: PathBuf::from("proj3"), + ts_config: Some(PathBuf::from("proj3/tsconfig.json")), + implicit_dependencies: vec!["proj1".to_string()], + targets: vec![], + }, + ], + include, + ignored_paths: vec![], + }; + + let profiler = Arc::new(Profiler::new(false)); + + find_affected(config, profiler) + .expect("Failed to find affected projects") + .affected_projects +} + +#[test] +fn test_included_test_file_marks_project_affected() { + let branch = TestBranch::new("test-include-spec-file"); + + // Create a .spec.ts file in proj1 (test file matching default include pattern) + branch.make_change( + "proj1/utils.spec.ts", + r#"import { proj1 } from './index'; + +describe('proj1', () => { + it('should work', () => { + expect(proj1()).toBe('proj1'); + }); +}); +"#, + ); + + // Now modify only the test file + branch.make_change( + "proj1/utils.spec.ts", + r#"import { proj1 } from './index'; + +describe('proj1', () => { + it('should work correctly', () => { + expect(proj1()).toBe('proj1-modified'); + }); +}); +"#, + ); + + // Use default include (empty vec triggers default test file pattern) + let affected = get_affected_with_include(vec![]); + + // proj1 should be affected because the .spec.ts file matches the default include pattern + assert!( + affected.contains(&"proj1".to_string()), + "proj1 should be affected when a .spec.ts file changes (default include pattern). Got: {:?}", + affected + ); +} + +#[test] +fn test_included_file_with_custom_pattern() { + let branch = TestBranch::new("test-include-custom"); + + // Create a .stories.ts file in proj2 + branch.make_change( + "proj2/button.stories.ts", + r#"export const Primary = { args: { label: 'Click' } }; +"#, + ); + + // Modify the stories file + branch.make_change( + "proj2/button.stories.ts", + r#"export const Primary = { args: { label: 'Click Me' } }; +"#, + ); + + // Use a custom include pattern that matches .stories.ts files + let affected = get_affected_with_include(vec![r"\.stories\.(ts|js)x?$".to_string()]); + + // proj2 should be affected because the .stories.ts file matches the custom pattern + assert!( + affected.contains(&"proj2".to_string()), + "proj2 should be affected when a .stories.ts file changes (custom include pattern). Got: {:?}", + affected + ); +}