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.) 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/cli.rs b/src/cli.rs index 1d0a7f9..4678b38 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -39,7 +39,11 @@ 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). Checks just these \ + files, the packages and `.codeowner` files whose directories contain them, and that the \ + CODEOWNERS file gives each file its owner." + )] files: Vec, }, @@ -47,7 +51,11 @@ 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). 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 ba34b9a..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}, @@ -23,6 +24,7 @@ use crate::{ }; pub use validator::Errors as ValidatorErrors; +pub(crate) use validator::SuppliedPath; use self::{ codeowners_file_parser::parse_for_team, @@ -109,27 +111,6 @@ impl Entry { } } -/// Tells whether anything names an owner for a project-relative path, even a team that doesn't exist: an annotation naming one -/// still counts for its file, and a `.codeowner`, `package.yml` or `package.json` naming one for every path under its directory. -/// It sees only files the project reads, so an annotated file, `.codeowner` or package file names no owner here if it is -/// untracked or under a top-level directory named in ignore_dirs, as in `validate`. Building one goes through every project -/// file, so reuse it. -pub(crate) struct OwnerLookup { - owner_matchers: Vec, - unknown_team_roots: Vec, -} - -impl OwnerLookup { - pub(crate) fn has_owner(&self, relative_path: &Path) -> bool { - // FileOwnerFinder::find returns an owner exactly when some matcher names a team, but builds every owner to do it. - self.unknown_team_roots.iter().any(|root| relative_path.starts_with(root)) - || self - .owner_matchers - .iter() - .any(|owner_matcher| owner_matcher.owner_for(relative_path).0.is_some()) - } -} - impl Ownership { pub fn build(project: Project) -> Self { Self { @@ -143,13 +124,29 @@ impl Ownership { let validator = Validator { project: self.project.clone(), mappers: self.mappers(), - file_generator: FileGenerator { - mappers: self.codeowners_file_mappers(), - }, executable_name: self.project.executable_name.clone(), }; - validator.validate() + validator.validate(&FileGenerator { + mappers: self.codeowners_file_mappers(), + }) + } + + #[instrument(name = "ownership_validate_files", level = "debug", skip_all)] + 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(), + mappers: self.mappers(), + executable_name: self.project.executable_name.clone(), + }; + + validator.validate_files(supplied, selectors, listed) } #[instrument(level = "debug", skip_all)] @@ -177,23 +174,6 @@ impl Ownership { .collect()) } - pub(crate) fn owner_lookup(&self) -> OwnerLookup { - // `validate` reports a package.yml, package.json or `.codeowner` naming a team that doesn't exist, but a list of files - // isn't checked for that, so here each still names an owner for the files under its directory. The directory mapper - // matches a `.codeowner` whatever team it names, but the package mappers drop such a package, so its directory is matched - // as a path prefix. - let package_roots = self - .project - .packages - .iter() - .filter(|package| !self.project.teams_by_name.contains_key(&package.owner)) - .filter_map(|package| package.package_root()); - OwnerLookup { - owner_matchers: self.mappers().iter().flat_map(|mapper| mapper.owner_matchers()).collect(), - unknown_team_roots: package_roots.map(Path::to_path_buf).collect(), - } - } - #[instrument(level = "debug", skip_all)] pub fn for_team(&self, team_name: &str) -> Result, Box> { info!("getting team ownership for {}", team_name); diff --git a/src/ownership/validator.rs b/src/ownership/validator.rs index 3952046..b2c6bd9 100644 --- a/src/ownership/validator.rs +++ b/src/ownership/validator.rs @@ -1,7 +1,7 @@ use crate::path_utils::matches_globs; -use crate::project::{Project, ProjectFile}; +use crate::project::{Package, Project, ProjectFile, Team}; use core::fmt; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fmt::Display; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -22,10 +22,26 @@ use super::mapper::{Mapper, OwnerMatcher, TeamName}; pub struct Validator { pub project: Arc, pub mappers: Vec>, - pub file_generator: FileGenerator, pub executable_name: String, } +/// A path a caller asked to validate. +pub struct SuppliedPath { + /// The path as given, which is how it's reported. + pub given: String, + /// What the matchers see: the project-relative path, or the given path, without the root if it's absolute, when it names + /// nothing under the project. + pub path: PathBuf, + pub in_project: bool, +} + +struct OwnershipQuery<'a> { + shown_as: &'a Path, + path: &'a Path, + // optionally_owned_globs never excuses a path outside the project. + in_project: bool, +} + #[derive(Debug)] enum Error { InvalidTeam { @@ -53,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)] @@ -60,17 +83,101 @@ pub struct Errors(Vec); impl Validator { #[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<(&Path, &ProjectFile)> = self + .project + .files + .iter() + .map(|file| (self.project.relative_path(&file.path), file)) + .collect(); + let packages: Vec<&Package> = self.project.packages.iter().collect(); + let queries: Vec = files + .iter() + .map(|(relative_path, _)| OwnershipQuery { + shown_as: relative_path, + path: relative_path, + in_project: true, + }) + .collect(); debug!("validate_invalid_team"); - validation_errors.append(&mut self.validate_invalid_team()); + validation_errors.append(&mut self.validate_invalid_team(&files, &packages)); + validation_errors.append(&mut self.invalid_directory_ownership(None)); debug!("validate_file_ownership"); - validation_errors.append(&mut self.validate_file_ownership()); + 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()); + validation_errors.append(&mut self.validate_codeowners_file(file_generator)); + + if validation_errors.is_empty() { + Ok(()) + } else { + Err(Errors(validation_errors)) + } + } + + /// Runs the per-file checks of [`Validator::validate`] over just `supplied`, resolving ownership through the mappers as + /// the whole-project run does. Annotations are read only from files the project walked, so an untracked file's own + /// annotation doesn't count, as in `validate`. + /// + /// 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 was just generated. + #[instrument(name = "validator_validate_scoped", level = "debug", skip_all)] + 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() + .filter(|supplied| supplied.in_project) + .map(|supplied| supplied.path.as_path()) + .collect(); + let walked: HashMap<&Path, &ProjectFile> = self + .project + .files + .iter() + .map(|file| (self.project.relative_path(&file.path), file)) + .filter(|(relative_path, _)| in_project.contains(relative_path)) + .collect(); + let files: Vec<(&Path, &ProjectFile)> = supplied + .iter() + .filter(|supplied| supplied.in_project) + .filter_map(|supplied| walked.get(supplied.path.as_path()).map(|file| (Path::new(&supplied.given), *file))) + .collect(); + let packages: Vec<&Package> = self + .project + .packages + .iter() + .filter(|package| self.package_contains_any(package, selectors)) + .collect(); + let queries: Vec = supplied + .iter() + .map(|supplied| OwnershipQuery { + shown_as: Path::new(&supplied.given), + path: &supplied.path, + in_project: supplied.in_project, + }) + .collect(); + + let mut validation_errors = Vec::new(); + + debug!("validate_invalid_team"); + validation_errors.append(&mut self.validate_invalid_team(&files, &packages)); + validation_errors.append(&mut self.invalid_directory_ownership(Some(selectors))); + + debug!("validate_file_ownership"); + 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(()) @@ -79,33 +186,65 @@ 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 { + package + .package_root() + .map(|package_root| self.project.relative_path(package_root)) + .is_some_and(|package_root| selectors.iter().any(|path| path.starts_with(package_root))) + } + #[instrument(name = "validate_invalid_team", level = "debug", skip_all)] - fn validate_invalid_team(&self) -> Vec { + fn validate_invalid_team(&self, files: &[(&Path, &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)); - errors.append(&mut self.invalid_package_ownership(&team_names)); - errors.append(&mut self.invalid_directory_ownership()); + errors.append(&mut self.invalid_team_annotation(&team_names, files)); + errors.append(&mut self.invalid_package_ownership(&team_names, packages)); errors } - fn invalid_team_annotation(&self, team_names: &HashSet<&String>) -> Vec { - let project = self.project.clone(); - - self.project - .files + fn invalid_team_annotation(&self, team_names: &HashSet<&String>, files: &[(&Path, &ProjectFile)]) -> Vec { + files .par_iter() - .flat_map(|file| { + .flat_map(|(shown_as, file)| { if let Some(owner) = &file.owner && !team_names.contains(owner) { return Some(Error::InvalidTeam { name: owner.clone(), - path: project.relative_path(&file.path).to_owned(), + path: shown_as.to_path_buf(), }); } @@ -114,9 +253,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) { @@ -137,7 +275,7 @@ impl Validator { /// Resolves through `teams_by_name` rather than `teams[].name` so the predicate is /// identical to the mapper's lookup: that map is keyed by both `name` and /// `github_team`, and a `.codeowner` holding either one generates a correct line. - fn invalid_directory_ownership(&self) -> Vec { + fn invalid_directory_ownership(&self, selectors: Option<&[PathBuf]>) -> Vec { let resolvable_roots: HashSet<&Path> = self .project .directory_codeowner_files @@ -149,6 +287,14 @@ impl Validator { self.project .directory_codeowner_files .iter() + .filter(|directory_codeowner_file| { + selectors.is_none_or(|selectors| { + directory_codeowner_file + .directory_root() + .map(|root| self.project.relative_path(root)) + .is_some_and(|root| selectors.iter().any(|path| path.starts_with(root))) + }) + }) .flat_map(|directory_codeowner_file| { if !self.project.teams_by_name.contains_key(&directory_codeowner_file.owner) { Some(Error::InvalidDirectoryTeam { @@ -167,23 +313,23 @@ impl Validator { } #[instrument(name = "validate_file_ownership", level = "debug", skip_all)] - fn validate_file_ownership(&self) -> Vec { - let project = &self.project; + fn validate_file_ownership(&self, owners: Vec<(&OwnershipQuery, Vec)>) -> Vec { + let optionally_owned_globs = &self.project.optionally_owned_globs; - self.file_to_owners() + owners .into_par_iter() - .filter_map(|(file, owners)| { - let relative_path = project.relative_path(&file.path).to_owned(); - + .filter_map(|(query, owners)| { if owners.is_empty() { - if matches_globs(&relative_path, &project.optionally_owned_globs) { + if query.in_project && matches_globs(query.path, optionally_owned_globs) { None } else { - Some(Error::FileWithoutOwner { path: relative_path }) + Some(Error::FileWithoutOwner { + path: query.shown_as.to_path_buf(), + }) } } else if owners.len() > 1 { Some(Error::FileWithMultipleOwners { - path: relative_path, + path: query.shown_as.to_path_buf(), owners, }) } else { @@ -194,8 +340,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 { @@ -208,22 +354,18 @@ impl Validator { } } - #[instrument(name = "file_to_owners", level = "debug", skip_all)] - fn file_to_owners(&self) -> Vec<(&ProjectFile, Vec)> { + // Keyed on paths rather than ProjectFiles so that a scoped run can ask about a path the walk didn't record, such as an + // untracked file in a directory with a `.codeowner`, and agree with `for-file`. + #[instrument(name = "path_to_owners", level = "debug", skip_all)] + fn path_to_owners<'a>(&self, queries: &'a [OwnershipQuery<'a>]) -> Vec<(&'a OwnershipQuery<'a>, 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 + queries .par_iter() - .filter_map(|project_file| { - let relative_path = project.relative_path(&project_file.path); - let owners = file_owner_finder.find(relative_path); - Some((project_file, owners)) - }) + .map(|query| (query, file_owner_finder.find(query.path))) .collect() } } @@ -253,7 +395,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(), @@ -282,6 +425,20 @@ 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 + ), + // 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 } => { // `owner` is `content.trim()`, so an empty or whitespace-only file yields "". @@ -351,8 +508,183 @@ impl core::error::Error for Errors {} #[cfg(test)] mod tests { use super::*; + use crate::project::{DirectoryCodeownersFile, PackageType, Team}; use indoc::indoc; + const ROOT: &str = "/proj"; + + // No mappers means no file has an owner, so every file in scope is reported as unowned, which makes the scoping visible. + fn validator(files: &[&str], packages: &[(&str, &str)], codeowner_files: &[(&str, &str)], teams: &[&str]) -> Validator { + let teams: Vec = teams + .iter() + .map(|name| Team { + name: (*name).to_string(), + ..Default::default() + }) + .collect(); + 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_by_name: teams.iter().map(|team| (team.name.clone(), team.clone())).collect(), + teams, + codeowners_file_path: PathBuf::from(".github/CODEOWNERS"), + directory_codeowner_files: codeowner_files + .iter() + .map(|(path, owner)| DirectoryCodeownersFile { + path: PathBuf::from(ROOT).join(path), + owner: (*owner).to_string(), + }) + .collect(), + executable_name: "codeowners".to_string(), + optionally_owned_globs: vec![], + }; + + Validator { + project: Arc::new(project), + mappers: vec![], + executable_name: "codeowners".to_string(), + } + } + + fn supplied(paths: &[&str]) -> Vec { + paths + .iter() + .map(|path| SuppliedPath { + given: (*path).to_string(), + path: PathBuf::from(path), + in_project: true, + }) + .collect() + } + + fn selectors(paths: &[&str]) -> Vec { + paths.iter().map(PathBuf::from).collect() + } + + fn report(result: Result<(), Errors>) -> String { + result.map_or_else(|errors| errors.to_string(), |_| String::new()) + } + + #[test] + 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"]), None)); + + assert!(report.contains("ruby/a.rb"), "{report}"); + assert!(!report.contains("ruby/b.rb"), "{report}"); + } + + #[test] + fn validate_files_reports_a_path_supplied_twice_once() { + let validator = validator(&[], &[], &[], &[]); + + let errors = validator + .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:?}"); + } + + #[test] + fn validate_files_skips_a_package_or_codeowner_containing_no_supplied_path() { + let validator = validator( + &[], + &[("ruby/packages/foo/package.yml", "NoSuchTeam")], + &[("ruby/legacy/.codeowner", "NoSuchTeam")], + &["Payroll"], + ); + + assert_eq!(report(validator.validate_files(&[], &selectors(&["ruby/app/a.rb"]), None)), ""); + } + + #[test] + fn validate_files_reports_a_package_or_codeowner_containing_a_supplied_path() { + let validator = validator( + &[], + &[("ruby/packages/foo/package.yml", "NoSuchTeam")], + &[("ruby/packages/foo/app/.codeowner", "NoSuchTeam")], + &["Payroll"], + ); + + 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}"); + } + + #[test] + fn validate_files_reports_a_supplied_package_or_codeowner_itself() { + // Neither matches owned_globs, so each reaches the check only as a selector, and neither is reported as unowned. + let validator = validator( + &[], + &[("ruby/packages/foo/package.yml", "NoSuchTeam")], + &[("ruby/legacy/.codeowner", "NoSuchTeam")], + &["Payroll"], + ); + + 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}"); + assert!(!report.contains("missing ownership"), "{report}"); + } + + #[test] + fn validate_files_does_not_select_a_sibling_by_name_prefix() { + let validator = validator( + &[], + &[("ruby/packages/foo/package.yml", "NoSuchTeam")], + &[("ruby/legacy/.codeowner", "NoSuchTeam")], + &["Payroll"], + ); + + assert_eq!( + report(validator.validate_files(&[], &selectors(&["ruby/packages/foobar/a.rb", "ruby/legacy2/a.rb"]), None)), + "" + ); + } + + #[test] + 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"]), None)); + + assert!(report.contains("- package.yml"), "{report}"); + assert!(report.contains("- .codeowner"), "{report}"); + } + + #[test] + fn validate_files_accepts_a_valid_package_or_codeowner() { + let validator = validator( + &[], + &[("ruby/packages/foo/package.yml", "Payroll")], + &[("ruby/packages/foo/.codeowner", "Payroll")], + &["Payroll"], + ); + + assert_eq!( + report(validator.validate_files(&[], &selectors(&["ruby/packages/foo/a.rb"]), None)), + "" + ); + } + #[test] fn test_codeowners_diff_reports_added_and_removed_lines() { let current = indoc! {" diff --git a/src/runner.rs b/src/runner.rs index d79fcce..65da40a 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,4 +1,3 @@ -use std::cell::LazyCell; use std::path::{Path, PathBuf}; use std::process::Command; @@ -9,7 +8,7 @@ use tracing::debug_span; use crate::{ cache::{Cache, Caching, file::GlobalCache, noop::NoopCache}, config::Config, - ownership::{FileOwner, Ownership}, + ownership::{FileOwner, Ownership, SuppliedPath}, path_utils::{matches_globs, project_relative_path, unplaced_path}, project_builder::ProjectBuilder, }; @@ -143,91 +142,71 @@ 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. 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 mut unowned_files = Vec::new(); - let mut optionally_owned_files_with_unlisted_owners = 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<(String, Option)> = file_paths + let project_root = &self.run_config.project_root; + let supplied: Vec = file_paths .into_iter() - .map(|file_path| { - let relative_path = project_relative_path(&self.run_config.project_root, Path::new(&file_path)); - (file_path, relative_path) - }) - // Mirror the filtering applied by ProjectBuilder when walking the project - .filter(|(file_path, relative_path)| { - let path = relative_path - .as_deref() - .unwrap_or_else(|| unplaced_path(&self.run_config.project_root, Path::new(file_path))); - matches_globs(path, &self.config.owned_globs) && !matches_globs(path, &self.config.unowned_globs) + .map(|given| { + let relative_path = project_relative_path(project_root, Path::new(&given)); + let in_project = relative_path.is_some(); + let path = relative_path.unwrap_or_else(|| unplaced_path(project_root, Path::new(&given)).to_path_buf()); + SuppliedPath { given, path, in_project } }) .collect(); - // Built only if an optionally owned file needs it, and then once. - let owner_lookup = LazyCell::new(|| self.ownership.owner_lookup()); + // 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) + .map(|supplied| supplied.path.clone()) + .collect(); - debug_span!("per_file_query").in_scope(|| { - for (file_path, relative_path) in filtered_paths { - match team_for_file_from_codeowners(&self.run_config, &file_path) { - Ok(Some(_)) => {} - // optionally_owned_globs excuses only a file nothing names an owner for. One whose annotation, `.codeowner` or - // package names a team that doesn't exist, or whose owner CODEOWNERS doesn't list yet, is still reported, - // but not as unowned, which the key allows. Only files the project reads count, though, so an annotated - // file, `.codeowner` or package file names no owner here if it is untracked or under a top-level directory - // named in ignore_dirs, as in `validate`. A path that names nothing under the project root is never - // excused and is reported as unowned, as without the key. - Ok(None) => match relative_path.filter(|path| matches_globs(path, &self.config.optionally_owned_globs)) { - Some(path) if !owner_lookup.has_owner(&path) => {} - Some(_) => optionally_owned_files_with_unlisted_owners.push(file_path), - None => unowned_files.push(file_path), - }, - Err(err) => io_errors.push(format!("{}: {}", file_path, err)), - } - } - }); + // Mirror the filtering applied by ProjectBuilder when walking the project + let owned: Vec = supplied + .into_iter() + .filter(|supplied| { + matches_globs(&supplied.path, &self.config.owned_globs) && !matches_globs(&supplied.path, &self.config.unowned_globs) + }) + .collect(); - let mut validation_errors = Vec::new(); - if !unowned_files.is_empty() { - validation_errors.push("Unowned files detected:".to_string()); - validation_errors.extend(unowned_files.into_iter().map(|file| format!(" {}", file))); - } - if !optionally_owned_files_with_unlisted_owners.is_empty() { - // `gv` has just written CODEOWNERS, so updating it again can't help, and only a team that doesn't exist is left out. - let or_update_codeowners = if codeowners_generated { - String::new() - } else { - format!(", or run `{}` to update the CODEOWNERS file", self.config.executable_name) - }; - validation_errors.push(format!( - "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_update_codeowners - )); - validation_errors.extend( - optionally_owned_files_with_unlisted_owners - .into_iter() - .map(|file| format!(" {}", file)), - ); + if owned.is_empty() && selectors.is_empty() { + return RunResult::default(); } - if !validation_errors.is_empty() { - return RunResult { - validation_errors, - io_errors, - ..Default::default() - }; - } - - if !io_errors.is_empty() { - return RunResult { - io_errors, - ..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() + }; + } + } }; - } - RunResult::default() + match self.ownership.validate_files(&owned, &selectors, listed.as_ref()) { + Ok(_) => RunResult::default(), + Err(err) => RunResult { + validation_errors: vec![format!("{}", err)], + ..Default::default() + }, + } + }) } pub fn generate(&self, git_stage: bool) -> RunResult { diff --git a/tests/optionally_owned_globs_test.rs b/tests/optionally_owned_globs_test.rs index 221878d..f7a9215 100644 --- a/tests/optionally_owned_globs_test.rs +++ b/tests/optionally_owned_globs_test.rs @@ -9,19 +9,17 @@ 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 +// `validate`, so an optionally owned file naming one is reported that way rather than excused. +fn invalid_team(file: impl std::fmt::Display) -> String { + format!("- {file} is referencing an invalid team - 'Typo'") } -type FixtureChange = fn(&Path) -> std::io::Result<()>; +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> { @@ -169,8 +167,7 @@ fn test_validate_reports_unowned_files_outside_optionally_owned_globs() -> Resul fn test_validate_files_reports_an_unowned_file_outside_optionally_owned_globs() -> Result<(), Box> { run_on_modified_fixture(add_unowned_file_outside_optionally_owned_globs, &["validate", "app/stray.rb"])? .failure() - .stdout(predicate::str::contains("Unowned files detected:")) - .stdout(predicate::str::contains("app/stray.rb")); + .stdout(predicate::eq("\nSome files are missing ownership\n- app/stray.rb\n\n")); Ok(()) } @@ -209,17 +206,13 @@ fn test_validate_reports_invalid_teams_in_optionally_owned_globs() -> Result<(), #[test] fn test_validate_files_reports_an_optionally_owned_file_whose_annotation_names_an_unknown_team() -> Result<(), Box> { - // A list of files isn't checked for invalid teams. CODEOWNERS has no line for this file, and its annotation keeps - // optionally_owned_globs from excusing it, so it's reported, but not as unowned. for command in ["validate", "gv"] { run_on_modified_fixture( add_an_annotation_naming_an_unknown_team_in_an_optionally_owned_glob, &[command, "app/deprecated/typo.rb"], )? .failure() - .stdout(predicate::str::contains(unlisted_owner_heading(command))) - .stdout(predicate::str::contains("app/deprecated/typo.rb")) - .stdout(predicate::str::contains("Unowned files detected:").not()); + .stdout(predicate::eq(invalid_team_report("app/deprecated/typo.rb"))); } Ok(()) } @@ -238,8 +231,8 @@ fn test_validate_files_reports_optionally_owned_and_unowned_files_under_separate )? .failure() .stdout(predicate::eq(format!( - "Unowned files detected:\n app/stray.rb\n{}\n app/deprecated/typo.rb\n", - unlisted_owner_heading(command) + "\nFound invalid team references\n{}\n\nSome files are missing ownership\n- app/stray.rb\n\n", + invalid_team("app/deprecated/typo.rb") ))); } Ok(()) @@ -253,8 +246,9 @@ fn test_validate_files_reports_an_optionally_owned_file_whose_annotation_names_a "app/deprecated/typo.rb", )? .failure() - .stdout(predicate::str::contains(unlisted_owner_heading("validate"))) - .stdout(predicate::str::contains("app/deprecated/typo.rb")); + .stdout(predicate::str::contains( + "app/deprecated/typo.rb is referencing an invalid team - 'Typo'", + )); Ok(()) } @@ -267,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!( - "{}\n app/deprecated/../deprecated/typo.rb\n", - unlisted_owner_heading(command) - ))); + .stdout(predicate::eq(invalid_team_report("app/deprecated/../deprecated/typo.rb"))); } Ok(()) } @@ -284,11 +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!( - "{}\n {}\n", - unlisted_owner_heading("validate"), - file.display() - ))); + .stdout(predicate::eq(invalid_team_report(file.display()))); Ok(()) } @@ -304,16 +291,15 @@ fn own_every_ruby_file_and_annotate_an_optionally_owned_one_with_an_unknown_team #[test] fn test_validate_files_treats_a_dot_slash_path_like_a_plain_one() -> Result<(), Box> { - // Unlike the fixture's owned_globs, `**/*.rb` matches a path starting with `./`. The optionally owned glob doesn't, so these - // reach the owner lookup only if it's matched against the plain path. + // Unlike the fixture's owned_globs, `**/*.rb` matches a path starting with `./`. The optionally owned glob doesn't, so old.rb + // is excused only if it's matched against the plain path. for command in ["validate", "gv"] { run_on_modified_fixture( own_every_ruby_file_and_annotate_an_optionally_owned_one_with_an_unknown_team, &[command, "./app/deprecated/typo.rb"], )? .failure() - .stdout(predicate::str::contains(unlisted_owner_heading(command))) - .stdout(predicate::str::contains("./app/deprecated/typo.rb")); + .stdout(predicate::str::contains(invalid_team("./app/deprecated/typo.rb"))); run_on_modified_fixture( own_every_ruby_file_and_annotate_an_optionally_owned_one_with_an_unknown_team, &[command, "./app/deprecated/old.rb"], @@ -356,7 +342,7 @@ fn test_validate_files_does_not_excuse_a_path_outside_the_project() -> Result<() for command in ["validate", "gv"] { run_on_modified_fixture(make_every_file_optionally_owned, &[command, "../outside.rb"])? .failure() - .stdout(predicate::eq("Unowned files detected:\n ../outside.rb\n")); + .stdout(predicate::eq("\nSome files are missing ownership\n- ../outside.rb\n\n")); } Ok(()) } @@ -390,22 +376,23 @@ fn add_a_javascript_package_naming_an_unknown_team_in_an_optionally_owned_glob(p #[test] fn test_validate_files_reports_an_optionally_owned_file_in_a_package_naming_an_unknown_team() -> Result<(), Box> { - let packages: [(FixtureChange, &str); 2] = [ + let packages: [(FixtureChange, &str, &str); 2] = [ ( add_a_package_naming_an_unknown_team_in_an_optionally_owned_glob, "packs/typo/app/thing.rb", + "packs/typo/package.yml", ), ( add_a_javascript_package_naming_an_unknown_team_in_an_optionally_owned_glob, "frontend/typo/src/thing.js", + "frontend/typo/package.json", ), ]; - for (add_package, file) in packages { + for (add_package, file, package) in packages { for command in ["validate", "gv"] { run_on_modified_fixture(add_package, &[command, file])? .failure() - .stdout(predicate::str::contains(unlisted_owner_heading(command))) - .stdout(predicate::str::contains(file)); + .stdout(predicate::eq(invalid_team_report(package))); } } Ok(()) @@ -460,8 +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::str::contains(unlisted_owner_heading(command))) - .stdout(predicate::str::contains("packs/[typo]/app/thing.rb")); + .stdout(predicate::eq(invalid_team_report("packs/[typo]/package.yml"))); } Ok(()) } @@ -500,8 +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::str::contains(unlisted_owner_heading(command))) - .stdout(predicate::str::contains("packs/{typo}/app/thing.rb")); + .stdout(predicate::eq(invalid_team_report("packs/{typo}/package.yml"))); } Ok(()) } @@ -518,8 +503,7 @@ fn test_validate_files_reports_an_optionally_owned_file_under_a_codeowner_naming &[command, "app/deprecated/old.rb"], )? .failure() - .stdout(predicate::str::contains(unlisted_owner_heading(command))) - .stdout(predicate::str::contains("app/deprecated/old.rb")); + .stdout(predicate::eq(invalid_team_report("app/deprecated/.codeowner"))); } Ok(()) } @@ -537,8 +521,9 @@ fn test_validate_files_reports_an_optionally_owned_file_under_an_empty_codeowner &[command, "app/deprecated/old.rb"], )? .failure() - .stdout(predicate::str::contains(unlisted_owner_heading(command))) - .stdout(predicate::str::contains("app/deprecated/old.rb")); + .stdout(predicate::eq( + "\nFound invalid team references\n- app/deprecated/.codeowner is empty and names no team\n\n", + )); } Ok(()) } @@ -560,8 +545,7 @@ fn test_validate_files_reports_an_optionally_owned_file_under_a_codeowner_at_the &[command, "app/deprecated/old.rb"], )? .failure() - .stdout(predicate::str::contains(unlisted_owner_heading(command))) - .stdout(predicate::str::contains("app/deprecated/old.rb")); + .stdout(predicate::eq(invalid_team_report(".codeowner"))); } Ok(()) } @@ -583,8 +567,7 @@ fn test_validate_files_reports_an_optionally_owned_file_under_a_braced_codeowner &[command, "app/deprecated/{legacy}/thing.rb"], )? .failure() - .stdout(predicate::str::contains(unlisted_owner_heading(command))) - .stdout(predicate::str::contains("app/deprecated/{legacy}/thing.rb")); + .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"], @@ -683,8 +666,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 give it to a known team\n\n" + ))); } Ok(()) } @@ -713,7 +698,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(()) } @@ -731,7 +716,7 @@ fn test_generate_and_validate_files_does_not_suggest_the_configured_executable() &["gv", "app/deprecated/typo.rb"], )? .failure() - .stdout(predicate::str::contains(unlisted_owner_heading("gv"))) + .stdout(predicate::str::contains(invalid_team("app/deprecated/typo.rb"))) .stdout(predicate::str::contains("to update the CODEOWNERS file").not()); Ok(()) } @@ -764,14 +749,15 @@ fn put_a_codeowner_file_naming_an_unknown_team_in_both_globs(project_root: &Path #[test] fn test_validate_files_leaves_out_files_in_both_globs() -> Result<(), Box> { - // A `.codeowner` is read even in unowned_globs. Its team has no CODEOWNERS line, so the file passes only if it's left out. + // The file is left out, so it isn't reported. A `.codeowner` is read even in unowned_globs, and `validate` reports this one, + // so a list of files in its directory does too. for command in ["validate", "gv"] { run_on_modified_fixture( put_a_codeowner_file_naming_an_unknown_team_in_both_globs, &[command, "app/deprecated/old.rb"], )? - .success() - .stdout(predicate::eq("")); + .failure() + .stdout(predicate::eq(invalid_team_report("app/deprecated/.codeowner"))); } Ok(()) } diff --git a/tests/path_globs_test.rs b/tests/path_globs_test.rs index 2abc86d..ab6f94b 100644 --- a/tests/path_globs_test.rs +++ b/tests/path_globs_test.rs @@ -152,7 +152,7 @@ fn test_validate_reads_glob_metacharacters_in_names_literally() -> Result<(), Bo let files = OWNERS_OF_FILES_WITH_GLOB_METACHARACTERS_IN_NAMES.map(|(file, _)| file); codeowners(project.path(), &[["validate"].as_slice(), &files].concat())? .failure() - .stdout(predicate::eq("Unowned files detected:\n packs/a/y.rb\n lib/v2.rb\n")); + .stdout(predicate::eq("\nSome files are missing ownership\n- lib/v2.rb\n- packs/a/y.rb\n\n")); Ok(()) } diff --git a/tests/path_normalization_test.rs b/tests/path_normalization_test.rs index 2dc9f9a..f2a7f6a 100644 --- a/tests/path_normalization_test.rs +++ b/tests/path_normalization_test.rs @@ -80,7 +80,7 @@ fn test_validate_files_reports_an_unowned_file_by_a_dot_slash_path() -> Result<( for command in ["validate", "gv"] { codeowners(project.path(), &[command, "./app/unowned.rb"])? .failure() - .stdout(predicate::eq("Unowned files detected:\n ./app/unowned.rb\n")); + .stdout(predicate::eq("\nSome files are missing ownership\n- ./app/unowned.rb\n\n")); } Ok(()) } @@ -95,7 +95,10 @@ fn test_validate_files_reports_an_unowned_file_by_a_path_through_a_symlink() -> let path = link.join(file); codeowners(project.path(), &["validate", path.to_str().unwrap()])? .failure() - .stdout(predicate::eq(format!("Unowned files detected:\n {}\n", path.display()))); + .stdout(predicate::eq(format!( + "\nSome files are missing ownership\n- {}\n\n", + path.display() + ))); } Ok(()) } @@ -131,7 +134,7 @@ fn test_validate_files_reports_a_path_outside_the_project_as_unowned() -> Result for file in ["../outside.rb", outside.to_str().unwrap()] { codeowners(project.path(), &["validate", file])? .failure() - .stdout(predicate::eq(format!("Unowned files detected:\n {file}\n"))); + .stdout(predicate::eq(format!("\nSome files are missing ownership\n- {file}\n\n"))); } Ok(()) } @@ -144,7 +147,7 @@ fn test_validate_files_reports_a_path_that_leaves_the_project_by_either_form() - for file in ["app/../../outside.rb", absolute.to_str().unwrap()] { codeowners(project.path(), &["validate", file])? .failure() - .stdout(predicate::eq(format!("Unowned files detected:\n {file}\n"))); + .stdout(predicate::eq(format!("\nSome files are missing ownership\n- {file}\n\n"))); } Ok(()) } diff --git a/tests/validate_files_parity_test.rs b/tests/validate_files_parity_test.rs new file mode 100644 index 0000000..ef95b0f --- /dev/null +++ b/tests/validate_files_parity_test.rs @@ -0,0 +1,343 @@ +//! Parity between `validate` / `gv` with a list of files and the same command with none. +//! +//! 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. +//! +//! Normalization of the given paths is covered in `path_normalization_test.rs`. + +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] +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". + // + // 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); + + 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] +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`. + // + // 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); + + 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] +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'". + // + // 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); + + 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(()) +} + +// 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() +} + +fn tracked_files(project_root: &std::path::Path) -> Result, Box> { + let tracked = Command::new("git").arg("ls-files").current_dir(project_root).output()?; + Ok(String::from_utf8(tracked.stdout)?.lines().map(str::to_string).collect()) +} + +#[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); + + // 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)?; + + assert_eq!( + (every_path.status.code(), String::from_utf8_lossy(&every_path.stdout)), + (no_paths.status.code(), String::from_utf8_lossy(&no_paths.stdout)), + "{}", + fixture.display() + ); + } + + Ok(()) +} + +// The fixture's CODEOWNERS is empty, so a scoped `validate` also reports each file it checks as missing from it. +fn generate_codeowners(project_root: &std::path::Path) { + codeowners(project_root, &["generate"]).expect("codeowners binary").success(); +} + +/// Rewrite the fixture's one package manifest to name a team that does not exist, and +/// return the repo. Used by the package-scoping tests below. +fn fixture_with_an_invalid_package_owner() -> (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); + generate_codeowners(&project_root); + (temp_dir, project_root) +} + +#[test] +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. + // + // 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); + 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"); + 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); + + 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("ruby/app/orphan_new.rb") + .assert() + .failure() + .stdout(predicate::str::contains("orphan_new.rb").and(predicate::str::contains("missing ownership"))); + + 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 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 files = tracked_files(project_root)?; + 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 aca6a2c..8aa3ec1 100644 --- a/tests/validate_files_test.rs +++ b/tests/validate_files_test.rs @@ -34,7 +34,7 @@ fn test_validate_with_unowned_file() -> Result<(), Box> { codeowners(project.path(), &["validate", "ruby/app/unowned.rb"])? .failure() - .stdout(predicate::eq("Unowned files detected:\n ruby/app/unowned.rb\n")); + .stdout(predicate::eq("\nSome files are missing ownership\n- ruby/app/unowned.rb\n\n")); Ok(()) } @@ -45,7 +45,7 @@ fn test_validate_with_mixed_files() -> Result<(), Box> { codeowners(project.path(), &["validate", "ruby/app/models/payroll.rb", "ruby/app/unowned.rb"])? .failure() - .stdout(predicate::eq("Unowned files detected:\n ruby/app/unowned.rb\n")); + .stdout(predicate::eq("\nSome files are missing ownership\n- ruby/app/unowned.rb\n\n")); Ok(()) } @@ -99,7 +99,7 @@ fn test_generate_and_validate_with_unowned_file() -> Result<(), Box> .arg("ruby/app/unowned.rb") .assert() .failure() - .stdout(predicate::eq("Unowned files detected:\n ruby/app/unowned.rb\n")); + .stdout(predicate::eq("\nSome files are missing ownership\n- ruby/app/unowned.rb\n\n")); Ok(()) } @@ -126,14 +126,8 @@ 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 - // - // 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 +fn test_validate_with_an_annotated_file() -> Result<(), Box> { + // ruby/app/models/bank_account.rb has a @team Payments annotation and is in CODEOWNERS. run_codeowners( "valid_project", &["validate", "ruby/app/models/bank_account.rb"], @@ -145,6 +139,69 @@ fn test_validate_only_checks_codeowners_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 give it to a known team\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