From 805f6c97d4c7841c932cadfdd78261257c3c6a1b Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Mon, 24 Aug 2026 15:18:24 -0500 Subject: [PATCH 01/13] fix: normalize the paths callers supply to validate `validate ` compared caller-supplied paths against project-relative ones without reducing them to the same form first. What that costs depends on the shape of owned_globs, and both outcomes are wrong. Under directory-anchored globs (`{gems,ruby,...}/**/*.rb`), the mishandled path matches nothing, is dropped before any ownership query runs, and the command exits 0 having checked nothing. Silent, and in the unsafe direction: a pre-commit hook or CI job reports success on a file it never looked at. Under `**`-leading globs (`**/*.rb`), it survives the filter instead and is queried in the caller's spelling, which matches no CODEOWNERS entry, so a well-owned file is reported unowned. Three spellings hit this. `./ruby/app/x.rb` and `ruby/a/../x.rb` were never reduced at all. Absolute paths were reduced with strip_prefix against a root that need not agree with them about symlinks: cli.rs canonicalizes --project-root, so on macOS, where TMPDIR lives under /var, a symlink to /private/var, a caller passing the TMPDIR spelling fails to strip -- and a library caller building its own RunConfig (which is how the code_ownership gem calls in) can pass an unresolved root against a resolved path, the mirror image. Fixing only one side leaves the other failing exactly as silently, so the retry resolves both. Absolute paths were also echoed back in the caller's spelling rather than project-relative, since the raw string was what got reported. path_utils::project_relative resolves `.` and `..` lexically and reports failure rather than passing an unstrippable path through, which is how a /var/... path came to be compared against project-relative ones in the first place. Lexically, not by canonicalizing: the project walk records symlink paths rather than their targets, so resolving symlinks would produce paths matching no walked file. The first attempt uses the root as given, so relative paths -- the common case -- cost no syscalls, and the root is resolved once per run rather than per path. Paths that no longer exist are now skipped. A changeset that deletes a file lists it, so a deleted path reaches validate in normal use, and a deleted file cannot have an owner -- reporting it as unowned fails a commit for removing code. `gv ` did exactly that. The gem already filters its list by File.exist? before calling in, so this matches what its callers see and extends it to direct library callers. Only a definite "not there" skips: try_exists().unwrap_or(true) keeps a path whose status is unknown, because a visible error is investigable and a silent pass is not. Three tests asserted on valid_project/ruby/app/unowned.rb, which does not exist -- valid_project has to validate cleanly, so it ships no unowned file. They passed only because a nonexistent path was reported as unowned, meaning they covered typo handling while claiming to cover unowned files, and skipping nonexistent paths removes that accident. Repointed at invalid_project, which has a real one, and narrowed to assert the path and exit status rather than the category wording, so they do not depend on how the report is phrased. The new tests assert the mechanism rather than the outcome: that the report names the *normalized* path and does not echo the caller's spelling. Without that they cannot distinguish "checked correctly" from "mishandled and spuriously reported" -- an earlier draft of this file, written against invalid_project's `**` globs, passed against unfixed code for precisely that reason. Seven of the nine fail without this change; the two that pass are the plain-relative control and the outside-the-project skip, both of which already worked. Co-Authored-By: Claude Fable 5 --- src/cli.rs | 8 +- src/path_utils.rs | 96 ++++++++- src/runner.rs | 71 +++++-- tests/supplied_path_normalization_test.rs | 242 ++++++++++++++++++++++ tests/validate_files_test.rs | 27 ++- 5 files changed, 419 insertions(+), 25 deletions(-) create mode 100644 tests/supplied_path_normalization_test.rs diff --git a/src/cli.rs b/src/cli.rs index 1d0a7f9..202114b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -39,7 +39,9 @@ enum Command { visible_alias = "v" )] Validate { - #[arg(help = "Optional list of files to validate ownership for (fast mode for git hooks)")] + #[arg(help = "Optional list of files to validate ownership for (fast mode for git hooks). Paths are \ + resolved relative to the project root; ones that no longer exist are skipped, so a \ + changeset that deletes files is not reported as unowned.")] files: Vec, }, @@ -47,7 +49,9 @@ enum Command { GenerateAndValidate { #[arg(long, short, default_value = "false", help = "Skip staging the CODEOWNERS file")] skip_stage: bool, - #[arg(help = "Optional list of files to validate ownership for (fast mode for git hooks)")] + #[arg(help = "Optional list of files to validate ownership for (fast mode for git hooks). Paths are \ + resolved relative to the project root; ones that no longer exist are skipped, so a \ + changeset that deletes files is not reported as unowned.")] files: Vec, }, diff --git a/src/path_utils.rs b/src/path_utils.rs index 230b1d1..f5962a8 100644 --- a/src/path_utils.rs +++ b/src/path_utils.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; /// Return `path` relative to `root` if possible; otherwise return `path` unchanged. pub fn relative_to<'a>(root: &'a Path, path: &'a Path) -> &'a Path { @@ -10,6 +10,58 @@ pub fn relative_to_buf(root: &Path, path: &Path) -> PathBuf { relative_to(root, path).to_path_buf() } +/// Reduce a caller-supplied `path` to the project-relative form that +/// [`crate::project::Project::relative_path`] produces for walked files. +/// +/// Unlike [`relative_to`], which passes an unstrippable path through unchanged, this +/// reports failure. A path that cannot be placed inside the project is not a path the +/// per-file checks can say anything about, and silently treating it as relative is how +/// `/var/...` came to be compared against project-relative paths and matched nothing. +/// +/// Purely lexical — no filesystem access, so it is safe on a path that no longer exists +/// (a deleted file in a changeset). `.` components are dropped and `..` pops the +/// preceding component, so `./a/b.rb` and `a/c/../b.rb` both reduce to `a/b.rb`. +/// +/// Returns `None` when `path` is absolute and does not lie under `root`, when it escapes +/// `root` via `..`, or when it *is* `root`. The absolute case is not necessarily final: +/// `cli.rs` canonicalizes `--project-root`, so on macOS a root of `/private/var/...` will +/// not strip a caller-supplied `/var/...`. A caller that gets `None` for an absolute path +/// should retry with a canonicalized copy. +pub fn project_relative(root: &Path, path: &Path) -> Option { + let relative = if path.is_absolute() { path.strip_prefix(root).ok()? } else { path }; + + let normalized = lexically_normalize(relative); + if normalized.as_os_str().is_empty() || normalized.starts_with("..") { + return None; + } + + Some(normalized) +} + +/// Resolve `.` and `..` without touching the filesystem. +/// +/// Deliberately lexical: canonicalizing would also resolve symlinks, and the project walk +/// records the symlink path rather than its target, so resolving here would produce a path +/// that matches no walked file. +fn lexically_normalize(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + // A `..` that cannot pop is retained, so the caller can detect the escape. + if !normalized.pop() { + normalized.push(Component::ParentDir); + } + } + other => normalized.push(other), + } + } + + normalized +} + #[cfg(test)] mod tests { use super::*; @@ -46,4 +98,46 @@ mod tests { let rel_buf = relative_to_buf(root, path); assert_eq!(rel_ref, rel_buf.as_path()); } + + #[test] + fn project_relative_passes_through_a_plain_relative_path() { + let rel = project_relative(Path::new("/proj"), Path::new("ruby/app/a.rb")); + assert_eq!(rel, Some(PathBuf::from("ruby/app/a.rb"))); + } + + #[test] + fn project_relative_strips_a_leading_dot_slash() { + // `./a.rb` and `a.rb` name the same file, but only one of them used to match a + // walked project file -- the other was silently dropped by the owned_globs filter. + let rel = project_relative(Path::new("/proj"), Path::new("./ruby/app/a.rb")); + assert_eq!(rel, Some(PathBuf::from("ruby/app/a.rb"))); + } + + #[test] + fn project_relative_resolves_interior_parent_dirs() { + let rel = project_relative(Path::new("/proj"), Path::new("ruby/services/../app/a.rb")); + assert_eq!(rel, Some(PathBuf::from("ruby/app/a.rb"))); + } + + #[test] + fn project_relative_strips_the_root_from_an_absolute_path() { + let rel = project_relative(Path::new("/proj"), Path::new("/proj/ruby/app/a.rb")); + assert_eq!(rel, Some(PathBuf::from("ruby/app/a.rb"))); + } + + #[test] + fn project_relative_rejects_an_absolute_path_outside_the_root() { + // The caller retries with a canonicalized copy; see `Runner::project_relative_path`. + assert_eq!(project_relative(Path::new("/private/proj"), Path::new("/proj/a.rb")), None); + } + + #[test] + fn project_relative_rejects_a_path_escaping_the_root() { + assert_eq!(project_relative(Path::new("/proj"), Path::new("../outside/a.rb")), None); + } + + #[test] + fn project_relative_rejects_the_root_itself() { + assert_eq!(project_relative(Path::new("/proj"), Path::new("/proj")), None); + } } diff --git a/src/runner.rs b/src/runner.rs index 5562979..e48ca40 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -146,26 +146,45 @@ impl Runner { let mut unowned_files = Vec::new(); let mut io_errors = Vec::new(); - // Filter files based on owned_globs and unowned_globs configuration - // Only validate files that match owned_globs and don't match unowned_globs - let filtered_paths: Vec = file_paths - .into_iter() - .filter(|file_path| { - // Convert to relative path for glob matching - let path = Path::new(file_path); - let relative_path = if path.is_absolute() { - path.strip_prefix(&self.run_config.project_root).unwrap_or(path) - } else { - path - }; - - // Mirror the filtering applied by ProjectBuilder when walking the project + // Normalize before anything else. A caller-supplied path has to be reduced to the + // project-relative form the rest of the pipeline speaks, or it silently matches + // nothing: `./ruby/app/x.rb`, and an absolute path that disagrees with the root + // about symlinks, were both dropped by the glob filter below, and the run then + // exited 0 having checked nothing -- a false pass in the unsafe direction. + // + // The canonical root is resolved once rather than per path, since only the retry + // inside `project_relative_path` needs it and that retry can fire for every path + // when a caller passes an absolute list. + let canonical_root = self.run_config.project_root.canonicalize().ok(); + + let relative_paths: Vec = file_paths + .iter() + .filter_map(|file_path| { + Self::project_relative_path(&self.run_config.project_root, canonical_root.as_deref(), Path::new(file_path)) + }) + // A path that no longer exists is dropped rather than reported. Changesets + // delete files routinely and `git diff --name-only` lists them, so reporting a + // deleted file as unowned fails a commit for removing code -- and a deleted + // file cannot have an owner. The wrapping `code_ownership` gem already filters + // its list by `File.exist?` before calling in; doing it here too covers callers + // that use the library directly. + // + // `unwrap_or(true)` because only a definite "this is not there" earns a silent + // skip. If the answer is unknown -- a permissions error, a broken symlink -- + // keep the path and let the check report it, because a visible error is + // investigable and a silent pass is not. + .filter(|relative_path| self.run_config.project_root.join(relative_path).try_exists().unwrap_or(true)) + // Mirror the filtering applied by ProjectBuilder when walking the project. + .filter(|relative_path| { matches_globs(relative_path, &self.config.owned_globs) && !matches_globs(relative_path, &self.config.unowned_globs) }) .collect(); debug_span!("per_file_query").in_scope(|| { - for file_path in filtered_paths { + for relative_path in relative_paths { + // Query with the normalized path rather than the caller's spelling, which + // made the query re-derive it using the same broken `strip_prefix`. + let file_path = relative_path.to_string_lossy().to_string(); match team_for_file_from_codeowners(&self.run_config, &file_path) { Ok(Some(_)) => {} Ok(None) => unowned_files.push(file_path), @@ -196,6 +215,28 @@ impl Runner { RunResult::default() } + /// Reduce a caller-supplied path to project-relative form. + /// + /// An absolute path only strips if it and the root agree about symlinks, and there is + /// no guarantee they do — `cli.rs` canonicalizes `--project-root`, but a library caller + /// building its own `RunConfig` (which is how the `code_ownership` gem calls in) does + /// not. So on macOS, where `TMPDIR` lives under `/var`, a symlink to `/private/var`, + /// *either* side can be the unresolved one, and in a symlinked checkout the same is + /// true generally. + /// + /// Hence the retry resolves both sides rather than just the path: fixing only the path + /// leaves the mirror-image case — a canonical path against an unresolved root — failing + /// exactly as silently. The first attempt uses the root as given, so the common case of + /// relative paths costs no syscalls at all. + fn project_relative_path(root: &Path, canonical_root: Option<&Path>, path: &Path) -> Option { + if let Some(relative) = crate::path_utils::project_relative(root, path) { + return Some(relative); + } + + let canonical_path = path.canonicalize().ok()?; + crate::path_utils::project_relative(canonical_root.unwrap_or(root), &canonical_path) + } + pub fn generate(&self, git_stage: bool) -> RunResult { let content = self.ownership.generate_file(); if let Some(parent) = &self.codeowners_file_path.parent() { diff --git a/tests/supplied_path_normalization_test.rs b/tests/supplied_path_normalization_test.rs new file mode 100644 index 0000000..a7b5beb --- /dev/null +++ b/tests/supplied_path_normalization_test.rs @@ -0,0 +1,242 @@ +//! Normalization of the paths a caller supplies to `validate` / `generate-and-validate`. +//! +//! A supplied path has to be reduced to the project-relative form the rest of the pipeline +//! speaks. When it is not, what happens depends on the shape of `owned_globs`, and both +//! outcomes are wrong: +//! +//! - **Directory-anchored globs** (`{gems,ruby,...}/**/*.rb`, as in `valid_project`): the +//! mishandled path matches nothing, is dropped before any ownership query runs, and the +//! command exits 0 having checked nothing. Silent, and in the unsafe direction — a hook +//! or CI job reports success on a file it never looked at. +//! - **`**`-leading globs** (`**/*.rb`, as in `invalid_project`): the mishandled path +//! survives the filter and is queried in the caller's spelling, which matches no +//! CODEOWNERS entry, so a perfectly well-owned file is reported unowned. +//! +//! Most tests here use `valid_project` and assert *failure* on a genuinely unowned file, +//! because under anchored globs a dropped path shows up as a pass. They also assert the +//! report names the **normalized** path rather than echoing the caller's spelling — without +//! that, a test cannot tell "checked correctly" from "mishandled and spuriously reported", +//! which is exactly how an earlier draft of this file passed against unfixed code. +//! +//! One test covers the `**`-glob direction, since the symptom there is the opposite. +//! +//! Path forms covered, against both states the project root can be in (resolved or not, +//! since `cli.rs` canonicalizes it but a library caller need not): +//! +//! - `./a/b.rb`, `a/c/../b.rb` -- leading `.`, interior `..` +//! - absolute, root and path agreeing about symlinks +//! - absolute, root resolved and path not +//! - absolute, path resolved and root not (library callers only) +//! - a deleted path, and a path outside the project -- both skipped, deliberately + +use assert_cmd::prelude::*; +use codeowners::runner::{self, RunConfig}; +use predicates::prelude::*; +use std::{error::Error, process::Command}; +use tempfile::TempDir; + +mod common; + +use common::*; + +/// The project-relative form every spelling below must reduce to. +const NORMALIZED: &str = "ruby/app/unowned.rb"; + +/// `valid_project` with a genuinely unowned file added. +/// +/// The fixture ships without one on purpose — `test_validate_with_no_files` requires it to +/// validate cleanly — so the file is injected here rather than committed. Its `owned_globs` +/// are directory-anchored, which is what makes a mishandled path get dropped, so that +/// asserting failure below is a real signal. +fn fixture_with_an_unowned_file() -> TempDir { + let temp_dir = setup_fixture_repo(std::path::Path::new("tests/fixtures/valid_project")); + std::fs::write(temp_dir.path().join(NORMALIZED), "# nobody owns this\n").expect("failed to write unowned file"); + git_add_all_files(temp_dir.path()); + temp_dir +} + +/// Assert `validate ` reached the ownership check and reported the file under its +/// normalized name. +fn assert_normalizes(spelling_from: impl Fn(&std::path::Path) -> String) -> Result<(), Box> { + let temp_dir = fixture_with_an_unowned_file(); + let project_root = temp_dir.path(); + let spelling = spelling_from(project_root); + + let output = Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(project_root) + .arg("--no-cache") + .arg("validate") + .arg(&spelling) + .output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + !output.status.success(), + "`validate {spelling}` exited 0 -- the path was dropped before any ownership query ran.\nstdout={stdout}" + ); + assert!( + stdout.contains(NORMALIZED), + "report does not name the normalized path `{NORMALIZED}`.\nstdout={stdout}" + ); + if spelling != NORMALIZED { + assert!( + !stdout.contains(&spelling), + "report echoes the caller's spelling `{spelling}` instead of normalizing it, \ + which means the path reached the query unnormalized.\nstdout={stdout}" + ); + } + + Ok(()) +} + +#[test] +fn test_plain_relative_path() -> Result<(), Box> { + // The control. If this fails, none of the others mean what they claim. + assert_normalizes(|_| NORMALIZED.to_string()) +} + +#[test] +fn test_dot_slash_prefixed_path() -> Result<(), Box> { + assert_normalizes(|_| format!("./{NORMALIZED}")) +} + +#[test] +fn test_path_with_an_interior_parent_dir() -> Result<(), Box> { + assert_normalizes(|_| "ruby/app/models/../unowned.rb".to_string()) +} + +#[test] +fn test_absolute_path_agreeing_with_the_root() -> Result<(), Box> { + assert_normalizes(|root| { + root.canonicalize() + .expect("temp dir should canonicalize") + .join(NORMALIZED) + .to_string_lossy() + .to_string() + }) +} + +#[test] +fn test_absolute_path_when_only_the_root_is_resolved() -> Result<(), Box> { + // `cli.rs` canonicalizes `--project-root`, so on macOS the root becomes + // `/private/var/...` while a caller passing the `TMPDIR` spelling supplies `/var/...`. + // `strip_prefix` then fails, the path stays absolute, and the glob filter drops it. + // Deliberately NOT canonicalized here -- that is the case under test. + assert_normalizes(|root| root.join(NORMALIZED).to_string_lossy().to_string()) +} + +#[test] +fn test_absolute_path_when_only_the_path_is_resolved() { + // The mirror image of the test above, and it has to go through the library API: the CLI + // always canonicalizes `--project-root`, so it cannot produce an unresolved root. A + // library caller can, and does -- the `code_ownership` gem builds its own `RunConfig`. + // + // Worth its own test because resolving only the supplied path fixes the case above + // while leaving this one failing exactly as silently. + let temp_dir = fixture_with_an_unowned_file(); + // Deliberately NOT canonicalized -- that is the point. + let project_root = temp_dir.path().to_path_buf(); + + let canonical_file = project_root.join(NORMALIZED).canonicalize().expect("injected file should exist"); + + let run_config = RunConfig { + project_root: project_root.clone(), + codeowners_file_path: Some(project_root.join(".github/CODEOWNERS")), + config_path: project_root.join("config/code_ownership.yml"), + no_cache: true, + executable_name: None, + }; + + let result = runner::validate(&run_config, vec![canonical_file.to_string_lossy().to_string()]); + + assert!( + result.validation_errors.iter().any(|error| error.contains(NORMALIZED)), + "an unowned file was silently skipped: root={} file={} errors={:?}", + project_root.display(), + canonical_file.display(), + result.validation_errors, + ); +} + +#[test] +fn test_owned_file_is_not_spuriously_reported_under_star_star_globs() -> Result<(), Box> { + // The other failure mode. `invalid_project`'s `owned_globs` are `**`-leading, so a + // mishandled path is not dropped by the filter -- it survives, gets queried in the + // caller's spelling, matches no CODEOWNERS entry, and a well-owned file is reported + // unowned. Same cause, opposite symptom, which is why it needs its own fixture. + // + // Uses `generate-and-validate` because that fixture ships an empty CODEOWNERS, so the + // file has to be generated into it before the query can find it. + let temp_dir = setup_fixture_repo(std::path::Path::new("tests/fixtures/invalid_project")); + let project_root = temp_dir.path(); + git_add_all_files(project_root); + + let codeowners_path = project_root.join("tmp/CODEOWNERS"); + + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(project_root) + .arg("--codeowners-file-path") + .arg(&codeowners_path) + .arg("--no-cache") + .arg("generate-and-validate") + .arg("./ruby/app/models/payroll.rb") + .assert() + .success() + .stdout(predicate::eq("")); + + Ok(()) +} + +#[test] +fn test_deleted_path_is_skipped() -> Result<(), Box> { + // A changeset that deletes a file lists it, so a deleted path reaches `validate` in + // normal use. A deleted file cannot have an owner, so reporting it as unowned fails a + // commit for removing code. + // + // This is the one case where dropping a supplied path silently is right -- unlike every + // other test here, there is no file left to check. Asserted rather than assumed, + // because "silently skipped" is otherwise exactly the bug this file is about. + // + // Uses `generate-and-validate` so the regenerated CODEOWNERS no longer carries the + // deleted file's stale entry, which would otherwise mask the behavior. + let temp_dir = setup_fixture_repo(std::path::Path::new("tests/fixtures/valid_project")); + let project_root = temp_dir.path(); + git_add_all_files(project_root); + + let codeowners_path = project_root.join("tmp/CODEOWNERS"); + std::fs::remove_file(project_root.join("ruby/app/models/payroll.rb"))?; + + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(project_root) + .arg("--codeowners-file-path") + .arg(&codeowners_path) + .arg("--no-cache") + .arg("generate-and-validate") + .arg("ruby/app/models/payroll.rb") + .assert() + .success() + .stdout(predicate::eq("")); + + Ok(()) +} + +#[test] +fn test_path_outside_the_project_is_skipped_not_panicked_on() -> Result<(), Box> { + // A `..` that escapes the root cannot name a project file, so it is dropped rather than + // treated as relative. Pinned mainly so it stays a skip and not a panic. + let temp_dir = fixture_with_an_unowned_file(); + + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(temp_dir.path()) + .arg("--no-cache") + .arg("validate") + .arg("../outside/the_project.rb") + .assert() + .success(); + + Ok(()) +} diff --git a/tests/validate_files_test.rs b/tests/validate_files_test.rs index 541b5bf..47f21fe 100644 --- a/tests/validate_files_test.rs +++ b/tests/validate_files_test.rs @@ -21,12 +21,22 @@ fn test_validate_with_owned_files() -> Result<(), Box> { #[test] fn test_validate_with_unowned_file() -> Result<(), Box> { + // `invalid_project`, not `valid_project`: this needs a file that genuinely has no + // owner, and `valid_project/ruby/app/unowned.rb` does not exist -- by design, since + // `test_validate_with_no_files` requires that fixture to validate cleanly. Pointed at + // the nonexistent path, this test passed only because a nonexistent path was reported + // as unowned, so it was really covering typo handling while claiming to cover unowned + // files. Now that a path which no longer exists is skipped, that accident is gone. + // `invalid_project/ruby/app/unowned.rb` is a real file with no owner. + // + // Asserts the path and the exit status, not the category wording, so it stays valid + // however the report is phrased. run_codeowners( - "valid_project", + "invalid_project", &["validate", "ruby/app/unowned.rb"], false, OutputStream::Stdout, - predicate::str::contains("ruby/app/unowned.rb").and(predicate::str::contains("Unowned")), + predicate::str::contains("ruby/app/unowned.rb"), )?; Ok(()) @@ -34,12 +44,14 @@ fn test_validate_with_unowned_file() -> Result<(), Box> { #[test] fn test_validate_with_mixed_files() -> Result<(), Box> { + // One owned file and one genuinely unowned one; see `test_validate_with_unowned_file` + // for why this uses `invalid_project`. run_codeowners( - "valid_project", + "invalid_project", &["validate", "ruby/app/models/payroll.rb", "ruby/app/unowned.rb"], false, OutputStream::Stdout, - predicate::str::contains("ruby/app/unowned.rb").and(predicate::str::contains("Unowned")), + predicate::str::contains("ruby/app/unowned.rb"), )?; Ok(()) @@ -79,7 +91,9 @@ fn test_generate_and_validate_with_owned_files() -> Result<(), Box> { #[test] fn test_generate_and_validate_with_unowned_file() -> Result<(), Box> { - let fixture_root = std::path::Path::new("tests/fixtures/valid_project"); + // `invalid_project` for the same reason as `test_validate_with_unowned_file`: it holds + // a file that genuinely has no owner. + let fixture_root = std::path::Path::new("tests/fixtures/invalid_project"); let temp_dir = setup_fixture_repo(fixture_root); let project_root = temp_dir.path(); git_add_all_files(project_root); @@ -96,8 +110,7 @@ fn test_generate_and_validate_with_unowned_file() -> Result<(), Box> .arg("ruby/app/unowned.rb") .assert() .failure() - .stdout(predicate::str::contains("ruby/app/unowned.rb")) - .stdout(predicate::str::contains("Unowned")); + .stdout(predicate::str::contains("ruby/app/unowned.rb")); Ok(()) } From 1749a4f402552ca4a99859063944ec46db6657b8 Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Mon, 24 Aug 2026 19:34:05 -0500 Subject: [PATCH 02/13] fix: resolve only the parent when retrying an absolute path Two findings from reviewing the previous commit, one of them a false pass it introduced. The retry canonicalized the whole supplied path, which follows a symlinked *file*. The project walk records the symlink path rather than its target -- the reason lexically_normalize is lexical in the first place, stated in its own doc comment two lines above the code that violated it. So an absolute path naming an unowned symlink was silently checked as its owned target and exited 0: validate ruby/app/models/link_unowned.rb -> exit 1 (correct) validate /tmp/proj/ruby/app/models/link_unowned.rb -> exit 0 (false pass) realpath(link_unowned.rb) = /private/tmp/proj/ruby/app/models/payroll.rb A false pass on a different file than the caller named, which is the exact failure class this branch exists to rule out. The retry now resolves the parent and re-attaches the file name, so the ancestor /var -> /private/var discrepancy is still fixed without following the leaf. A symlinked *ancestor* is still resolved, unavoidably -- that is the point in the /var case -- and the walk does not follow symlinked directories anyway, so such a path names no walked file under either spelling. Writing the invariant down was not enough to enforce it. There was no symlink test, so nothing caught the contradiction; there is one now, and it fails if the whole-path canonicalize is reintroduced. The same defect survived in codeowners_query::teams_for_files_from_codeowners, reached from public API as runner::teams_for_files_from_codeowners. It relativized with relative_to_buf, which passes an unstrippable path through unchanged, so a /var/... path against a /private/var/... root was looked up in the CODEOWNERS file as an absolute path, matched no entry, and came back unowned. Fixing validate while leaving the bulk-lookup entry point beside it would have made the branch's claim narrower than it reads. That one falls back to the path as given rather than dropping it, because the returned map is contracted to hold one entry per input and team_for_file_from_codeowners asserts on that. Note the keys were already the relativized form, not the caller's spelling, so they were inconsistent depending on whether strip_prefix happened to succeed; they are now consistently relative. The retry logic moves to path_utils::resolve_project_relative so both callers share it rather than growing a second copy. Adds two positive guards. Every other assertion in the file is that an unowned file gets reported, which would also hold if normalization mangled a path into some other unowned path; these pin that a well-owned file still resolves to itself and passes under `./` and interior `..` spellings. Co-Authored-By: Claude Fable 5 --- src/ownership/codeowners_query.rs | 18 +++-- src/path_utils.rs | 31 ++++++++- src/runner.rs | 26 +------ tests/supplied_path_normalization_test.rs | 85 ++++++++++++++++++++++- 4 files changed, 129 insertions(+), 31 deletions(-) diff --git a/src/ownership/codeowners_query.rs b/src/ownership/codeowners_query.rs index 562bb48..9dff3a7 100644 --- a/src/ownership/codeowners_query.rs +++ b/src/ownership/codeowners_query.rs @@ -10,15 +10,23 @@ pub(crate) fn teams_for_files_from_codeowners( team_file_globs: &[String], file_paths: &[String], ) -> Result>, String> { + // Normalize the same way `Runner::validate_files` does. This is reached from public API + // (`runner::teams_for_files_from_codeowners`) and had the same defect: + // `relative_to_buf` passes an unstrippable path through unchanged, so an absolute path + // that disagreed with `project_root` about symlinks -- a `/var/...` path against a + // `/private/var/...` root -- was looked up in the CODEOWNERS file *as an absolute path*, + // matched no entry, and came back unowned. + // + // Falls back to the path as given rather than dropping it, because the returned map is + // contracted to hold one entry per input and `team_for_file_from_codeowners` asserts on + // that. A path that cannot be placed inside the project has no owner, which is the + // honest answer for a lookup. + let canonical_root = project_root.canonicalize().ok(); let relative_file_paths: Vec = file_paths .iter() .map(Path::new) .map(|path| { - if path.is_absolute() { - crate::path_utils::relative_to_buf(project_root, path) - } else { - path.to_path_buf() - } + crate::path_utils::resolve_project_relative(project_root, canonical_root.as_deref(), path).unwrap_or_else(|| path.to_path_buf()) }) .collect(); diff --git a/src/path_utils.rs b/src/path_utils.rs index f5962a8..9497b31 100644 --- a/src/path_utils.rs +++ b/src/path_utils.rs @@ -38,6 +38,35 @@ pub fn project_relative(root: &Path, path: &Path) -> Option { Some(normalized) } +/// Like [`project_relative`], but consults the filesystem when the lexical attempt fails. +/// +/// An absolute path only strips if it and `root` agree about symlinks, and there is no +/// guarantee they do. `cli.rs` canonicalizes `--project-root`, but a library caller building +/// its own `RunConfig` (which is how the `code_ownership` gem calls in) does not. So on +/// macOS, where `TMPDIR` lives under `/var`, a symlink to `/private/var`, *either* side can +/// be the unresolved one, and in a symlinked checkout the same is true generally. Resolving +/// only one side leaves the other failing exactly as silently, so the retry resolves both. +/// +/// It resolves the **parent** and re-attaches the file name, rather than canonicalizing the +/// whole path. Canonicalizing the leaf would follow a symlinked *file*, and the project walk +/// records the symlink path rather than its target — so an absolute path naming a symlink +/// would be checked as a different file than the caller asked about, and pass or fail on +/// that file's ownership instead. A symlinked *ancestor* is still resolved, unavoidably: +/// that is the whole point in the `/var` case, and the walk does not follow symlinked +/// directories anyway, so such a path names no walked file under either spelling. +/// +/// `canonical_root` is the resolved `root`, passed in rather than computed so a caller +/// normalizing a whole changeset pays for it once instead of once per path. +pub fn resolve_project_relative(root: &Path, canonical_root: Option<&Path>, path: &Path) -> Option { + if let Some(relative) = project_relative(root, path) { + return Some(relative); + } + + let resolved = path.parent()?.canonicalize().ok()?.join(path.file_name()?); + + project_relative(canonical_root.unwrap_or(root), &resolved) +} + /// Resolve `.` and `..` without touching the filesystem. /// /// Deliberately lexical: canonicalizing would also resolve symlinks, and the project walk @@ -127,7 +156,7 @@ mod tests { #[test] fn project_relative_rejects_an_absolute_path_outside_the_root() { - // The caller retries with a canonicalized copy; see `Runner::project_relative_path`. + // The caller retries with the parent resolved; see `resolve_project_relative`. assert_eq!(project_relative(Path::new("/private/proj"), Path::new("/proj/a.rb")), None); } diff --git a/src/runner.rs b/src/runner.rs index e48ca40..a5c30cf 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -153,14 +153,14 @@ impl Runner { // exited 0 having checked nothing -- a false pass in the unsafe direction. // // The canonical root is resolved once rather than per path, since only the retry - // inside `project_relative_path` needs it and that retry can fire for every path + // inside `resolve_project_relative` needs it and that retry can fire for every path // when a caller passes an absolute list. let canonical_root = self.run_config.project_root.canonicalize().ok(); let relative_paths: Vec = file_paths .iter() .filter_map(|file_path| { - Self::project_relative_path(&self.run_config.project_root, canonical_root.as_deref(), Path::new(file_path)) + crate::path_utils::resolve_project_relative(&self.run_config.project_root, canonical_root.as_deref(), Path::new(file_path)) }) // A path that no longer exists is dropped rather than reported. Changesets // delete files routinely and `git diff --name-only` lists them, so reporting a @@ -215,28 +215,6 @@ impl Runner { RunResult::default() } - /// Reduce a caller-supplied path to project-relative form. - /// - /// An absolute path only strips if it and the root agree about symlinks, and there is - /// no guarantee they do — `cli.rs` canonicalizes `--project-root`, but a library caller - /// building its own `RunConfig` (which is how the `code_ownership` gem calls in) does - /// not. So on macOS, where `TMPDIR` lives under `/var`, a symlink to `/private/var`, - /// *either* side can be the unresolved one, and in a symlinked checkout the same is - /// true generally. - /// - /// Hence the retry resolves both sides rather than just the path: fixing only the path - /// leaves the mirror-image case — a canonical path against an unresolved root — failing - /// exactly as silently. The first attempt uses the root as given, so the common case of - /// relative paths costs no syscalls at all. - fn project_relative_path(root: &Path, canonical_root: Option<&Path>, path: &Path) -> Option { - if let Some(relative) = crate::path_utils::project_relative(root, path) { - return Some(relative); - } - - let canonical_path = path.canonicalize().ok()?; - crate::path_utils::project_relative(canonical_root.unwrap_or(root), &canonical_path) - } - pub fn generate(&self, git_stage: bool) -> RunResult { let content = self.ownership.generate_file(); if let Some(parent) = &self.codeowners_file_path.parent() { diff --git a/tests/supplied_path_normalization_test.rs b/tests/supplied_path_normalization_test.rs index a7b5beb..41e8e8a 100644 --- a/tests/supplied_path_normalization_test.rs +++ b/tests/supplied_path_normalization_test.rs @@ -18,7 +18,10 @@ //! that, a test cannot tell "checked correctly" from "mishandled and spuriously reported", //! which is exactly how an earlier draft of this file passed against unfixed code. //! -//! One test covers the `**`-glob direction, since the symptom there is the opposite. +//! One test covers the `**`-glob direction, since the symptom there is the opposite. Two +//! more assert an *owned* file still passes under each odd spelling — without those, every +//! assertion here would also hold if normalization mangled one path into some other unowned +//! path. //! //! Path forms covered, against both states the project root can be in (resolved or not, //! since `cli.rs` canonicalizes it but a library caller need not): @@ -27,6 +30,7 @@ //! - absolute, root and path agreeing about symlinks //! - absolute, root resolved and path not //! - absolute, path resolved and root not (library callers only) +//! - absolute, naming a symlinked *file* -- must check the symlink, not its target //! - a deleted path, and a path outside the project -- both skipped, deliberately use assert_cmd::prelude::*; @@ -55,6 +59,27 @@ fn fixture_with_an_unowned_file() -> TempDir { temp_dir } +/// Assert `validate ` succeeds, i.e. the path reached the check *and* resolved to +/// a file that really is owned. +/// +/// The counterpart to `assert_normalizes`: those tests would still pass if normalization +/// mangled a path into some *other* unowned path, and these would not. +fn assert_owned_file_passes(spelling: &str) -> Result<(), Box> { + let temp_dir = fixture_with_an_unowned_file(); + + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(temp_dir.path()) + .arg("--no-cache") + .arg("validate") + .arg(spelling) + .assert() + .success() + .stdout(predicate::eq("")); + + Ok(()) +} + /// Assert `validate ` reached the ownership check and reported the file under its /// normalized name. fn assert_normalizes(spelling_from: impl Fn(&std::path::Path) -> String) -> Result<(), Box> { @@ -159,6 +184,64 @@ fn test_absolute_path_when_only_the_path_is_resolved() { ); } +#[test] +fn test_owned_file_passes_with_a_dot_slash_prefix() -> Result<(), Box> { + // A positive guard. Every assertion above is that an *unowned* file gets reported, which + // would also hold if normalization mangled the path into some other unowned path. This + // pins that a well-owned file still resolves to itself and passes. + assert_owned_file_passes("./ruby/app/models/payroll.rb") +} + +#[test] +fn test_owned_file_passes_with_an_interior_parent_dir() -> Result<(), Box> { + assert_owned_file_passes("ruby/app/payments/../models/payroll.rb") +} + +#[test] +fn test_absolute_path_to_a_symlink_names_the_symlink_not_its_target() { + // Regression guard. The retry used to canonicalize the whole supplied path, which + // follows a symlinked *file*, so an absolute path naming a symlink was checked as its + // target -- a different file than the caller asked about. The retry now resolves only + // the parent and re-attaches the file name, fixing the ancestor `/var` -> + // `/private/var` discrepancy without following the leaf. + // + // Both the symlink and its target are unowned here, and the assertion is on *which path + // the report names* rather than on pass/fail. An earlier version pointed the symlink at + // an owned file and asserted failure, which was fixture-coupled and wrong: reading + // through a symlink sees the target's contents, so once ownership is resolved through + // the mappers rather than by reading CODEOWNERS back, the symlink genuinely inherits the + // target's `@team` annotation and is owned. Naming the path sidesteps that entirely. + // + // The symlink is created after `setup_fixture_repo` because that helper copies with + // `fs::copy`, which would follow it and write a regular file instead. + let temp_dir = fixture_with_an_unowned_file(); + let project_root = temp_dir.path(); + + let link = project_root.join("ruby/app/link_to_unowned.rb"); + std::os::unix::fs::symlink("unowned.rb", &link).expect("failed to create symlink"); + git_add_all_files(project_root); + + // Deliberately NOT canonicalized, so the retry fires. + let absolute = link.to_string_lossy().to_string(); + + let output = Command::cargo_bin("codeowners") + .expect("binary") + .arg("--project-root") + .arg(project_root) + .arg("--no-cache") + .arg("validate") + .arg(&absolute) + .output() + .expect("run"); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + stdout.contains("link_to_unowned.rb"), + "the report names the symlink's target instead of the symlink the caller asked \ + about, so the retry followed the leaf.\nstdout={stdout}" + ); +} + #[test] fn test_owned_file_is_not_spuriously_reported_under_star_star_globs() -> Result<(), Box> { // The other failure mode. `invalid_project`'s `owned_globs` are `**`-leading, so a From 9e204f175ea897843b1150531e145563619a77c7 Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Tue, 25 Aug 2026 08:34:22 -0500 Subject: [PATCH 03/13] fix: keep a retained `..` from cancelling its own escape Addresses review of the previous commit, plus a bug found while checking it. lexically_normalize retained a leading `..` so the caller could detect an escape, but pop() does not distinguish that retained `..` from a real component, so a later one popped it. `../../a` therefore cancelled its own escape and came out as `a`: a path plainly outside the project was reported as though it named a file inside it. validate ../ruby/app/unowned.rb -> exit 0 (correctly out of scope) validate ../../ruby/app/unowned.rb -> exit 1 "Unowned files detected: ruby/app/unowned.rb" Same class as the symlink bug the previous commit fixed -- answering about a different file than the caller named -- and the single-`..` case passing made it look handled. A `..` is now only allowed to pop a real component. Restrict the filesystem retry to absolute paths. A relative path is interpreted against the project root by contract, which is what --help promises, and the lexical pass is the whole of that interpretation -- so failure means it escapes the root. Retrying resolved it against the process CWD instead, quietly switching interpretation frames: identical arguments would mean different files depending on where the command ran from. It also spent a syscall per path to reach that wrong answer. Reported by review. Gate the symlink test behind #[cfg(unix)]. std::os::unix::fs::symlink has no portable equivalent and would fail to compile on Windows. This crate ships only macOS and Linux artifacts, so the test is gated rather than made portable, which keeps `cargo test` compiling everywhere. Reported by review. Not changed: review also flagged the `\` line continuations in cli.rs help text as leaving runs of literal spaces. Rust's string-continuation escape strips the newline *and* the following indentation, so it does not. Verified against the rendered output -- the description contains zero interior multi-space runs; the only gap is clap's own column alignment between the argument name and its text. Co-Authored-By: Claude Fable 5 --- src/path_utils.rs | 50 ++++++++++++++++++++++- tests/supplied_path_normalization_test.rs | 8 +++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/path_utils.rs b/src/path_utils.rs index 9497b31..d58f126 100644 --- a/src/path_utils.rs +++ b/src/path_utils.rs @@ -62,6 +62,16 @@ pub fn resolve_project_relative(root: &Path, canonical_root: Option<&Path>, path return Some(relative); } + // Only an absolute path can be rescued. A relative path is interpreted against the + // project root by contract -- that is what `--help` promises -- and the lexical pass is + // the whole of that interpretation, so failure means it escapes the root. Retrying would + // resolve it against the process CWD instead, quietly switching interpretation frames: + // the same arguments would then mean different files depending on where the command was + // run from. It also spends a syscall per path to reach that wrong answer. + if !path.is_absolute() { + return None; + } + let resolved = path.parent()?.canonicalize().ok()?.join(path.file_name()?); project_relative(canonical_root.unwrap_or(root), &resolved) @@ -80,7 +90,13 @@ fn lexically_normalize(path: &Path) -> PathBuf { Component::CurDir => {} Component::ParentDir => { // A `..` that cannot pop is retained, so the caller can detect the escape. - if !normalized.pop() { + // + // A retained `..` must never itself be popped by a later one: `pop()` does + // not distinguish it from a real component, so `../../a` cancelled its own + // escape and came out as `a` -- reporting an out-of-project path as though + // it named a file inside the project. + let escaped = matches!(normalized.components().next_back(), Some(Component::ParentDir)); + if escaped || !normalized.pop() { normalized.push(Component::ParentDir); } } @@ -165,6 +181,38 @@ mod tests { assert_eq!(project_relative(Path::new("/proj"), Path::new("../outside/a.rb")), None); } + #[test] + fn project_relative_rejects_a_path_escaping_via_repeated_parent_dirs() { + // `pop()` does not distinguish a retained `..` from a real component, so this used to + // cancel its own escape and come out as `ruby/app/a.rb` -- an out-of-project path + // silently reported as though it named a file inside the project. + assert_eq!(project_relative(Path::new("/proj"), Path::new("../../ruby/app/a.rb")), None); + assert_eq!(project_relative(Path::new("/proj"), Path::new("../../../a.rb")), None); + } + + #[test] + fn project_relative_rejects_a_path_that_climbs_back_out() { + // Interior `..` still pops normally; the escape only has to survive once it starts. + assert_eq!(project_relative(Path::new("/proj"), Path::new("ruby/../../a.rb")), None); + } + + #[test] + fn project_relative_pops_interior_parent_dirs_without_escaping() { + let rel = project_relative(Path::new("/proj"), Path::new("ruby/app/models/../../app/a.rb")); + assert_eq!(rel, Some(PathBuf::from("ruby/app/a.rb"))); + } + + #[test] + fn resolve_project_relative_does_not_touch_the_filesystem_for_a_relative_path() { + // A relative path is project-root-relative by contract, so the lexical pass is the + // whole interpretation. Retrying would resolve it against the process CWD, making + // the same arguments mean different files depending on where the command was run. + assert_eq!( + resolve_project_relative(Path::new("/proj"), Some(Path::new("/proj")), Path::new("../outside/a.rb")), + None + ); + } + #[test] fn project_relative_rejects_the_root_itself() { assert_eq!(project_relative(Path::new("/proj"), Path::new("/proj")), None); diff --git a/tests/supplied_path_normalization_test.rs b/tests/supplied_path_normalization_test.rs index 41e8e8a..9c215ba 100644 --- a/tests/supplied_path_normalization_test.rs +++ b/tests/supplied_path_normalization_test.rs @@ -197,6 +197,10 @@ fn test_owned_file_passes_with_an_interior_parent_dir() -> Result<(), Box Result<(), Box> { #[test] fn test_path_outside_the_project_is_skipped_not_panicked_on() -> Result<(), Box> { // A `..` that escapes the root cannot name a project file, so it is dropped rather than - // treated as relative. Pinned mainly so it stays a skip and not a panic. + // treated as relative. Pinned mainly so it stays a skip and not a panic -- and, since the + // filesystem retry is now gated to absolute paths, so that it stays a skip regardless of + // the process CWD. let temp_dir = fixture_with_an_unowned_file(); Command::cargo_bin("codeowners")? From 879a1db54c9adeb77968ddd57e7958dd50b46d50 Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Fri, 21 Aug 2026 09:13:44 -0500 Subject: [PATCH 04/13] test: expose the validate/gv parity gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passing file paths swaps validate_all() for validate_files() (runner.rs:124). validate_all runs three checks — validate_invalid_team, validate_file_ownership, validate_codeowners_file (validator.rs:40-57). validate_files runs none of them; it only asks whether each path resolves to a team when reading the CODEOWNERS file. gv regenerates before validating, which cures staleness by construction, but not the other two. Worse, regenerating writes a dual-owned file into CODEOWNERS under one of its owners, so the per-path check then sees an owner and passes. Regenerating conceals that defect rather than exposing it. Five tests, all failing, all #[ignore]d so the suite stays green: - gv exits 0 with no output, twice over — once for annotation vs .codeowner, once for annotation vs owned_gems. They travel through different mappers, so a fix could catch one and miss the other. - gv fails, but reports "unowned" instead of naming the nonexistent team, sending the developer after the wrong problem. - gv with every owned path disagrees with gv with no paths about which defects exist. This is the general form, and needs no knowledge of what the fixture contains. - validate exits 0 having never checked the file. cli.rs canonicalizes --project-root, so a /var/... path fails strip_prefix against a /private/var/... root, stays absolute, and is then dropped by the owned_globs filter. Silent, and it fails in the unsafe direction. The last one is unrelated to the parity gap and predates it — it dates to the owned_globs filter added by #89 for #88. No production code changes. Run with: cargo test --test validate_files_parity_test -- --ignored Co-Authored-By: Claude Fable 5 --- tests/validate_files_parity_test.rs | 205 ++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 tests/validate_files_parity_test.rs diff --git a/tests/validate_files_parity_test.rs b/tests/validate_files_parity_test.rs new file mode 100644 index 0000000..174948e --- /dev/null +++ b/tests/validate_files_parity_test.rs @@ -0,0 +1,205 @@ +//! Parity between `validate` / `gv` with an explicit file list and the same command with +//! no file list. +//! +//! Passing paths swaps `validate_all()` for `validate_files()` (`runner.rs:124`). +//! `validate_all` runs three checks -- `validate_invalid_team`, `validate_file_ownership`, +//! `validate_codeowners_file` (`validator.rs:40-57`). `validate_files` runs none of them; +//! it only asks whether each path resolves to a team when reading the CODEOWNERS file. +//! +//! `gv ` regenerates before validating, which cures staleness by construction, but +//! not the other two. Worse, regenerating makes a dual-owned file *appear* owned, so the +//! per-path check waves it through. +//! +//! EVERY TEST IN THIS FILE CURRENTLY FAILS, so all are `#[ignore]`d to keep the suite green. +//! They assert the behavior we want and exist to document the gap. Run them with: +//! +//! ```sh +//! cargo test --test validate_files_parity_test -- --ignored +//! ``` +//! +//! Remove the `#[ignore]` attributes as each is fixed. + +use assert_cmd::prelude::*; +use predicates::prelude::*; +use std::{error::Error, process::Command}; + +mod common; + +use common::*; + +/// The `invalid_project` fixture carries one defect of each class. Full `validate` reports +/// all of them; see `tests/invalid_project_test.rs`. +const FIXTURE: &str = "tests/fixtures/invalid_project"; + +#[test] +#[ignore = "documents the validate/gv parity gap; remove when fixed"] +fn test_gv_with_paths_detects_dual_ownership_via_codeowner_file() -> Result<(), Box> { + // `ruby/app/services/multi_owned.rb` is owned twice: a `@team Payments` annotation and + // `ruby/app/services/.codeowner` naming Payroll. Full `gv` reports "Code ownership + // should only be defined for each file in one way". + // + // BUG: `gv` regenerates first, which writes the file into CODEOWNERS as @PaymentTeam. + // The per-path check then finds an owner and exits 0. A false pass -- the commit is + // waved through with genuinely ambiguous ownership. + let temp_dir = setup_fixture_repo(std::path::Path::new(FIXTURE)); + let project_root = temp_dir.path(); + git_add_all_files(project_root); + + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(project_root) + .arg("--no-cache") + .arg("gv") + .arg("ruby/app/services/multi_owned.rb") + .assert() + .failure() + .stdout(predicate::str::contains("multi_owned.rb").and(predicate::str::contains("one way"))); + + Ok(()) +} + +#[test] +#[ignore = "documents the validate/gv parity gap; remove when fixed"] +fn test_gv_with_paths_detects_dual_ownership_via_owned_gems() -> Result<(), Box> { + // Same class, different source: `gems/payroll_calculator/calculator.rb` has a + // `@team Payments` annotation while Payroll claims it through `owned_gems`. + // + // BUG: same false pass. Included separately because the two travel through different + // mappers, so a fix could plausibly catch one and miss the other. + let temp_dir = setup_fixture_repo(std::path::Path::new(FIXTURE)); + let project_root = temp_dir.path(); + git_add_all_files(project_root); + + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(project_root) + .arg("--no-cache") + .arg("gv") + .arg("gems/payroll_calculator/calculator.rb") + .assert() + .failure() + .stdout(predicate::str::contains("calculator.rb").and(predicate::str::contains("one way"))); + + Ok(()) +} + +#[test] +#[ignore = "documents the validate/gv parity gap; remove when fixed"] +fn test_gv_with_paths_names_the_invalid_team() -> Result<(), Box> { + // `ruby/app/models/blockchain.rb` is annotated `@team Web3`, which is not a team. Full + // `gv` reports "is referencing an invalid team - 'Web3'". + // + // BUG: this one does fail, but for the wrong reason. An invalid team yields no owner, so + // the file is absent from the generated CODEOWNERS and gets reported as merely "unowned". + // The actual fault -- a typo'd team name -- is never named, so the developer goes looking + // for missing ownership instead of fixing the annotation. + let temp_dir = setup_fixture_repo(std::path::Path::new(FIXTURE)); + let project_root = temp_dir.path(); + git_add_all_files(project_root); + + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(project_root) + .arg("--no-cache") + .arg("gv") + .arg("ruby/app/models/blockchain.rb") + .assert() + .failure() + .stdout(predicate::str::contains("Web3")); + + Ok(()) +} + +#[test] +#[ignore = "documents the validate/gv parity gap; remove when fixed"] +fn test_gv_with_every_path_matches_gv_with_no_paths() -> Result<(), Box> { + // The differential check: handing over every owned file should be equivalent to handing + // over none. This is the general form of the three tests above -- it needs no knowledge + // of which defects the fixture contains, so it keeps working as fixtures change. + // + // BUG: the no-paths run reports dual ownership and the invalid team; the all-paths run + // reports neither. + let temp_dir = setup_fixture_repo(std::path::Path::new(FIXTURE)); + let project_root = temp_dir.path(); + git_add_all_files(project_root); + + // owned_globs for this fixture is `**/*.{rb,tsx}`. + let tracked = Command::new("git").arg("ls-files").current_dir(project_root).output()?; + let owned_files: Vec = String::from_utf8(tracked.stdout)? + .lines() + .filter(|line| line.ends_with(".rb") || line.ends_with(".tsx")) + .map(str::to_string) + .collect(); + assert!(!owned_files.is_empty(), "fixture should contain owned files"); + + let no_paths = Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(project_root) + .arg("--no-cache") + .arg("gv") + .output()?; + + let all_paths = Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(project_root) + .arg("--no-cache") + .arg("gv") + .args(&owned_files) + .output()?; + + // Compare the defects each run found, not byte-for-byte output: the two use different + // report formats, and only the substance is being claimed here. + let no_paths_out = String::from_utf8_lossy(&no_paths.stdout); + let all_paths_out = String::from_utf8_lossy(&all_paths.stdout); + + for defect in ["one way", "Web3"] { + assert_eq!( + no_paths_out.contains(defect), + all_paths_out.contains(defect), + "`gv` with no paths and `gv` with every path disagree about {:?}.\n\ + \n--- no paths (exit {:?}) ---\n{}\n--- every path (exit {:?}) ---\n{}", + defect, + no_paths.status.code(), + no_paths_out, + all_paths.status.code(), + all_paths_out, + ); + } + + Ok(()) +} + +#[test] +#[ignore = "documents the validate/gv parity gap; remove when fixed"] +fn test_validate_does_not_silently_skip_absolute_paths() -> Result<(), Box> { + // Unrelated to the parity gap above, and the most dangerous of the set because it is + // completely silent. + // + // `cli.rs` canonicalizes `--project-root`. On macOS the temp dir is under `/var`, which + // canonicalizes to `/private/var`, so a caller-supplied `/var/...` path fails + // `strip_prefix`, stays absolute, and is then rejected by the `owned_globs` filter -- + // dropped before any ownership query runs. Exit 0, no output, file never checked. + // + // valid_project is used here because its owned_globs are directory-anchored + // (`{gems,config,javascript,ruby,components}/**`). With a `**`-leading glob the same path + // survives the filter and is reported spuriously unowned instead, so the symptom is + // config-dependent while the cause is the same. + let temp_dir = setup_fixture_repo(std::path::Path::new("tests/fixtures/valid_project")); + let project_root = temp_dir.path(); + git_add_all_files(project_root); + + // Deliberately NOT canonicalized -- that is the bug. + let absolute = project_root.join("ruby/app/unowned.rb"); + + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(project_root) + .arg("--no-cache") + .arg("validate") + .arg(absolute.to_str().unwrap()) + .assert() + .failure() + .stdout(predicate::str::contains("unowned.rb")); + + Ok(()) +} From 3acb8b1249a04190b25034bb46172dd5959b5ae9 Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Fri, 21 Aug 2026 10:39:29 -0500 Subject: [PATCH 05/13] fix: run the real ownership checks when paths are supplied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate_files answered only "does this path have an owner in the CODEOWNERS file". That could not see the two defects validate_all catches per file: - A file owned two ways. Generation picks one winner and writes it, so reading CODEOWNERS back finds an owner and passes. `gv ` exited 0 with empty output — regenerating first concealed the defect instead of exposing it. - An annotation naming a nonexistent team. That yields no owner, so the file was absent from the generated CODEOWNERS and reported as merely "unowned", sending the developer after missing ownership rather than a typo'd team. Now ownership for supplied paths is resolved through the mappers, the same way the whole-project run does. Validator gains a scoped entry point that runs validate_invalid_team and validate_file_ownership over just the named files, so a caller pays O(changed files) rather than O(repo). Both checks were already per-file — validate_file_ownership iterates file_to_owners(), which is a par_iter over project.files — so scoping them is a filter, not a rewrite. The mappers are built either way, by the project build both paths already pay. Package ownership is checked in full regardless of the path list. Packages are orders of magnitude fewer than files, and skipping them would leave a second blind spot. Staleness is still not checked for a supplied path list, and cannot be: it compares the whole generated file against the whole on-disk one. `gv ` makes it moot by regenerating first. A team file or .codeowner change can therefore still alter ownership of files outside the changeset without being caught — that gap wants an escalation path, which this commit does not add. One behavior change worth noting: unowned files supplied by path now report as "Some files are missing ownership", the same wording the whole-project run uses, rather than "Unowned files detected:". Same defect, same words, whether or not paths are passed — which is the point. Three test assertions updated for the new wording, and test_validate_only_checks_codeowners_file is renamed, since it documented the very behavior this removes. Absolute paths now render project-relative rather than as the caller wrote them, because the validator reports relative paths. Four of the five parity tests from the previous commit now pass and are un-ignored. The fifth stays ignored: non-canonical absolute paths are still dropped by the owned_globs filter before any check runs, which is a separate pre-existing bug. Co-Authored-By: Claude Fable 5 --- src/ownership.rs | 18 ++++++- src/ownership/validator.rs | 83 +++++++++++++++++++++++------ src/runner.rs | 74 +++++++++---------------- tests/validate_files_parity_test.rs | 57 ++++++++++---------- tests/validate_files_test.rs | 25 +++++---- 5 files changed, 154 insertions(+), 103 deletions(-) diff --git a/src/ownership.rs b/src/ownership.rs index bbd2099..c964444 100644 --- a/src/ownership.rs +++ b/src/ownership.rs @@ -4,7 +4,7 @@ use mapper::{OwnerMatcher, Source, TeamName}; use std::{ error::Error, fmt::{self, Display}, - path::Path, + path::{Path, PathBuf}, sync::Arc, }; use tracing::{info, instrument}; @@ -129,6 +129,22 @@ impl Ownership { validator.validate() } + /// Like [`Ownership::validate`], but restricted to the supplied project-relative + /// paths. Skips the staleness check, which cannot be scoped — see + /// [`Validator::validate_files`]. + #[instrument(name = "ownership_validate_files", level = "debug", skip_all)] + pub fn validate_files(&self, relative_paths: &[PathBuf]) -> Result<(), ValidatorErrors> { + info!("validating file ownership for {} supplied paths", relative_paths.len()); + let validator = Validator { + project: self.project.clone(), + mappers: self.mappers(), + file_generator: FileGenerator { mappers: self.mappers() }, + executable_name: self.project.executable_name.clone(), + }; + + validator.validate_files(relative_paths) + } + #[instrument(level = "debug", skip_all)] pub fn for_file(&self, file_path: &Path) -> Result, ValidatorErrors> { info!("getting file ownership for {}", file_path.display()); diff --git a/src/ownership/validator.rs b/src/ownership/validator.rs index e7362d8..8b75b8c 100644 --- a/src/ownership/validator.rs +++ b/src/ownership/validator.rs @@ -2,7 +2,7 @@ use crate::project::{Project, ProjectFile}; use core::fmt; use std::collections::HashSet; use std::fmt::Display; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use itertools::Itertools; @@ -39,12 +39,13 @@ impl Validator { #[instrument(name = "validator_validate", level = "debug", skip_all)] pub fn validate(&self) -> Result<(), Errors> { let mut validation_errors = Vec::new(); + let files: Vec<&ProjectFile> = self.project.files.iter().collect(); debug!("validate_invalid_team"); - validation_errors.append(&mut self.validate_invalid_team()); + validation_errors.append(&mut self.validate_invalid_team(&files)); debug!("validate_file_ownership"); - validation_errors.append(&mut self.validate_file_ownership()); + validation_errors.append(&mut self.validate_file_ownership(&files)); debug!("validate_codeowners_file"); validation_errors.append(&mut self.validate_codeowners_file()); @@ -56,24 +57,77 @@ impl Validator { } } + /// Validation restricted to `relative_paths`. + /// + /// Runs the same per-file checks as [`Validator::validate`] — invalid team + /// annotations and file ownership — over just the named files, so a caller with a + /// changeset pays O(changed files) rather than O(repo). Ownership is resolved + /// through the mappers, exactly as the whole-project run does, so a file owned two + /// ways is reported rather than silently resolving to whichever owner happened to + /// win in the generated CODEOWNERS. + /// + /// The staleness check is deliberately absent: it compares the entire generated + /// file against the entire on-disk one and cannot be scoped. `generate_and_validate` + /// makes it moot by regenerating first; a caller that needs it on its own must run + /// [`Validator::validate`]. + /// + /// Package ownership is checked in full regardless of the path list — packages are + /// orders of magnitude fewer than files, and skipping them would leave a second + /// blind spot. + #[instrument(name = "validate_scoped", level = "debug", skip_all)] + pub fn validate_files(&self, relative_paths: &[PathBuf]) -> Result<(), Errors> { + let requested: HashSet<&Path> = relative_paths.iter().map(PathBuf::as_path).collect(); + + let files: Vec<&ProjectFile> = self + .project + .files + .iter() + .filter(|file| requested.contains(self.project.relative_path(&file.path))) + .collect(); + + let mut validation_errors = Vec::new(); + + // A requested path the project never walked cannot be attributed to a team. + // Report it as unowned, which is what a whole-project run says about any file + // it can't attribute. + let known: HashSet<&Path> = files.iter().map(|file| self.project.relative_path(&file.path)).collect(); + validation_errors.extend( + relative_paths + .iter() + .filter(|path| !known.contains(path.as_path())) + .map(|path| Error::FileWithoutOwner { path: path.clone() }), + ); + + debug!("validate_invalid_team (scoped)"); + validation_errors.append(&mut self.validate_invalid_team(&files)); + + debug!("validate_file_ownership (scoped)"); + validation_errors.append(&mut self.validate_file_ownership(&files)); + + if validation_errors.is_empty() { + Ok(()) + } else { + Err(Errors(validation_errors)) + } + } + #[instrument(name = "validate_invalid_team", level = "debug", skip_all)] - fn validate_invalid_team(&self) -> Vec { + fn validate_invalid_team(&self, files: &[&ProjectFile]) -> Vec { debug!("validating project"); let mut errors: Vec = Vec::new(); let team_names: HashSet<&TeamName> = self.project.teams.iter().map(|team| &team.name).collect(); - errors.append(&mut self.invalid_team_annotation(&team_names)); + errors.append(&mut self.invalid_team_annotation(&team_names, files)); errors.append(&mut self.invalid_package_ownership(&team_names)); errors } - fn invalid_team_annotation(&self, team_names: &HashSet<&String>) -> Vec { + fn invalid_team_annotation(&self, team_names: &HashSet<&String>, files: &[&ProjectFile]) -> Vec { let project = self.project.clone(); - self.project - .files + files .par_iter() .flat_map(|file| { if let Some(owner) = &file.owner @@ -108,10 +162,10 @@ impl Validator { } #[instrument(name = "validate_file_ownership", level = "debug", skip_all)] - fn validate_file_ownership(&self) -> Vec { + fn validate_file_ownership(&self, files: &[&ProjectFile]) -> Vec { let mut validation_errors = Vec::new(); - for (file, owners) in self.file_to_owners() { + for (file, owners) in self.file_to_owners(files) { let relative_path = self.project.relative_path(&file.path).to_owned(); if owners.is_empty() { @@ -143,20 +197,19 @@ impl Validator { } #[instrument(name = "file_to_owners", level = "debug", skip_all)] - fn file_to_owners(&self) -> Vec<(&ProjectFile, Vec)> { + fn file_to_owners<'a>(&'a self, files: &[&'a ProjectFile]) -> Vec<(&'a ProjectFile, Vec)> { let owner_matchers: Vec = self.mappers.iter().flat_map(|mapper| mapper.owner_matchers()).collect(); let file_owner_finder = FileOwnerFinder { owner_matchers: &owner_matchers, }; let project = self.project.clone(); - self.project - .files + files .par_iter() - .filter_map(|project_file| { + .map(|project_file| { let relative_path = project.relative_path(&project_file.path); let owners = file_owner_finder.find(relative_path); - Some((project_file, owners)) + (*project_file, owners) }) .collect() } diff --git a/src/runner.rs b/src/runner.rs index a5c30cf..1932424 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -142,15 +142,22 @@ impl Runner { } } + /// Validate just the supplied paths. + /// + /// This resolves ownership through the mappers, the same way [`Runner::validate_all`] + /// does, rather than by reading the generated CODEOWNERS back. Reading it back could + /// only ever answer "does this path have an owner" — it could not see a file owned two + /// ways (generation picks one winner, so the file looks owned) nor name an annotation + /// referencing a nonexistent team (which yields no owner, so the file merely looked + /// unowned). + /// + /// Staleness is not checked here; it is a property of the whole CODEOWNERS file. + /// `generate_and_validate` makes it moot by regenerating first. fn validate_files(&self, file_paths: Vec) -> RunResult { - let mut unowned_files = Vec::new(); - let mut io_errors = Vec::new(); - // Normalize before anything else. A caller-supplied path has to be reduced to the - // project-relative form the rest of the pipeline speaks, or it silently matches - // nothing: `./ruby/app/x.rb`, and an absolute path that disagrees with the root - // about symlinks, were both dropped by the glob filter below, and the run then - // exited 0 having checked nothing -- a false pass in the unsafe direction. + // project-relative form the rest of the pipeline speaks, or the per-file checks + // below match nothing and the run exits 0 having checked nothing -- a false pass in + // the unsafe direction. // // The canonical root is resolved once rather than per path, since only the retry // inside `resolve_project_relative` needs it and that retry can fire for every path @@ -162,57 +169,28 @@ impl Runner { .filter_map(|file_path| { crate::path_utils::resolve_project_relative(&self.run_config.project_root, canonical_root.as_deref(), Path::new(file_path)) }) - // A path that no longer exists is dropped rather than reported. Changesets - // delete files routinely and `git diff --name-only` lists them, so reporting a - // deleted file as unowned fails a commit for removing code -- and a deleted - // file cannot have an owner. The wrapping `code_ownership` gem already filters - // its list by `File.exist?` before calling in; doing it here too covers callers - // that use the library directly. - // - // `unwrap_or(true)` because only a definite "this is not there" earns a silent - // skip. If the answer is unknown -- a permissions error, a broken symlink -- - // keep the path and let the check report it, because a visible error is - // investigable and a silent pass is not. + // A path that no longer exists is dropped rather than reported: a deleted file + // cannot have an owner, and changesets delete files routinely. .filter(|relative_path| self.run_config.project_root.join(relative_path).try_exists().unwrap_or(true)) - // Mirror the filtering applied by ProjectBuilder when walking the project. + // Mirror the filtering applied by ProjectBuilder when walking the project, so a + // path the project would never have considered is not reported as unowned. .filter(|relative_path| { matches_globs(relative_path, &self.config.owned_globs) && !matches_globs(relative_path, &self.config.unowned_globs) }) .collect(); - debug_span!("per_file_query").in_scope(|| { - for relative_path in relative_paths { - // Query with the normalized path rather than the caller's spelling, which - // made the query re-derive it using the same broken `strip_prefix`. - let file_path = relative_path.to_string_lossy().to_string(); - match team_for_file_from_codeowners(&self.run_config, &file_path) { - Ok(Some(_)) => {} - Ok(None) => unowned_files.push(file_path), - Err(err) => io_errors.push(format!("{}: {}", file_path, err)), - } - } - }); - - if !unowned_files.is_empty() { - let validation_errors = std::iter::once("Unowned files detected:".to_string()) - .chain(unowned_files.into_iter().map(|file| format!(" {}", file))) - .collect(); - - return RunResult { - validation_errors, - io_errors, - ..Default::default() - }; + if relative_paths.is_empty() { + return RunResult::default(); } - if !io_errors.is_empty() { - return RunResult { - io_errors, + match self.ownership.validate_files(&relative_paths) { + Ok(_) => RunResult::default(), + Err(err) => RunResult { + info_messages: err.info_messages(), + validation_errors: vec![format!("{}", err)], ..Default::default() - }; + }, } - - RunResult::default() } pub fn generate(&self, git_stage: bool) -> RunResult { diff --git a/tests/validate_files_parity_test.rs b/tests/validate_files_parity_test.rs index 174948e..73d2a23 100644 --- a/tests/validate_files_parity_test.rs +++ b/tests/validate_files_parity_test.rs @@ -1,23 +1,25 @@ //! Parity between `validate` / `gv` with an explicit file list and the same command with //! no file list. //! -//! Passing paths swaps `validate_all()` for `validate_files()` (`runner.rs:124`). -//! `validate_all` runs three checks -- `validate_invalid_team`, `validate_file_ownership`, -//! `validate_codeowners_file` (`validator.rs:40-57`). `validate_files` runs none of them; -//! it only asks whether each path resolves to a team when reading the CODEOWNERS file. +//! Passing paths routes through `validate_files()` instead of `validate_all()` +//! (`runner.rs:124`). Both resolve ownership through the mappers, so both catch an +//! invalid team annotation and a file owned two ways; `validate_files` simply scopes the +//! per-file checks to the supplied paths. //! -//! `gv ` regenerates before validating, which cures staleness by construction, but -//! not the other two. Worse, regenerating makes a dual-owned file *appear* owned, so the -//! per-path check waves it through. +//! Only the staleness check differs, and unavoidably so: it compares the whole generated +//! CODEOWNERS against the whole on-disk one, so it cannot be scoped to a subset. +//! `gv ` makes it moot by regenerating first. //! -//! EVERY TEST IN THIS FILE CURRENTLY FAILS, so all are `#[ignore]`d to keep the suite green. -//! They assert the behavior we want and exist to document the gap. Run them with: +//! These previously all failed. `validate_files` used to answer only "does this path have +//! an owner in the CODEOWNERS file", which could not see a file owned two ways — generation +//! picks one winner, so the file looked owned and the command exited 0. They are kept as +//! regression guards against reintroducing that shortcut. +//! +//! One test remains `#[ignore]`d for a separate, still-unfixed bug. Run it with: //! //! ```sh //! cargo test --test validate_files_parity_test -- --ignored //! ``` -//! -//! Remove the `#[ignore]` attributes as each is fixed. use assert_cmd::prelude::*; use predicates::prelude::*; @@ -32,15 +34,14 @@ use common::*; const FIXTURE: &str = "tests/fixtures/invalid_project"; #[test] -#[ignore = "documents the validate/gv parity gap; remove when fixed"] fn test_gv_with_paths_detects_dual_ownership_via_codeowner_file() -> Result<(), Box> { // `ruby/app/services/multi_owned.rb` is owned twice: a `@team Payments` annotation and // `ruby/app/services/.codeowner` naming Payroll. Full `gv` reports "Code ownership // should only be defined for each file in one way". // - // BUG: `gv` regenerates first, which writes the file into CODEOWNERS as @PaymentTeam. - // The per-path check then finds an owner and exits 0. A false pass -- the commit is - // waved through with genuinely ambiguous ownership. + // Regression guard. This used to exit 0 with empty output: `gv` regenerates first, + // writing the file into CODEOWNERS under @PaymentTeam, so a check that read CODEOWNERS + // back found an owner and passed. Regeneration concealed the defect. let temp_dir = setup_fixture_repo(std::path::Path::new(FIXTURE)); let project_root = temp_dir.path(); git_add_all_files(project_root); @@ -59,13 +60,12 @@ fn test_gv_with_paths_detects_dual_ownership_via_codeowner_file() -> Result<(), } #[test] -#[ignore = "documents the validate/gv parity gap; remove when fixed"] fn test_gv_with_paths_detects_dual_ownership_via_owned_gems() -> Result<(), Box> { // Same class, different source: `gems/payroll_calculator/calculator.rb` has a // `@team Payments` annotation while Payroll claims it through `owned_gems`. // - // BUG: same false pass. Included separately because the two travel through different - // mappers, so a fix could plausibly catch one and miss the other. + // Regression guard, same false pass. Kept separate because the two travel through + // different mappers, so a regression could reappear in one and not the other. let temp_dir = setup_fixture_repo(std::path::Path::new(FIXTURE)); let project_root = temp_dir.path(); git_add_all_files(project_root); @@ -84,15 +84,13 @@ fn test_gv_with_paths_detects_dual_ownership_via_owned_gems() -> Result<(), Box< } #[test] -#[ignore = "documents the validate/gv parity gap; remove when fixed"] fn test_gv_with_paths_names_the_invalid_team() -> Result<(), Box> { // `ruby/app/models/blockchain.rb` is annotated `@team Web3`, which is not a team. Full // `gv` reports "is referencing an invalid team - 'Web3'". // - // BUG: this one does fail, but for the wrong reason. An invalid team yields no owner, so - // the file is absent from the generated CODEOWNERS and gets reported as merely "unowned". - // The actual fault -- a typo'd team name -- is never named, so the developer goes looking - // for missing ownership instead of fixing the annotation. + // Regression guard. This used to fail, but for the wrong reason: an invalid team yields + // no owner, so the file was absent from the generated CODEOWNERS and reported as merely + // "unowned", sending the developer after missing ownership instead of a typo'd team. let temp_dir = setup_fixture_repo(std::path::Path::new(FIXTURE)); let project_root = temp_dir.path(); git_add_all_files(project_root); @@ -111,14 +109,14 @@ fn test_gv_with_paths_names_the_invalid_team() -> Result<(), Box> { } #[test] -#[ignore = "documents the validate/gv parity gap; remove when fixed"] fn test_gv_with_every_path_matches_gv_with_no_paths() -> Result<(), Box> { // The differential check: handing over every owned file should be equivalent to handing // over none. This is the general form of the three tests above -- it needs no knowledge // of which defects the fixture contains, so it keeps working as fixtures change. // - // BUG: the no-paths run reports dual ownership and the invalid team; the all-paths run - // reports neither. + // Regression guard, and the most valuable of the set: it needs no knowledge of the + // fixture's contents, so it keeps working as fixtures change. The all-paths run used to + // report neither the dual ownership nor the invalid team. let temp_dir = setup_fixture_repo(std::path::Path::new(FIXTURE)); let project_root = temp_dir.path(); git_add_all_files(project_root); @@ -147,8 +145,9 @@ fn test_gv_with_every_path_matches_gv_with_no_paths() -> Result<(), Box Result<(), Box Result<(), Box> { // Unrelated to the parity gap above, and the most dangerous of the set because it is // completely silent. diff --git a/tests/validate_files_test.rs b/tests/validate_files_test.rs index 47f21fe..bbe53b1 100644 --- a/tests/validate_files_test.rs +++ b/tests/validate_files_test.rs @@ -36,7 +36,9 @@ fn test_validate_with_unowned_file() -> Result<(), Box> { &["validate", "ruby/app/unowned.rb"], false, OutputStream::Stdout, - predicate::str::contains("ruby/app/unowned.rb"), + // Same wording a whole-project `validate` uses for an unattributable file -- + // supplying paths no longer produces a separate "Unowned files detected:" format. + predicate::str::contains("ruby/app/unowned.rb").and(predicate::str::contains("missing ownership")), )?; Ok(()) @@ -51,7 +53,9 @@ fn test_validate_with_mixed_files() -> Result<(), Box> { &["validate", "ruby/app/models/payroll.rb", "ruby/app/unowned.rb"], false, OutputStream::Stdout, - predicate::str::contains("ruby/app/unowned.rb"), + // Same wording a whole-project `validate` uses for an unattributable file -- + // supplying paths no longer produces a separate "Unowned files detected:" format. + predicate::str::contains("ruby/app/unowned.rb").and(predicate::str::contains("missing ownership")), )?; Ok(()) @@ -110,7 +114,8 @@ fn test_generate_and_validate_with_unowned_file() -> Result<(), Box> .arg("ruby/app/unowned.rb") .assert() .failure() - .stdout(predicate::str::contains("ruby/app/unowned.rb")); + .stdout(predicate::str::contains("ruby/app/unowned.rb")) + .stdout(predicate::str::contains("missing ownership")); Ok(()) } @@ -137,14 +142,14 @@ fn test_validate_with_absolute_path() -> Result<(), Box> { } #[test] -fn test_validate_only_checks_codeowners_file() -> Result<(), Box> { - // This test demonstrates that `validate` with files only checks the CODEOWNERS file - // It does NOT check file annotations or other ownership sources +fn test_validate_with_paths_resolves_ownership_through_mappers() -> Result<(), Box> { + // Ownership for a supplied path is resolved through the mappers, not by reading the + // generated CODEOWNERS back. This test used to assert the opposite -- that `validate` + // with files consulted only the CODEOWNERS file and ignored annotations -- which is + // exactly the weakness that let a dual-owned file pass. // - // If a file has an annotation but is missing from CODEOWNERS, `validate` will report it as unowned - // This is why `generate-and-validate` should be used for accuracy - - // ruby/app/models/bank_account.rb has @team Payments annotation and is in CODEOWNERS + // ruby/app/models/bank_account.rb has a @team Payments annotation and is in CODEOWNERS, + // so it is owned exactly once and validates cleanly either way. run_codeowners( "valid_project", &["validate", "ruby/app/models/bank_account.rb"], From a32b81a83414689e0292f54571b9f9047cb09b2b Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Mon, 24 Aug 2026 15:03:14 -0500 Subject: [PATCH 06/13] fix: address review of the scoped validation path Three review passes over the scoped path, each of which found a silent false pass in the one before it. Grouped by theme rather than by pass, since the passes are not interesting on their own. Scope the package check. validate_invalid_team ran invalid_package_ownership over every package regardless of the path list, so `validate ` exited 1 over a package that file had nothing to do with. Since the gem's --diff mode feeds a changeset in, one pre-existing bad package owner would block every commit in the repo until someone fixed it. It was inconsistent too: the empty-path early return keyed off the glob-filtered list, so whether the unrelated error surfaced depended on whether some supplied path happened to match owned_globs -- `validate foo.rb` failed while `validate README.md` passed, same repo, same bad package. Packages are now selected by containing at least one supplied path. That needs two path lists, because a manifest must be able to select its own package while not being eligible to be reported unowned: owned_paths for the per-file checks, supplied_paths for package selection. Selecting on the wider list means editing a package.yml to name a nonexistent team is caught by the commit that does it, not only by a later commit that happens to touch a file inside that package. The early return is now a pure optimization. Normalize the paths callers supply. `./ruby/app/x.rb` exited 0 having checked nothing: it never matched a walked project file, and the owned_globs filter then dropped it. Same shape as the absolute-path bug that was #[ignore]d as pre-existing, and the same cause -- paths were compared without being reduced to the form Project::relative_path produces. path_utils::project_relative resolves `.` and `..` lexically and reports failure rather than passing an unstrippable path through. Lexically, not by canonicalizing: the walk records symlink paths rather than their targets, so resolving symlinks would match nothing. An absolute path only strips if it and the root agree about symlinks, and both sides can disagree. cli.rs canonicalizes --project-root but a library caller building its own RunConfig does not -- which is how the gem calls in -- so resolving only the path leaves the mirror-image case failing exactly as silently: root /var/..., path /private/var/..., dropped before any check runs. The retry resolves both sides. The first attempt uses the root as given, so relative paths cost no syscalls, and the root is resolved once per run. Skip paths that no longer exist. `validate ` exited 1 with "missing ownership". Changesets delete files routinely and git lists them, so this failed commits for removing code. A deleted file cannot have an owner. The gem already filters by File.exist? before calling in, so this matches what its callers see and extends it to direct library callers. Only a definite "not there" skips: try_exists().unwrap_or(true) keeps a path whose status is unknown, because a visible error is investigable and a silent pass is not. Resolve unwalked paths through the mappers instead of assuming them unowned. The walk only records git-tracked files, so a brand-new unstaged file was reported "Some files are missing ownership" even when a .codeowner in its directory owned it -- while for-file on the same path, resolving through the mappers, correctly named the team. Two commands in one binary disagreeing about one path, failing in the direction that rejects a commit for lacking an owner it has. That simplified rather than complicated: ownership resolution never needed ProjectFile, only the relative path, which is what the matchers key on. file_to_owners becomes path_to_owners and the separate "unwalked, therefore unowned" branch disappears instead of growing a special case. A genuinely unowned untracked file is still reported, which a test pins. The annotation check still runs only over walked files, since an untracked file's annotation has not been parsed -- narrower gap, left deliberately. Smaller things: deduplicate a path supplied twice, which was reported twice; make the unused FileGenerator structurally impossible in the scoped path by passing it to validate() rather than holding it as a field; name the scoped span validator_validate_scoped to parallel validator_validate; and rewrite the --help text for `files`, which promised "fast mode for git hooks" with no hint that it checks less. Three tests asserted on valid_project/ruby/app/unowned.rb, which does not exist -- valid_project has to validate cleanly, so it has no unowned file. They passed only because a nonexistent path was reported as unowned, meaning they covered typo handling while claiming to cover unowned files. Repointed at invalid_project, which has a real one. Adds unit coverage for Validator::validate_files, which was reachable only end-to-end through the binary, and asserts two things the scoping predicate silently depends on: starts_with is component-wise, so ruby/packages/foo must not select ruby/packages/foobar, and a root-level manifest has an empty relative root that prefixes every path. Measured, replacing the complexity claim the first pass corrected but left unverified. On a large monorepo (~130k files, ~18k-line CODEOWNERS; codeowners-perf, best of 3 warm): validation drops from 933ms whole-project to 28ms for one path and 56ms for 2000 -- near-flat in path count, the variable term collapsing as predicted. Wall clock only improves 3.0s to 2.0s, because the ~1.9s project build is the fixed term and is paid either way. So the files param is worth about a second at that scale, not an order of magnitude, which is the number the "should the fast path exist at all" question needed. Co-Authored-By: Claude Fable 5 --- src/cli.rs | 16 +- src/ownership.rs | 18 +- src/ownership/validator.rs | 320 +++++++++++++++++++++++----- src/runner.rs | 49 +++-- tests/validate_files_parity_test.rs | 187 +++++++++++++--- tests/validate_files_test.rs | 28 +-- 6 files changed, 496 insertions(+), 122 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 202114b..b1c9787 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -39,9 +39,12 @@ enum Command { visible_alias = "v" )] Validate { - #[arg(help = "Optional list of files to validate ownership for (fast mode for git hooks). Paths are \ - resolved relative to the project root; ones that no longer exist are skipped, so a \ - changeset that deletes files is not reported as unowned.")] + #[arg(help = "Optional list of files to validate ownership for (for git hooks). Checks ownership of \ + just these files, and of packages containing them. Paths are resolved relative to the \ + project root, and ones that no longer exist are skipped, so a changeset that deletes \ + files is not reported as unowned. Does NOT check whether the CODEOWNERS file itself is \ + up to date -- that is a property of the whole file. Run without files, or use \ + generate-and-validate, to catch a stale CODEOWNERS.")] files: Vec, }, @@ -49,9 +52,10 @@ enum Command { GenerateAndValidate { #[arg(long, short, default_value = "false", help = "Skip staging the CODEOWNERS file")] skip_stage: bool, - #[arg(help = "Optional list of files to validate ownership for (fast mode for git hooks). Paths are \ - resolved relative to the project root; ones that no longer exist are skipped, so a \ - changeset that deletes files is not reported as unowned.")] + #[arg(help = "Optional list of files to validate ownership for (for git hooks). Checks ownership of \ + just these files, and of packages containing them. Paths are resolved relative to the \ + project root, and ones that no longer exist are skipped. Staleness is covered \ + regardless, since the CODEOWNERS file is regenerated first.")] files: Vec, }, diff --git a/src/ownership.rs b/src/ownership.rs index c964444..8a6ed99 100644 --- a/src/ownership.rs +++ b/src/ownership.rs @@ -122,27 +122,31 @@ impl Ownership { let validator = Validator { project: self.project.clone(), mappers: self.mappers(), - file_generator: FileGenerator { mappers: self.mappers() }, executable_name: self.project.executable_name.clone(), }; - validator.validate() + // A second set of mappers, because FileGenerator owns rather than borrows them + // and `Box` is not Clone. Construction is trivial (each `build` just + // stores an Arc); the O(repo) work happens in `owner_matchers`/`entries`. + let file_generator = FileGenerator { mappers: self.mappers() }; + + validator.validate(&file_generator) } /// Like [`Ownership::validate`], but restricted to the supplied project-relative /// paths. Skips the staleness check, which cannot be scoped — see - /// [`Validator::validate_files`]. + /// [`Validator::validate_files`], which also documents the two path lists. Builds no + /// `FileGenerator`, since nothing here generates. #[instrument(name = "ownership_validate_files", level = "debug", skip_all)] - pub fn validate_files(&self, relative_paths: &[PathBuf]) -> Result<(), ValidatorErrors> { - info!("validating file ownership for {} supplied paths", relative_paths.len()); + pub fn validate_files(&self, owned_paths: &[PathBuf], supplied_paths: &[PathBuf]) -> Result<(), ValidatorErrors> { + info!("validating file ownership for {} supplied paths", supplied_paths.len()); let validator = Validator { project: self.project.clone(), mappers: self.mappers(), - file_generator: FileGenerator { mappers: self.mappers() }, executable_name: self.project.executable_name.clone(), }; - validator.validate_files(relative_paths) + validator.validate_files(owned_paths, supplied_paths) } #[instrument(level = "debug", skip_all)] diff --git a/src/ownership/validator.rs b/src/ownership/validator.rs index 8b75b8c..f235036 100644 --- a/src/ownership/validator.rs +++ b/src/ownership/validator.rs @@ -1,4 +1,4 @@ -use crate::project::{Project, ProjectFile}; +use crate::project::{Package, Project, ProjectFile}; use core::fmt; use std::collections::HashSet; use std::fmt::Display; @@ -20,7 +20,6 @@ use super::mapper::{Mapper, OwnerMatcher, TeamName}; pub struct Validator { pub project: Arc, pub mappers: Vec>, - pub file_generator: FileGenerator, pub executable_name: String, } @@ -36,19 +35,26 @@ enum Error { pub struct Errors(Vec); impl Validator { + /// Whole-project validation. + /// + /// The `FileGenerator` is a parameter rather than a field so that + /// [`Validator::validate_files`], which cannot check staleness, is structurally + /// incapable of being handed one it would never use. #[instrument(name = "validator_validate", level = "debug", skip_all)] - pub fn validate(&self) -> Result<(), Errors> { + pub fn validate(&self, file_generator: &FileGenerator) -> Result<(), Errors> { let mut validation_errors = Vec::new(); let files: Vec<&ProjectFile> = self.project.files.iter().collect(); + let packages: Vec<&Package> = self.project.packages.iter().collect(); + let relative_paths: Vec<&Path> = files.iter().map(|file| self.project.relative_path(&file.path)).collect(); debug!("validate_invalid_team"); - validation_errors.append(&mut self.validate_invalid_team(&files)); + validation_errors.append(&mut self.validate_invalid_team(&files, &packages)); debug!("validate_file_ownership"); - validation_errors.append(&mut self.validate_file_ownership(&files)); + validation_errors.append(&mut self.validate_file_ownership(&relative_paths)); debug!("validate_codeowners_file"); - validation_errors.append(&mut self.validate_codeowners_file()); + validation_errors.append(&mut self.validate_codeowners_file(file_generator)); if validation_errors.is_empty() { Ok(()) @@ -57,26 +63,52 @@ impl Validator { } } - /// Validation restricted to `relative_paths`. + /// Validation restricted to the supplied paths. /// /// Runs the same per-file checks as [`Validator::validate`] — invalid team - /// annotations and file ownership — over just the named files, so a caller with a - /// changeset pays O(changed files) rather than O(repo). Ownership is resolved + /// annotations and file ownership — over just the named files. Ownership is resolved /// through the mappers, exactly as the whole-project run does, so a file owned two /// ways is reported rather than silently resolving to whichever owner happened to /// win in the generated CODEOWNERS. /// + /// This scopes the *per-file* work, not all of it. Building the owner matchers is + /// still O(repo): `TeamFileMapper::owner_matchers` enumerates every annotated file + /// in the project. So the cost is a fixed O(repo) term plus a variable + /// O(supplied paths × matchers) term, where the whole-project run pays + /// O(repo × matchers) for the latter. + /// + /// Measured on a large monorepo (~130k files, ~18k-line CODEOWNERS; `codeowners-perf`, + /// best of 3 warm): the variable term is what collapses — validation drops from 933ms + /// whole-project to 28ms for one path and 56ms for 2000, so it is near-flat in the + /// number of paths. Wall clock only improves 3.0s to 2.0s, because the ~1.9s project + /// build is the fixed term and is paid either way. Scoping is worth about a second on + /// a repo that size, not an order of magnitude. + /// /// The staleness check is deliberately absent: it compares the entire generated /// file against the entire on-disk one and cannot be scoped. `generate_and_validate` /// makes it moot by regenerating first; a caller that needs it on its own must run /// [`Validator::validate`]. /// - /// Package ownership is checked in full regardless of the path list — packages are - /// orders of magnitude fewer than files, and skipping them would leave a second - /// blind spot. - #[instrument(name = "validate_scoped", level = "debug", skip_all)] - pub fn validate_files(&self, relative_paths: &[PathBuf]) -> Result<(), Errors> { - let requested: HashSet<&Path> = relative_paths.iter().map(PathBuf::as_path).collect(); + /// Package ownership is scoped too, to packages containing at least one supplied + /// path. Checking every package would mean validating one file can fail over a + /// package that file has nothing to do with — and since the gem's `--diff` mode feeds + /// a changeset in, a single pre-existing bad package owner would block every commit in + /// the repo until it was fixed. + /// + /// Hence the two path lists. `owned_paths` are the paths the project walk would have + /// considered, and are what the per-file checks run over. `supplied_paths` is + /// everything the caller named, including paths the walk skips — which is what puts a + /// package in scope, so that editing a `package.yml` into naming a nonexistent team is + /// caught by the commit that does it, not merely by a later commit that happens to + /// touch a file inside that package. + /// + /// The per-check spans (`validate_invalid_team`, `validate_file_ownership`) are + /// shared with the whole-project run, so a profile tells the two apart by parent + /// span — `validator_validate_scoped` here, `validator_validate` there — not by the + /// child span name. + #[instrument(name = "validator_validate_scoped", level = "debug", skip_all)] + pub fn validate_files(&self, owned_paths: &[PathBuf], supplied_paths: &[PathBuf]) -> Result<(), Errors> { + let requested: HashSet<&Path> = owned_paths.iter().map(PathBuf::as_path).collect(); let files: Vec<&ProjectFile> = self .project @@ -85,24 +117,29 @@ impl Validator { .filter(|file| requested.contains(self.project.relative_path(&file.path))) .collect(); + let packages: Vec<&Package> = self + .project + .packages + .iter() + .filter(|package| self.package_contains_any(package, supplied_paths)) + .collect(); + let mut validation_errors = Vec::new(); - // A requested path the project never walked cannot be attributed to a team. - // Report it as unowned, which is what a whole-project run says about any file - // it can't attribute. - let known: HashSet<&Path> = files.iter().map(|file| self.project.relative_path(&file.path)).collect(); - validation_errors.extend( - relative_paths - .iter() - .filter(|path| !known.contains(path.as_path())) - .map(|path| Error::FileWithoutOwner { path: path.clone() }), - ); + // Every requested path goes to the matchers, including ones the walk never + // recorded. An untracked file is the case that matters: it is absent from + // `project.files`, but the matchers can still attribute it -- a new file in a + // directory with a `.codeowner` is owned the moment it exists. Reporting such a + // path as unowned instead put `validate` at odds with `for-file` on the same path. + // + // Iterating the deduped set also means a path supplied twice is one defect. + let requested_paths: Vec<&Path> = requested.iter().copied().collect(); - debug!("validate_invalid_team (scoped)"); - validation_errors.append(&mut self.validate_invalid_team(&files)); + debug!("validate_invalid_team"); + validation_errors.append(&mut self.validate_invalid_team(&files, &packages)); - debug!("validate_file_ownership (scoped)"); - validation_errors.append(&mut self.validate_file_ownership(&files)); + debug!("validate_file_ownership"); + validation_errors.append(&mut self.validate_file_ownership(&requested_paths)); if validation_errors.is_empty() { Ok(()) @@ -111,15 +148,29 @@ impl Validator { } } + /// Whether any of `supplied_paths` lies inside `package`. + /// + /// The manifest itself counts, since it sits at the package root and so is prefixed by + /// it. A package at the project root has an empty relative root, which every path is + /// prefixed by — correctly, since it owns the whole tree. + fn package_contains_any(&self, package: &Package, supplied_paths: &[PathBuf]) -> bool { + let Some(package_root) = package.package_root() else { + return false; + }; + let package_root = self.project.relative_path(package_root); + + supplied_paths.iter().any(|path| path.starts_with(package_root)) + } + #[instrument(name = "validate_invalid_team", level = "debug", skip_all)] - fn validate_invalid_team(&self, files: &[&ProjectFile]) -> Vec { + fn validate_invalid_team(&self, files: &[&ProjectFile], packages: &[&Package]) -> Vec { debug!("validating project"); let mut errors: Vec = Vec::new(); let team_names: HashSet<&TeamName> = self.project.teams.iter().map(|team| &team.name).collect(); errors.append(&mut self.invalid_team_annotation(&team_names, files)); - errors.append(&mut self.invalid_package_ownership(&team_names)); + errors.append(&mut self.invalid_package_ownership(&team_names, packages)); errors } @@ -144,9 +195,8 @@ impl Validator { .collect() } - fn invalid_package_ownership(&self, team_names: &HashSet<&String>) -> Vec { - self.project - .packages + fn invalid_package_ownership(&self, team_names: &HashSet<&String>, packages: &[&Package]) -> Vec { + packages .iter() .flat_map(|package| { if !team_names.contains(&package.owner) { @@ -162,17 +212,17 @@ impl Validator { } #[instrument(name = "validate_file_ownership", level = "debug", skip_all)] - fn validate_file_ownership(&self, files: &[&ProjectFile]) -> Vec { + fn validate_file_ownership(&self, relative_paths: &[&Path]) -> Vec { let mut validation_errors = Vec::new(); - for (file, owners) in self.file_to_owners(files) { - let relative_path = self.project.relative_path(&file.path).to_owned(); - + for (relative_path, owners) in self.path_to_owners(relative_paths) { if owners.is_empty() { - validation_errors.push(Error::FileWithoutOwner { path: relative_path }) + validation_errors.push(Error::FileWithoutOwner { + path: relative_path.to_owned(), + }) } else if owners.len() > 1 { validation_errors.push(Error::FileWithMultipleOwners { - path: relative_path, + path: relative_path.to_owned(), owners, }) } @@ -182,8 +232,8 @@ impl Validator { } #[instrument(name = "validate_codeowners_file", level = "debug", skip_all)] - fn validate_codeowners_file(&self) -> Vec { - let generated_file = self.file_generator.generate_file(); + fn validate_codeowners_file(&self, file_generator: &FileGenerator) -> Vec { + let generated_file = file_generator.generate_file(); let current_file = self.project.get_codeowners_file().unwrap_or_default(); if generated_file == current_file { @@ -196,21 +246,22 @@ impl Validator { } } - #[instrument(name = "file_to_owners", level = "debug", skip_all)] - fn file_to_owners<'a>(&'a self, files: &[&'a ProjectFile]) -> Vec<(&'a ProjectFile, Vec)> { + /// Resolve ownership for project-relative paths. + /// + /// Keyed on paths rather than `ProjectFile`s because that is all the matchers consume, + /// and because it lets a scoped run ask about a path the walk never recorded — an + /// untracked file, say. Answering those from the matchers is what makes + /// `validate ` agree with `for-file `; assuming they were unowned did not. + #[instrument(name = "path_to_owners", level = "debug", skip_all)] + fn path_to_owners<'a>(&self, relative_paths: &[&'a Path]) -> Vec<(&'a Path, Vec)> { let owner_matchers: Vec = self.mappers.iter().flat_map(|mapper| mapper.owner_matchers()).collect(); let file_owner_finder = FileOwnerFinder { owner_matchers: &owner_matchers, }; - let project = self.project.clone(); - files + relative_paths .par_iter() - .map(|project_file| { - let relative_path = project.relative_path(&project_file.path); - let owners = file_owner_finder.find(relative_path); - (*project_file, owners) - }) + .map(|relative_path| (*relative_path, file_owner_finder.find(relative_path))) .collect() } } @@ -319,7 +370,174 @@ impl core::error::Error for Errors {} #[cfg(test)] mod tests { use super::*; + use crate::project::{PackageType, Team}; use indoc::indoc; + use std::collections::HashMap; + + const ROOT: &str = "/proj"; + + /// A validator over a synthetic project with no mappers. + /// + /// No mappers means no file resolves to an owner, so every file that makes it into + /// scope is reported as unowned. That is the point: it makes the *scoping* visible + /// without any ownership rules to reason about. `validate_files` is otherwise covered + /// only end-to-end through the binary, which cannot isolate the predicate. + fn validator(files: &[&str], packages: &[(&str, &str)], teams: &[&str]) -> Validator { + let project = Project { + base_path: PathBuf::from(ROOT), + files: files + .iter() + .map(|path| ProjectFile { + owner: None, + path: PathBuf::from(ROOT).join(path), + }) + .collect(), + packages: packages + .iter() + .map(|(path, owner)| Package { + path: PathBuf::from(ROOT).join(path), + package_type: PackageType::Ruby, + owner: (*owner).to_string(), + }) + .collect(), + vendored_gems: vec![], + teams: teams + .iter() + .map(|name| Team { + name: (*name).to_string(), + ..Default::default() + }) + .collect(), + codeowners_file_path: PathBuf::from(".github/CODEOWNERS"), + directory_codeowner_files: vec![], + teams_by_name: HashMap::new(), + executable_name: "codeowners".to_string(), + }; + + Validator { + project: Arc::new(project), + mappers: vec![], + executable_name: "codeowners".to_string(), + } + } + + fn paths(paths: &[&str]) -> Vec { + paths.iter().map(PathBuf::from).collect() + } + + #[test] + fn validate_files_reports_only_the_supplied_files() { + let validator = validator(&["ruby/a.rb", "ruby/b.rb"], &[], &[]); + + let errors = validator + .validate_files(&paths(&["ruby/a.rb"]), &paths(&["ruby/a.rb"])) + .expect_err("unowned file should be an error"); + let report = format!("{}", errors); + + assert!(report.contains("ruby/a.rb"), "{report}"); + assert!(!report.contains("ruby/b.rb"), "an unsupplied file leaked into scope: {report}"); + } + + #[test] + fn validate_files_reports_a_path_supplied_twice_once() { + let validator = validator(&[], &[], &[]); + + let errors = validator + .validate_files( + &paths(&["ruby/ghost.rb", "ruby/ghost.rb"]), + &paths(&["ruby/ghost.rb", "ruby/ghost.rb"]), + ) + .expect_err("an unwalked path should be an error"); + + assert_eq!(errors.0.len(), 1, "{errors:?}"); + } + + #[test] + fn validate_files_skips_a_package_containing_no_supplied_path() { + // The blast-radius case: one bad package owner elsewhere in the repo must not fail + // a run scoped to an unrelated file. + let validator = validator(&["ruby/app/a.rb"], &[("ruby/packages/foo/package.yml", "NoSuchTeam")], &["Payroll"]); + + let errors = validator + .validate_files(&paths(&["ruby/app/a.rb"]), &paths(&["ruby/app/a.rb"])) + .expect_err("the unowned file is still an error"); + let report = format!("{}", errors); + + assert!(!report.contains("NoSuchTeam"), "unrelated package leaked into scope: {report}"); + } + + #[test] + fn validate_files_reports_a_package_containing_a_supplied_path() { + let validator = validator( + &["ruby/packages/foo/app/a.rb"], + &[("ruby/packages/foo/package.yml", "NoSuchTeam")], + &["Payroll"], + ); + + let supplied = paths(&["ruby/packages/foo/app/a.rb"]); + let errors = validator + .validate_files(&supplied, &supplied) + .expect_err("bad package owner is an error"); + let report = format!("{}", errors); + + assert!(report.contains("NoSuchTeam"), "{report}"); + assert!(report.contains("ruby/packages/foo/package.yml"), "{report}"); + } + + #[test] + fn validate_files_reports_a_package_whose_manifest_is_itself_supplied() { + // A manifest does not match owned_globs, so it never appears in `owned_paths` -- + // it reaches the package check through `supplied_paths` only. Without this, editing + // a manifest to name a nonexistent team would not be caught by the commit doing it. + let validator = validator(&[], &[("ruby/packages/foo/package.yml", "NoSuchTeam")], &["Payroll"]); + + let errors = validator + .validate_files(&[], &paths(&["ruby/packages/foo/package.yml"])) + .expect_err("bad package owner is an error"); + let report = format!("{}", errors); + + assert!(report.contains("NoSuchTeam"), "{report}"); + assert!( + !report.contains("missing ownership"), + "a manifest is not eligible to be reported unowned: {report}" + ); + } + + #[test] + fn validate_files_does_not_select_a_sibling_package_by_name_prefix() { + // `starts_with` is component-wise, so `ruby/packages/foo` must not swallow + // `ruby/packages/foobar`. A plain string prefix check would. + let validator = validator(&[], &[("ruby/packages/foo/package.yml", "NoSuchTeam")], &["Payroll"]); + + assert!( + validator.validate_files(&[], &paths(&["ruby/packages/foobar/app/a.rb"])).is_ok(), + "a sibling package sharing a name prefix was selected" + ); + } + + #[test] + fn validate_files_selects_a_package_at_the_project_root() { + // A root-level manifest has an empty relative package root, and every path is + // prefixed by the empty path -- correctly, since it owns the whole tree. Asserted + // because the scoping predicate silently depends on it. + let validator = validator(&[], &[("package.yml", "NoSuchTeam")], &["Payroll"]); + + let errors = validator + .validate_files(&[], &paths(&["ruby/app/anything.rb"])) + .expect_err("a root-level package owns every path"); + + assert!(format!("{}", errors).contains("NoSuchTeam")); + } + + #[test] + fn validate_files_accepts_a_valid_package_owner() { + let validator = validator(&[], &[("ruby/packages/foo/package.yml", "Payroll")], &["Payroll"]); + + assert!( + validator.validate_files(&[], &paths(&["ruby/packages/foo/package.yml"])).is_ok(), + "a package owned by a real team is not an error" + ); + } #[test] fn test_codeowners_diff_reports_added_and_removed_lines() { diff --git a/src/runner.rs b/src/runner.rs index 1932424..5645f7f 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -154,39 +154,60 @@ impl Runner { /// Staleness is not checked here; it is a property of the whole CODEOWNERS file. /// `generate_and_validate` makes it moot by regenerating first. fn validate_files(&self, file_paths: Vec) -> RunResult { - // Normalize before anything else. A caller-supplied path has to be reduced to the - // project-relative form the rest of the pipeline speaks, or the per-file checks - // below match nothing and the run exits 0 having checked nothing -- a false pass in - // the unsafe direction. + // Normalize before anything else. `./app/x.rb`, `app/x.rb` and an absolute path to + // the same file all have to reduce to the form `Project::relative_path` produces, + // or the per-file checks below match nothing and the run exits 0 having checked + // nothing -- a false pass in the unsafe direction. See + // `path_utils::resolve_project_relative` for why an absolute path needs both sides + // resolved, and why only its parent is. + // + // A path that no longer exists is dropped rather than reported: changesets delete + // files routinely, and a deleted file cannot have an owner, so reporting it as + // unowned would fail a commit for removing code. // // The canonical root is resolved once rather than per path, since only the retry // inside `resolve_project_relative` needs it and that retry can fire for every path // when a caller passes an absolute list. let canonical_root = self.run_config.project_root.canonicalize().ok(); - let relative_paths: Vec = file_paths + let supplied_paths: Vec = file_paths .iter() .filter_map(|file_path| { crate::path_utils::resolve_project_relative(&self.run_config.project_root, canonical_root.as_deref(), Path::new(file_path)) }) - // A path that no longer exists is dropped rather than reported: a deleted file - // cannot have an owner, and changesets delete files routinely. - .filter(|relative_path| self.run_config.project_root.join(relative_path).try_exists().unwrap_or(true)) - // Mirror the filtering applied by ProjectBuilder when walking the project, so a - // path the project would never have considered is not reported as unowned. .filter(|relative_path| { - matches_globs(relative_path, &self.config.owned_globs) && !matches_globs(relative_path, &self.config.unowned_globs) + // `unwrap_or(true)` on purpose: only a definite "this is not there" earns a + // silent skip. If the answer is unknown -- a permissions error, a bad + // symlink -- keep the path and let the checks report it, because a visible + // error is investigable and a silent pass is not. + self.run_config.project_root.join(relative_path).try_exists().unwrap_or(true) }) .collect(); - if relative_paths.is_empty() { + // Mirror the filtering ProjectBuilder applies when walking, so a path the project + // would never have considered is not reported as unowned. This is narrower than + // `supplied_paths`: a supplied package.yml or README.md is not itself an ownership + // defect, but it does put its package in scope for the check below. + let owned_paths: Vec = supplied_paths + .iter() + .filter(|path| matches_globs(path, &self.config.owned_globs) && !matches_globs(path, &self.config.unowned_globs)) + .cloned() + .collect(); + + // Purely an optimization -- it skips building the mappers. With no paths in scope + // the validator finds no files and no packages and returns Ok regardless, so this + // is not a semantic special case. It used to be one: when the early return keyed + // off the glob-filtered list, whether an unrelated package error surfaced depended + // on whether some supplied path happened to match owned_globs. + if supplied_paths.is_empty() { return RunResult::default(); } - match self.ownership.validate_files(&relative_paths) { + match self.ownership.validate_files(&owned_paths, &supplied_paths) { Ok(_) => RunResult::default(), + // No `info_messages`: the only error carrying one is the stale-CODEOWNERS diff, + // which a scoped run cannot produce. Err(err) => RunResult { - info_messages: err.info_messages(), validation_errors: vec![format!("{}", err)], ..Default::default() }, diff --git a/tests/validate_files_parity_test.rs b/tests/validate_files_parity_test.rs index 73d2a23..1ec073f 100644 --- a/tests/validate_files_parity_test.rs +++ b/tests/validate_files_parity_test.rs @@ -6,20 +6,21 @@ //! invalid team annotation and a file owned two ways; `validate_files` simply scopes the //! per-file checks to the supplied paths. //! -//! Only the staleness check differs, and unavoidably so: it compares the whole generated -//! CODEOWNERS against the whole on-disk one, so it cannot be scoped to a subset. -//! `gv ` makes it moot by regenerating first. +//! Two things differ, both deliberately. The staleness check is unavoidable: it compares +//! the whole generated CODEOWNERS against the whole on-disk one, so it cannot be scoped to +//! a subset, and `gv ` makes it moot by regenerating first. The package check is +//! scoped to packages containing a supplied path, so that one bad package owner elsewhere +//! in the repo does not fail every scoped run; the `*_invalid_package*` tests pin both +//! halves of that, and the `*_untracked_*` pair pins that a path the walk never recorded is +//! asked about rather than assumed unowned. //! -//! These previously all failed. `validate_files` used to answer only "does this path have -//! an owner in the CODEOWNERS file", which could not see a file owned two ways — generation -//! picks one winner, so the file looked owned and the command exited 0. They are kept as -//! regression guards against reintroducing that shortcut. +//! The `gv_*` tests previously all failed. `validate_files` used to answer only "does this +//! path have an owner in the CODEOWNERS file", which could not see a file owned two ways — +//! generation picks one winner, so the file looked owned and the command exited 0. They are +//! kept as regression guards against reintroducing that shortcut. //! -//! One test remains `#[ignore]`d for a separate, still-unfixed bug. Run it with: -//! -//! ```sh -//! cargo test --test validate_files_parity_test -- --ignored -//! ``` +//! Normalization of the supplied paths themselves is covered separately, in +//! `supplied_path_normalization_test.rs`. use assert_cmd::prelude::*; use predicates::prelude::*; @@ -168,37 +169,163 @@ fn test_gv_with_every_path_matches_gv_with_no_paths() -> Result<(), Box (tempfile::TempDir, std::path::PathBuf) { + let temp_dir = setup_fixture_repo(std::path::Path::new(FIXTURE)); + let project_root = temp_dir.path().to_path_buf(); + + let manifest = project_root.join("ruby/packages/payroll_flow/package.yml"); + assert!(manifest.exists(), "fixture should contain a package manifest"); + std::fs::write(&manifest, "owner: NoSuchTeam\n").expect("failed to write package manifest"); + + // A file inside that package, so the in-scope case has something to name. + let inside = project_root.join("ruby/packages/payroll_flow/app/thing.rb"); + std::fs::create_dir_all(inside.parent().unwrap()).expect("failed to create package dir"); + std::fs::write(&inside, "# inside the badly-owned package\n").expect("failed to write file"); + + git_add_all_files(&project_root); + (temp_dir, project_root) +} + #[test] -#[ignore = "separate pre-existing bug: owned_globs filter drops non-canonical absolute paths"] -fn test_validate_does_not_silently_skip_absolute_paths() -> Result<(), Box> { - // Unrelated to the parity gap above, and the most dangerous of the set because it is - // completely silent. - // - // `cli.rs` canonicalizes `--project-root`. On macOS the temp dir is under `/var`, which - // canonicalizes to `/private/var`, so a caller-supplied `/var/...` path fails - // `strip_prefix`, stays absolute, and is then rejected by the `owned_globs` filter -- - // dropped before any ownership query runs. Exit 0, no output, file never checked. +fn test_validate_with_paths_ignores_an_unrelated_invalid_package() -> Result<(), Box> { + // The package check is scoped to packages containing a supplied path. Checking every + // package meant validating one file could fail over a package that file had nothing to + // do with -- and since the gem's `--diff` mode feeds a changeset in, one pre-existing + // bad package owner would block every commit in the repo until someone fixed it. + let (_temp_dir, project_root) = fixture_with_an_invalid_package_owner(); + + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(&project_root) + .arg("--no-cache") + .arg("validate") + .arg("ruby/app/models/bank_account.rb") + .assert() + .success() + .stdout(predicate::eq("")); + + Ok(()) +} + +#[test] +fn test_validate_with_paths_reports_an_invalid_package_containing_a_supplied_path() -> Result<(), Box> { + // The other half of the scoping: in scope means reported. Without this, scoping the + // package check would just be a blind spot. + let (_temp_dir, project_root) = fixture_with_an_invalid_package_owner(); + + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(&project_root) + .arg("--no-cache") + .arg("validate") + .arg("ruby/packages/payroll_flow/app/thing.rb") + .assert() + .failure() + .stdout(predicate::str::contains("package.yml").and(predicate::str::contains("NoSuchTeam"))); + + Ok(()) +} + +#[test] +fn test_validate_with_paths_reports_a_supplied_package_manifest() -> Result<(), Box> { + // Supplying the manifest itself has to work, or the scoped check would never catch a + // bad package owner at the moment it is introduced -- only later, via some unrelated + // commit that happened to touch a file inside that package. The manifest does not match + // `owned_globs`, so it is not eligible to be reported as unowned; it is in scope purely + // as a package selector. + let (_temp_dir, project_root) = fixture_with_an_invalid_package_owner(); + + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(&project_root) + .arg("--no-cache") + .arg("validate") + .arg("ruby/packages/payroll_flow/package.yml") + .assert() + .failure() + .stdout( + predicate::str::contains("package.yml") + .and(predicate::str::contains("NoSuchTeam")) + .and(predicate::str::contains("missing ownership").not()), + ); + + Ok(()) +} + +#[test] +fn test_validate_with_no_paths_still_reports_every_invalid_package() -> Result<(), Box> { + // Scoping applies only to the scoped run. The whole-project run must keep reporting + // every bad package, including the one the tests above deliberately do not name. + let (_temp_dir, project_root) = fixture_with_an_invalid_package_owner(); + + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(&project_root) + .arg("--no-cache") + .arg("validate") + .assert() + .failure() + .stdout(predicate::str::contains("package.yml").and(predicate::str::contains("NoSuchTeam"))); + + Ok(()) +} + +#[test] +fn test_validate_attributes_an_untracked_file_through_the_mappers() -> Result<(), Box> { + // A brand-new file, not yet staged, in a directory carrying a `.codeowner`. It is + // absent from `project.files` (the walk only records git-tracked files), but it is + // genuinely owned -- `.codeowner` owns the directory, so the file is owned the moment + // it exists. // - // valid_project is used here because its owned_globs are directory-anchored - // (`{gems,config,javascript,ruby,components}/**`). With a `**`-leading glob the same path - // survives the filter and is reported spuriously unowned instead, so the symptom is - // config-dependent while the cause is the same. - let temp_dir = setup_fixture_repo(std::path::Path::new("tests/fixtures/valid_project")); + // This used to report "Some files are missing ownership": paths the walk had not + // recorded were assumed unowned rather than asked about. That put two commands in the + // same binary at odds on the same path, since `for-file` resolves through the mappers + // and correctly answered Payroll. It also failed in the annoying direction -- a + // pre-commit hook rejecting a file for lacking an owner it does have. + let temp_dir = setup_fixture_repo(std::path::Path::new(FIXTURE)); + let project_root = temp_dir.path(); + git_add_all_files(project_root); + + // `ruby/app/services/.codeowner` names Payroll. Written after `git add`, so untracked. + let untracked = project_root.join("ruby/app/services/brand_new.rb"); + std::fs::write(&untracked, "# no annotation; owned by the directory\n")?; + + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(project_root) + .arg("--no-cache") + .arg("validate") + .arg("ruby/app/services/brand_new.rb") + .assert() + .success() + .stdout(predicate::eq("")); + + Ok(()) +} + +#[test] +fn test_validate_still_reports_an_untracked_file_with_no_owner() -> Result<(), Box> { + // The other side of the test above: resolving unwalked paths through the mappers must + // not turn into "unwalked paths always pass". `ruby/app/` has no `.codeowner`, so a new + // file there is genuinely unowned and must still be reported. + let temp_dir = setup_fixture_repo(std::path::Path::new(FIXTURE)); let project_root = temp_dir.path(); git_add_all_files(project_root); - // Deliberately NOT canonicalized -- that is the bug. - let absolute = project_root.join("ruby/app/unowned.rb"); + let untracked = project_root.join("ruby/app/orphan_new.rb"); + std::fs::write(&untracked, "# nobody owns this\n")?; Command::cargo_bin("codeowners")? .arg("--project-root") .arg(project_root) .arg("--no-cache") .arg("validate") - .arg(absolute.to_str().unwrap()) + .arg("ruby/app/orphan_new.rb") .assert() .failure() - .stdout(predicate::str::contains("unowned.rb")); + .stdout(predicate::str::contains("orphan_new.rb").and(predicate::str::contains("missing ownership"))); Ok(()) } diff --git a/tests/validate_files_test.rs b/tests/validate_files_test.rs index bbe53b1..9f2e745 100644 --- a/tests/validate_files_test.rs +++ b/tests/validate_files_test.rs @@ -21,16 +21,13 @@ fn test_validate_with_owned_files() -> Result<(), Box> { #[test] fn test_validate_with_unowned_file() -> Result<(), Box> { - // `invalid_project`, not `valid_project`: this needs a file that genuinely has no + // `invalid_project`, not `valid_project`: this test needs a file that genuinely has no // owner, and `valid_project/ruby/app/unowned.rb` does not exist -- by design, since - // `test_validate_with_no_files` requires that fixture to validate cleanly. Pointed at - // the nonexistent path, this test passed only because a nonexistent path was reported - // as unowned, so it was really covering typo handling while claiming to cover unowned - // files. Now that a path which no longer exists is skipped, that accident is gone. - // `invalid_project/ruby/app/unowned.rb` is a real file with no owner. - // - // Asserts the path and the exit status, not the category wording, so it stays valid - // however the report is phrased. + // `test_validate_with_no_files` requires that fixture to validate cleanly. Asserted + // against the nonexistent path, this test used to pass only because a nonexistent path + // was reported as unowned, so it was really covering typo handling while claiming to + // cover unowned files. `invalid_project/ruby/app/unowned.rb` is a real file with no + // owner. run_codeowners( "invalid_project", &["validate", "ruby/app/unowned.rb"], @@ -46,16 +43,19 @@ fn test_validate_with_unowned_file() -> Result<(), Box> { #[test] fn test_validate_with_mixed_files() -> Result<(), Box> { - // One owned file and one genuinely unowned one; see `test_validate_with_unowned_file` - // for why this uses `invalid_project`. + // One owned file and one genuinely unowned one; see the note in + // `test_validate_with_unowned_file` for why this uses `invalid_project`. The scoping + // matters here too: `invalid_project` also holds a dual-owned file and an invalid team + // annotation, and neither is named below, so neither should be reported. run_codeowners( "invalid_project", &["validate", "ruby/app/models/payroll.rb", "ruby/app/unowned.rb"], false, OutputStream::Stdout, - // Same wording a whole-project `validate` uses for an unattributable file -- - // supplying paths no longer produces a separate "Unowned files detected:" format. - predicate::str::contains("ruby/app/unowned.rb").and(predicate::str::contains("missing ownership")), + predicate::str::contains("ruby/app/unowned.rb") + .and(predicate::str::contains("missing ownership")) + .and(predicate::str::contains("multi_owned.rb").not()) + .and(predicate::str::contains("Web3").not()), )?; Ok(()) From 80e255c9febfda1e9882fbd4db5e4d8a7de11495 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Thu, 12 Nov 2026 16:17:48 -0800 Subject: [PATCH 07/13] Check that CODEOWNERS gives each supplied file its owner Reading CODEOWNERS back was the wrong way to find a supplied file's owner, but it was also the only sign `validate ` had that CODEOWNERS was stale. Without it, a file annotated and staged without regenerating CODEOWNERS passed, though a full `validate` reports the missing line. That's the gem's `validate --diff --skip-autocorrect`. The whole-file staleness check can't be scoped, so each supplied file with one owner is now looked up in CODEOWNERS, in one parse for the whole list, and reported under the same "CODEOWNERS out of date" heading if CODEOWNERS doesn't list it or gives it to another team. A file with no owner or more than one, or whose team doesn't exist, is already reported. `gv` skips the lookup, since it has just written CODEOWNERS. This replaces #135's "Files matching optionally_owned_globs name an owner that CODEOWNERS doesn't list" for the optionally owned files it covered, so the two tests of those are turned back on with the new heading. New tests cover a missing line, a line naming another team, a team with do_not_add_to_codeowners_file, and every fixture, which must report nothing stale once CODEOWNERS is generated. The parity tests that check scoping on invalid_project, whose CODEOWNERS is empty, now generate it first. --- src/cli.rs | 10 ++-- src/ownership.rs | 10 +++- src/ownership/validator.rs | 89 +++++++++++++++++++++++----- src/runner.rs | 32 ++++++++-- tests/optionally_owned_globs_test.rs | 22 ++----- tests/validate_files_parity_test.rs | 46 ++++++++++++++ tests/validate_files_test.rs | 63 ++++++++++++++++++++ 7 files changed, 227 insertions(+), 45 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index f928f94..4678b38 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -41,9 +41,8 @@ enum Command { Validate { #[arg( help = "Optional list of files to validate ownership for (fast mode for git hooks). Checks just these \ - files, and the packages that contain them. Doesn't check whether the CODEOWNERS file is up to \ - date, which is a property of the whole file: run without files, or use generate-and-validate, \ - for that." + files, the packages and `.codeowner` files whose directories contain them, and that the \ + CODEOWNERS file gives each file its owner." )] files: Vec, }, @@ -53,8 +52,9 @@ enum Command { #[arg(long, short, default_value = "false", help = "Skip staging the CODEOWNERS file")] skip_stage: bool, #[arg( - help = "Optional list of files to validate ownership for (fast mode for git hooks). Checks just these \ - files, and the packages that contain them, after regenerating the CODEOWNERS file." + help = "Optional list of files to validate ownership for (fast mode for git hooks). Regenerates the \ + CODEOWNERS file, then checks just these files and the packages and `.codeowner` files whose \ + directories contain them." )] files: Vec, }, diff --git a/src/ownership.rs b/src/ownership.rs index d7855ff..beabbca 100644 --- a/src/ownership.rs +++ b/src/ownership.rs @@ -2,6 +2,7 @@ use file_owner_finder::FileOwnerFinder; use itertools::Itertools; use mapper::{OwnerMatcher, Source, TeamName}; use std::{ + collections::HashMap, error::Error, fmt::{self, Display}, path::{Path, PathBuf}, @@ -132,7 +133,12 @@ impl Ownership { } #[instrument(name = "ownership_validate_files", level = "debug", skip_all)] - pub(crate) fn validate_files(&self, supplied: &[SuppliedPath], selectors: &[PathBuf]) -> Result<(), ValidatorErrors> { + pub(crate) fn validate_files( + &self, + supplied: &[SuppliedPath], + selectors: &[PathBuf], + listed: Option<&HashMap>>, + ) -> Result<(), ValidatorErrors> { info!("validating file ownership for {} supplied paths", supplied.len()); let validator = Validator { project: self.project.clone(), @@ -140,7 +146,7 @@ impl Ownership { executable_name: self.project.executable_name.clone(), }; - validator.validate_files(supplied, selectors) + validator.validate_files(supplied, selectors, listed) } #[instrument(level = "debug", skip_all)] diff --git a/src/ownership/validator.rs b/src/ownership/validator.rs index 9d5e2d4..d42e6ec 100644 --- a/src/ownership/validator.rs +++ b/src/ownership/validator.rs @@ -1,5 +1,5 @@ use crate::path_utils::matches_globs; -use crate::project::{Package, Project, ProjectFile}; +use crate::project::{Package, Project, ProjectFile, Team}; use core::fmt; use std::collections::{HashMap, HashSet}; use std::fmt::Display; @@ -69,6 +69,13 @@ enum Error { executable_name: String, diff: String, }, + /// A supplied file whose one owner CODEOWNERS doesn't give it, the part of a stale CODEOWNERS a scoped run can see. + FileOwnerNotInCodeownersFile { + executable_name: String, + path: PathBuf, + owner: TeamName, + listed: Option, + }, } #[derive(Debug)] @@ -99,7 +106,7 @@ impl Validator { validation_errors.append(&mut self.invalid_directory_ownership(None)); debug!("validate_file_ownership"); - validation_errors.append(&mut self.validate_file_ownership(&queries)); + validation_errors.append(&mut self.validate_file_ownership(self.path_to_owners(&queries))); debug!("validate_codeowners_file"); validation_errors.append(&mut self.validate_codeowners_file(file_generator)); @@ -118,9 +125,15 @@ impl Validator { /// A package or `.codeowner` is checked if its directory contains one of `selectors`, which can include paths outside /// `owned_globs` such as the `package.yml` or `.codeowner` itself, so that one bad one elsewhere doesn't fail every scoped /// run. The staleness check compares the - /// whole file and can't be scoped. + /// whole file and can't be scoped, so instead each supplied file with one owner is checked against `listed`, the team + /// CODEOWNERS gives each path as given. Pass `None` when CODEOWNERS has just been generated. #[instrument(name = "validator_validate_scoped", level = "debug", skip_all)] - pub fn validate_files(&self, supplied: &[SuppliedPath], selectors: &[PathBuf]) -> Result<(), Errors> { + pub fn validate_files( + &self, + supplied: &[SuppliedPath], + selectors: &[PathBuf], + listed: Option<&HashMap>>, + ) -> Result<(), Errors> { let supplied: Vec<&SuppliedPath> = supplied.iter().unique_by(|supplied| &supplied.given).collect(); let in_project: HashSet<&Path> = supplied .iter() @@ -161,7 +174,11 @@ impl Validator { validation_errors.append(&mut self.invalid_directory_ownership(Some(selectors))); debug!("validate_file_ownership"); - validation_errors.append(&mut self.validate_file_ownership(&queries)); + let owners = self.path_to_owners(&queries); + if let Some(listed) = listed { + validation_errors.append(&mut self.owners_not_in_codeowners_file(&owners, listed)); + } + validation_errors.append(&mut self.validate_file_ownership(owners)); if validation_errors.is_empty() { Ok(()) @@ -170,6 +187,33 @@ impl Validator { } } + // A file with no owner or more than one is already reported, and so is a team that doesn't exist. + fn owners_not_in_codeowners_file( + &self, + owners: &[(&OwnershipQuery, Vec)], + listed: &HashMap>, + ) -> Vec { + owners + .iter() + .filter_map(|(query, owners)| { + let [owner] = owners.as_slice() else { + return None; + }; + let team = self.project.teams_by_name.get(&owner.team_name)?; + let listed = listed + .get(query.shown_as.to_string_lossy().as_ref()) + .and_then(|listed| listed.as_ref()) + .map(|listed| listed.name.clone()); + (listed.as_ref() != Some(&team.name)).then(|| Error::FileOwnerNotInCodeownersFile { + executable_name: self.executable_name.clone(), + path: query.shown_as.to_path_buf(), + owner: team.name.clone(), + listed, + }) + }) + .collect() + } + // `starts_with` compares whole components, so `packs/foo` doesn't contain `packs/foobar`. A package at the root has an // empty relative root, which contains every path. fn package_contains_any(&self, package: &Package, selectors: &[PathBuf]) -> bool { @@ -270,10 +314,10 @@ impl Validator { } #[instrument(name = "validate_file_ownership", level = "debug", skip_all)] - fn validate_file_ownership(&self, queries: &[OwnershipQuery]) -> Vec { + fn validate_file_ownership(&self, owners: Vec<(&OwnershipQuery, Vec)>) -> Vec { let optionally_owned_globs = &self.project.optionally_owned_globs; - self.path_to_owners(queries) + owners .into_par_iter() .filter_map(|(query, owners)| { if owners.is_empty() { @@ -352,7 +396,8 @@ impl Error { match self { Error::FileWithoutOwner { path: _ } => "Some files are missing ownership".to_owned(), Error::FileWithMultipleOwners { path: _, owners: _ } => "Code ownership should only be defined for each file in one way. The following files have declared ownership in multiple ways".to_owned(), - Error::CodeownershipFileIsStale { executable_name, diff: _ } => { + Error::CodeownershipFileIsStale { executable_name, diff: _ } + | Error::FileOwnerNotInCodeownersFile { executable_name, .. } => { format!("CODEOWNERS out of date. Run `{}` to update the CODEOWNERS file", executable_name) } Error::InvalidTeam { name: _, path: _ } => "Found invalid team references".to_owned(), @@ -381,6 +426,15 @@ impl Error { // surfaced separately as an informational message (see `Errors::info_messages`) // so that a long diff doesn't bury the actionable headline. Error::CodeownershipFileIsStale { .. } => vec![], + Error::FileOwnerNotInCodeownersFile { path, owner, listed, .. } => vec![match listed { + Some(listed) => format!( + "- {} is owned by {}, but CODEOWNERS gives it to {}", + path.to_string_lossy(), + owner, + listed + ), + None => format!("- {} is owned by {}, but CODEOWNERS doesn't list it", path.to_string_lossy(), owner), + }], Error::InvalidTeam { name, path } => vec![format!("- {} is referencing an invalid team - '{}'", path.to_string_lossy(), name)], Error::InvalidDirectoryTeam { name, path, inherits_from } => { // `owner` is `content.trim()`, so an empty or whitespace-only file yields "". @@ -526,7 +580,7 @@ mod tests { fn validate_files_reports_only_the_supplied_files() { let validator = validator(&["ruby/a.rb", "ruby/b.rb"], &[], &[], &[]); - let report = report(validator.validate_files(&supplied(&["ruby/a.rb"]), &selectors(&["ruby/a.rb"]))); + let report = report(validator.validate_files(&supplied(&["ruby/a.rb"]), &selectors(&["ruby/a.rb"]), None)); assert!(report.contains("ruby/a.rb"), "{report}"); assert!(!report.contains("ruby/b.rb"), "{report}"); @@ -537,7 +591,7 @@ mod tests { let validator = validator(&[], &[], &[], &[]); let errors = validator - .validate_files(&supplied(&["ruby/ghost.rb", "ruby/ghost.rb"]), &selectors(&["ruby/ghost.rb"])) + .validate_files(&supplied(&["ruby/ghost.rb", "ruby/ghost.rb"]), &selectors(&["ruby/ghost.rb"]), None) .expect_err("an unowned path is an error"); assert_eq!(errors.0.len(), 1, "{errors:?}"); @@ -552,7 +606,7 @@ mod tests { &["Payroll"], ); - assert_eq!(report(validator.validate_files(&[], &selectors(&["ruby/app/a.rb"]))), ""); + assert_eq!(report(validator.validate_files(&[], &selectors(&["ruby/app/a.rb"]), None)), ""); } #[test] @@ -564,7 +618,7 @@ mod tests { &["Payroll"], ); - let report = report(validator.validate_files(&[], &selectors(&["ruby/packages/foo/app/a.rb"]))); + let report = report(validator.validate_files(&[], &selectors(&["ruby/packages/foo/app/a.rb"]), None)); assert!(report.contains("ruby/packages/foo/package.yml"), "{report}"); assert!(report.contains("ruby/packages/foo/app/.codeowner"), "{report}"); @@ -580,7 +634,7 @@ mod tests { &["Payroll"], ); - let report = report(validator.validate_files(&[], &selectors(&["ruby/packages/foo/package.yml", "ruby/legacy/.codeowner"]))); + let report = report(validator.validate_files(&[], &selectors(&["ruby/packages/foo/package.yml", "ruby/legacy/.codeowner"]), None)); assert!(report.contains("ruby/packages/foo/package.yml"), "{report}"); assert!(report.contains("ruby/legacy/.codeowner"), "{report}"); @@ -597,7 +651,7 @@ mod tests { ); assert_eq!( - report(validator.validate_files(&[], &selectors(&["ruby/packages/foobar/a.rb", "ruby/legacy2/a.rb"]))), + report(validator.validate_files(&[], &selectors(&["ruby/packages/foobar/a.rb", "ruby/legacy2/a.rb"]), None)), "" ); } @@ -606,7 +660,7 @@ mod tests { fn validate_files_selects_a_package_or_codeowner_at_the_project_root() { let validator = validator(&[], &[("package.yml", "NoSuchTeam")], &[(".codeowner", "NoSuchTeam")], &["Payroll"]); - let report = report(validator.validate_files(&[], &selectors(&["ruby/app/anything.rb"]))); + let report = report(validator.validate_files(&[], &selectors(&["ruby/app/anything.rb"]), None)); assert!(report.contains("- package.yml"), "{report}"); assert!(report.contains("- .codeowner"), "{report}"); @@ -621,7 +675,10 @@ mod tests { &["Payroll"], ); - assert_eq!(report(validator.validate_files(&[], &selectors(&["ruby/packages/foo/a.rb"]))), ""); + assert_eq!( + report(validator.validate_files(&[], &selectors(&["ruby/packages/foo/a.rb"]), None)), + "" + ); } #[test] diff --git a/src/runner.rs b/src/runner.rs index febb961..b0a384e 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -125,7 +125,7 @@ impl Runner { if file_paths.is_empty() { self.validate_all() } else { - self.validate_files(file_paths) + self.validate_files(file_paths, false) } } @@ -143,8 +143,9 @@ impl Runner { } // Resolves ownership through the mappers, as `validate_all` does, rather than by reading CODEOWNERS back, which picks one - // owner for a file owned two ways and can't tell a typo'd team name from no owner. - fn validate_files(&self, file_paths: Vec) -> RunResult { + // owner for a file owned two ways and can't tell a typo'd team name from no owner. CODEOWNERS is read only to check that + // it gives each file that owner, which `gv` has just made so. + fn validate_files(&self, file_paths: Vec, codeowners_generated: bool) -> RunResult { let project_root = &self.run_config.project_root; let supplied: Vec = file_paths .into_iter() @@ -175,7 +176,28 @@ impl Runner { return RunResult::default(); } - debug_span!("per_file_query").in_scope(|| match self.ownership.validate_files(&owned, &selectors) { + let listed = if codeowners_generated { + None + } else { + let given: Vec = owned.iter().map(|supplied| supplied.given.clone()).collect(); + match crate::ownership::codeowners_query::teams_for_files_from_codeowners( + project_root, + &self.codeowners_file_path, + &self.config.team_file_glob, + self.config.node_limit(project_root), + &given, + ) { + Ok(listed) => Some(listed), + Err(err) => { + return RunResult { + io_errors: vec![err], + ..Default::default() + }; + } + } + }; + + debug_span!("per_file_query").in_scope(|| match self.ownership.validate_files(&owned, &selectors, listed.as_ref()) { Ok(_) => RunResult::default(), Err(err) => RunResult { validation_errors: vec![format!("{}", err)], @@ -211,7 +233,7 @@ impl Runner { if file_paths.is_empty() { self.validate_all() } else { - self.validate_files(file_paths) + self.validate_files(file_paths, true) } } diff --git a/tests/optionally_owned_globs_test.rs b/tests/optionally_owned_globs_test.rs index 3357c15..4492568 100644 --- a/tests/optionally_owned_globs_test.rs +++ b/tests/optionally_owned_globs_test.rs @@ -9,18 +9,6 @@ use common::{OutputStream, codeowners, git_add_all_files, run_codeowners, setup_ const FIXTURE: &str = "optionally_owned_globs"; -// `validate` suggests running the default executable_name, but `gv` has just written CODEOWNERS, so it doesn't. -fn unlisted_owner_heading(command: &str) -> &'static str { - if command == "gv" { - "Files matching optionally_owned_globs name an owner that CODEOWNERS doesn't list. \ - Check the team name in each one's annotation, `.codeowner` or package:" - } else { - "Files matching optionally_owned_globs name an owner that CODEOWNERS doesn't list. \ - Check the team name in each one's annotation, `.codeowner` or package, \ - or run `codeowners generate` to update the CODEOWNERS file:" - } -} - type FixtureChange = fn(&Path) -> std::io::Result<()>; // A list of files is checked for invalid teams in the annotations, packages and `.codeowner` files that apply to it, as in @@ -683,7 +671,6 @@ fn add_a_javascript_package_without_regenerating_codeowners(project_root: &Path) } #[test] -#[ignore = "needs a per-path staleness check"] fn test_validate_files_reports_an_optionally_owned_file_missing_from_codeowners() -> Result<(), Box> { // `validate`, not `gv`, which would add each file to CODEOWNERS. let owners: [(FixtureChange, &str); 5] = [ @@ -702,8 +689,10 @@ fn test_validate_files_reports_an_optionally_owned_file_missing_from_codeowners( for (add_owner, file) in owners { run_on_modified_fixture(add_owner, &["validate", file])? .failure() - .stdout(predicate::str::contains(unlisted_owner_heading("validate"))) - .stdout(predicate::str::contains(file)); + .stdout(predicate::eq(format!( + "\nCODEOWNERS out of date. Run `codeowners generate` to update the CODEOWNERS file\n\ + - {file} is owned by Foo, but CODEOWNERS doesn't list it\n\n" + ))); } Ok(()) } @@ -724,7 +713,6 @@ fn annotate_the_unowned_file_and_name_an_executable(project_root: &Path) -> std: } #[test] -#[ignore = "needs a per-path staleness check"] fn test_validate_files_suggests_the_configured_executable_for_an_optionally_owned_file_missing_from_codeowners() -> Result<(), Box> { run_on_modified_fixture( @@ -733,7 +721,7 @@ fn test_validate_files_suggests_the_configured_executable_for_an_optionally_owne )? .failure() .stdout(predicate::str::contains( - "run `bin/codeownership validate` to update the CODEOWNERS file:\n app/deprecated/old.rb\n", + "Run `bin/codeownership validate` to update the CODEOWNERS file\n- app/deprecated/old.rb is owned by Foo", )); Ok(()) } diff --git a/tests/validate_files_parity_test.rs b/tests/validate_files_parity_test.rs index 1ec073f..8f69312 100644 --- a/tests/validate_files_parity_test.rs +++ b/tests/validate_files_parity_test.rs @@ -169,6 +169,18 @@ fn test_gv_with_every_path_matches_gv_with_no_paths() -> Result<(), Box (tempfile::TempDir, std::path::PathBuf) { @@ -185,6 +197,7 @@ fn fixture_with_an_invalid_package_owner() -> (tempfile::TempDir, std::path::Pat std::fs::write(&inside, "# inside the badly-owned package\n").expect("failed to write file"); git_add_all_files(&project_root); + generate_codeowners(&project_root); (temp_dir, project_root) } @@ -287,6 +300,7 @@ fn test_validate_attributes_an_untracked_file_through_the_mappers() -> Result<() let temp_dir = setup_fixture_repo(std::path::Path::new(FIXTURE)); let project_root = temp_dir.path(); git_add_all_files(project_root); + generate_codeowners(project_root); // `ruby/app/services/.codeowner` names Payroll. Written after `git add`, so untracked. let untracked = project_root.join("ruby/app/services/brand_new.rb"); @@ -329,3 +343,35 @@ fn test_validate_still_reports_an_untracked_file_with_no_owner() -> Result<(), B Ok(()) } + +#[test] +fn test_validate_with_every_path_finds_no_stale_codeowners_line_after_generating() -> Result<(), Box> { + // Whatever else is wrong in a fixture, a CODEOWNERS file just generated is up to date, so checking each file against it + // must not call it stale. + for fixture in std::fs::read_dir("tests/fixtures")? { + let fixture = fixture?.path(); + // missing_github_team's project can't be built, which missing_github_team_test.rs covers. + if !fixture.join("config/code_ownership.yml").exists() || fixture.ends_with("missing_github_team") { + continue; + } + let temp_dir = setup_fixture_repo(&fixture); + let project_root = temp_dir.path(); + git_add_all_files(project_root); + generate_codeowners(project_root); + + let tracked = Command::new("git").arg("ls-files").current_dir(project_root).output()?; + let files: Vec = String::from_utf8(tracked.stdout)?.lines().map(str::to_string).collect(); + let output = Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(project_root) + .arg("--no-cache") + .arg("validate") + .args(&files) + .output()?; + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(!stdout.contains("CODEOWNERS out of date"), "{}:\n{stdout}", fixture.display()); + } + + Ok(()) +} diff --git a/tests/validate_files_test.rs b/tests/validate_files_test.rs index 9df6fc0..f88dbb1 100644 --- a/tests/validate_files_test.rs +++ b/tests/validate_files_test.rs @@ -139,6 +139,69 @@ fn test_validate_with_an_annotated_file() -> Result<(), Box> { Ok(()) } +// A temp copy of valid_project with `change` made and staged, and CODEOWNERS left as it was. +fn valid_project_with(change: impl FnOnce(&std::path::Path) -> std::io::Result<()>) -> tempfile::TempDir { + let temp_dir = setup_fixture_repo(std::path::Path::new("tests/fixtures/valid_project")); + change(temp_dir.path()).expect("failed to change the fixture"); + git_add_all_files(temp_dir.path()); + temp_dir +} + +#[test] +fn test_validate_with_files_reports_a_file_missing_from_codeowners() -> Result<(), Box> { + let project = valid_project_with(|root| std::fs::write(root.join("ruby/app/models/new_thing.rb"), "# @team Payroll\n")); + + codeowners(project.path(), &["validate", "ruby/app/models/new_thing.rb"])? + .failure() + .stdout(predicate::eq( + "\nCODEOWNERS out of date. Run `codeowners generate` to update the CODEOWNERS file\n\ + - ruby/app/models/new_thing.rb is owned by Payroll, but CODEOWNERS doesn't list it\n\n", + )); + codeowners(project.path(), &["gv", "ruby/app/models/new_thing.rb"])? + .success() + .stdout(predicate::eq("")); + + Ok(()) +} + +#[test] +fn test_validate_with_files_reports_a_file_codeowners_gives_another_team() -> Result<(), Box> { + let project = valid_project_with(|root| { + let file = root.join("ruby/app/models/bank_account.rb"); + std::fs::write(&file, std::fs::read_to_string(&file)?.replace("Payments", "Payroll")) + }); + + codeowners(project.path(), &["validate", "ruby/app/models/bank_account.rb"])? + .failure() + .stdout(predicate::str::contains( + "- ruby/app/models/bank_account.rb is owned by Payroll, but CODEOWNERS gives it to Payments", + )); + + Ok(()) +} + +#[test] +fn test_validate_with_files_accepts_a_team_left_out_of_codeowners() -> Result<(), Box> { + // A team with do_not_add_to_codeowners_file gets commented-out lines, which the CODEOWNERS lookup still reads. + let project = valid_project_with(|root| { + std::fs::write( + root.join("config/teams/payments.yml"), + "name: Payments\ngithub:\n team: '@PaymentsTeam'\n do_not_add_to_codeowners_file: true\n\ + owned_globs:\n - ruby/app/payments/**/*\nunowned_globs:\n - ruby/app/payments/foo/**/*\n", + ) + }); + codeowners(project.path(), &["generate"])?.success(); + + codeowners( + project.path(), + &["validate", "ruby/app/models/bank_account.rb", "ruby/app/payments/nacha.rb"], + )? + .success() + .stdout(predicate::eq("")); + + Ok(()) +} + #[test] fn test_validate_files_respects_owned_globs_with_excluded_extensions() -> Result<(), Box> { // Validates that files not matching owned_globs are silently skipped when From aa30f3deaf7b8429190fb6ea070ce7ffd9256d3c Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Thu, 12 Nov 2026 16:21:06 -0800 Subject: [PATCH 08/13] Compare the whole output of gv with and without a list of files The differential test said it needed no knowledge of the fixture, but it compared only two hardcoded defects, so a check a scoped run dropped, such as the one for unowned files or #116's for `.codeowner` files, wouldn't have shown up. It now runs on every fixture whose project builds, passes every tracked file, and requires the same exit code and output. `gv` regenerates CODEOWNERS first, so nothing is stale, and with each package.yml and `.codeowner` in the list, every package and directory is checked. Files that `.ignore` leaves out of the walk are left out of the list, since a list of files isn't checked against `.ignore`, as on main. Dropping the scoped `.codeowner` check fails the test. The module comment, which still described staleness as unchecked and pointed at #126's deleted normalization tests, is updated. --- tests/validate_files_parity_test.rs | 127 +++++++++++----------------- 1 file changed, 50 insertions(+), 77 deletions(-) diff --git a/tests/validate_files_parity_test.rs b/tests/validate_files_parity_test.rs index 8f69312..219071f 100644 --- a/tests/validate_files_parity_test.rs +++ b/tests/validate_files_parity_test.rs @@ -1,26 +1,10 @@ -//! Parity between `validate` / `gv` with an explicit file list and the same command with -//! no file list. +//! Parity between `validate` / `gv` with a list of files and the same command with none. //! -//! Passing paths routes through `validate_files()` instead of `validate_all()` -//! (`runner.rs:124`). Both resolve ownership through the mappers, so both catch an -//! invalid team annotation and a file owned two ways; `validate_files` simply scopes the -//! per-file checks to the supplied paths. +//! Both resolve ownership through the mappers, so both catch a file owned two ways and an invalid team. A list of files scopes +//! the checks: a package or `.codeowner` is checked only if its directory contains a listed path, so one bad one elsewhere +//! doesn't fail every scoped run, and staleness is checked file by file, since the whole-file comparison can't be scoped. //! -//! Two things differ, both deliberately. The staleness check is unavoidable: it compares -//! the whole generated CODEOWNERS against the whole on-disk one, so it cannot be scoped to -//! a subset, and `gv ` makes it moot by regenerating first. The package check is -//! scoped to packages containing a supplied path, so that one bad package owner elsewhere -//! in the repo does not fail every scoped run; the `*_invalid_package*` tests pin both -//! halves of that, and the `*_untracked_*` pair pins that a path the walk never recorded is -//! asked about rather than assumed unowned. -//! -//! The `gv_*` tests previously all failed. `validate_files` used to answer only "does this -//! path have an owner in the CODEOWNERS file", which could not see a file owned two ways — -//! generation picks one winner, so the file looked owned and the command exited 0. They are -//! kept as regression guards against reintroducing that shortcut. -//! -//! Normalization of the supplied paths themselves is covered separately, in -//! `supplied_path_normalization_test.rs`. +//! Normalization of the given paths is covered in `path_normalization_test.rs`. use assert_cmd::prelude::*; use predicates::prelude::*; @@ -109,60 +93,55 @@ fn test_gv_with_paths_names_the_invalid_team() -> Result<(), Box> { Ok(()) } -#[test] -fn test_gv_with_every_path_matches_gv_with_no_paths() -> Result<(), Box> { - // The differential check: handing over every owned file should be equivalent to handing - // over none. This is the general form of the three tests above -- it needs no knowledge - // of which defects the fixture contains, so it keeps working as fixtures change. - // - // Regression guard, and the most valuable of the set: it needs no knowledge of the - // fixture's contents, so it keeps working as fixtures change. The all-paths run used to - // report neither the dual ownership nor the invalid team. - let temp_dir = setup_fixture_repo(std::path::Path::new(FIXTURE)); - let project_root = temp_dir.path(); - git_add_all_files(project_root); +// Every fixture whose project can be built. missing_github_team's can't, which missing_github_team_test.rs covers. +fn buildable_fixtures() -> Vec { + std::fs::read_dir("tests/fixtures") + .expect("tests/fixtures") + .map(|fixture| fixture.expect("fixture").path()) + .filter(|fixture| fixture.join("config/code_ownership.yml").exists() && !fixture.ends_with("missing_github_team")) + .collect() +} - // owned_globs for this fixture is `**/*.{rb,tsx}`. +fn tracked_files(project_root: &std::path::Path) -> Result, Box> { let tracked = Command::new("git").arg("ls-files").current_dir(project_root).output()?; - let owned_files: Vec = String::from_utf8(tracked.stdout)? - .lines() - .filter(|line| line.ends_with(".rb") || line.ends_with(".tsx")) - .map(str::to_string) - .collect(); - assert!(!owned_files.is_empty(), "fixture should contain owned files"); - - let no_paths = Command::cargo_bin("codeowners")? - .arg("--project-root") - .arg(project_root) - .arg("--no-cache") - .arg("gv") - .output()?; + Ok(String::from_utf8(tracked.stdout)?.lines().map(str::to_string).collect()) +} - let all_paths = Command::cargo_bin("codeowners")? - .arg("--project-root") - .arg(project_root) - .arg("--no-cache") - .arg("gv") - .args(&owned_files) - .output()?; +#[test] +fn test_gv_with_every_path_matches_gv_with_no_paths() -> Result<(), Box> { + // `gv` regenerates CODEOWNERS first, so nothing is stale. Given every tracked file, each package.yml and `.codeowner` + // included, a list of files is checked for everything a full run checks. + for fixture in buildable_fixtures() { + let temp_dir = setup_fixture_repo(&fixture); + let project_root = temp_dir.path(); + git_add_all_files(project_root); - // Compare the defects each run found rather than byte-for-byte output. The two now - // share a report format, but the no-paths run legitimately reports more (staleness, - // and files outside the supplied list), so only the shared substance is claimed here. - let no_paths_out = String::from_utf8_lossy(&no_paths.stdout); - let all_paths_out = String::from_utf8_lossy(&all_paths.stdout); + // A list of files isn't checked against `.ignore`, which leaves files out of the project walk. + let ignored = std::fs::read_to_string(project_root.join(".ignore")).unwrap_or_default(); + let files: Vec = tracked_files(project_root)? + .into_iter() + .filter(|file| !ignored.lines().any(|dir| std::path::Path::new(file).starts_with(dir))) + .collect(); + + let gv = |files: &[String]| { + Command::cargo_bin("codeowners") + .expect("codeowners binary") + .arg("--project-root") + .arg(project_root) + .arg("--no-cache") + .arg("gv") + .arg("--skip-stage") + .args(files) + .output() + }; + let no_paths = gv(&[])?; + let every_path = gv(&files)?; - for defect in ["one way", "Web3"] { assert_eq!( - no_paths_out.contains(defect), - all_paths_out.contains(defect), - "`gv` with no paths and `gv` with every path disagree about {:?}.\n\ - \n--- no paths (exit {:?}) ---\n{}\n--- every path (exit {:?}) ---\n{}", - defect, - no_paths.status.code(), - no_paths_out, - all_paths.status.code(), - all_paths_out, + (every_path.status.code(), String::from_utf8_lossy(&every_path.stdout)), + (no_paths.status.code(), String::from_utf8_lossy(&no_paths.stdout)), + "{}", + fixture.display() ); } @@ -348,19 +327,13 @@ fn test_validate_still_reports_an_untracked_file_with_no_owner() -> Result<(), B fn test_validate_with_every_path_finds_no_stale_codeowners_line_after_generating() -> Result<(), Box> { // Whatever else is wrong in a fixture, a CODEOWNERS file just generated is up to date, so checking each file against it // must not call it stale. - for fixture in std::fs::read_dir("tests/fixtures")? { - let fixture = fixture?.path(); - // missing_github_team's project can't be built, which missing_github_team_test.rs covers. - if !fixture.join("config/code_ownership.yml").exists() || fixture.ends_with("missing_github_team") { - continue; - } + for fixture in buildable_fixtures() { let temp_dir = setup_fixture_repo(&fixture); let project_root = temp_dir.path(); git_add_all_files(project_root); generate_codeowners(project_root); - let tracked = Command::new("git").arg("ls-files").current_dir(project_root).output()?; - let files: Vec = String::from_utf8(tracked.stdout)?.lines().map(str::to_string).collect(); + let files = tracked_files(project_root)?; let output = Command::cargo_bin("codeowners")? .arg("--project-root") .arg(project_root) From 2e944f32187a0bc145be71ff16d4dc3a0fcc7378 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Thu, 12 Nov 2026 16:22:32 -0800 Subject: [PATCH 09/13] Update the README for validate with a list of files The README said that given files, `validate` and `gv` look each one up in CODEOWNERS and don't check for more than one owner, and described #135's "CODEOWNERS doesn't list" heading. They now run the full checks on the listed files and the packages and `.codeowner` files whose directories contain them, and check each file against CODEOWNERS. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 47269f8..1e47806 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ js_package_paths: - `generate` (`g`): Generate the CODEOWNERS file and write it to `--codeowners-file-path`. - Flags: `--skip-stage, -s` to avoid `git add` after writing -- `validate [files...]` (`v`): Validate the CODEOWNERS file and configuration. Given files, it checks only their ownership, a fast mode for git hooks. +- `validate [files...]` (`v`): Validate the CODEOWNERS file and configuration. Given files, a fast mode for git hooks, it runs the same checks on just those files and the packages and `.codeowner` files whose directories contain them, and checks that CODEOWNERS gives each file its owner rather than comparing the whole file. - `generate-and-validate [files...]` (`gv`): Run `generate` then `validate`. - Flags: `--skip-stage, -s` - `for-file ` (`f`): Print the owner of a file. @@ -215,7 +215,7 @@ codeowners gv --no-cache - `js_package_paths` / `javascript_package_paths` (default: `['frontend/**/*']`) - `team_file_glob` (default: `['config/teams/**/*.yml']`) - `unowned_globs` (default: `['frontend/**/node_modules/**/*', 'frontend/**/__generated__/**/*']`): Files matched here are left out of the project: they don't need an owner, validation doesn't check them, and file annotations in them are ignored. A `.codeowner`, `package.yml` or `package.json` among them is still read, though, so its owner still goes into CODEOWNERS and an invalid team in it is still reported. A team file among them is still read too, so it still defines its team and keeps its own line in CODEOWNERS. Use this for third-party or generated code. -- `optionally_owned_globs` (default: `[]`, added in 0.6.0): Files in `owned_globs` that match these globs don't need an owner. It doesn't add to `owned_globs`, so a file must match both. Unlike `unowned_globs`, file annotations in them still count, and a full `validate` or `gv` still runs every other check, so a file there can't have more than one owner or name an invalid team. Given a list of files, as in a git hook, `validate` and `gv` look each one up in CODEOWNERS and don't check for more than one owner. An optionally owned file with no line passes only if nothing names an owner for it, so one whose annotation, `.codeowner` or package names a team that doesn't exist, or whose owner CODEOWNERS doesn't list yet, is reported under "Files matching optionally_owned_globs name an owner that CODEOWNERS doesn't list". As in a full `validate`, an annotation, `.codeowner` or package file doesn't count if the file is untracked or in a top-level directory named in `ignore_dirs`, so a new file's own annotation counts once the file is staged. Use `['**/*']` if only some of your code has owners, or scope it, e.g. `['**/deprecated/**/*']`. `unowned_globs` takes precedence, so a file that matches both is left out. If you use `unowned_globs: ['**/*']` only so that files can go unowned, replace it with `optionally_owned_globs: ['**/*']` to make annotations work, and run `codeowners gv` to add a CODEOWNERS line for each annotated file. The files that `unowned_globs` left out are then read and checked like any other owned file, which takes longer and may turn up problems that were hidden before. Keep third-party and generated code in `unowned_globs`. Versions before 0.6.0 ignore the key without a warning, so they still report unowned files in these globs. +- `optionally_owned_globs` (default: `[]`, added in 0.6.0): Files in `owned_globs` that match these globs don't need an owner. It doesn't add to `owned_globs`, so a file must match both. Unlike `unowned_globs`, file annotations in them still count, and `validate` and `gv` still run every other check, with or without a list of files, so a file there can't have more than one owner or name an invalid team, and given a list of files, `validate` reports one whose owner CODEOWNERS doesn't list yet. An annotation, `.codeowner` or package file doesn't count if the file is untracked or in a top-level directory named in `ignore_dirs`, so a new file's own annotation counts once the file is staged. Use `['**/*']` if only some of your code has owners, or scope it, e.g. `['**/deprecated/**/*']`. `unowned_globs` takes precedence, so a file that matches both is left out. If you use `unowned_globs: ['**/*']` only so that files can go unowned, replace it with `optionally_owned_globs: ['**/*']` to make annotations work, and run `codeowners gv` to add a CODEOWNERS line for each annotated file. The files that `unowned_globs` left out are then read and checked like any other owned file, which takes longer and may turn up problems that were hidden before. Keep third-party and generated code in `unowned_globs`. Versions before 0.6.0 ignore the key without a warning, so they still report unowned files in these globs. - `vendored_gems_path` (default: `'vendored/'`) - `cache_directory` (default: `'tmp/cache/codeowners'`) - `ignore_dirs` (default includes: `.git`, `node_modules`, `tmp`, etc.) From 596c038b75691bad5bb3acbc6cf6f6bba14b9a37 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Thu, 12 Nov 2026 16:24:49 -0800 Subject: [PATCH 10/13] Fix two comments on scoped validation Rejoin a sentence the doc comment on Validator::validate_files broke across lines, and say that a listed path selects `.codeowner` files as well as packages. --- src/ownership/validator.rs | 5 ++--- src/runner.rs | 3 ++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ownership/validator.rs b/src/ownership/validator.rs index d42e6ec..d23e7be 100644 --- a/src/ownership/validator.rs +++ b/src/ownership/validator.rs @@ -124,9 +124,8 @@ impl Validator { /// /// A package or `.codeowner` is checked if its directory contains one of `selectors`, which can include paths outside /// `owned_globs` such as the `package.yml` or `.codeowner` itself, so that one bad one elsewhere doesn't fail every scoped - /// run. The staleness check compares the - /// whole file and can't be scoped, so instead each supplied file with one owner is checked against `listed`, the team - /// CODEOWNERS gives each path as given. Pass `None` when CODEOWNERS has just been generated. + /// run. The staleness check compares the whole file and can't be scoped, so instead each supplied file with one owner is + /// checked against `listed`, the team CODEOWNERS gives each path as given. Pass `None` when CODEOWNERS was just generated. #[instrument(name = "validator_validate_scoped", level = "debug", skip_all)] pub fn validate_files( &self, diff --git a/src/runner.rs b/src/runner.rs index b0a384e..f54ac1d 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -157,7 +157,8 @@ impl Runner { }) .collect(); - // Any path in the project selects the packages containing it, including a `package.yml` that owned_globs leaves out. + // Any path in the project selects the packages and `.codeowner` files whose directories contain it, even one that + // owned_globs leaves out, such as a `package.yml`. let selectors: Vec = supplied .iter() .filter(|supplied| supplied.in_project) From bbb498ad0e68e9cca504be8e1c73b92e698df3ca Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Thu, 12 Nov 2026 16:28:15 -0800 Subject: [PATCH 11/13] Time the CODEOWNERS lookup and name what it can't tell apart The per-path CODEOWNERS lookup ran outside the per_file_query span, which held main's per-file lookups, so a profile left it unattributed. It now runs inside it. perf/README.md still named the file_to_owners span, which is now path_to_owners, and put it inside validate_file_ownership. A file CODEOWNERS gives no known team may have no line, or a line naming a team that's no longer in the team files, so the message now says "doesn't give it to a known team" rather than "doesn't list it". --- perf/README.md | 6 ++-- src/ownership/validator.rs | 7 +++- src/runner.rs | 52 +++++++++++++++------------- tests/optionally_owned_globs_test.rs | 2 +- tests/validate_files_test.rs | 2 +- 5 files changed, 38 insertions(+), 31 deletions(-) diff --git a/perf/README.md b/perf/README.md index ca1fa6d..a580f06 100644 --- a/perf/README.md +++ b/perf/README.md @@ -113,12 +113,12 @@ a hook that runs `gv` sees the smaller of the two wins, and quoting the Spans are inclusive of children, so sibling spans can be summed and nested ones cannot. `config_load`, `cache_init`, `project_build`, `cache_persist` and `per_file_query` are disjoint — percentages across those are sound. But -`ownership_validate` ⊃ `validator_validate` ⊃ `validate_file_ownership` ⊃ -`file_to_owners`: quoting those together as shares of one total double-counts. +`ownership_validate` ⊃ `validator_validate` ⊃ `path_to_owners`: quoting those +together as shares of one total double-counts. - The **phase breakdown** comes from `tracing` spans inside the library. Phases are **inclusive of nested children**, so they do not sum to the total — `project_build` contains its own sub-work, and `ownership_validate` contains - `validator_validate`, which contains `file_to_owners`. Use them to attribute a + `validator_validate`, which contains `path_to_owners`. Use them to attribute a win, not to reconstruct a total. - `mapper_build` is **accumulated across all calls in a run**. That is deliberate: it is currently invoked more than once per validate, and the sum is diff --git a/src/ownership/validator.rs b/src/ownership/validator.rs index d23e7be..b2c6bd9 100644 --- a/src/ownership/validator.rs +++ b/src/ownership/validator.rs @@ -432,7 +432,12 @@ impl Error { owner, listed ), - None => format!("- {} is owned by {}, but CODEOWNERS doesn't list it", path.to_string_lossy(), owner), + // CODEOWNERS may have no line for the file, or a line naming a team that's no longer in the team files. + None => format!( + "- {} is owned by {}, but CODEOWNERS doesn't give it to a known team", + path.to_string_lossy(), + owner + ), }], Error::InvalidTeam { name, path } => vec![format!("- {} is referencing an invalid team - '{}'", path.to_string_lossy(), name)], Error::InvalidDirectoryTeam { name, path, inherits_from } => { diff --git a/src/runner.rs b/src/runner.rs index f54ac1d..65da40a 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -177,33 +177,35 @@ impl Runner { return RunResult::default(); } - let listed = if codeowners_generated { - None - } else { - let given: Vec = owned.iter().map(|supplied| supplied.given.clone()).collect(); - match crate::ownership::codeowners_query::teams_for_files_from_codeowners( - project_root, - &self.codeowners_file_path, - &self.config.team_file_glob, - self.config.node_limit(project_root), - &given, - ) { - Ok(listed) => Some(listed), - Err(err) => { - return RunResult { - io_errors: vec![err], - ..Default::default() - }; + debug_span!("per_file_query").in_scope(|| { + let listed = if codeowners_generated { + None + } else { + let given: Vec = owned.iter().map(|supplied| supplied.given.clone()).collect(); + match crate::ownership::codeowners_query::teams_for_files_from_codeowners( + project_root, + &self.codeowners_file_path, + &self.config.team_file_glob, + self.config.node_limit(project_root), + &given, + ) { + Ok(listed) => Some(listed), + Err(err) => { + return RunResult { + io_errors: vec![err], + ..Default::default() + }; + } } - } - }; + }; - debug_span!("per_file_query").in_scope(|| match self.ownership.validate_files(&owned, &selectors, listed.as_ref()) { - Ok(_) => RunResult::default(), - Err(err) => RunResult { - validation_errors: vec![format!("{}", err)], - ..Default::default() - }, + match self.ownership.validate_files(&owned, &selectors, listed.as_ref()) { + Ok(_) => RunResult::default(), + Err(err) => RunResult { + validation_errors: vec![format!("{}", err)], + ..Default::default() + }, + } }) } diff --git a/tests/optionally_owned_globs_test.rs b/tests/optionally_owned_globs_test.rs index 4492568..557a3c1 100644 --- a/tests/optionally_owned_globs_test.rs +++ b/tests/optionally_owned_globs_test.rs @@ -691,7 +691,7 @@ fn test_validate_files_reports_an_optionally_owned_file_missing_from_codeowners( .failure() .stdout(predicate::eq(format!( "\nCODEOWNERS out of date. Run `codeowners generate` to update the CODEOWNERS file\n\ - - {file} is owned by Foo, but CODEOWNERS doesn't list it\n\n" + - {file} is owned by Foo, but CODEOWNERS doesn't give it to a known team\n\n" ))); } Ok(()) diff --git a/tests/validate_files_test.rs b/tests/validate_files_test.rs index f88dbb1..8aa3ec1 100644 --- a/tests/validate_files_test.rs +++ b/tests/validate_files_test.rs @@ -155,7 +155,7 @@ fn test_validate_with_files_reports_a_file_missing_from_codeowners() -> Result<( .failure() .stdout(predicate::eq( "\nCODEOWNERS out of date. Run `codeowners generate` to update the CODEOWNERS file\n\ - - ruby/app/models/new_thing.rb is owned by Payroll, but CODEOWNERS doesn't list it\n\n", + - ruby/app/models/new_thing.rb is owned by Payroll, but CODEOWNERS doesn't give it to a known team\n\n", )); codeowners(project.path(), &["gv", "ruby/app/models/new_thing.rb"])? .success() From cb1ae1d29e9e7d62dcc6e78f8c17c8aa50e431de Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Thu, 12 Nov 2026 16:32:35 -0800 Subject: [PATCH 12/13] Build the expected invalid team report in one place Ten optionally_owned_globs tests repeated the heading and blank lines around the expected invalid team line. invalid_team_report builds the whole report. --- tests/optionally_owned_globs_test.rs | 54 ++++++++-------------------- 1 file changed, 14 insertions(+), 40 deletions(-) diff --git a/tests/optionally_owned_globs_test.rs b/tests/optionally_owned_globs_test.rs index 557a3c1..f7a9215 100644 --- a/tests/optionally_owned_globs_test.rs +++ b/tests/optionally_owned_globs_test.rs @@ -17,6 +17,10 @@ fn invalid_team(file: impl std::fmt::Display) -> String { format!("- {file} is referencing an invalid team - 'Typo'") } +fn invalid_team_report(file: impl std::fmt::Display) -> String { + format!("\nFound invalid team references\n{}\n\n", invalid_team(file)) +} + #[test] fn test_validate_passes_with_unowned_files_in_optionally_owned_globs() -> Result<(), Box> { // The fixture's CODEOWNERS lists `app/deprecated/annotated_old.rb`, so if annotations in optionally owned globs stop @@ -208,10 +212,7 @@ fn test_validate_files_reports_an_optionally_owned_file_whose_annotation_names_a &[command, "app/deprecated/typo.rb"], )? .failure() - .stdout(predicate::eq(format!( - "\nFound invalid team references\n{}\n\n", - invalid_team("app/deprecated/typo.rb") - ))); + .stdout(predicate::eq(invalid_team_report("app/deprecated/typo.rb"))); } Ok(()) } @@ -260,10 +261,7 @@ fn test_validate_files_reports_an_optionally_owned_file_whose_annotation_names_a &[command, "app/deprecated/../deprecated/typo.rb"], )? .failure() - .stdout(predicate::eq(format!( - "\nFound invalid team references\n{}\n\n", - invalid_team("app/deprecated/../deprecated/typo.rb") - ))); + .stdout(predicate::eq(invalid_team_report("app/deprecated/../deprecated/typo.rb"))); } Ok(()) } @@ -277,10 +275,7 @@ fn test_validate_files_reports_an_optionally_owned_file_whose_annotation_names_a let file = link.join("app/deprecated/typo.rb"); codeowners(temp_dir.path(), &[OsStr::new("validate"), file.as_os_str()])? .failure() - .stdout(predicate::eq(format!( - "\nFound invalid team references\n{}\n\n", - invalid_team(file.display()) - ))); + .stdout(predicate::eq(invalid_team_report(file.display()))); Ok(()) } @@ -397,10 +392,7 @@ fn test_validate_files_reports_an_optionally_owned_file_in_a_package_naming_an_u for command in ["validate", "gv"] { run_on_modified_fixture(add_package, &[command, file])? .failure() - .stdout(predicate::eq(format!( - "\nFound invalid team references\n{}\n\n", - invalid_team(package) - ))); + .stdout(predicate::eq(invalid_team_report(package))); } } Ok(()) @@ -455,10 +447,7 @@ fn test_validate_files_reports_an_optionally_owned_file_in_a_bracketed_package_n &[command, "packs/[typo]/app/thing.rb"], )? .failure() - .stdout(predicate::eq(format!( - "\nFound invalid team references\n{}\n\n", - invalid_team("packs/[typo]/package.yml") - ))); + .stdout(predicate::eq(invalid_team_report("packs/[typo]/package.yml"))); } Ok(()) } @@ -497,10 +486,7 @@ fn test_validate_files_reports_an_optionally_owned_file_in_a_braced_package_nami &[command, "packs/{typo}/app/thing.rb"], )? .failure() - .stdout(predicate::eq(format!( - "\nFound invalid team references\n{}\n\n", - invalid_team("packs/{typo}/package.yml") - ))); + .stdout(predicate::eq(invalid_team_report("packs/{typo}/package.yml"))); } Ok(()) } @@ -517,10 +503,7 @@ fn test_validate_files_reports_an_optionally_owned_file_under_a_codeowner_naming &[command, "app/deprecated/old.rb"], )? .failure() - .stdout(predicate::eq(format!( - "\nFound invalid team references\n{}\n\n", - invalid_team("app/deprecated/.codeowner") - ))); + .stdout(predicate::eq(invalid_team_report("app/deprecated/.codeowner"))); } Ok(()) } @@ -562,10 +545,7 @@ fn test_validate_files_reports_an_optionally_owned_file_under_a_codeowner_at_the &[command, "app/deprecated/old.rb"], )? .failure() - .stdout(predicate::eq(format!( - "\nFound invalid team references\n{}\n\n", - invalid_team(".codeowner") - ))); + .stdout(predicate::eq(invalid_team_report(".codeowner"))); } Ok(()) } @@ -587,10 +567,7 @@ fn test_validate_files_reports_an_optionally_owned_file_under_a_braced_codeowner &[command, "app/deprecated/{legacy}/thing.rb"], )? .failure() - .stdout(predicate::eq(format!( - "\nFound invalid team references\n{}\n\n", - invalid_team("app/deprecated/{legacy}/.codeowner") - ))); + .stdout(predicate::eq(invalid_team_report("app/deprecated/{legacy}/.codeowner"))); run_on_modified_fixture( add_a_braced_codeowner_file_naming_an_unknown_team_in_an_optionally_owned_glob, &[command, "app/deprecated/legacy/thing.rb", "app/deprecated/old.rb"], @@ -780,10 +757,7 @@ fn test_validate_files_leaves_out_files_in_both_globs() -> Result<(), Box Date: Thu, 12 Nov 2026 16:36:13 -0800 Subject: [PATCH 13/13] Generate CODEOWNERS in the parity tests with the shared helper --- tests/validate_files_parity_test.rs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/validate_files_parity_test.rs b/tests/validate_files_parity_test.rs index 219071f..ef95b0f 100644 --- a/tests/validate_files_parity_test.rs +++ b/tests/validate_files_parity_test.rs @@ -150,14 +150,7 @@ fn test_gv_with_every_path_matches_gv_with_no_paths() -> Result<(), Box