diff --git a/CHANGELOG.md b/CHANGELOG.md index 231523cc..20dc58e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,27 @@ identical entries. JSON also counted both in `violation_count`, so it could exceed the number of distinct violations. Each violation now appears once, and `violation_count` counts distinct violations. Text output is unchanged. +#### `check ` and `check-contents` no longer flag other files' entries as stale + +When given paths, `pks check` treated a `package_todo.yml` entry for any file +it had not checked as if that file had been deleted, and reported it as stale. +So checking one file, as editor integrations do, failed whenever any other file +had an entry. `check-contents` behaved the same way. A run given paths now only +considers entries for files at or under those paths. An entry for a file that +was deleted or is no longer included is still reported as stale when the run is +given that file, a directory containing it, or `.`, as a full `pks check` does. + +#### `check` and `check-contents` warn about paths that match no included file + +A path argument that matched no included file was silently skipped, so a typo, +a path containing `..`, an absolute directory, or an absolute path through a +symlink checked nothing without saying so. `check` and `check-contents` now warn +on stderr, naming the argument and, where there is one, the path to pass +instead. An existing file that just isn't included, such as a README or the +Gemfile, is still skipped quietly, so passing every changed file doesn't warn +about each of them. The exit code and stdout are unchanged, so `-o json` and +`-o csv` output still parses. + ## 0.5.0 ### Breaking Changes diff --git a/src/packs/checker.rs b/src/packs/checker.rs index 11d194a2..27294c92 100644 --- a/src/packs/checker.rs +++ b/src/packs/checker.rs @@ -24,7 +24,11 @@ use rayon::prelude::IntoParallelRefIterator; use rayon::prelude::ParallelIterator; use reference::Reference; use std::collections::HashMap; -use std::{collections::HashSet, path::PathBuf}; +use std::{ + collections::HashSet, + fs, + path::{Path, PathBuf}, +}; use tracing::debug; use super::bin_locater; @@ -100,6 +104,10 @@ struct CheckAllBuilder<'a> { #[derive(Debug)] struct FoundViolations { absolute_paths: HashSet, + // The paths the caller named, joined onto the root; empty for a full run. + // A named path can match no included file, or be a deleted one, so this + // cannot be derived from `absolute_paths`. + supplied_paths: Vec, violations: HashSet, } @@ -188,6 +196,9 @@ impl<'a> CheckAllBuilder<'a> { }) .collect::>>()?; + let absolute_root = &self.configuration.absolute_root; + let included_files = &self.configuration.included_files; + let supplied_paths = &self.found_violations.supplied_paths; let stale_violations = recorded_violations .par_iter() .filter(|v_identifier| { @@ -195,6 +206,9 @@ impl<'a> CheckAllBuilder<'a> { &relative_files, &found_violation_identifiers, v_identifier, + absolute_root, + included_files, + supplied_paths, ) }) .collect::>(); @@ -205,11 +219,21 @@ impl<'a> CheckAllBuilder<'a> { relative_files: &HashSet<&str>, found_violation_identifiers: &HashSet<&ViolationIdentifier>, todo_violation_identifier: &ViolationIdentifier, + absolute_root: &Path, + included_files: &HashSet, + supplied_paths: &[PathBuf], ) -> bool { - let violation_path_exists = + let file_was_checked = relative_files.contains(todo_violation_identifier.file.as_str()); - if violation_path_exists { + if file_was_checked { !found_violation_identifiers.contains(todo_violation_identifier) + } else if !supplied_paths.is_empty() { + let path = absolute_root.join(&todo_violation_identifier.file); + // Not being an included file is what makes an entry stale in a full + // run too. `Path::starts_with` compares whole components, so + // `packs/foo` does not cover `packs/foobar`. + supplied_paths.iter().any(|p| path.starts_with(p)) + && !included_files.contains(&path) } else { true // The todo violation references a file that no longer exists } @@ -243,13 +267,16 @@ pub(crate) fn check_all( let checkers = get_checkers(configuration); debug!("Intersecting input files with configuration included files"); - let absolute_paths: HashSet = - configuration.intersect_files(files.clone()); + let absolute_paths = files_to_check(configuration, &files); let violations: HashSet = get_all_violations(configuration, &absolute_paths, &checkers)?; let found_violations = FoundViolations { absolute_paths, + supplied_paths: files + .iter() + .map(|f| configuration.absolute_root.join(f)) + .collect(), violations, }; debug!("Building check-all result (diffing against package_todo.yml)"); @@ -258,6 +285,81 @@ pub(crate) fn check_all( result } +/// Intersects each path argument with the included files on its own, so an +/// argument that matches none of them can be reported instead of silently +/// checking nothing. The union is what `intersect_files` returns for all of +/// them at once. +/// +/// The warning goes to stderr and leaves the exit code alone: `-o json` and +/// `-o csv` stay parseable, and editor plugins, which check one file on every +/// open and save, don't treat it as a failure. +fn files_to_check( + configuration: &Configuration, + files: &[String], +) -> HashSet { + if files.is_empty() { + return configuration.intersect_files(vec![]); + } + + let mut absolute_paths = HashSet::new(); + for file in files { + let matched = configuration.intersect_files(vec![file.clone()]); + if matched.is_empty() { + warn_about_unmatched_path(configuration, file); + } + absolute_paths.extend(matched); + } + absolute_paths +} + +/// Stays silent for an existing file that simply isn't included (a README, +/// `package.yml`, the Gemfile), so a hook passing every staged file doesn't +/// warn on each of them. +fn warn_about_unmatched_path(configuration: &Configuration, file: &str) { + let path = configuration.absolute_root.join(file); + if !path.exists() { + eprintln!("Warning: `{}` does not exist.", file); + } else if let Some(relative) = included_spelling(configuration, &path) { + eprintln!( + "Warning: `{}` was not checked. Pass it as `{}` instead.", + file, + relative.display() + ); + } else if !path.is_file() { + eprintln!( + "Warning: no included file matches `{}`, so it was not checked. Check the path and the include and exclude globs in the config file.", + file + ); + } +} + +/// The root-relative path to pass instead of `path`, when `path` is an +/// included file or a directory holding one, spelled in a way that matches +/// nothing. Included files are listed under the canonical root with no `..`, +/// and only relative directories are expanded, so a `..`, symlinked or +/// absolute spelling misses them. +fn included_spelling( + configuration: &Configuration, + path: &Path, +) -> Option { + let canonical = fs::canonicalize(path).ok()?; + let included = &configuration.included_files; + let holds_included = if path.is_file() { + included.contains(&canonical) + } else { + included.iter().any(|f| f.starts_with(&canonical)) + }; + if !holds_included { + return None; + } + let relative = canonical.strip_prefix(&configuration.absolute_root).ok()?; + if relative.as_os_str().is_empty() { + Some(PathBuf::from(".")) + } else { + Some(relative.to_path_buf()) + } +} + fn validate(configuration: &Configuration) -> Vec { debug!("Running validators against packages"); let validators: Vec> = vec![ diff --git a/tests/check_test.rs b/tests/check_test.rs index 1b09c14b..836c9c86 100644 --- a/tests/check_test.rs +++ b/tests/check_test.rs @@ -25,6 +25,25 @@ pub fn stripped_output(output: Vec) -> String { String::from_utf8_lossy(&strip_ansi_escapes::strip(output)).to_string() } +fn sorted_stale_todos( + json_output: &serde_json::Value, +) -> Vec<(&str, &str, &str)> { + let mut stale_todos: Vec<(&str, &str, &str)> = json_output["stale_todos"] + .as_array() + .unwrap() + .iter() + .map(|t| { + ( + t["file"].as_str().unwrap(), + t["constant_name"].as_str().unwrap(), + t["violation_type"].as_str().unwrap(), + ) + }) + .collect(); + stale_todos.sort(); + stale_todos +} + #[test] fn test_check_with_privacy_dependency_error_template_overrides( ) -> Result<(), Box> { @@ -317,6 +336,632 @@ fn test_check_with_stale_violations_when_file_no_longer_exists( Ok(()) } +#[test] +fn test_check_with_recorded_strict_mode_violation_when_file_is_deleted( +) -> Result<(), Box> { + let fixture = common::Fixture::new("uses_strict_mode"); + fs::remove_file(fixture.path("packs/foo/app/services/foo.rb"))?; + + cargo_bin_cmd!("pks") + .arg("--project-root") + .arg(fixture.root()) + .arg("check") + .assert() + .code(1) + .stdout(predicate::str::contains( + "There were stale violations found, please run `packs update`", + )); + + Ok(()) +} + +#[test] +fn test_check_with_deleted_file_reports_its_recorded_violations_as_stale( +) -> Result<(), Box> { + let fixture = common::Fixture::new("uses_strict_mode"); + fs::remove_file(fixture.path("packs/foo/app/services/foo.rb"))?; + + let output = cargo_bin_cmd!("pks") + .arg("--project-root") + .arg(fixture.root()) + .arg("check") + .arg("-o") + .arg("json") + .arg("packs/foo/app/services/foo.rb") + .assert() + .code(1) + .stderr(predicate::str::contains( + "Warning: `packs/foo/app/services/foo.rb` does not exist.", + )) + .stderr(predicate::str::contains("include and exclude globs").not()) + .get_output() + .stdout + .clone(); + + let json_output: serde_json::Value = + serde_json::from_slice(&output).expect("Output should be valid JSON"); + + validate_check_output_schema(&json_output); + + assert_eq!( + sorted_stale_todos(&json_output), + vec![ + ("packs/foo/app/services/foo.rb", "::Bar", "dependency"), + ("packs/foo/app/services/foo.rb", "::Bar", "privacy"), + ] + ); + + Ok(()) +} + +#[test] +fn test_check_with_directory_reports_recorded_violations_of_deleted_files_as_stale( +) -> Result<(), Box> { + let fixture = common::Fixture::new("uses_strict_mode"); + fs::remove_file(fixture.path("packs/foo/app/services/foo.rb"))?; + + let output = cargo_bin_cmd!("pks") + .arg("--project-root") + .arg(fixture.root()) + .arg("check") + .arg("-o") + .arg("json") + .arg("packs/foo") + .assert() + .code(1) + .stderr(predicate::str::contains( + "Warning: no included file matches `packs/foo`", + )) + .get_output() + .stdout + .clone(); + + let json_output: serde_json::Value = + serde_json::from_slice(&output).expect("Output should be valid JSON"); + + validate_check_output_schema(&json_output); + + assert_eq!( + sorted_stale_todos(&json_output), + vec![ + ("packs/foo/app/services/foo.rb", "::Bar", "dependency"), + ("packs/foo/app/services/foo.rb", "::Bar", "privacy"), + ] + ); + + Ok(()) +} + +#[test] +fn test_check_with_dot_reports_recorded_violations_of_deleted_files_as_stale( +) -> Result<(), Box> { + let fixture = common::Fixture::new("uses_strict_mode"); + fs::remove_file(fixture.path("packs/foo/app/services/foo.rb"))?; + + let output = cargo_bin_cmd!("pks") + .arg("--project-root") + .arg(fixture.root()) + .arg("check") + .arg("-o") + .arg("json") + .arg(".") + .assert() + .code(1) + .get_output() + .stdout + .clone(); + + let json_output: serde_json::Value = + serde_json::from_slice(&output).expect("Output should be valid JSON"); + + validate_check_output_schema(&json_output); + + assert_eq!( + sorted_stale_todos(&json_output), + vec![ + ("packs/foo/app/services/foo.rb", "::Bar", "dependency"), + ("packs/foo/app/services/foo.rb", "::Bar", "privacy"), + ] + ); + + Ok(()) +} + +#[test] +fn test_check_with_dot_reports_recorded_violations_of_excluded_files_as_stale( +) -> Result<(), Box> { + // foo.rb is still on disk but no longer included, which a full run + // reports as stale too. + let fixture = common::Fixture::new("uses_strict_mode"); + let packwerk_yml = fixture.path("packwerk.yml"); + let config = fs::read_to_string(&packwerk_yml)?; + fs::write( + &packwerk_yml, + format!("{}\nexclude:\n- \"packs/foo/**/*\"\n", config), + )?; + + let output = cargo_bin_cmd!("pks") + .arg("--project-root") + .arg(fixture.root()) + .arg("check") + .arg("-o") + .arg("json") + .arg(".") + .assert() + .code(1) + .get_output() + .stdout + .clone(); + + let json_output: serde_json::Value = + serde_json::from_slice(&output).expect("Output should be valid JSON"); + + validate_check_output_schema(&json_output); + + assert_eq!( + sorted_stale_todos(&json_output), + vec![ + ("packs/foo/app/services/foo.rb", "::Bar", "dependency"), + ("packs/foo/app/services/foo.rb", "::Bar", "privacy"), + ] + ); + + Ok(()) +} + +#[test] +fn test_check_with_other_paths_ignores_recorded_violations_of_deleted_files( +) -> Result<(), Box> { + let fixture = common::Fixture::new("uses_strict_mode"); + fs::remove_file(fixture.path("packs/foo/app/services/foo.rb"))?; + + for path in ["packs/bar", "packs/bar/app/services/bar.rb"] { + cargo_bin_cmd!("pks") + .arg("--project-root") + .arg(fixture.root()) + .arg("check") + .arg(path) + .assert() + .code(0) + .stdout( + predicate::str::contains("There were stale violations found") + .not(), + ); + } + + Ok(()) +} + +#[test] +fn test_check_with_absolute_directory_ignores_recorded_violations_of_included_files( +) -> Result<(), Box> { + // Absolute directories aren't expanded into files, so this checks nothing. + // foo.rb is under the argument but still an included file, so its entries + // must not be reported as stale. + let packs_foo = + fs::canonicalize("tests/fixtures/uses_strict_mode/packs/foo")?; + + cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/uses_strict_mode") + .arg("check") + .arg(packs_foo) + .assert() + .code(0) + .stdout( + predicate::str::contains("There were stale violations found").not(), + ); + + common::teardown(); + Ok(()) +} + +#[test] +fn test_check_with_single_file_ignores_recorded_violations_in_other_files( +) -> Result<(), Box> { + // In `uses_strict_mode`, every recorded violation is in foo.rb, so a run + // that checks only bar.rb has no recorded violations in scope. + cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/uses_strict_mode") + .arg("check") + .arg("packs/bar/app/services/bar.rb") + .assert() + .code(0) + .stdout( + predicate::str::contains("There were stale violations found").not(), + ); + + common::teardown(); + Ok(()) +} + +#[test] +fn test_check_contents_ignores_recorded_violations_in_other_files( +) -> Result<(), Box> { + let project_root = "tests/fixtures/uses_strict_mode"; + let relative_path = "packs/bar/app/services/bar.rb"; + let bar_rb_contents = + fs::read_to_string(format!("{}/{}", project_root, relative_path))?; + + cargo_bin_cmd!("pks") + .arg("--project-root") + .arg(project_root) + .arg("check-contents") + .arg(relative_path) + .write_stdin(bar_rb_contents) + .assert() + .code(0) + .stdout( + predicate::str::contains("There were stale violations found").not(), + ); + + common::teardown(); + Ok(()) +} + +#[test] +fn test_check_with_directory_ignores_recorded_violations_in_other_files( +) -> Result<(), Box> { + cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/uses_strict_mode") + .arg("check") + .arg("packs/bar") + .assert() + .code(0) + .stdout( + predicate::str::contains("There were stale violations found").not(), + ); + + common::teardown(); + Ok(()) +} + +#[test] +fn test_check_with_only_unincluded_files_ignores_recorded_violations( +) -> Result<(), Box> { + // package.yml is not in the include globs, so this run checks no files at + // all. It is still a run given paths, not a full run. + cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/uses_strict_mode") + .arg("check") + .arg("packs/foo/package.yml") + .assert() + .code(0) + .stdout( + predicate::str::contains("There were stale violations found").not(), + ); + + common::teardown(); + Ok(()) +} + +#[test] +fn test_check_warns_about_path_that_does_not_exist( +) -> Result<(), Box> { + cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/uses_strict_mode") + .arg("check") + .arg("packs/foo/app/services/typo.rb") + .assert() + .code(0) + .stderr(predicate::str::contains( + "Warning: `packs/foo/app/services/typo.rb` does not exist.", + )); + + common::teardown(); + Ok(()) +} + +#[test] +fn test_check_warns_only_about_paths_matching_no_included_file( +) -> Result<(), Box> { + // Absolute directories aren't expanded into files, so this one matches + // nothing, while bar.rb is still checked. + let packs_foo = + fs::canonicalize("tests/fixtures/uses_strict_mode/packs/foo")?; + + cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/uses_strict_mode") + .arg("check") + .arg("packs/bar/app/services/bar.rb") + .arg(&packs_foo) + .assert() + .code(0) + .stderr(predicate::str::contains(format!( + "Warning: `{}` was not checked. Pass it as `packs/foo` instead.", + packs_foo.display() + ))) + .stderr(predicate::str::contains("bar.rb").not()); + + common::teardown(); + Ok(()) +} + +#[test] +fn test_check_warns_about_absolute_project_root() -> Result<(), Box> +{ + let root = fs::canonicalize("tests/fixtures/uses_strict_mode")?; + + cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/uses_strict_mode") + .arg("check") + .arg(&root) + .assert() + .code(0) + .stderr(predicate::str::contains(format!( + "Warning: `{}` was not checked. Pass it as `.` instead.", + root.display() + ))); + + common::teardown(); + Ok(()) +} + +#[test] +fn test_check_with_json_output_warns_on_stderr() -> Result<(), Box> { + let output = cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/uses_strict_mode") + .arg("check") + .arg("-o") + .arg("json") + .arg("packs/foo/app/services/typo.rb") + .assert() + .code(0) + .stderr(predicate::str::contains( + "Warning: `packs/foo/app/services/typo.rb` does not exist.", + )) + .get_output() + .stdout + .clone(); + + let json_output: serde_json::Value = + serde_json::from_slice(&output).expect("Output should be valid JSON"); + + validate_check_output_schema(&json_output); + + common::teardown(); + Ok(()) +} + +#[test] +fn test_check_contents_does_not_warn_about_existing_file_that_is_not_included( +) -> Result<(), Box> { + // package.yml exists but is not in the include globs. + cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/uses_strict_mode") + .arg("check-contents") + .arg("packs/foo/package.yml") + .write_stdin("enforce_dependencies: true\n") + .assert() + .code(0) + .stderr(predicate::str::is_empty()); + + common::teardown(); + Ok(()) +} + +#[test] +fn test_check_does_not_warn_about_existing_files_that_are_not_included( +) -> Result<(), Box> { + // What a hook passing every staged file sends alongside the Ruby files. + cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/uses_strict_mode") + .arg("check") + .arg("packs/bar/app/services/bar.rb") + .arg("packs/foo/package.yml") + .arg("packwerk.yml") + .assert() + .code(0) + .stderr(predicate::str::is_empty()); + + common::teardown(); + Ok(()) +} + +#[test] +fn test_check_warns_about_other_spelling_of_included_file( +) -> Result<(), Box> { + cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/uses_strict_mode") + .arg("check") + .arg("packs/bar/../foo/app/services/foo.rb") + .assert() + .code(0) + .stderr(predicate::str::contains( + "Warning: `packs/bar/../foo/app/services/foo.rb` was not checked. Pass it as `packs/foo/app/services/foo.rb` instead.", + )); + + common::teardown(); + Ok(()) +} + +#[test] +fn test_check_does_not_warn_when_every_path_matches_an_included_file( +) -> Result<(), Box> { + cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/uses_strict_mode") + .arg("check") + .arg("packs/bar/app/services/bar.rb") + .arg("packs/foo") + .assert() + .code(0) + .stderr(predicate::str::is_empty()); + + common::teardown(); + Ok(()) +} + +#[test] +fn test_check_with_single_file_reports_stale_violations_in_foo_rb( +) -> Result<(), Box> { + let output = cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/contains_stale_violations") + .arg("check") + .arg("-o") + .arg("json") + .arg("packs/foo/app/services/foo.rb") + .assert() + .code(1) + .get_output() + .stdout + .clone(); + + let json_output: serde_json::Value = + serde_json::from_slice(&output).expect("Output should be valid JSON"); + + validate_check_output_schema(&json_output); + + assert_eq!(json_output["summary"]["stale_todo_count"], 1); + let stale_todos = json_output["stale_todos"].as_array().unwrap(); + assert_eq!(stale_todos.len(), 1); + assert_eq!(stale_todos[0]["file"], "packs/foo/app/services/foo.rb"); + assert_eq!(stale_todos[0]["constant_name"], "::Bar"); + assert_eq!(stale_todos[0]["violation_type"], "dependency"); + + common::teardown(); + Ok(()) +} + +#[test] +fn test_check_with_single_absolute_file_reports_stale_violations_in_foo_rb( +) -> Result<(), Box> { + // Absolute arguments aren't joined onto the project root, so they take + // their own branch in `user_inputted_paths_to_absolute_filepaths`. + let foo_rb = fs::canonicalize( + "tests/fixtures/contains_stale_violations/packs/foo/app/services/foo.rb", + )?; + + let output = cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/contains_stale_violations") + .arg("check") + .arg("-o") + .arg("json") + .arg(foo_rb) + .assert() + .code(1) + .get_output() + .stdout + .clone(); + + let json_output: serde_json::Value = + serde_json::from_slice(&output).expect("Output should be valid JSON"); + + validate_check_output_schema(&json_output); + + assert_eq!(json_output["summary"]["stale_todo_count"], 1); + let stale_todos = json_output["stale_todos"].as_array().unwrap(); + assert_eq!(stale_todos.len(), 1); + assert_eq!(stale_todos[0]["file"], "packs/foo/app/services/foo.rb"); + assert_eq!(stale_todos[0]["constant_name"], "::Bar"); + assert_eq!(stale_todos[0]["violation_type"], "dependency"); + + common::teardown(); + Ok(()) +} + +#[test] +fn test_check_with_single_file_reports_stale_violations_in_bar_rb( +) -> Result<(), Box> { + let output = cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/contains_stale_violations") + .arg("check") + .arg("-o") + .arg("json") + .arg("packs/bar/app/services/bar.rb") + .assert() + .code(1) + .get_output() + .stdout + .clone(); + + let json_output: serde_json::Value = + serde_json::from_slice(&output).expect("Output should be valid JSON"); + + validate_check_output_schema(&json_output); + + assert_eq!(json_output["summary"]["stale_todo_count"], 2); + assert_eq!( + sorted_stale_todos(&json_output), + vec![ + ("packs/bar/app/services/bar.rb", "::Foo", "dependency"), + ("packs/bar/app/services/bar.rb", "::Foo", "privacy"), + ] + ); + + common::teardown(); + Ok(()) +} + +#[test] +fn test_check_with_directory_reports_stale_violations_in_that_directory( +) -> Result<(), Box> { + let output = cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/contains_stale_violations") + .arg("check") + .arg("-o") + .arg("json") + .arg("packs/foo") + .assert() + .code(1) + .get_output() + .stdout + .clone(); + + let json_output: serde_json::Value = + serde_json::from_slice(&output).expect("Output should be valid JSON"); + + validate_check_output_schema(&json_output); + + assert_eq!( + sorted_stale_todos(&json_output), + vec![("packs/foo/app/services/foo.rb", "::Bar", "dependency")] + ); + + common::teardown(); + Ok(()) +} + +#[test] +fn test_check_contents_reports_stale_violations_in_that_file( +) -> Result<(), Box> { + // bar.rb's entries are out of scope and bar.rb is still on disk, so the + // stale line can only come from foo.rb's own `::Bar` dependency entry. + let project_root = "tests/fixtures/contains_stale_violations"; + let relative_path = "packs/foo/app/services/foo.rb"; + let foo_rb_contents = + fs::read_to_string(format!("{}/{}", project_root, relative_path))?; + + cargo_bin_cmd!("pks") + .arg("--project-root") + .arg(project_root) + .arg("check-contents") + .arg(relative_path) + .write_stdin(foo_rb_contents) + .assert() + .code(1) + .stdout(predicate::str::contains( + "There were stale violations found, please run `packs update`", + )); + + common::teardown(); + Ok(()) +} + #[test] fn test_check_with_relationship_violations() -> Result<(), Box> { cargo_bin_cmd!("pks")