Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,20 @@ 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 (for git hooks). Checks ownership of \
just these files. 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<String>,
},

#[clap(about = "Chains both `generate` and `validate` commands.", visible_alias = "gv")]
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 (for git hooks). Checks ownership of \
just these files. Staleness is covered regardless, since the CODEOWNERS file is \
regenerated first.")]
files: Vec<String>,
},

Expand Down
26 changes: 23 additions & 3 deletions src/ownership.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -122,11 +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<dyn Mapper>` 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`]. 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());
let validator = Validator {
project: self.project.clone(),
mappers: self.mappers(),
executable_name: self.project.executable_name.clone(),
};

validator.validate_files(relative_paths)
}

#[instrument(level = "debug", skip_all)]
Expand Down
114 changes: 94 additions & 20 deletions src/ownership/validator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,7 +20,6 @@ use super::mapper::{Mapper, OwnerMatcher, TeamName};
pub struct Validator {
pub project: Arc<Project>,
pub mappers: Vec<Box<dyn Mapper>>,
pub file_generator: FileGenerator,
pub executable_name: String,
}

Expand All @@ -36,18 +35,95 @@ enum Error {
pub struct Errors(Vec<Error>);

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();

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());
validation_errors.append(&mut self.validate_codeowners_file(file_generator));

if validation_errors.is_empty() {
Ok(())
} else {
Err(Errors(validation_errors))
}
}

/// 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. 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. The saving is in the variable term, which
/// dominates on a large repo with a small changeset — but it is not free, and it has
/// not been measured.
///
/// 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. A consequence worth knowing: validating one file can fail over a
/// package that file has nothing to do with.
///
/// 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, 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.
//
// Iterate the deduped set rather than the raw argument list: the same path
// supplied twice is one defect, not two. Paths the project *does* know about are
// deduped for free by going through `project.files`.
let known: HashSet<&Path> = files.iter().map(|file| self.project.relative_path(&file.path)).collect();
validation_errors.extend(
requested
.iter()
.filter(|path| !known.contains(**path))
.map(|path| Error::FileWithoutOwner { path: path.to_path_buf() }),
);

debug!("validate_invalid_team");
validation_errors.append(&mut self.validate_invalid_team(&files));

debug!("validate_file_ownership");
validation_errors.append(&mut self.validate_file_ownership(&files));

if validation_errors.is_empty() {
Ok(())
Expand All @@ -57,23 +133,22 @@ impl Validator {
}

#[instrument(name = "validate_invalid_team", level = "debug", skip_all)]
fn validate_invalid_team(&self) -> Vec<Error> {
fn validate_invalid_team(&self, files: &[&ProjectFile]) -> Vec<Error> {
debug!("validating project");
let mut errors: Vec<Error> = 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<Error> {
fn invalid_team_annotation(&self, team_names: &HashSet<&String>, files: &[&ProjectFile]) -> Vec<Error> {
let project = self.project.clone();

self.project
.files
files
.par_iter()
.flat_map(|file| {
if let Some(owner) = &file.owner
Expand Down Expand Up @@ -108,10 +183,10 @@ impl Validator {
}

#[instrument(name = "validate_file_ownership", level = "debug", skip_all)]
fn validate_file_ownership(&self) -> Vec<Error> {
fn validate_file_ownership(&self, files: &[&ProjectFile]) -> Vec<Error> {
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() {
Expand All @@ -128,8 +203,8 @@ impl Validator {
}

#[instrument(name = "validate_codeowners_file", level = "debug", skip_all)]
fn validate_codeowners_file(&self) -> Vec<Error> {
let generated_file = self.file_generator.generate_file();
fn validate_codeowners_file(&self, file_generator: &FileGenerator) -> Vec<Error> {
let generated_file = file_generator.generate_file();
let current_file = self.project.get_codeowners_file().unwrap_or_default();

if generated_file == current_file {
Expand All @@ -143,20 +218,19 @@ impl Validator {
}

#[instrument(name = "file_to_owners", level = "debug", skip_all)]
fn file_to_owners(&self) -> Vec<(&ProjectFile, Vec<Owner>)> {
fn file_to_owners<'a>(&'a self, files: &[&'a ProjectFile]) -> Vec<(&'a ProjectFile, Vec<Owner>)> {
let owner_matchers: Vec<OwnerMatcher> = 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()
}
Expand Down
74 changes: 32 additions & 42 deletions src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,58 +142,48 @@ 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<String>) -> RunResult {
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<String> = file_paths
// Mirror the filtering applied by ProjectBuilder when walking the project, so a
// path the project would never have considered is not reported as unowned.
//
// Relativize with the same helper the project uses, so these paths match the
// form `Project::relative_path` produces.
let relative_paths: Vec<PathBuf> = 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
};
.filter_map(|file_path| {
let relative_path = crate::path_utils::relative_to(&self.run_config.project_root, Path::new(&file_path));

// Mirror the filtering applied by ProjectBuilder when walking the project
matches_globs(relative_path, &self.config.owned_globs) && !matches_globs(relative_path, &self.config.unowned_globs)
if matches_globs(relative_path, &self.config.owned_globs) && !matches_globs(relative_path, &self.config.unowned_globs) {
Some(relative_path.to_path_buf())
} else {
None
}
})
.collect();

debug_span!("per_file_query").in_scope(|| {
for file_path in filtered_paths {
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 {
Expand Down
Loading
Loading