Repository navigation
fix: run the real ownership checks when validate/gv are given paths - #125
Conversation
2fb8a35 to
33bf305
Compare
`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>
33bf305 to
1be12bc
Compare
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>
cf04538 to
1749a4f
Compare
1be12bc to
03f91a0
Compare
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>
03f91a0 to
a32b81a
Compare
dduugg
left a comment
There was a problem hiding this comment.
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.
- 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 onrunner.rs. - A rebase onto
main. #126 is now closed as superseded by #148, so this needs retargeting. Main has changed most of what this touches since99d25cd, 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'svalidate_file_ownershipalready excuses an optionally owned file with no owner, so reusing it here gets that for free. Main'svalidate_filesalso has the "names an owner CODEOWNERS doesn't list" category and thecodeowners_generatedflag that drops the "run X to update" hint undergv. Those need a decision rather than a conflict resolution.- README. The
validate [files...]line and theoptionally_owned_globsparagraph say that given files,validateandgv"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.
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.
Summary
validate <files>andgv <files>now resolve ownership through the mappers, asvalidateandgvwith 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 onmain:codeowners gv ruby/app/services/multi_owned.rbininvalid_projectexits 0 with empty output.What a list of files now checks
validate..codeownerwhose directory contains a listed path names a team that exists. Scoping them means one bad one elsewhere doesn't fail every scoped run. A listedpackage.ymlor.codeownerselects itself.validate, but notgv, 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'sbin/codeownership validate --diff --skip-autocorrect. Such files are reported under the same "CODEOWNERS out of date" heading as a fullvalidate.Behavior changes
validateuses, instead of "Unowned files detected:". Paths are still shown as given, per Normalize the paths validate, gv and for-file are given #148..codeownernaming a team that doesn't exist is reported as an invalid team reference, and an owner missing from CODEOWNERS as out of date..codeowneris owned, sovalidate <file>agrees withfor-file <file>. An untracked file's own annotation still doesn't count, as invalidate.validatetoo, as it already was undergvonmain. The gem drops files that don't exist before calling in, so its users aren't affected.Commits since the review
08fd38amergesmain. Normalize the paths validate, gv and for-file are given #148's path handling replaces fix: normalize the paths callers supply to validate #126's, fix: write CODEOWNERS annotations after glob-based sections #130's generator mappers are kept, fix: report .codeowner files that reference an unregistered team #116's.codeownercheck is scoped like packages, and Add optionally_owned_globs to permit files with no owner #135's handling is reconciled as above.80e255cadds the per-path CODEOWNERS check.aa30f3dreplaces the differential test's two hardcoded strings with a byte-for-byte comparison ofgvwith every tracked file andgvwith none, across every fixture.2e944f3updates the README.596c038andbbb498afix comments and the perf README's span names, time the CODEOWNERS lookup insideper_file_query, and reword the message when CODEOWNERS gives a file no known team.cb1ae1dand4329638tidy the tests.Test plan
cargo fmt --all -- --check,cargo clippy --all-targets --all-features -- -D warningsandcargo testpass (328 passed, the 3 existing ignores).tests/validate_files_parity_test.rs:gvwith every tracked file, leaving out what.ignorehides, prints the same output and exits the same way asgvwith no files. Dropping the scoped.codeownercheck fails it.validatewith every tracked file reports nothing stale.tests/validate_files_test.rscovers a file missing from CODEOWNERS, one CODEOWNERS gives another team, and a team withdo_not_add_to_codeowners_file..codeownerfiles, including whole-component prefixes and the project root.Not in this PR
.ignore, which leaves files out of the project walk, so a listed file it hides is reported as unowned.maindoes the same..codeownercan change the owner of files outside the list, which a scoped run doesn't see.gvregenerates CODEOWNERS, so it writes the change but only checks the listed files.main'svalidate_filesalready does.Original description
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