Skip to content

fix: run the real ownership checks when validate/gv are given paths - #125

Merged
dduugg merged 14 commits into
mainfrom
fix/validate-paths-parity-gap
Nov 13, 2026
Merged

dduugg merged 14 commits into
mainfrom
fix/validate-paths-parity-gap

Conversation

@perryqh

@perryqh perryqh commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Updated to address the review. This branch was stacked on #126, which was closed as superseded by #148, so it now merges main and targets it. Perry's original description is kept at the end. Its notes on path normalization and skipping deleted files describe #126 and no longer apply.

Summary

validate <files> and gv <files> now resolve ownership through the mappers, as validate and gv with no files do, rather than by reading CODEOWNERS back. Reading it back hid two defects:

  • gv <a file owned two ways> exited 0, because generating CODEOWNERS gives the file to one of its owners. It's still the case on main: codeowners gv ruby/app/services/multi_owned.rb in invalid_project exits 0 with empty output.
  • A file annotated with a team that doesn't exist was reported as unowned, rather than as naming an invalid team.

What a list of files now checks

  • Each listed file has exactly one owner, and its annotation names a team that exists. An optionally owned file with no owner is excused, as in a full validate.
  • Each package and .codeowner whose directory contains a listed path names a team that exists. Scoping them means one bad one elsewhere doesn't fail every scoped run. A listed package.yml or .codeowner selects itself.
  • Under validate, but not gv, which has just written it, CODEOWNERS gives each listed file its owner. The whole-file staleness check can't be scoped, and without this a file annotated and staged without regenerating CODEOWNERS passed. That's the gem's bin/codeownership validate --diff --skip-autocorrect. Such files are reported under the same "CODEOWNERS out of date" heading as a full validate.

Behavior changes

  • Unowned files are reported under "Some files are missing ownership", the format a full validate uses, instead of "Unowned files detected:". Paths are still shown as given, per Normalize the paths validate, gv and for-file are given #148.
  • Add optionally_owned_globs to permit files with no owner #135's "Files matching optionally_owned_globs name an owner that CODEOWNERS doesn't list" heading is gone. An annotation, package or .codeowner naming a team that doesn't exist is reported as an invalid team reference, and an owner missing from CODEOWNERS as out of date.
  • An untracked file in a directory with a .codeowner is owned, so validate <file> agrees with for-file <file>. An untracked file's own annotation still doesn't count, as in validate.
  • A path that doesn't exist is still checked, per Normalize the paths validate, gv and for-file are given #148. A deleted file that only its annotation owned is now reported as missing ownership under validate too, as it already was under gv on main. The gem drops files that don't exist before calling in, so its users aren't affected.

Commits since the review

Test plan

  • cargo fmt --all -- --check, cargo clippy --all-targets --all-features -- -D warnings and cargo test pass (328 passed, the 3 existing ignores).
  • tests/validate_files_parity_test.rs:
    • On every fixture whose project builds, gv with every tracked file, leaving out what .ignore hides, prints the same output and exits the same way as gv with no files. Dropping the scoped .codeowner check fails it.
    • On every such fixture, once CODEOWNERS is generated, validate with every tracked file reports nothing stale.
  • tests/validate_files_test.rs covers a file missing from CODEOWNERS, one CODEOWNERS gives another team, and a team with do_not_add_to_codeowners_file.
  • Validator unit tests cover the scoping of packages and .codeowner files, including whole-component prefixes and the project root.
  • Mutation-checked: turning off the CODEOWNERS check fails 4 tests, and comparing teams that don't exist fails 8.

Not in this PR

  • A list of files isn't checked against .ignore, which leaves files out of the project walk, so a listed file it hides is reported as unowned. main does the same.
  • A change to a team file or .codeowner can change the owner of files outside the list, which a scoped run doesn't see. gv regenerates CODEOWNERS, so it writes the change but only checks the listed files.
  • The perf numbers below predate the merge and weren't re-measured. The CODEOWNERS check adds one parse of CODEOWNERS per run, which main's validate_files already does.
Original description

Stacked on #126. That PR fixes path normalization — a set of bugs that exist on main independently of this one — and was split out so each can be reviewed on its own. Review #126 first; this PR's diff is against its branch. main is the eventual merge target.

Summary

validate <paths> and gv <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:

  1. test: — failing tests documenting the gap.
  2. fix: — ownership for supplied paths is now resolved through the mappers, the same way the whole-project run does.
  3. 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_ownership iterates file_to_owners(), a par_iter over project.files, and validate_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() for validate_files() (runner.rs:124):

pub fn validate(&self, file_paths: Vec<String>) -> RunResult {
    if file_paths.is_empty() { self.validate_all() } else { self.validate_files(file_paths) }
}

validate_all → Validator::validate runs three checks (validator.rs:40-57):

  1. validate_invalid_team — annotation or package naming a nonexistent team
  2. validate_file_ownership — file owned two ways
  3. validate_codeowners_file — CODEOWNERS is stale

validate_files ran 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 calls generate_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 Payments annotation and ruby/app/services/.codeowner naming Payroll:

code=0
stdout=""

gv with 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_gems on gems/payroll_calculator/calculator.rb, so it is not one mapper misbehaving.

The differential

gv with every owned path vs. gv with none, same repo:

no paths every path
dual ownership (2 files) reported missed
invalid team Web3 reported missed
unowned files reported reported

This 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 said Unowned 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_team ran invalid_package_ownership over every package regardless of the path list. An earlier pass called this "defensible"; it is not:

validate <clean file unrelated to the bad package>  →  exit 1
  "ruby/packages/payroll_flow/package.yml is referencing an invalid team - 'NoSuchTeam'"

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 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 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 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 .codeowner in its directory owned it. 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.

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_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 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 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.

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_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

An earlier pass corrected the complexity claim but left it unverified, and asserted gv with no paths was not a perf-harness case — it is (validate_all, gv). Numbers from codeowners-perf, best of 3 warm runs, against a large monorepo (~130k tracked files, ~18k-line CODEOWNERS):

case wall project_build validation
validate_all 3018 ms 1968 ms 933 ms
validate_files_1 2034 ms 1895 ms 28 ms
validate_files_100 2028 ms 1885 ms 32 ms
validate_files_1000 2044 ms 1889 ms 42 ms
validate_files_2000 2068 ms 1898 ms 56 ms

Re-measured after the path_to_owners refactor — 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 files param 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:

  • Drop the files param. 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.
  • Add escalation (run the full check when team files, .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-rs as a magnus native extension and consumes RunResult.validation_errors directly, joining them into a RuntimeError. 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 pins tag = "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 with for-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 at v0.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 .codeowner change 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

@perryqh
perryqh requested a review from a team as a code owner August 21, 2026 14:15
@github-project-automation github-project-automation Bot moved this to Triage in Modularity Aug 21, 2026
@perryqh perryqh changed the title test: expose the validate/gv <paths> parity gap fix: run the real ownership checks when validate/gv are given paths Aug 21, 2026
@perryqh
perryqh marked this pull request as draft August 21, 2026 15:44
@perryqh
perryqh force-pushed the fix/validate-paths-parity-gap branch 2 times, most recently from 2fb8a35 to 33bf305 Compare August 24, 2026 20:03
`validate <paths>` compared caller-supplied paths against project-relative ones
without reducing them to the same form first. What that costs depends on the
shape of owned_globs, and both outcomes are wrong.

Under directory-anchored globs (`{gems,ruby,...}/**/*.rb`), the mishandled path
matches nothing, is dropped before any ownership query runs, and the command
exits 0 having checked nothing. Silent, and in the unsafe direction: a
pre-commit hook or CI job reports success on a file it never looked at.

Under `**`-leading globs (`**/*.rb`), it survives the filter instead and is
queried in the caller's spelling, which matches no CODEOWNERS entry, so a
well-owned file is reported unowned.

Three spellings hit this. `./ruby/app/x.rb` and `ruby/a/../x.rb` were never
reduced at all. Absolute paths were reduced with strip_prefix against a root
that need not agree with them about symlinks: cli.rs canonicalizes
--project-root, so on macOS, where TMPDIR lives under /var, a symlink to
/private/var, a caller passing the TMPDIR spelling fails to strip -- and a
library caller building its own RunConfig (which is how the code_ownership gem
calls in) can pass an unresolved root against a resolved path, the mirror image.
Fixing only one side leaves the other failing exactly as silently, so the retry
resolves both. Absolute paths were also echoed back in the caller's spelling
rather than project-relative, since the raw string was what got reported.

path_utils::project_relative resolves `.` and `..` lexically and reports failure
rather than passing an unstrippable path through, which is how a /var/... path
came to be compared against project-relative ones in the first place. Lexically,
not by canonicalizing: the project walk records symlink paths rather than their
targets, so resolving symlinks would produce paths matching no walked file. The
first attempt uses the root as given, so relative paths -- the common case --
cost no syscalls, and the root is resolved once per run rather than per path.

Paths that no longer exist are now skipped. A changeset that deletes a file
lists it, so a deleted path reaches validate in normal use, and a deleted file
cannot have an owner -- reporting it as unowned fails a commit for removing
code. `gv <deleted file>` did exactly that. The gem already filters its list by
File.exist? before calling in, so this matches what its callers see and extends
it to direct library callers. Only a definite "not there" skips:
try_exists().unwrap_or(true) keeps a path whose status is unknown, because a
visible error is investigable and a silent pass is not.

Three tests asserted on valid_project/ruby/app/unowned.rb, which does not
exist -- valid_project has to validate cleanly, so it ships no unowned file.
They passed only because a nonexistent path was reported as unowned, meaning
they covered typo handling while claiming to cover unowned files, and skipping
nonexistent paths removes that accident. Repointed at invalid_project, which has
a real one, and narrowed to assert the path and exit status rather than the
category wording, so they do not depend on how the report is phrased.

The new tests assert the mechanism rather than the outcome: that the report
names the *normalized* path and does not echo the caller's spelling. Without
that they cannot distinguish "checked correctly" from "mishandled and
spuriously reported" -- an earlier draft of this file, written against
invalid_project's `**` globs, passed against unfixed code for precisely that
reason. Seven of the nine fail without this change; the two that pass are the
plain-relative control and the outside-the-project skip, both of which already
worked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@perryqh
perryqh force-pushed the fix/validate-paths-parity-gap branch from 33bf305 to 1be12bc Compare August 24, 2026 22:25
@perryqh
perryqh changed the base branch from main to fix/normalize-supplied-paths August 24, 2026 22:25
Two findings from reviewing the previous commit, one of them a false pass it
introduced.

The retry canonicalized the whole supplied path, which follows a symlinked
*file*. The project walk records the symlink path rather than its target -- the
reason lexically_normalize is lexical in the first place, stated in its own doc
comment two lines above the code that violated it. So an absolute path naming an
unowned symlink was silently checked as its owned target and exited 0:

    validate ruby/app/models/link_unowned.rb              -> exit 1  (correct)
    validate /tmp/proj/ruby/app/models/link_unowned.rb    -> exit 0  (false pass)
    realpath(link_unowned.rb) = /private/tmp/proj/ruby/app/models/payroll.rb

A false pass on a different file than the caller named, which is the exact
failure class this branch exists to rule out. The retry now resolves the parent
and re-attaches the file name, so the ancestor /var -> /private/var discrepancy
is still fixed without following the leaf. A symlinked *ancestor* is still
resolved, unavoidably -- that is the point in the /var case -- and the walk does
not follow symlinked directories anyway, so such a path names no walked file
under either spelling.

Writing the invariant down was not enough to enforce it. There was no symlink
test, so nothing caught the contradiction; there is one now, and it fails if the
whole-path canonicalize is reintroduced.

The same defect survived in codeowners_query::teams_for_files_from_codeowners,
reached from public API as runner::teams_for_files_from_codeowners. It
relativized with relative_to_buf, which passes an unstrippable path through
unchanged, so a /var/... path against a /private/var/... root was looked up in
the CODEOWNERS file as an absolute path, matched no entry, and came back
unowned. Fixing validate while leaving the bulk-lookup entry point beside it
would have made the branch's claim narrower than it reads.

That one falls back to the path as given rather than dropping it, because the
returned map is contracted to hold one entry per input and
team_for_file_from_codeowners asserts on that. Note the keys were already the
relativized form, not the caller's spelling, so they were inconsistent depending
on whether strip_prefix happened to succeed; they are now consistently relative.

The retry logic moves to path_utils::resolve_project_relative so both callers
share it rather than growing a second copy.

Adds two positive guards. Every other assertion in the file is that an unowned
file gets reported, which would also hold if normalization mangled a path into
some other unowned path; these pin that a well-owned file still resolves to
itself and passes under `./` and interior `..` spellings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@perryqh
perryqh force-pushed the fix/normalize-supplied-paths branch from cf04538 to 1749a4f Compare August 25, 2026 00:46
@perryqh
perryqh force-pushed the fix/validate-paths-parity-gap branch from 1be12bc to 03f91a0 Compare August 25, 2026 00:58
perryqh and others added 4 commits August 25, 2026 08:34
Addresses review of the previous commit, plus a bug found while checking it.

lexically_normalize retained a leading `..` so the caller could detect an escape,
but pop() does not distinguish that retained `..` from a real component, so a
later one popped it. `../../a` therefore cancelled its own escape and came out as
`a`: a path plainly outside the project was reported as though it named a file
inside it.

    validate ../ruby/app/unowned.rb      -> exit 0   (correctly out of scope)
    validate ../../ruby/app/unowned.rb   -> exit 1   "Unowned files detected:
                                                       ruby/app/unowned.rb"

Same class as the symlink bug the previous commit fixed -- answering about a
different file than the caller named -- and the single-`..` case passing made it
look handled. A `..` is now only allowed to pop a real component.

Restrict the filesystem retry to absolute paths. A relative path is interpreted
against the project root by contract, which is what --help promises, and the
lexical pass is the whole of that interpretation -- so failure means it escapes
the root. Retrying resolved it against the process CWD instead, quietly switching
interpretation frames: identical arguments would mean different files depending on
where the command ran from. It also spent a syscall per path to reach that wrong
answer. Reported by review.

Gate the symlink test behind #[cfg(unix)]. std::os::unix::fs::symlink has no
portable equivalent and would fail to compile on Windows. This crate ships only
macOS and Linux artifacts, so the test is gated rather than made portable, which
keeps `cargo test` compiling everywhere. Reported by review.

Not changed: review also flagged the `\` line continuations in cli.rs help text as
leaving runs of literal spaces. Rust's string-continuation escape strips the
newline *and* the following indentation, so it does not. Verified against the
rendered output -- the description contains zero interior multi-space runs; the
only gap is clap's own column alignment between the argument name and its text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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
perryqh force-pushed the fix/validate-paths-parity-gap branch from 03f91a0 to a32b81a Compare August 25, 2026 13:36

@dduugg dduugg left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, and for how much care went into it. The write-up made the false pass easy to understand and reproduce, and the differential test, the measured perf numbers, and the unit tests pinning component-wise starts_with and the root-level package are the kind of detail that makes a change like this reviewable.

The core change is right, and the bug is still live on main: codeowners gv ruby/app/services/multi_owned.rb in invalid_project exits 0 with empty output there, and this branch reports the dual ownership. Resolving supplied paths through the mappers is the correct fix, and scoping the package check to packages that contain a supplied path is a good call.

Two things need to happen before it can land. Both are detailed inline.

  1. A regression in validate <paths>. Removing the CODEOWNERS read-back also removes the only per-path staleness signal, so a file added without regenerating now passes. There's a repro and a suggested per-path check on runner.rs.
  2. A rebase onto main. #126 is now closed as superseded by #148, so this needs retargeting. Main has changed most of what this touches since 99d25cd, and some of it went the other way from what this branch inherited from #126. The inline notes cover #148's path handling, #116's directory check, and #130's generator mappers. Three more have no line to attach to:
    • optionally_owned_globs (#135). Main's validate_file_ownership already excuses an optionally owned file with no owner, so reusing it here gets that for free. Main's validate_files also has the "names an owner CODEOWNERS doesn't list" category and the codeowners_generated flag that drops the "run X to update" hint under gv. Those need a decision rather than a conflict resolution.
    • README. The validate [files...] line and the optionally_owned_globs paragraph say that given files, validate and gv "look each one up in CODEOWNERS and don't check for more than one owner", which is the behavior this changes.
    • Wording. The move from "Unowned files detected:" to "Some files are missing ownership" is reasonable, but main now has many more exact-output assertions on the old wording (path_normalization_test.rs, optionally_owned_globs_test.rs, path_globs_test.rs).

A few points in the description have gone stale: #116 has landed, #124 is closed, and the gem now pins v0.5.0 rather than v0.3.3. Running the gem's suite against this before bumping the pin is still a good idea.

Comment thread src/runner.rs Resolved
Comment thread src/cli.rs Resolved
Comment thread src/runner.rs Resolved
Comment thread src/runner.rs Resolved
Comment thread src/ownership/validator.rs Resolved
Comment thread src/ownership.rs Resolved
Comment thread tests/validate_files_parity_test.rs Resolved
This branch was stacked on #126, which is closed as superseded by #148,
so main's path handling replaces #126's. path_utils.rs and
codeowners_query.rs are main's, and #126's
supplied_path_normalization_test.rs is removed in favor of #148's
path_normalization_test.rs. A path that doesn't exist, or that names
nothing under the project root, is still checked, and every report
shows each path as given.

validate_files resolves ownership through the mappers as this branch
intended, now on main's validator:

- The FileGenerator is still built from codeowners_file_mappers (#130),
  now passed to Validator::validate.
- #116's check of each `.codeowner` is scoped the way packages are: one
  is checked if its directory contains a supplied path. Unscoped, one
  bad `.codeowner` would fail every scoped run.
- An optionally owned file with no owner is excused as in a full
  `validate` (#135). One whose annotation, `.codeowner` or package names
  a team that doesn't exist is reported as an invalid team reference,
  as `validate` reports it, rather than under the "CODEOWNERS doesn't
  list" heading, which goes away with OwnerLookup.

The two tests of an optionally owned file missing from CODEOWNERS are
ignored here. A per-path staleness check in the next commit turns them
back on. The validator unit tests from this branch are ported and
cover `.codeowner` files too.
Reading CODEOWNERS back was the wrong way to find a supplied file's
owner, but it was also the only sign `validate <files>` had that
CODEOWNERS was stale. Without it, a file annotated and staged without
regenerating CODEOWNERS passed, though a full `validate` reports the
missing line. That's the gem's `validate --diff --skip-autocorrect`.

The whole-file staleness check can't be scoped, so each supplied file
with one owner is now looked up in CODEOWNERS, in one parse for the
whole list, and reported under the same "CODEOWNERS out of date"
heading if CODEOWNERS doesn't list it or gives it to another team. A
file with no owner or more than one, or whose team doesn't exist, is
already reported. `gv` skips the lookup, since it has just written
CODEOWNERS.

This replaces #135's "Files matching optionally_owned_globs name an
owner that CODEOWNERS doesn't list" for the optionally owned files it
covered, so the two tests of those are turned back on with the new
heading. New tests cover a missing line, a line naming another team,
a team with do_not_add_to_codeowners_file, and every fixture, which
must report nothing stale once CODEOWNERS is generated. The parity
tests that check scoping on invalid_project, whose CODEOWNERS is empty,
now generate it first.
The differential test said it needed no knowledge of the fixture, but
it compared only two hardcoded defects, so a check a scoped run dropped,
such as the one for unowned files or #116's for `.codeowner` files,
wouldn't have shown up. It now runs on every fixture whose project
builds, passes every tracked file, and requires the same exit code and
output. `gv` regenerates CODEOWNERS first, so nothing is stale, and with
each package.yml and `.codeowner` in the list, every package and
directory is checked.

Files that `.ignore` leaves out of the walk are left out of the list,
since a list of files isn't checked against `.ignore`, as on main.
Dropping the scoped `.codeowner` check fails the test.

The module comment, which still described staleness as unchecked and
pointed at #126's deleted normalization tests, is updated.
The README said that given files, `validate` and `gv` look each one up
in CODEOWNERS and don't check for more than one owner, and described
#135's "CODEOWNERS doesn't list" heading. They now run the full checks
on the listed files and the packages and `.codeowner` files whose
directories contain them, and check each file against CODEOWNERS.
Rejoin a sentence the doc comment on Validator::validate_files broke
across lines, and say that a listed path selects `.codeowner` files as
well as packages.
The per-path CODEOWNERS lookup ran outside the per_file_query span,
which held main's per-file lookups, so a profile left it unattributed.
It now runs inside it.

perf/README.md still named the file_to_owners span, which is now
path_to_owners, and put it inside validate_file_ownership.

A file CODEOWNERS gives no known team may have no line, or a line
naming a team that's no longer in the team files, so the message now
says "doesn't give it to a known team" rather than "doesn't list it".
Ten optionally_owned_globs tests repeated the heading and blank lines
around the expected invalid team line. invalid_team_report builds the
whole report.
@dduugg
dduugg removed this pull request from stack #127 November 13, 2026 00:44
@dduugg
dduugg changed the base branch from fix/normalize-supplied-paths to main November 13, 2026 00:45
@dduugg dduugg closed this Nov 13, 2026
@dduugg dduugg reopened this Nov 13, 2026
@github-project-automation github-project-automation Bot moved this from Triage to Done in Modularity Nov 13, 2026
@dduugg
dduugg marked this pull request as ready for review November 13, 2026 00:58
@dduugg
dduugg merged commit dd6bcfa into main Nov 13, 2026
27 of 28 checks passed
@dduugg
dduugg deleted the fix/validate-paths-parity-gap branch November 13, 2026 01:06
@dduugg dduugg mentioned this pull request Nov 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants