diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index dbb200f..81abeea 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -468,7 +468,10 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add package.json Cargo.toml + # generate-packages.js bumps optionalDependencies to the new version — refresh lockfile + # so CI (yarn install --frozen-lockfile) does not fail on the version commit. + yarn install + git add package.json Cargo.toml yarn.lock git commit -m "${{ steps.bump.outputs.new_version }}" git tag "v${{ steps.bump.outputs.new_version }}" git push --atomic origin main "v${{ steps.bump.outputs.new_version }}" diff --git a/package.json b/package.json index 9fc3673..c319bdc 100644 --- a/package.json +++ b/package.json @@ -80,19 +80,19 @@ "typescript": "^5.9.2" }, "optionalDependencies": { + "@front-ops/domino-darwin-arm64": "1.0.1", + "@front-ops/domino-darwin-x64": "1.0.1", + "@front-ops/domino-linux-arm64-gnu": "1.0.1", + "@front-ops/domino-linux-arm64-musl": "1.0.1", + "@front-ops/domino-linux-x64-gnu": "1.0.1", + "@front-ops/domino-linux-x64-musl": "1.0.1", + "@front-ops/domino-win32-x64-msvc": "1.0.1", "@oxc-node/core-darwin-arm64": "^0.0.34", "@oxc-node/core-darwin-x64": "^0.0.34", "@oxc-node/core-linux-arm64-gnu": "^0.0.34", "@oxc-node/core-linux-arm64-musl": "^0.0.35", "@oxc-node/core-linux-x64-gnu": "^0.0.34", - "@oxc-node/core-linux-x64-musl": "^0.0.35", - "@front-ops/domino-darwin-x64": "1.0.1", - "@front-ops/domino-darwin-arm64": "1.0.1", - "@front-ops/domino-win32-x64-msvc": "1.0.1", - "@front-ops/domino-linux-x64-gnu": "1.0.1", - "@front-ops/domino-linux-x64-musl": "1.0.1", - "@front-ops/domino-linux-arm64-musl": "1.0.1", - "@front-ops/domino-linux-arm64-gnu": "1.0.1" + "@oxc-node/core-linux-x64-musl": "^0.0.35" }, "lint-staged": { "*.@(js|ts|tsx)": [ diff --git a/src/core.rs b/src/core.rs index 4c3cd79..6674271 100644 --- a/src/core.rs +++ b/src/core.rs @@ -59,7 +59,9 @@ fn find_affected_internal( } // Step 2: Build project index for O(unique_roots) lookups instead of O(n_projects) - let project_index = ProjectIndex::new(&config.projects); + // Also parses each project's tsconfig to extract exclude patterns, so that + // files excluded by tsconfig (e.g. stories, specs) don't mark a project affected. + let project_index = ProjectIndex::new(&config.projects, &config.cwd); // Step 3: Build workspace analyzer (includes building import index) debug!("Building workspace semantic analysis..."); diff --git a/src/lib.rs b/src/lib.rs index d25ef38..9db97a7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod lockfile; pub mod profiler; pub mod report; pub mod semantic; +pub mod tsconfig; pub mod types; pub mod utils; pub mod workspace; diff --git a/src/main.rs b/src/main.rs index f618a93..42bd0d8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod lockfile; mod profiler; mod report; mod semantic; +mod tsconfig; mod types; mod utils; mod workspace; diff --git a/src/tsconfig.rs b/src/tsconfig.rs new file mode 100644 index 0000000..07f8b9c --- /dev/null +++ b/src/tsconfig.rs @@ -0,0 +1,384 @@ +use glob::Pattern; +use json_strip_comments::StripComments; +use serde::Deserialize; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use tracing::warn; + +const MAX_EXTENDS_DEPTH: usize = 64; + +#[derive(Deserialize)] +struct TsconfigFile { + extends: Option, + #[serde(default)] + exclude: Option>, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum TsconfigExtends { + Single(String), + Multiple(Vec), +} + +impl TsconfigExtends { + fn into_vec(self) -> Vec { + match self { + TsconfigExtends::Single(s) => vec![s], + TsconfigExtends::Multiple(v) => v, + } + } +} + +/// Compiled exclude patterns from a project's tsconfig, used to filter +/// files that shouldn't count toward project ownership (e.g. stories, specs). +#[derive(Debug)] +pub struct TsconfigExcludes { + patterns: Vec, + /// Directory containing the tsconfig — patterns are relative to this. + base_dir: PathBuf, +} + +impl TsconfigExcludes { + /// Parse a tsconfig file and extract its `exclude` patterns, following the + /// `extends` chain. Returns `None` if the tsconfig doesn't exist, can't be + /// parsed, or has no exclude patterns. + pub fn parse(tsconfig_path: &Path, cwd: &Path) -> Option { + let base_dir = tsconfig_path.parent()?.to_path_buf(); + + let excludes = collect_excludes(tsconfig_path); + if excludes.is_empty() { + return None; + } + + let patterns: Vec = excludes + .iter() + .filter_map(|pat| match Pattern::new(pat) { + Ok(p) => Some(p), + Err(e) => { + warn!( + "Invalid exclude pattern '{}' in {}: {}", + pat, + tsconfig_path.display(), + e + ); + None + } + }) + .collect(); + + if patterns.is_empty() { + return None; + } + + let rel_base = base_dir + .strip_prefix(cwd) + .unwrap_or(&base_dir) + .to_path_buf(); + + Some(Self { + patterns, + base_dir: rel_base, + }) + } + + pub fn pattern_count(&self) -> usize { + self.patterns.len() + } + + /// Check if a workspace-relative file path is excluded by this tsconfig. + pub fn is_excluded(&self, file_rel_path: &Path) -> bool { + let relative = match file_rel_path.strip_prefix(&self.base_dir) { + Ok(r) => r, + Err(_) => return false, + }; + + let rel_str = match relative.to_str() { + Some(s) => s, + None => return false, + }; + + self + .patterns + .iter() + .any(|p| p.matches_with(rel_str, glob_match_options())) + } +} + +fn glob_match_options() -> glob::MatchOptions { + glob::MatchOptions { + case_sensitive: true, + require_literal_separator: false, + require_literal_leading_dot: false, + } +} + +fn read_tsconfig_file(path: &Path) -> Option { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return None, + }; + let stripped = StripComments::new(content.as_bytes()); + match serde_json::from_reader(stripped) { + Ok(t) => Some(t), + Err(e) => { + warn!("Failed to parse tsconfig at {}: {}", path.display(), e); + None + } + } +} + +/// Resolve a tsconfig `extends` specifier to a filesystem path. +/// +/// Only relative (`./…`) and absolute (`/…`) specifiers are resolved. +/// Bare package specifiers (e.g. `@nx/js/tsconfig-lib`) are intentionally +/// skipped because resolving them would require full Node module resolution +/// against `node_modules`, which is out of scope here. In practice this means +/// `exclude` patterns defined inside an npm-published tsconfig preset are not +/// inherited — an acceptable trade-off since project-level tsconfigs almost +/// always declare their own `exclude` array. +/// +/// See `resolve_extends_specifier` in `resolve_options.rs` for the equivalent +/// logic used when collecting `compilerOptions.paths`. +fn resolve_extends(parent_dir: &Path, specifier: &str) -> Option { + if !specifier.starts_with('.') && !specifier.starts_with('/') { + return None; + } + let mut path = parent_dir.join(specifier); + if path.extension().is_none() { + path.set_extension("json"); + } + Some(path) +} + +/// Walk the `extends` chain and collect all `exclude` patterns. +/// Child excludes fully replace parent excludes (matching TypeScript semantics). +fn collect_excludes(start_path: &Path) -> Vec { + let mut visited = HashSet::new(); + collect_excludes_recursive(start_path, &mut visited, 0) +} + +fn collect_excludes_recursive( + config_path: &Path, + visited: &mut HashSet, + depth: usize, +) -> Vec { + if depth >= MAX_EXTENDS_DEPTH { + return vec![]; + } + + let canonical = config_path + .canonicalize() + .unwrap_or_else(|_| config_path.to_path_buf()); + if !visited.insert(canonical) { + return vec![]; + } + + let tsconfig = match read_tsconfig_file(config_path) { + Some(t) => t, + None => return vec![], + }; + + // If this config has its own excludes, use them (child overrides parent entirely). + if let Some(excludes) = tsconfig.exclude { + return excludes; + } + + // No excludes here — inherit from parent(s). + if let Some(extends) = tsconfig.extends { + let parent_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); + for specifier in extends.into_vec() { + if let Some(parent_path) = resolve_extends(parent_dir, &specifier) { + let inherited = collect_excludes_recursive(&parent_path, visited, depth + 1); + if !inherited.is_empty() { + return inherited; + } + } + } + } + + vec![] +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_basic_exclude_matching() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path(); + let lib_dir = cwd.join("libs/my-lib"); + fs::create_dir_all(&lib_dir).unwrap(); + + fs::write( + lib_dir.join("tsconfig.lib.json"), + r#"{ + "exclude": [ + "**/*.spec.ts", + "**/*.stories.tsx", + "jest.config.ts" + ] + }"#, + ) + .unwrap(); + + let excludes = + TsconfigExcludes::parse(&lib_dir.join("tsconfig.lib.json"), cwd).expect("should parse"); + + assert!(excludes.is_excluded(Path::new("libs/my-lib/src/utils.spec.ts"))); + assert!(excludes.is_excluded(Path::new("libs/my-lib/src/components/Grid.stories.tsx"))); + assert!(excludes.is_excluded(Path::new("libs/my-lib/jest.config.ts"))); + + assert!(!excludes.is_excluded(Path::new("libs/my-lib/src/utils.ts"))); + assert!(!excludes.is_excluded(Path::new("libs/my-lib/src/components/Grid.tsx"))); + } + + #[test] + fn test_no_exclude_returns_none() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path(); + let lib_dir = cwd.join("libs/my-lib"); + fs::create_dir_all(&lib_dir).unwrap(); + + fs::write( + lib_dir.join("tsconfig.json"), + r#"{ "compilerOptions": { "strict": true } }"#, + ) + .unwrap(); + + let result = TsconfigExcludes::parse(&lib_dir.join("tsconfig.json"), cwd); + assert!(result.is_none()); + } + + #[test] + fn test_missing_tsconfig_returns_none() { + let tmp = TempDir::new().unwrap(); + let result = TsconfigExcludes::parse(&tmp.path().join("nonexistent.json"), tmp.path()); + assert!(result.is_none()); + } + + #[test] + fn test_tsconfig_with_comments() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path(); + let lib_dir = cwd.join("libs/my-lib"); + fs::create_dir_all(&lib_dir).unwrap(); + + fs::write( + lib_dir.join("tsconfig.lib.json"), + r#"{ + // Build config for this library + "exclude": [ + "**/*.spec.ts", // test files + "**/*.stories.tsx" /* storybook files */ + ] + }"#, + ) + .unwrap(); + + let excludes = + TsconfigExcludes::parse(&lib_dir.join("tsconfig.lib.json"), cwd).expect("should parse JSONC"); + assert!(excludes.is_excluded(Path::new("libs/my-lib/src/index.spec.ts"))); + assert!(excludes.is_excluded(Path::new("libs/my-lib/src/Button.stories.tsx"))); + } + + #[test] + fn test_excludes_inherited_from_parent() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path(); + let lib_dir = cwd.join("libs/my-lib"); + fs::create_dir_all(&lib_dir).unwrap(); + + fs::write( + lib_dir.join("tsconfig.base.json"), + r#"{ "exclude": ["**/*.spec.ts", "**/*.stories.tsx"] }"#, + ) + .unwrap(); + + fs::write( + lib_dir.join("tsconfig.lib.json"), + r#"{ "extends": "./tsconfig.base.json" }"#, + ) + .unwrap(); + + let excludes = + TsconfigExcludes::parse(&lib_dir.join("tsconfig.lib.json"), cwd).expect("should inherit"); + assert!(excludes.is_excluded(Path::new("libs/my-lib/src/foo.spec.ts"))); + assert!(excludes.is_excluded(Path::new("libs/my-lib/src/Bar.stories.tsx"))); + } + + #[test] + fn test_child_excludes_override_parent() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path(); + let lib_dir = cwd.join("libs/my-lib"); + fs::create_dir_all(&lib_dir).unwrap(); + + fs::write( + lib_dir.join("tsconfig.base.json"), + r#"{ "exclude": ["**/*.spec.ts", "**/*.stories.tsx"] }"#, + ) + .unwrap(); + + fs::write( + lib_dir.join("tsconfig.lib.json"), + r#"{ + "extends": "./tsconfig.base.json", + "exclude": ["**/*.spec.ts"] + }"#, + ) + .unwrap(); + + let excludes = + TsconfigExcludes::parse(&lib_dir.join("tsconfig.lib.json"), cwd).expect("should parse"); + assert!(excludes.is_excluded(Path::new("libs/my-lib/src/foo.spec.ts"))); + assert!( + !excludes.is_excluded(Path::new("libs/my-lib/src/Bar.stories.tsx")), + "child exclude should fully replace parent, not merge" + ); + } + + #[test] + fn test_file_outside_base_dir_not_excluded() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path(); + let lib_dir = cwd.join("libs/my-lib"); + fs::create_dir_all(&lib_dir).unwrap(); + + fs::write( + lib_dir.join("tsconfig.lib.json"), + r#"{ "exclude": ["**/*.spec.ts"] }"#, + ) + .unwrap(); + + let excludes = + TsconfigExcludes::parse(&lib_dir.join("tsconfig.lib.json"), cwd).expect("should parse"); + + assert!( + !excludes.is_excluded(Path::new("libs/other-lib/src/index.spec.ts")), + "files outside the tsconfig's directory should never be excluded" + ); + } + + #[test] + fn test_circular_extends() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path(); + let lib_dir = cwd.join("libs/my-lib"); + fs::create_dir_all(&lib_dir).unwrap(); + + fs::write( + lib_dir.join("a.json"), + r#"{ "extends": "./b.json", "exclude": ["**/*.spec.ts"] }"#, + ) + .unwrap(); + fs::write(lib_dir.join("b.json"), r#"{ "extends": "./a.json" }"#).unwrap(); + + let excludes = + TsconfigExcludes::parse(&lib_dir.join("a.json"), cwd).expect("should handle circular"); + assert!(excludes.is_excluded(Path::new("libs/my-lib/foo.spec.ts"))); + } +} diff --git a/src/utils.rs b/src/utils.rs index 004da55..6833fa9 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,8 @@ +use crate::tsconfig::TsconfigExcludes; use crate::types::Project; +use rustc_hash::FxHashMap; use std::path::{Path, PathBuf}; +use tracing::debug; /// Extensions considered as source files (analyzed by Oxc parser) const SOURCE_EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx"]; @@ -16,16 +19,24 @@ pub fn is_source_file(path: &Path) -> bool { /// Pre-built index from sourceRoot to project names for O(unique_roots) lookups /// instead of O(total_projects) on every call. +/// +/// Also holds per-project tsconfig exclude patterns so that files excluded +/// by a project's tsconfig (e.g. `*.stories.tsx`, `*.spec.ts`) don't count +/// toward marking that project as affected. pub struct ProjectIndex { /// Each entry is a unique sourceRoot paired with all project names that share it. entries: Vec<(PathBuf, Vec)>, + /// Compiled exclude patterns per project name. + excludes: FxHashMap, } impl ProjectIndex { - /// Build the index once from a slice of projects. - pub fn new(projects: &[Project]) -> Self { - // Group project names by sourceRoot + /// Build the index from a slice of projects, parsing each project's tsconfig + /// to extract exclude patterns. + pub fn new(projects: &[Project], cwd: &Path) -> Self { let mut map: Vec<(PathBuf, Vec)> = Vec::new(); + let mut excludes = FxHashMap::default(); + for project in projects { if let Some(entry) = map .iter_mut() @@ -35,16 +46,44 @@ impl ProjectIndex { } else { map.push((project.source_root.clone(), vec![project.name.clone()])); } + + if let Some(ts_config) = &project.ts_config { + if let Some(parsed) = TsconfigExcludes::parse(ts_config, cwd) { + debug!( + "Loaded {} exclude patterns for project '{}' from {}", + parsed.pattern_count(), + project.name, + ts_config.display() + ); + excludes.insert(project.name.clone(), parsed); + } + } + } + + Self { + entries: map, + excludes, } - Self { entries: map } } - /// Find ALL project names whose sourceRoot is a prefix of `file_path`. + /// Find ALL project names whose sourceRoot is a prefix of `file_path`, + /// excluding projects whose tsconfig excludes the file. pub fn get_package_names_by_path(&self, file_path: &Path) -> Vec { let mut result = Vec::new(); for (root, names) in &self.entries { if file_path.starts_with(root) { - result.extend(names.iter().cloned()); + 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; + } + } + result.push(name.clone()); + } } } result @@ -133,6 +172,7 @@ mod tests { #[test] fn test_project_index() { + let tmp = tempfile::TempDir::new().unwrap(); let projects = vec![ Project { name: "core".to_string(), @@ -150,7 +190,7 @@ mod tests { }, ]; - let index = ProjectIndex::new(&projects); + let index = ProjectIndex::new(&projects, tmp.path()); assert_eq!( index.get_package_names_by_path(Path::new("libs/core/src/index.ts")), @@ -168,6 +208,7 @@ mod tests { #[test] fn test_project_index_shared_source_root() { + let tmp = tempfile::TempDir::new().unwrap(); let projects = vec![ Project { name: "app-desktop".to_string(), @@ -192,7 +233,7 @@ mod tests { }, ]; - let index = ProjectIndex::new(&projects); + let index = ProjectIndex::new(&projects, tmp.path()); // File in shared sourceRoot should match both projects let mut result = index.get_package_names_by_path(Path::new("projects/app-desktop/src/main.ts")); @@ -207,4 +248,46 @@ mod tests { let result = index.get_package_names_by_path(Path::new("unknown/file.ts")); assert!(result.is_empty()); } + + #[test] + fn test_project_index_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(), + 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); + + assert_eq!( + index.get_package_names_by_path(Path::new("libs/ui-widgets/src/index.ts")), + vec!["ui-widgets"], + "normal source files should match" + ); + assert!( + index + .get_package_names_by_path(Path::new("libs/ui-widgets/src/Grid.stories.tsx")) + .is_empty(), + "stories files should be excluded" + ); + assert!( + index + .get_package_names_by_path(Path::new("libs/ui-widgets/src/utils.spec.ts")) + .is_empty(), + "spec files should be excluded" + ); + } } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 31dd457..9dccfc9 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -2636,3 +2636,270 @@ fn test_lockfile_no_change_zero_impact() { affected ); } + +/// Verifies that files excluded by a project's tsconfig (e.g. `*.stories.tsx`) +/// do NOT cause that project to be marked as affected, even when a transitive +/// type dependency chain reaches the excluded file. +/// +/// Layout: +/// shared-types/src/types.ts — exports `SharedType` (changed) +/// shared-types/src/index.ts — barrel re-export +/// ui-widgets/src/Grid.tsx — normal source (no import from shared-types) +/// ui-widgets/src/Grid.stories.tsx — stories file that imports SharedType +/// ui-widgets/tsconfig.lib.json — excludes **/*.stories.tsx +/// +/// Without tsconfig-exclude filtering, domino would mark ui-widgets as affected +/// because Grid.stories.tsx imports SharedType. With the fix, the stories file +/// is excluded from project ownership, so ui-widgets is not affected. +#[test] +fn test_tsconfig_exclude_prevents_false_positive_via_stories() { + let tmp = TempDir::new().expect("Failed to create temp dir"); + let root = tmp + .path() + .canonicalize() + .expect("Failed to canonicalize temp dir"); + + // -- scaffold monorepo -- + let shared_src = root.join("shared-types/src"); + let widgets_src = root.join("ui-widgets/src"); + let widgets_dir = root.join("ui-widgets"); + fs::create_dir_all(&shared_src).unwrap(); + fs::create_dir_all(&widgets_src).unwrap(); + + fs::write( + shared_src.join("types.ts"), + r#"export interface SharedType { + name: string; +} +"#, + ) + .unwrap(); + + fs::write( + shared_src.join("index.ts"), + "export { SharedType } from './types';\n", + ) + .unwrap(); + + fs::write( + widgets_src.join("Grid.tsx"), + r#"export function Grid() { + return null; +} +"#, + ) + .unwrap(); + + // stories file imports SharedType — this is the only link from ui-widgets to shared-types + fs::write( + widgets_src.join("Grid.stories.tsx"), + r#"import type { SharedType } from '../../shared-types/src'; + +export const mockData: SharedType = { name: 'test' }; +"#, + ) + .unwrap(); + + // tsconfig that excludes stories + fs::write( + widgets_dir.join("tsconfig.lib.json"), + r#"{ + "compilerOptions": { "strict": true }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": [ + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.stories.ts", + "**/*.stories.tsx" + ] +}"#, + ) + .unwrap(); + + // -- init git -- + git_in(&root, &["init"]); + git_in(&root, &["config", "user.email", "test@test.com"]); + git_in(&root, &["config", "user.name", "Test"]); + git_in(&root, &["branch", "-M", "main"]); + git_in(&root, &["add", "."]); + git_in(&root, &["commit", "-m", "initial"]); + + // -- feature branch: change SharedType -- + git_in(&root, &["checkout", "-b", "feature"]); + + fs::write( + shared_src.join("types.ts"), + r#"export interface SharedType { + name: string; + description?: string; +} +"#, + ) + .unwrap(); + git_in(&root, &["add", "."]); + git_in(&root, &["commit", "-m", "add description field"]); + + // -- run with tsconfig exclude -- + let config = TrueAffectedConfig { + cwd: root.to_path_buf(), + base: "main".to_string(), + root_ts_config: None, + projects: vec![ + Project { + name: "shared-types".to_string(), + source_root: PathBuf::from("shared-types/src"), + ts_config: None, + implicit_dependencies: vec![], + targets: vec![], + }, + Project { + name: "ui-widgets".to_string(), + source_root: PathBuf::from("ui-widgets/src"), + ts_config: Some(widgets_dir.join("tsconfig.lib.json")), + implicit_dependencies: vec![], + targets: vec![], + }, + ], + include: vec![], + ignored_paths: vec![], + lockfile_strategy: LockfileStrategy::None, + }; + + let profiler = Arc::new(Profiler::new(false)); + let result = find_affected(config, profiler).expect("find_affected failed"); + let affected = result.affected_projects; + + assert!( + affected.contains(&"shared-types".to_string()), + "shared-types should be affected (directly changed). Got: {:?}", + affected + ); + assert!( + !affected.contains(&"ui-widgets".to_string()), + "ui-widgets should NOT be affected (only link is via excluded stories file). Got: {:?}", + affected + ); +} + +/// Complement to the above: when a non-excluded file in ui-widgets imports +/// from shared-types, ui-widgets IS correctly marked as affected. +#[test] +fn test_tsconfig_exclude_does_not_suppress_real_dependencies() { + let tmp = TempDir::new().expect("Failed to create temp dir"); + let root = tmp + .path() + .canonicalize() + .expect("Failed to canonicalize temp dir"); + + let shared_src = root.join("shared-types/src"); + let widgets_src = root.join("ui-widgets/src"); + let widgets_dir = root.join("ui-widgets"); + fs::create_dir_all(&shared_src).unwrap(); + fs::create_dir_all(&widgets_src).unwrap(); + + fs::write( + shared_src.join("types.ts"), + r#"export interface SharedType { + name: string; +} +"#, + ) + .unwrap(); + + fs::write( + shared_src.join("index.ts"), + "export { SharedType } from './types';\n", + ) + .unwrap(); + + // Production source file that imports SharedType + fs::write( + widgets_src.join("Grid.tsx"), + r#"import type { SharedType } from '../../shared-types/src'; + +export function Grid(props: SharedType) { + return null; +} +"#, + ) + .unwrap(); + + // stories file also imports it (but excluded) + fs::write( + widgets_src.join("Grid.stories.tsx"), + r#"import type { SharedType } from '../../shared-types/src'; + +export const mockData: SharedType = { name: 'test' }; +"#, + ) + .unwrap(); + + fs::write( + widgets_dir.join("tsconfig.lib.json"), + r#"{ + "exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.stories.ts", "**/*.stories.tsx"] +}"#, + ) + .unwrap(); + + git_in(&root, &["init"]); + git_in(&root, &["config", "user.email", "test@test.com"]); + git_in(&root, &["config", "user.name", "Test"]); + git_in(&root, &["branch", "-M", "main"]); + git_in(&root, &["add", "."]); + git_in(&root, &["commit", "-m", "initial"]); + + git_in(&root, &["checkout", "-b", "feature"]); + + fs::write( + shared_src.join("types.ts"), + r#"export interface SharedType { + name: string; + description?: string; +} +"#, + ) + .unwrap(); + git_in(&root, &["add", "."]); + git_in(&root, &["commit", "-m", "add description field"]); + + let config = TrueAffectedConfig { + cwd: root.to_path_buf(), + base: "main".to_string(), + root_ts_config: None, + projects: vec![ + Project { + name: "shared-types".to_string(), + source_root: PathBuf::from("shared-types/src"), + ts_config: None, + implicit_dependencies: vec![], + targets: vec![], + }, + Project { + name: "ui-widgets".to_string(), + source_root: PathBuf::from("ui-widgets/src"), + ts_config: Some(widgets_dir.join("tsconfig.lib.json")), + implicit_dependencies: vec![], + targets: vec![], + }, + ], + include: vec![], + ignored_paths: vec![], + lockfile_strategy: LockfileStrategy::None, + }; + + let profiler = Arc::new(Profiler::new(false)); + let result = find_affected(config, profiler).expect("find_affected failed"); + let affected = result.affected_projects; + + assert!( + affected.contains(&"shared-types".to_string()), + "shared-types should be affected. Got: {:?}", + affected + ); + assert!( + affected.contains(&"ui-widgets".to_string()), + "ui-widgets SHOULD be affected (Grid.tsx imports SharedType and is not excluded). Got: {:?}", + affected + ); +} diff --git a/yarn.lock b/yarn.lock index 3c5e06d..d3df4c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -33,12 +33,68 @@ __metadata: languageName: node linkType: hard +"@front-ops/domino-darwin-arm64@npm:1.0.1": + version: 1.0.1 + resolution: "@front-ops/domino-darwin-arm64@npm:1.0.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@front-ops/domino-darwin-x64@npm:1.0.1": + version: 1.0.1 + resolution: "@front-ops/domino-darwin-x64@npm:1.0.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@front-ops/domino-linux-arm64-gnu@npm:1.0.1": + version: 1.0.1 + resolution: "@front-ops/domino-linux-arm64-gnu@npm:1.0.1" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@front-ops/domino-linux-arm64-musl@npm:1.0.1": + version: 1.0.1 + resolution: "@front-ops/domino-linux-arm64-musl@npm:1.0.1" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@front-ops/domino-linux-x64-gnu@npm:1.0.1": + version: 1.0.1 + resolution: "@front-ops/domino-linux-x64-gnu@npm:1.0.1" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@front-ops/domino-linux-x64-musl@npm:1.0.1": + version: 1.0.1 + resolution: "@front-ops/domino-linux-x64-musl@npm:1.0.1" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@front-ops/domino-win32-x64-msvc@npm:1.0.1": + version: 1.0.1 + resolution: "@front-ops/domino-win32-x64-msvc@npm:1.0.1" + conditions: os=win32 & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@front-ops/domino@workspace:.": version: 0.0.0-use.local resolution: "@front-ops/domino@workspace:." dependencies: "@emnapi/core": "npm:^1.5.0" "@emnapi/runtime": "npm:^1.5.0" + "@front-ops/domino-darwin-arm64": "npm:1.0.1" + "@front-ops/domino-darwin-x64": "npm:1.0.1" + "@front-ops/domino-linux-arm64-gnu": "npm:1.0.1" + "@front-ops/domino-linux-arm64-musl": "npm:1.0.1" + "@front-ops/domino-linux-x64-gnu": "npm:1.0.1" + "@front-ops/domino-linux-x64-musl": "npm:1.0.1" + "@front-ops/domino-win32-x64-msvc": "npm:1.0.1" "@napi-rs/cli": "npm:^3.2.0" "@oxc-node/core": "npm:^0.0.34" "@oxc-node/core-darwin-arm64": "npm:^0.0.34" @@ -58,6 +114,20 @@ __metadata: prettier: "npm:^3.6.2" typescript: "npm:^5.9.2" dependenciesMeta: + "@front-ops/domino-darwin-arm64": + optional: true + "@front-ops/domino-darwin-x64": + optional: true + "@front-ops/domino-linux-arm64-gnu": + optional: true + "@front-ops/domino-linux-arm64-musl": + optional: true + "@front-ops/domino-linux-x64-gnu": + optional: true + "@front-ops/domino-linux-x64-musl": + optional: true + "@front-ops/domino-win32-x64-msvc": + optional: true "@oxc-node/core-darwin-arm64": optional: true "@oxc-node/core-darwin-x64":