fix: run the real ownership checks when validate/gv are given paths - #125
Draft
perryqh wants to merge 3 commits into
Draft
fix: run the real ownership checks when validate/gv are given paths#125perryqh wants to merge 3 commits into
perryqh wants to merge 3 commits into
Conversation
perryqh
marked this pull request as draft
August 21, 2026 15:44
perryqh
force-pushed
the
fix/validate-paths-parity-gap
branch
2 times, most recently
from
August 24, 2026 20:03
2fb8a35 to
33bf305
Compare
perryqh
force-pushed
the
fix/validate-paths-parity-gap
branch
from
August 24, 2026 22:25
33bf305 to
1be12bc
Compare
perryqh
force-pushed
the
fix/validate-paths-parity-gap
branch
from
August 25, 2026 00:58
1be12bc to
03f91a0
Compare
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 <paths> 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 <dual-owned file> 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 <invalid-team file> 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 <non-canonical absolute path> 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 <noreply@anthropic.com>
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 <dual-owned file>` 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 <paths>` 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 <noreply@anthropic.com>
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 <clean unrelated file>` 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 <deleted file>` 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 <noreply@anthropic.com>
perryqh
force-pushed
the
fix/validate-paths-parity-gap
branch
from
August 25, 2026 13:36
03f91a0 to
a32b81a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
validate <paths>andgv <paths>were not equivalent to the same commands with no paths, and one of the gaps was a silent false pass:gv <dual-owned file>exited 0 with empty output.Three commits:
test:— failing tests documenting the gap.fix:— ownership for supplied paths is now resolved through the mappers, the same way the whole-project run does.fix:— review, described under Review below. Three passes squashed into one: each found a silent false pass in the one before it.The fix is cheap because two of the three checks were already per-file:
validate_file_ownershipiteratesfile_to_owners(), apar_iteroverproject.files, andvalidate_invalid_team's file half does the same. Scoping them to the supplied paths is a filter, not a rewrite. The old code was doing more I/O (re-reading and re-parsing CODEOWNERS per path) to learn less.Why
Passing file paths swaps
validate_all()forvalidate_files()(runner.rs:124):validate_all→Validator::validateruns three checks (validator.rs:40-57):validate_invalid_team— annotation or package naming a nonexistent teamvalidate_file_ownership— file owned two waysvalidate_codeowners_file— CODEOWNERS is stalevalidate_filesran none of them. It only asked whether each path resolves to a team when reading the CODEOWNERS file.gv <paths>regenerates before validating, so check 3 is moot by construction. Checks 1 and 2 were still skipped —generate()only callsgenerate_file()and writes the string (ownership.rs:167); it validates nothing.The false pass
gv ruby/app/services/multi_owned.rb, a file owned by both a@team Paymentsannotation andruby/app/services/.codeownernaming Payroll:gvwith no paths reports "Code ownership should only be defined for each file in one way."The mechanism is the concerning part: regenerating writes the file into CODEOWNERS under one of its two owners, so the per-path check finds an owner and passes. Regenerating conceals the defect rather than exposing it. Reproduces independently through
owned_gemsongems/payroll_calculator/calculator.rb, so it is not one mapper misbehaving.The differential
gvwith every owned path vs.gvwith none, same repo:Web3This is the general form of the targeted tests — it needs no knowledge of which defects the fixture holds, so it keeps working as fixtures change. It is also the check requested back in December when #89 landed, which was never added.
Wrong diagnosis, not just a miss
gv ruby/app/models/blockchain.rb(annotated@team Web3, not a team) exits 1 — but saidUnowned files detected: ruby/app/models/blockchain.rb. An invalid team yields no owner, so the file is simply absent from the generated CODEOWNERS and reads as unowned. The developer goes looking for missing ownership instead of fixing a typo.Review
Three passes' worth, grouped by theme rather than by which pass found what. Everything here came from reading the code rather than from a test failing. (The normalization findings from those passes live in #126.)
The package check was unscoped, and inconsistently so
validate_invalid_teamraninvalid_package_ownershipover every package regardless of the path list. An earlier pass called this "defensible"; it is not:Since the gem's
--diffmode feeds a changeset in, one pre-existing bad package owner would block every commit in the repo until someone fixed it. It was also inconsistent: the empty-path early return keyed off the glob-filtered list, so whether the unrelated package error surfaced depended on whether some supplied path happened to matchowned_globs—validate foo.rbfailed whilevalidate README.mdpassed, 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_pathsfor the per-file checks,supplied_pathsfor package selection. Selecting on the wider list means editing apackage.ymlto name a nonexistent team is caught by the commit that does it, not only by some later commit that happens to touch a file inside that package. The early return is now a pure optimization.An unwalked path was assumed unowned rather than asked about
The walk only records git-tracked files, so a brand-new unstaged file was reported "Some files are missing ownership" even when a
.codeownerin its directory owned it.for-fileon 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.Every requested path now goes to the matchers. This simplified rather than complicated: ownership resolution never needed
ProjectFile, only the relative path, which is what the matchers key on.file_to_ownersbecomespath_to_ownersand the separate "unwalked, therefore unowned" branch disappears instead of growing a special case. A genuinely unowned untracked file is still reported, which a second test pins — resolving unwalked paths must not degrade into passing them.The annotation check still runs only over walked files, since an untracked file's annotation has not been parsed. Narrower gap, left alone deliberately.
Smaller things
Deduplicate a path supplied twice, which was reported twice. Make the unused
FileGeneratorstructurally impossible in the scoped path by passing it tovalidate()rather than holding it as a field. Name the scoped spanvalidator_validate_scopedto parallelvalidator_validate.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 but which were previously only claimed in a doc comment:starts_withis component-wise, soruby/packages/foomust not selectruby/packages/foobar; and a root-level manifest has an empty relative root that prefixes every path.Measured
An earlier pass corrected the complexity claim but left it unverified, and asserted
gvwith no paths was not a perf-harness case — it is (validate_all,gv). Numbers fromcodeowners-perf, best of 3 warm runs, against a large monorepo (~130k tracked files, ~18k-line CODEOWNERS):project_buildvalidate_allvalidate_files_1validate_files_100validate_files_1000validate_files_2000Re-measured after the
path_to_ownersrefactor — 933 to 945 ms whole-project, 28 to 32 ms at one path, 56 to 54 ms at two thousand — so it is perf-neutral and these numbers stand.The variable term collapses exactly as predicted — validation goes from 933 ms to 28 ms, and is near-flat in path count (28 ms at one path, 56 ms at two thousand). But wall clock only improves 3.0 s → 2.0 s, because the ~1.9 s project build is the fixed O(repo) term and is paid either way.
So the
filesparam is worth about a second on a repo that size, not an order of magnitude. That is the number the "should the fast path exist at all" question needed:filesparam. Costs ~1 s per invocation at that scale — one third of wall clock, not a second project build. Defensible either way now, but it is a real second in a pre-commit hook..codeowner, or CODEOWNERS are in the changeset). Closes the staleness blind spot below, and now has a price attached: ~1 s when it fires.Behavior changes
Unowned files supplied by path report as "Some files are missing ownership" — the 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.This is safe, though not for the reason an earlier revision of this description gave. The gem does not parse CLI stdout at all: it links
codeowners-rsas a magnus native extension and consumesRunResult.validation_errorsdirectly, joining them into aRuntimeError. Its own spec already asserts/Some files are missing ownership/for the no-paths case, so this aligns the<paths>branch with what the gem already expects. No public consumer greps"Unowned files detected". Note the gem pinstag = "v0.3.3", so nothing changes for its callers until that is bumped.Also: a bad package owner elsewhere in the repo no longer fails a scoped run, and an untracked file is attributed through the mappers rather than assumed unowned, so
validate <path>now agrees withfor-file <path>.Taken together with #126, this PR has changed
validate <paths>behavior in several ways — deleted paths, untracked paths, package scope, output wording, path forms. Each is individually tested, but the aggregate is a meaningfully different command than the gem was pinned against atv0.3.3. Worth running the gem's own suite against this stack before bumping that tag, not after.Still open
Staleness is not checked for a supplied path list and cannot be — it compares the whole generated file against the whole on-disk one.
gv <paths>makes it moot by regenerating first, but a team file or.codeownerchange can still alter ownership of files outside the changeset without being caught. That gap wants the escalation path priced above, which this PR does not add.One follow-up left alone: #116 overlaps the invalid-team reporting this now runs on supplied paths. If #116 lands first there may be a conflict in
validate_invalid_team; if this lands first, #116's fix applies to the scoped path for free.Consequence for #124
#124 optimized reading the CODEOWNERS file back per path. This deletes that code path, so #124 is obsolete rather than parked — it can be closed.
🤖 Generated with Claude Code