Skip to content

Replace serde_yaml with serde-saphyr to drop unsafe-libyaml - #142

Merged
dduugg merged 1 commit into
mainfrom
replace-serde-yaml-with-serde-saphyr
Oct 21, 2026
Merged

dduugg merged 1 commit into
mainfrom
replace-serde-yaml-with-serde-saphyr

Conversation

@dduugg

@dduugg dduugg commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #141.

unsafe-libyaml is unmaintained (RUSTSEC-2024-0449) and reaches us only through serde_yaml, which is itself deprecated. This replaces serde_yaml with serde-saphyr 2.0.0, the crate the advisory names for serde users. serde-saphyr and its parser, granit-parser, are pure Rust with #![forbid(unsafe_code)], though granit-parser depends on arraydeque, which uses unsafe. serde-saphyr is actively maintained: 32 releases in the past year, outside issues closed in a median of under a day, and kube-rs among its 400+ dependents. Both crates have a single maintainer, though, and their security policy covers only the latest release. 2.0.0 shipped about ten weeks after 1.0.0, so we should expect to take major versions promptly, which is why Dependabot now opens serde-saphyr bumps on their own.

Alternatives considered

Crate Why not
noyalib 0.0.58 (the advisory's other suggestion) 50 of the 178 existing tests fail, even through its serde_yaml compatibility feature, mostly on the empty unowned_globs: that four fixtures use. It is still 0.0.x, so every release is semver-incompatible with the last.
serde_yaml_ng, serde_norway, serde_yaml_bw Still parse through unsafe-libyaml or a fork of it.
saphyr, yaml-rust2 No serde support, so every struct would need hand-written mapping code.

What changed

All YAML parsing goes through the new src/yaml.rs:

  • Callers read the file with fs::read and parse it with from_slice_with_options, which accepts the same encodings serde_yaml did (UTF-8, with or without a BOM). from_reader would also accept UTF-16 and silently replace invalid UTF-8 after a BOM, and it parses about 2.3x slower than serde_yaml.
  • Each option that differs from serde-saphyr's defaults has a one-line reason next to it. The one that matters is duplicate_keys: LastWins. The default rejects a repeated key anywhere in a file, including keys we never read, and one such package.yml would abort generate and validate for the whole repo. These exist in the wild: this public packwerk app repeats enforce_privacy. The other options keep serde_yaml's behavior for <<, for !!binary, for .inf and .nan in values we ignore, for YAML 1.1 booleans (yes and no stay errors), and for single-line error messages.
  • Resource budgets are tighter than serde-saphyr's defaults: nesting depth 16 instead of 64, and 20,000 nodes instead of 250,000. Caps on scalar bytes, parser events and anchor copies grow with the node limit. At the default the scalar-byte and anchor caps are tighter than serde-saphyr's (about 5 MB of scalars), and the event cap equals its 1,000,000, so raising the node limit never hits a fixed event cap first. LastWins buffers each mapping read into one of our structs before serde sees it, and buffers a nested mapping again at each level where it follows an integer key, so under the defaults a crafted 480 KB package.yml made generate peak at about 2.7 GB, against 35 MB on main. Under these budgets that file fails with a budget error. The worst file found that fits, 68 KB of 14 nested mappings that each start with 0: x around 9,750 []: [] entries, holds about 115 MB of heap at its peak and makes generate peak at about 290 MB on macOS, against 12 MB on main. With only string keys the same nesting holds about 12 MB of heap. The depth limit also keeps nested input from overflowing the stack. The 1,015 team and package.yml files in two large private monorepos nest at most 5 deep, with fewer than 2,200 nodes and no anchors, so the node limit leaves 9x headroom. It is the new yaml_max_nodes config key, so a repo whose files outgrow it can raise it, and the caps that grow with it, without waiting for a release. A file over the node limit or any of those caps fails like a malformed file, with a hint to raise the key in the config file in use. The worst case grows with the limit, by about 14 KB of resident memory per node, so a 50,000-node default would give 23x headroom but raise it to about 710 MB. The 290 MB and 710 MB figures are for a file crafted to waste memory. Ordinary files add almost nothing: on the larger monorepo, generate peaks at the same memory as main, within about 2%.
  • serde-saphyr rejects an empty, ~ or null value in a String field or list item, which serde_yaml accepted. Two small deserialize_with helpers keep accepting them, reading all three as "", where serde_yaml kept ~ and null as text. The list helper also reads an empty, ~ or null list as empty itself, rather than relying on serde-saphyr to, and package.yml is read as an optional struct, so an empty file still means no owner. serde-saphyr's master branch (2.1.0, not yet released) stops reading null as an empty list (serde-saphyr#233) or as an empty struct, and our 2.0.0 requirement admits 2.1. The tests pass against that branch too.
  • The config tests and the helper in src/common_test.rs now go through Config::load_from_path, so they exercise the production path.
  • 31 new tests pin these behaviors, 14 of them directly in src/yaml.rs, next to the options they cover. Ten assert what serde_yaml already did (checked by running the same assertions on main). The other 21 pin intended changes: a repeated owner takes the last value; a BOM before several keys now parses; a ~ or null list reads as empty, also through the list helper on its own; an empty, ~, null or ... package.yml means no owner; 16 levels of nesting parse and 17 don't; and a list of 100,000 items is rejected unless yaml_max_nodes, read from the config, raises the limit. That last part is checked through generate, for-file and for-file --from-codeowners, and for the team cache and the team map that --from-codeowners memoizes, which are both keyed on the limit. The rest check that each cap that should grow with yaml_max_nodes does, that a breach of the node limit or any of those caps, even through an alias, gets the hint while a depth breach doesn't, that the hint names the config file in use, including one given with --config-path, for team files through each of those commands and for package.yml through generate and for-file, that at the default limit the event cap is serde-saphyr's own default, and that an error inside an alias is printed once.
  • Mutation testing backs this up. Of 114 mutations to the options, each budget field and multiplier, the hint, the helpers, all 15 deserialize_with attributes, the 19 places that pass yaml_max_nodes on and the 18 that pass on or record the config file the hint names, 111 fail at least one test. The 3 that pass change nothing serde-saphyr 2.0.0 lets a test observe: non_finite_float_policy: AsString, since we read no floats; unwrapping one AliasError instead of looping, since serde-saphyr nests at most one; and reading package.yml as the struct instead of an Option, which the empty-file test catches under serde-saphyr 2.1. The event cap is reached first only at a limit of 0, where every multiplier gives 0, so no parse can tell its multiplier of 50 from 25 or 100, and a test checks the cap's value instead.
  • Dependabot's cargo group now excludes serde-saphyr, so its bumps arrive as their own pull requests and never wait on unrelated breakage. granit-parser is a transitive dependency, which Dependabot version updates skip, so a granit-parser fix reaches Cargo.lock when a serde-saphyr release requires it, through a RustSec advisory, or through a manual cargo update -p granit-parser.
  • Release archives now also hold a licenses/ directory next to the codeowners binary, which stays at the archive root, where dotslash-config.json looks for it. The new dev/license-notices script copies into it, verbatim, the license files of two crates serde-saphyr brings in, whose licenses require redistributions of the binary to carry their notices: granit-parser, whose LICENSE asks for all inherited and new notices, and encoding_rs, whose WHATWG-derived data is under BSD-3-Clause. The script takes their versions from Cargo.lock and fails if a listed crate leaves the dependency graph or ships no license files. The jobs that build release archives run only on main after a version bump, so a new step in the Check job runs the script on pull requests. Today's macOS binaries don't link encoding_rs, since we parse only byte slices, so its notice is a precaution for a later switch to reader input. The README's installation section mentions the new directory.

Behavior changes

Verified unchanged:

  • Exit codes, stdout and every CODEOWNERS file written, across 74 CLI runs in 37 small projects: valid and broken config, team and package.yml files, the empty-file cases in the last bullet, and all 13 fixtures (including docs/CODEOWNERS from the custom-path fixture).
  • 2,356 real files parse to the same values, or fail in both: the 42 fixtures, 1,134 public package.yml, team and config files from GitHub, and about 1,200 files from nine private repositories. The one exception is two public GitHub Actions workflows named package.yml that start with a UTF-8 BOM, which serde_yaml rejected and serde-saphyr accepts. For the seven private repositories with a config, generate gives byte-identical CODEOWNERS, stdout, stderr and exit codes.
  • With the final budgets: generate on two large private monorepos, which parses 1,015 team and package.yml files, gives byte-identical CODEOWNERS, empty stdout and stderr and exit code 0, the same as main, and peaks at the same memory as main, within about 2%. validate, crosscheck-owners, and for-file with and without --from-codeowners on 1,719 sampled files also give the same exit codes and output lines as main. None of a wider local corpus of 1,615 package.yml, team and config files, or the 42 fixtures, hits a budget.
  • An empty, comment-only or ----only package.yml still means "no owner", also under serde-saphyr 2.1.

Changed: error text on every failure path, and a handful of rare inputs that are now rejected, accepted, or read differently. Apart from the error text of files that fail in both, and the two BOM files above, none of these occur in the 2,356 files.

Every change, with examples

Error text changes on every failure path. Messages no longer start with the key path, type errors no longer say what was found, and locations read line L, column C, including the line 1, column 1 that serde_yaml left out:

Input Before After
team file without github missing field `github` missing field `github` at line 1, column 1
owned_globs: 5 owned_globs: invalid type: integer `5`, expected a sequence at line 1 column 14 expected sequence start at line 1, column 14
do_not_add_to_codeowners_file: yes github.do_not_add_to_codeowners_file: invalid type: string "yes", expected a boolean at line 4 column 34 invalid boolean (strict mode expects true/false) at line 4, column 34
empty team file missing field `name` unexpected end of input at line 1, column 1
unterminated quote found unexpected end of stream at line 4 column 1, while scanning a quoted scalar at line 3 column 9 invalid indentation in multiline quoted scalar at line 4, column 1
a second document deserializing from YAML containing more than one document is not supported multiple YAML documents detected; use from_str_multiple or from_str_multiple_with_options at line 3, column 1
~: x at the top level (accepted) cannot deserialize null into string; use Option<String> at line 1, column 1

The last two messages give advice meant for Rust callers of serde-saphyr. A budget breach reads like budget breached: Depth { depth: 17 } at line 2, column 70, under the path of the file. A breach of the node limit or a cap that grows with it adds the hint raise `yaml_max_nodes` (now 20000) in config/code_ownership.yml to allow larger files, which names the config file in use. The CLI resolves --config-path against the project root and cleans it, so the hint shows that file relative to the root when it is inside it and as an absolute path otherwise. An error inside an alias also says where the anchor was defined and used.

A missing field is also reported at a different line. serde_yaml gave the line where the mapping starts. serde-saphyr gives the line of the last key it read in that mapping, so a longer team file without github can point at an unrelated key such as ruby:.

Newly rejected. serde_yaml accepts these, and so does Psych except where noted:

  • A quoted string continued on a line indented no deeper than its own key, or for a list item its -, such as:
    metadata:
      description: "first line
      second line"
    serde-saphyr applies the YAML 1.2 rule here and has no option to relax it. Indent the continuation past the key, or use a block scalar. A continuation line that starts with a tab is rejected the same way.
  • A line inside a multi-line [...] or {...} value that has a tab before it is indented past its key, such as dependencies: [ followed by a tab-indented "packs/a",. libyaml treats that tab as whitespace. Spaces before the tab, or a whole-file JSON document indented with tabs, still parse.
  • A comment not separated by a space from the token before it: a closing quote or bracket (name: "Payroll"# note), a block scalar header (description: |# note), a comma in a flow list ([a,# note), or a directive (%YAML 1.1# note).
  • A null key such as ~: x directly in a mapping we read: the top level, metadata, github or ruby. Nested deeper inside a value we ignore, null keys still parse.
  • A core tag that serde-saphyr cannot apply, even under keys we ignore: !!timestamp with any value (Psych's load_file rejects it too), !!int with a value outside 64 bits or in YAML 1.1 form such as 017 or 1:20, !!float 1_000.5, or a value that does not fit, such as !!int abc. In a field we read, a non-string core tag such as name: !!int 123 is rejected too, and so is !!str true for a boolean. serde-saphyr also types some local tags: !int, !float, !bool, !str, !seq, !map, !timestamp and !time on a value that doesn't fit them fail too, as do !include on a list or mapping and !degrees or !radians on any value. So do a key with a non-string core tag such as !!int 1: in a mapping we read, and metadata: ! or metadata: !!map with no value.
  • Nesting deeper than 16 levels, counting the top level, or more than 20,000 nodes (configurable with yaml_max_nodes), anywhere in the file. serde_yaml had no limits for values we ignore.
  • Files with at least 100 aliases and more than 10 aliases per anchor, an alias with the colon attached, such as *a: 1, and a recursive alias such as &a [*a], even under keys we ignore. That last one fails with recursive references require weak recursion types, more advice meant for Rust callers. Psych 4's load_file rejects aliases by default.
  • A BOM at the start of a line once the document has begun, after --- or after the first key: before an indented key or a - entry, on a blank or comment line, or as the file's last bytes. A BOM at the start of a value, too. libyaml skips a BOM at the start of a line, so serde_yaml and Psych accept these. A BOM directly before a key at column 0 already failed on main.
  • U+0085, U+2028 or U+2029 used as a line break, which YAML 1.2 no longer allows.

Newly accepted:

  • A repeated key for a field we read takes the last value, as in Psych. serde_yaml failed with duplicate field `owner` .
  • A UTF-8 BOM before --- or before the first of several keys. serde_yaml failed on these, usually with "deserializing from YAML containing more than one document is not supported".
  • ~ for a list field, read as an empty list, and for a boolean a quoted or block-scalar true, with or without surrounding spaces, or an odd-case tRuE. serde-saphyr 2.1 rejects tRuE again.
  • A package.yml that is only ~ or null, read as no owner, as in Psych, or only ..., which Psych rejects.
  • Syntax libyaml rejects but YAML 1.2 allows: tabs in more places, such as before a key under metadata:; anchor names containing /, . or non-ASCII characters; empty keys and :symbol items in flow lists, in values we ignore; a ... line before the content, which Psych rejects too; unknown % directives; several leading BOMs; and surrogate-pair escapes such as "😀".

Different values:

  • ~, null in any capitalization such as nUlL, a !!null-tagged value, or a local-tagged one such as !foo ~ or an empty !foo, in a string field or list item reads as "" instead of the literal text. In owner, ~, null, Null, NULL and an empty value already meant no owner, and now the rest do too. In a glob list such as owned_globs, generate writes that "" as a / line in CODEOWNERS, where main wrote /~ for a ~ item. An empty item already gave / on main. Psych reads these as nil, except that it keeps the text of a !!null-tagged value that isn't null, such as !!null x. serde-saphyr 2.1 reads odd capitalizations such as nUlL as text again.
  • A |, |+, > or >+ block scalar (not |- or >-) that ends the file without a trailing newline now ends in \n, so owner: | followed by TeamA at end of file reads as "TeamA\n".
  • U+0085, U+2028 and U+2029 are no longer line breaks, so name: TeamA followed by U+0085 reads as "TeamA\u{85}". Ruby's YAML.dump writes a U+2028 or U+2029 in a quoted string as a raw break followed by indentation, so such a dumped value now keeps the indentation spaces. After a comment, one of them no longer ends the comment, so a key after it on the same line is lost: # header, U+0085, owner: TeamA reads as no owner.
  • An anchor name followed directly by : or ?, such as &a:b TeamA, now includes them, so the value reads TeamA rather than :b TeamA.

In an owner or a team name, the block scalar and line break changes mean the name no longer matches its team, the way a typo would. In code_ownership, which compares the names Rust reads against the ones Psych reads, for_file then returns nil for that team's files, or raises with allow_raise: true.

Cost

  • The release binary grows by about 0.8 MB after strip -x, from 3.71 MB to 4.50 MB on aarch64-apple-darwin. Unstripped, each architecture's binary grows by 0.9 to 1.0 MB, and the universal mac binary the release job uploads by 1.9 MB. Most of it is serde-saphyr's deserializer, plus annotate-snippets, which stays linked even with snippets off.
  • Parsing takes about 1.3x as long per file on a large private corpus, about 6 µs more, and up to about 1.9x on small or synthetic files. End-to-end generate on a large monorepo is unchanged within noise, because walking the tree dominates.
  • serde-saphyr needs Rust 1.89, under our 1.97.1 pin and the Rust 1.98 in code_ownership's release images.

Verification

  • cargo audit: no warnings, down from RUSTSEC-2024-0449. cargo tree -i unsafe-libyaml and cargo tree -i serde_yaml match nothing.
  • cargo fmt --all -- --check, RUSTFLAGS=-Dwarnings cargo clippy --all-targets --all-features, and cargo test: 209 passed, 0 failed, 3 ignored (178 before, plus the 31 new tests). The same 209 pass with serde-saphyr's unreleased 2.1.0 (its master at 2454b53) patched in.
  • The release workflow's two-target mac build and its lipo step succeed. Its cross build succeeds for x86_64-unknown-linux-gnu, and for aarch64-unknown-linux-gnu it builds and links codeowners.
  • Release packaging, run locally: the mac job's build, lipo and tar blocks ran as written, minus gh release upload, and the archive lists codeowners at its root plus licenses/ with 9 files, byte-identical to the crates' own. The extracted binary runs. The linux job's upload block, minus the uploads, ran on stand-in binaries under BSD and GNU userlands, and the script also ran with uutils cp, mkdir and dirname 0.10.0 and 0.8.0, which ubuntu-26.04 uses by default, all giving identical trees. DotSlash 0.5.9 fetched a new-layout archive from localhost and ran codeowners from its root. The new Check step exits 1 when a listed crate is missing. zizmor reports no findings, the same as main, and actionlint and shellcheck report nothing new. nm finds granit_parser symbols but no encoding_rs symbols in either mac binary. No Linux binary was built for these checks, since Docker wasn't working locally.
  • Cargo.lock is exactly what cargo build resolves from main's lockfile, with no cargo update: 6 packages removed, 11 added.
  • The release job's grep "\+version" check finds nothing in this diff with BSD grep or GNU grep, so merging does not cut a release.
  • rubyatscale/code_ownership's specs, at 6fc6342, pass with its native extension built against this branch: 104 examples, 0 failures, 1 pending, the same as against main, in both the dev profile bundle exec rake uses and the release profile, on Ruby 4.0.7. The pending example runs only on Ruby before 3.4. They pass the same way with serde-saphyr's unreleased 2.1.0 (its master at 2454b53) patched in. They include Fix inverted stage_changes in CodeOwnership.validate! code_ownership#187's specs for an empty, comment-only and ----only package.yml, and through code_ownership's Ruby API, validate! also accepts a BOM-only, ~, null or ... package.yml on both 2.0.0 and 2.1.0.
  • About 56 million fuzzed inputs, parsed as each of our structs and as IgnoredAny with this PR's options, caused no panics.

Notes

@dduugg
dduugg requested a review from a team as a code owner October 20, 2026 00:07
@github-project-automation github-project-automation Bot moved this to Triage in Modularity Oct 20, 2026
@dduugg
dduugg force-pushed the replace-serde-yaml-with-serde-saphyr branch from 1721171 to 25be0f1 Compare October 21, 2026 07:28
dduugg added a commit to rubyatscale/code_ownership that referenced this pull request Oct 21, 2026
* Fix inverted stage_changes in CodeOwnership.validate!

validate! is documented to stage the regenerated .github/CODEOWNERS by
default and to leave it unstaged with stage_changes: false, which is
what bin/codeownership validate --skip-stage passes. It did the
opposite.

validate! passed !stage_changes to the extension, whose
generate_and_validate took it as skip_stage, as the RBI declared, but
handed it unchanged to codeowners-rs's runner::generate_and_validate,
whose third parameter is git_stage. That has been the case since the
extension was added in #126: the codeowners-rs it pinned already took
git_stage (rubyatscale/codeowners-rs#65). codeowners-rs ignores the
result of its git add, so nothing ever failed.

Pass stage_changes through, and rename the extension's parameter and
the RBI's to git_stage to match codeowners-rs, so the flag means the
same thing at every layer. The extension's behavior is unchanged, so
the tracked bundle needs no rebuild: the fix works with it as
committed, and the specs that call
RustCodeOwners.generate_and_validate(nil, false) to write a CODEOWNERS
file still don't stage it.

Add specs that validate! stages CODEOWNERS by default in a git
repository and leaves it unstaged with stage_changes: false. Both fail
without the fix. They clear git's repo-local environment variables
first, so a run from a hook or a linked worktree can't stage into the
enclosing repository. The CLI specs only check the arguments that
reach validate!, so nothing covered what it does to the index.

Add a regression guard for an empty package.yml.
rubyatscale/codeowners-rs#142 moves YAML parsing to serde-saphyr, whose
unreleased 2.1.0 no longer reads a null document as an empty struct
(the fix for bourumir-wyngs/serde-saphyr#233). That PR reads
package.yml as an Option so an empty file still means no owner. The
spec covers an empty file, a comment-only file and a bare "---".
validate! raises if such a file fails to parse, while for_file only
prints the error and falls back to the file's annotation, so the spec
also checks that for_file prints nothing. It passes today.

* Bump version to 2.2.1

* Clear git's repo-local env vars for the whole suite

The fix makes every real validate! call with default arguments run git
add, not only the new staging specs. Two cli_spec examples do that, and
under a hook's GIT_DIR they staged .github/CODEOWNERS into the enclosing
repository, since the scrub only wrapped the staging context. Move it
to a before(:suite) hook so every example's git runs against its own
temp project.

Split the empty package.yml example in two, so validation and the
for_file parse-error check pass or fail separately, without nesting
one expect inside another.
unsafe-libyaml is unmaintained (RUSTSEC-2024-0449) and reaches us only
through serde_yaml, which is itself deprecated. serde-saphyr is a
maintained serde deserializer written in safe Rust, and it is the crate
the advisory names for serde users.

All YAML parsing now goes through src/yaml.rs. Callers read the whole
file and parse the bytes with options that keep serde_yaml's behavior
where serde-saphyr allows it: `<<` stays an ordinary key, `!!binary` is
neither decoded nor validated, `.inf`, `.nan` and `1e999` still parse in
values we ignore, YAML 1.1 booleans such as `yes` are still rejected,
and errors stay on one line. A repeated key takes its last value, as in
Psych. serde-saphyr's default rejects one anywhere in a file, and
serde_yaml rejected one only in a field we read. Error text also
changes, and a few rare inputs are now rejected, accepted or read
differently, as the pull request describes.

Two deserialize_with helpers keep accepting an empty, `~` or `null`
string field or list item, but read all three as "", where serde_yaml
kept `~` and `null` as text. The list helper also reads an empty, `~` or
`null` list as empty, where serde_yaml rejected the last two.
package.yml is read as an Option, so an empty file still means no owner,
and now so does a file that is only `~`, `null` or `...`. serde-saphyr
2.0 does both of these itself, but its unreleased 2.1, which our "2.0.0"
requirement admits, stops (serde-saphyr#233). Errors from team files and
package.yml are wrapped in a type with no source(), since error_stack
would otherwise print an error inside an alias twice.

With duplicate keys set to last-wins, serde-saphyr buffers each mapping
it reads into one of our structs before serde sees it, and buffers a
nested mapping again at each level where it follows an integer key, so
under its default budget a crafted 480 KB package.yml made `generate`
peak at about 2.7 GB, against 35 MB on main. Nesting is now capped at 16
levels and nodes at 20,000, and the caps on scalar bytes, parser events,
recorded anchor events and recorded anchor bytes grow with the node
limit, at 256, 50, 5 and 64 per node. The worst file found that fits,
68 KB, makes `generate` peak at about 290 MB on macOS, against 12 MB on
main. Ordinary files are unaffected: the 1,015 team and package.yml
files in two large private monorepos nest at most 5 deep with fewer than
2,200 nodes, and `generate` gives the same output for both as on main,
with the same peak memory within about 2%.

The node limit is the new `yaml_max_nodes` key in
config/code_ownership.yml, so a repo whose team files or package.yml
files outgrow it can raise it, and the caps that grow with it, without
waiting for a release. Every command that reads team files or
package.yml honors it, and the team cache and the memoized team map are
keyed on it. A file over it, or over a cap that grows with it, fails
like a malformed file, with a hint to raise the key in the config file
in use, which `--config-path` can change. The config file itself is
always read with the default.

Release archives now also hold a licenses/ directory next to the
codeowners binary, which stays at the archive root, where
dotslash-config.json looks for it. dev/license-notices copies into it
the license files of two crates serde-saphyr brings in, whose licenses
require binary redistributions to carry their notices: granit-parser,
its parser, which asks for all inherited and new notices, and
encoding_rs, whose WHATWG-derived data is under BSD-3-Clause. Today's
macOS binaries don't link encoding_rs, since we parse only byte slices,
so its notice is a precaution. The script takes versions from Cargo.lock
and fails if a listed crate leaves the dependency graph or ships no
license files. The jobs that build release archives run only on main
after a version bump, so the Check job also runs the script on pull
requests.

Dependabot's cargo group now excludes serde-saphyr, so its bumps get
their own pull request.

New tests pin each option, budget and hint, both helpers and every
deserialize_with field, empty package.yml files, and the node limit and
the hint's config file through the CLI. No file reaches the event cap
before another cap unless the limit is 0, so a test checks its value
directly: at the default limit it is serde-saphyr's own default. The
config tests go through Config::load_from_path, as production does.

Fixes #141
@dduugg
dduugg force-pushed the replace-serde-yaml-with-serde-saphyr branch from 25be0f1 to 04dea65 Compare October 21, 2026 21:36
@dduugg
dduugg merged commit 18e0226 into main Oct 21, 2026
14 checks passed
@dduugg
dduugg deleted the replace-serde-yaml-with-serde-saphyr branch October 21, 2026 21:47
@github-project-automation github-project-automation Bot moved this from Triage to Done in Modularity Oct 21, 2026
dduugg added a commit that referenced this pull request Oct 21, 2026
This resolves the conflicts with #142, which moved YAML parsing to
serde-saphyr and added yaml_max_nodes. README.md, Config and the Config
literal in file_owner_resolver's tests keep both sides' additions:
allow_unowned_globs, then yaml_max_nodes and path. project_builder.rs
takes main's ruby_package_owner and keeps this branch's removal of
matches_globs, which now lives in path_utils.

serde_yaml is no longer a dependency, so the allow_unowned_globs config
test loads its file through Config::load_from_path, as the other config
tests do.
dduugg added a commit that referenced this pull request Oct 22, 2026
The README said yaml_max_nodes doesn't apply to the config file
itself, which can read as if the config had no limit. It is always
parsed with the default of 20,000 nodes, and nothing raises that, so
a config whose lists add up to about 20,000 entries fails with a
`budget breached` error and, unlike a team file, no hint. A long
literal allow_unowned_globs list is the likeliest way to get there,
and 0.5.0, which predates #142's limits, read such a config. The
sentence now gives the limit. Only the README changes.
dduugg added a commit that referenced this pull request Oct 28, 2026
* Add allow_unowned_files to permit files with no owner

code_ownership#140: a repo that assigns owners to only some files had no way
to pass `validate` except `unowned_globs: ['**/*']`, and files matched by
unowned_globs are never read, so that also switched off their `@team`
annotations.

With `allow_unowned_files: true`, a file in owned_globs that no mechanism
assigns an owner is no longer an error, in both `validate` and
`validate <files>`. Every mechanism, file annotations included, still applies
to those files, so the annotations keep working. It defaults to false, and
nothing else changes: unowned_globs files are still never read, which is what
keeps large third-party trees cheap.

Making annotations work inside unowned_globs instead would have meant opening
every file those globs match. Measured on a synthetic tree of tracked vendored
files, that made a cold `generate` about 7x slower at 100k files and 11x at
300k.

* Take globs in allow_unowned_globs rather than a boolean

A boolean could only exempt every file at once, while a glob list can also
scope the exemption, e.g. `**/deprecated/**/*`, and `['**/*']` still covers
code_ownership#140's case. It matches how owned_globs and unowned_globs are
configured. The difference from unowned_globs is that files here are still
read, so annotations and every other mechanism still apply; they just don't
have to have an owner.

The globs are only checked for files that have no owner, using the same
glob_match used for owned_globs on every walked file.

* Keep glob matching local to the validator and reuse relative_to_buf

The validator imported matches_globs from project_builder, which made
project_builder's copy crate-public just to share a one-line helper. It now
has its own private copy, like runner.rs and file_owner_resolver.rs, and
project_builder's copy is private again. validate_files' relative-path
closure now calls path_utils::relative_to_buf instead of hand-rolling it.

Also pin that `allow_unowned_globs: ['**/*']`, the README's way to allow
every unowned file, covers files at the project root, for both `validate`
and `validate <files>`.

* Share one matches_globs in path_utils

runner.rs, project_builder.rs and the validator each had an identical
private matches_globs. They now all use path_utils::matches_globs, next to
relative_to and relative_to_buf, with a unit test that `**/*` matches both
root-level and nested paths.

* Test allow_unowned_globs with absolute paths and the checks it keeps

Nothing tested that `validate <files>` matched allow_unowned_globs against the
project-relative path. Matching the raw argument instead passed the whole
suite, because the only test passed a path that was already relative. A test
with an absolute path now covers it. validate_files also relativizes each path
once, carrying it through the owned_globs filter, instead of twice per file.

Two more tests pin the README's claim that only the missing-owner check is
waived: a file in an allowed glob still fails when it has more than one owner
or names an invalid team.

The validator now reads allow_unowned_globs from its project instead of
keeping a copy, matches_globs is pub(crate) so it doesn't become public API,
and its imports sit with the other crate imports.

* Explain how allow_unowned_globs differs from unowned_globs

The README said unowned_globs files are never read, but `for-file` reads the
top of the file before ignoring its annotation. It also implied that no
ownership mechanism applies to unowned_globs files, when directory, package
and team-glob rules still cover them through CODEOWNERS. The real differences
are that annotations count and that the other checks still run.

It now also says that unowned_globs takes precedence, and how to move off
`unowned_globs: ['**/*']`, the setup code_ownership#140 started from, where
adding the new key alone changes nothing. The Getting Started example moves a
hand-picked app file from unowned_globs to allow_unowned_globs, since
unowned_globs is meant for third-party and generated code.

* Bump version to 0.6.0

A minor bump. Since 0.5.0:

  - `allow_unowned_globs` (#135) is a new config key. It defaults to `[]`, so
    existing configs behave as before, but it adds a `pub` field to `Config`,
    which breaks downstream code that builds a `Config` with a struct literal.
  - Test git no longer follows a hook's GIT_DIR into this repo (#133). This
    only affects the test suite.

* Read allow_unowned_globs with strings_or_nulls

serde-saphyr rejects a list item with no value, `~` or `null`, so such
an `allow_unowned_globs` entry made the whole config fail to parse.
serde_yaml read a bare `-` as "" and kept `~` and `null` as text. Like
the other list keys, the field now goes through strings_or_nulls, which
reads all three as "", and reads an empty, `~` or `null` list as empty:
serde-saphyr 2.0 does that by itself, but 2.1 stops doing it for a Vec.
The empty-values config test now covers this key too.

* Report allowed files that name an owner in validate <files>

Given a list of files, `validate` and `gv` check each one only against
CODEOWNERS, and allow_unowned_globs excused every allowed file that
CODEOWNERS had no line for. That included a file whose annotation names
a team that doesn't exist: without the key it is reported as unowned,
and a full `validate` reports the invalid team. Now the glob excuses
only a file that nothing names an owner for, counting owners as a full
`validate` does, so such a file is reported as unowned again, and so is
one that CODEOWNERS doesn't list yet. The owner matchers are collected
once, and only when an allowed file is missing from CODEOWNERS.

* Test that unowned_globs takes precedence over allow_unowned_globs

The README says a file matching both is left out, but no test checked
it: making allow_unowned_globs win passed the whole suite. The new test
gives a file matching both an annotation naming an unknown team and
checks that `gv` passes, which it can only do with the file left out.

* Count a package naming an unknown team in validate <files>

The previous commit made allow_unowned_globs excuse only an allowed
file that nothing names an owner for, but the owner lookup missed one
kind of owner. The package mappers drop a package whose package.yml or
package.json names a team that doesn't exist, so a file in it still
passed `validate <files>` and `gv <files>`. Without the key it is
reported as unowned, and a full `validate` reports the package.yml or
package.json. The lookup now adds a matcher for each such package, so
the file is reported as unowned again. A full `validate` is unchanged.

* Test more of how validate <files> treats allowed files

Three changes to the code passed every test. The owner lookup could
leave out `.codeowner` files, so an allowed file under one naming a
team that doesn't exist would pass. It could be given the path as typed
rather than the project-relative one, so an absolute path would miss
the file's annotation. And `validate <files>` applies the precedence of
unowned_globs with its own filter, where letting allow_unowned_globs
win went unnoticed: an annotated file in unowned_globs is never read,
but a `.codeowner` there still is. A new test catches each change. The
comment on the annotation test no longer says a list of files is
checked only against CODEOWNERS, which stopped being true when the
lookup was added.

* Say what unowned_globs still reads

The README said files matching unowned_globs are left out and that
validation doesn't check them. A `.codeowner`, package.yml or
package.json among them is still read, though: its owner still goes
into CODEOWNERS, and a team it names that doesn't exist is still
reported. main behaves the same way; only the sentence is new here.

* Make only absolute paths project-relative in validate <files>

Reusing path_utils::relative_to_buf in validate_files changed how it
reads a relative path. The helper strips the project root from any
path, but the closure it replaced, like the CODEOWNERS lookup, stripped
it only from an absolute one. The CLI canonicalizes the root, but a
library caller can pass a relative one, as the README's example does.
A relative path that starts with that root was then checked against
owned_globs with the root cut off, while the CODEOWNERS lookup read it
as given and found no line for it, so runner::validate and
runner::generate_and_validate reported an owned file as unowned. Before
that change the file was skipped.

A relative path is already relative to the project root, so it is now
left as given and only an absolute one has the root stripped. Such a
file is skipped again, and nothing changes when the root is absolute.

* Excuse only a plain project-relative path in validate <files>

allow_unowned_globs excuses a file in `validate <files>` and
`gv <files>` only if the owner lookup finds nothing naming an owner for
it. The lookup matches only paths in the project's own form, such as
app/x.rb, but it was given the path as passed. So `./app/x.rb`,
`app/../app/x.rb` or an absolute path not under the canonical root, as
through a symlink, found no owner, and the file was excused even when
its annotation, `.codeowner` or package names a team that doesn't
exist. Without the key it is reported as unowned.

The excuse now drops `.` components and repeated separators from the
path before matching it against allow_unowned_globs and looking up its
owners, so it treats `./app/x.rb` as app/x.rb, and such a file is
reported again. A path with a `..`, or an absolute one not under the
project root, can't be put in that form without asking the file
system, so it is never excused and is reported as without the key. The
owned_globs and unowned_globs checks and the CODEOWNERS lookup read the
path as before, so nothing changes without the key.

* Match a package naming an unknown team by its directory

The owner lookup behind allow_unowned_globs in `validate <files>` and
`gv <files>` gave each package naming a team that doesn't exist an
unescaped glob of its directory. For a package in `packs/[typo]`,
`[typo]` read as a character class, so a file in the package passed
although a full `validate` reports its package.yml, while an allowed
file with no owner in `packs/t` was reported as unowned. A package.yml
at the project root got `/**/**`, which matches nothing, so the files
under it passed too.

The lookup now keeps the directory of each such package and finds an
owner for any path under one. It compares paths a component at a time
rather than as globs, so brackets are literal and a package at the
project root covers every file. A file in such a package is reported
as unowned again, as without the key, and the file in `packs/t`
passes. A full `validate` is unchanged. The package test now covers
package.json too.

* Say the owner lookup sees only files the project reads

The comments on the owner lookup behind allow_unowned_globs in
`validate <files>` and `gv <files>` said that an allowed file is
excused only if nothing names an owner for it, and that one whose
annotation, `.codeowner` or package names a team that doesn't exist,
or whose owner CODEOWNERS doesn't list yet, is still reported. But the
lookup is built from the project, which leaves out untracked files and
anything under ignore_dirs. So an owner named only in such a file, as
in a new file's own annotation before it is staged, or in an untracked
`.codeowner` or package.yml, doesn't count, and the allowed file is
excused, although without the key it is reported as unowned.

A full `validate` doesn't read those files either, so it doesn't report
such a file, and `validate <files>` keeps excusing it. Both comments
now say that only files the project reads count, as in `validate`.
Nothing else changes.

* Check allow_unowned_globs in parallel in validate

A full `validate` or `gv` matched each file with no owner against
allow_unowned_globs in a loop on one thread. Such a file can be tried
against every glob in the list, so the time grows with the number of
those files times the length of the list. That is small for a few
globs, but the README's Getting Started example lists a file by path,
and a list of files still to be given owners can run to thousands of
paths. On main such a list goes in unowned_globs, which is matched in
the parallel walker.

The check now runs on rayon's thread pool, like the other per-file
checks in the validator. rayon's collect keeps the order of the files,
so it reports the same errors in the same order. On a synthetic
project of 10,000 files with no owner, each allowed by its path, the
best of 15 runs of `validate` took 1.0 s before and takes 0.45 s now.

* Share fixture setup between the allow_unowned_globs tests

The two absolute-path tests copied the fixture, staged it and built the
codeowners command line themselves, as run_on_modified_fixture does,
because that helper takes its arguments before the temp root that an
absolute path is built from exists. The file also named its fixture two
ways, as a path for setup_fixture_repo and as a bare name for
run_codeowners.

The fixture is now named once, and run_on_modified_fixture is split
into a builder that copies, modifies and stages the fixture and a
runner for the command. validate_absolute_path joins them to validate a
file by its canonical absolute path, so each absolute-path test is now
a single call. The assertions are unchanged, and no test is added or
removed.

* Name the allow_unowned_globs tests after what they check

Six tests of `validate <files>` were named as if they reported an
invalid team or a package naming one, but a list of files is never
checked for invalid teams. Each asserts that an allowed file whose
annotation, `.codeowner` or package names a team that doesn't exist is
reported under "Unowned files detected:". Beside
test_validate_reports_invalid_teams_in_allowed_globs, which does check
a full `validate` for invalid teams, the near-identical names misled.
They are now named after the allowed file they report.

The two `for-file` tests can't be broken by anything
allow_unowned_globs changes, since `for-file` never reads the key.
They are kept, now named after `for-file`, and a comment says they pin
that it reports an allowed file's owner as it would without the key.
Another comment says the test that a full `validate` passes relies on
the fixture's CODEOWNERS listing app/deprecated/annotated_old.rb, so it
also fails, with CODEOWNERS out of date, if annotations in allowed
globs stop counting. Only test names and comments change.

* Test more owner sources for allowed files

Five changes to the code passed every test. `validate <files>` could
report an allowed file with an owner even when CODEOWNERS lists it,
which would fail a pre-commit hook on a commit that edits an annotated
file in an allowed glob. The owner lookup could leave out team globs,
or packages naming a team that exists, because the only test of an
allowed file that CODEOWNERS doesn't list yet gave it an annotation.
And no test ran a full `validate` with a package.yml or `.codeowner`
naming an unknown team in an allowed glob, so it could stop reporting
them there.

New tests pass the annotated file and an unowned one to `validate` and
`gv`, and run that full `validate` with each. The latter checks for
each message, not just the exit code, since the `.codeowner` also
gives annotated_old.rb a second owner. It also checks that the
package's file, which has no owner, isn't reported as missing
ownership. The test of an allowed file that CODEOWNERS doesn't list
yet now also covers an owner from a team glob and one from a package,
not just an annotation. Only tests change.

* Keep unit tests next to what they test

When matches_globs moved from project_builder to path_utils, its test
stayed behind, as did test_glob_match, which tests fast_glob itself and
needed a new glob_match import in project_builder's tests. The only
check of the allow_unowned_globs default went in the test of the
executable_name default, so a change to it would fail a test named for
another key.

Both tests and their OWNED_GLOB now sit with the other matches_globs
test in path_utils, calling fast_glob::glob_match by its full path as
the code there does, and the import is gone. The default is checked in
test_parse_config with most of the others, between executable_name and
yaml_max_nodes as in the struct, and the executable_name test is back
to what main has. Only tests change, and none is added or removed.

* List allow_unowned_globs next to unowned_globs in the README

The README's list of configuration keys put allow_unowned_globs after
executable_name, with four other keys between it and unowned_globs,
though the bullet is written in terms of unowned_globs. It says how
the two keys differ, that unowned_globs takes precedence, and when to
switch from `unowned_globs: ['**/*']` to the new key, so a reader had
to look back up the list to follow it. The bullet now comes directly
after unowned_globs. Its text and the rest of the README are unchanged.

* Explain allow_unowned_globs for file lists, migration and old versions

The README said a file in allow_unowned_globs can't have more than one
owner or name an invalid team, since every other check still runs.
That holds for a full `validate` or `gv`, but the README never
mentioned what they do given a list of files, as from a git hook. They
look each file up in CODEOWNERS and never check for more than one
owner, and pass an allowed file with no line only if nothing names an
owner for it. So one whose annotation, `.codeowner` or package names a
team that doesn't exist, or whose owner CODEOWNERS doesn't list yet,
is reported under "Unowned files detected".

The advice to replace `unowned_globs: ['**/*']` with the new key left
out that CODEOWNERS then gains a line for each annotated file, so until
`codeowners gv` runs, `validate` reports it out of date and
`validate <files>` reports those files as unowned. It also left out
that the files are then read and checked, which takes longer. The
bullet now covers all of this, says to keep third-party and generated
code in unowned_globs, and says the key was added in 0.6.0, since
older versions ignore it without a warning and still report these
files. Only the README changes.

* Match a .codeowner naming an unknown team by its directory too

The owner lookup behind allow_unowned_globs in `validate <files>` and
`gv <files>` matched a `.codeowner` only by the glob the directory
mapper builds from its directory. For one at the project root that
glob is `/**/**`, which matches nothing, and the mapper escapes
brackets but not braces, so `app/deprecated/{legacy}/**/**` matches
files in `app/deprecated/legacy` but not in `app/deprecated/{legacy}`.
An allowed file under such a `.codeowner` naming a team that doesn't
exist therefore passed, although a full `validate` reports the
`.codeowner` and, without the key, the file is reported as unowned.

The lookup now keeps the directory of each `.codeowner` naming a team
that doesn't exist, as it already did for packages, and finds an owner
for any path under one. A file under such a `.codeowner` is reported
as unowned again, as without the key. Only the nearest `.codeowner`
gives a file its owner in `validate`, but the lookup asks only whether
anything names an owner. The directory mapper is unchanged, so
CODEOWNERS and a full `validate` are too. A test of a package in a
directory with braces is added as well. It passed before, but without
it no test fails if packages are matched by globs like the directory
mapper's.

* Report allowed files under their own heading in validate <files>

Given a list of files, `validate` and `gv` report an allowed file that
CODEOWNERS has no line for only if something names an owner for it: a
team that doesn't exist, or one CODEOWNERS doesn't list yet. Such a
file was still listed under "Unowned files detected:", with no hint,
although the config says it may go unowned. With the README's
`['**/*']`, every failure for a list of files is of this kind, and in
the code_ownership gem's pre-commit hook, which runs `gv` and so
regenerates CODEOWNERS first, the cause is almost always a team that
doesn't exist.

These files now get a heading of their own. It says that they match
allow_unowned_globs but name an owner that CODEOWNERS doesn't list,
and suggests checking the team name or running the configured
executable_name to update CODEOWNERS, as the stale-CODEOWNERS message
does. Files outside the globs, and a path with a `..` or an absolute
one not under the project root, are still listed under "Unowned files
detected:", and both headings can appear in one run. The exit status
is unchanged, and the gem only joins these lines into the error it
raises. The README names the new heading, and new tests pass both
kinds of file at once and check that the hint names the configured
executable.

* Say the config file is held to the default node limit

The README said yaml_max_nodes doesn't apply to the config file
itself, which can read as if the config had no limit. It is always
parsed with the default of 20,000 nodes, and nothing raises that, so
a config whose lists add up to about 20,000 entries fails with a
`budget breached` error and, unlike a team file, no hint. A long
literal allow_unowned_globs list is the likeliest way to get there,
and 0.5.0, which predates #142's limits, read such a config. The
sentence now gives the limit. Only the README changes.

* Say that unowned_globs still reads team files

The README's unowned_globs bullet says files matched there are left
out of the project, then names `.codeowner`, `package.yml` and
`package.json` as the files still read, which reads as the whole
list. A team file is another: the project builder matches
team_file_glob before owned_globs and unowned_globs, so a team file
in unowned_globs still defines its team and keeps its own line in
CODEOWNERS. The bullet now says so in a sentence of its own, since the
invalid-team check the sentence before it mentions doesn't apply to
team files. Only the README changes.

* Match a .codeowner naming an unknown team only by its directory

The owner lookup behind allow_unowned_globs in `validate <files>` and
`gv <files>` matches the directory of a `.codeowner` naming a team
that doesn't exist as a path prefix, but it still took the glob the
directory mapper builds from that directory as well. The mapper
escapes brackets but not braces, so the glob it builds for one in
`app/deprecated/{legacy}` matches files in `app/deprecated/legacy`
instead. An allowed file there, which nothing names an owner for, was
therefore listed under the heading for files that name an owner
CODEOWNERS doesn't list, and the run failed instead of passing.

The lookup now leaves out the glob for such a `.codeowner`, as the
prefix covers just the files under its directory, so that file passes
and a file under the `.codeowner` is still reported. The glob for a
`.codeowner` naming a team that exists is kept, since it names an
owner for files that CODEOWNERS may not list yet. The directory mapper
is unchanged, so CODEOWNERS and a full `validate` are too. The braced
test now also passes a file in `app/deprecated/legacy` and fails
without the change. The test of an allowed file that CODEOWNERS
doesn't list yet covers an owner from a `.codeowner` too, since
without it no test fails if the lookup drops every `.codeowner` glob.

* Look up a non-plain allowed path in CODEOWNERS by its plain form

Given a list of files, `validate` and `gv` look each one up in
CODEOWNERS by the path as given, but match a file with no line against
allow_unowned_globs and the owner lookup by its plain project-relative
path. So an allowed file that CODEOWNERS lists, such as an annotated
one, missed its line when passed as `./app/x.rb`, `app//x.rb` or
`app/./x.rb`, the owner lookup found its owner, and the file was
listed under the heading for files that name an owner CODEOWNERS
doesn't list. That is false, and neither checking the team name nor
regenerating CODEOWNERS made it pass. The `./` form passed before
allowed files were matched by their plain path.

Such a file is now looked up in CODEOWNERS again by its plain path,
and passes if that finds a line. The second lookup is made only when
the plain path differs from the one given and the owner lookup has
found an owner, so only a file that would otherwise be listed pays for
it. A file outside allow_unowned_globs is still looked up only by the
path as given, as on main, so nothing changes without the key. A new
test passes the annotated file to `validate` and `gv` by each of those
paths and fails without the change. The test of a `./` path now allows
only `app/deprecated/**/*`, which matches the plain path but not the
one given, so it also catches allow_unowned_globs being matched
against the path as given, which no test did before.

* Don't suggest updating CODEOWNERS that gv <files> has just written

Given a list of files, `gv` writes CODEOWNERS before it looks each one
up, so an allowed file it lists under "Files matching
allow_unowned_globs name an owner that CODEOWNERS doesn't list" is
almost always there because its annotation, `.codeowner` or package
names a team that doesn't exist. The heading still suggested running
the configured executable_name to update CODEOWNERS, which can't help.
The default, `codeowners generate`, writes the same file again, and
the code_ownership gem names `bin/codeownership validate`, which by
default runs `gv` again. For a file under an empty `.codeowner`,
"check the team name" didn't say where to look either.

Both commands now say to check the team name in each file's
annotation, `.codeowner` or package, and only `validate` still
suggests running the executable, as its CODEOWNERS may be out of date.
The heading's first sentence, which the README quotes, and the exit
status are unchanged. The tests now expect the heading each command
prints, and a new one runs `gv` with the gem's executable_name and
checks that it doesn't suggest updating CODEOWNERS. They fail without
the change.

* Test a package.json owner and a sibling directory for allowed files

Two changes to the owner lookup behind allow_unowned_globs in
`validate <files>` and `gv <files>` passed every test. It could leave
out package.json files naming a team that exists, because each one
the tests add names a team that doesn't, and those are matched by
their directory instead. `validate` would then pass an allowed file in
such a package that CODEOWNERS doesn't list yet, rather than list it
under the heading for files that name an owner CODEOWNERS doesn't
list. It could also match the directory of a package or `.codeowner`
naming a team that doesn't exist as a string prefix rather than a
component at a time, so a package in `packs/typo` would name an owner
for an allowed file with none in `packs/typo2`, which would be listed
under that heading instead of passing.

The test of an allowed file that CODEOWNERS doesn't list yet now also
covers an owner from a package.json, as it did one from a package.yml
and a `.codeowner`, and a new test passes the file in `packs/typo2` to
`validate` and `gv`. Each fails with its change. Only tests change.

* Share config writing between the allow_unowned_globs tests

Thirteen fixture changes in the allow_unowned_globs tests replaced
config/code_ownership.yml with an inline YAML string, each quote and
newline escaped, and several were copies of one another: owning
`**/*.rb` and allowing `**/*` three times, and owning
`{app,config,packs}/**/*.rb` and allowing `packs/**/*` three more.
Which globs a test's config owned or allowed had to be picked out of
the escapes.

A Config struct now takes each key's globs by name, and its write
method renders the YAML, leaving out a key with no globs so that
unowned_globs keeps its default. Every fixture change that writes the
config now uses it, including name_an_executable, which appended its
key to the fixture's config. The two that copied allow_every_file now
call it. Every fixture a test builds has the same files, byte for
byte, as before, and no test is added, removed or changed in what it
checks. Only tests change.

* Correct comments on the owner lookup and the config's node limit

Two comments on the owner lookup behind allow_unowned_globs in
`validate <files>` and `gv <files>` said an annotated file,
`.codeowner` or package file names no owner there if it is under
ignore_dirs, but the project builder prunes only a directory at the
project root that ignore_dirs names. With the default ignore_dirs, an
annotation naming a team that doesn't exist keeps a file in `app/tmp`
from passing, though not one in `tmp`. They now say a top-level
directory named in ignore_dirs. The one in validate_files also said a
file whose annotation, `.codeowner` or package names a team that
doesn't exist, "or that CODEOWNERS doesn't list yet", is still
reported, which can be read as a team that CODEOWNERS doesn't list. It
now says "or whose owner CODEOWNERS doesn't list yet", as the README
does, since an owner that CODEOWNERS lists for other files counts too.

The comment where the config is read said that at a few hundred nodes
it never needs a higher limit, but a long literal allow_unowned_globs
list can run to thousands of entries, and the README says the config is
held to the default of 20,000 nodes, about one per list entry. It now
says the config's lists can hold about 20,000 entries in all. Only
comments change.

* Clarify the README on team files and what the owner lookup reads

In the README's unowned_globs bullet, "So is a team file, which still
defines its team and keeps its own line in CODEOWNERS" followed "an
invalid team in it is still reported", so the elliptical "So is" could
be read as saying that a team file is still reported. It now says that
a team file among them is still read too, so it still defines its team
and keeps its own line in CODEOWNERS.

The allow_unowned_globs bullet said that, given a list of files, an
allowed file whose annotation, `.codeowner` or package names a team
that doesn't exist is reported, but the owner lookup that decides this
sees only the files the project reads. An untracked file's own
annotation names no owner there, nor does an untracked `.codeowner` or
package file, or any of them in a top-level directory named in
ignore_dirs, as in a full `validate`. So `validate app/new.rb` passes
a new allowed file whose annotation names a team that doesn't exist,
and reports it only once the file is staged. A sentence after the one
quoting the heading now says so. It says that such a file doesn't
count, rather than that only a tracked one does, since outside a git
repository no file is left out for being untracked. Only the README
changes.

* Give executable_name's real default in the README

The README said executable_name defaults to `'codeowners'`, but the
default, here and on main, is `codeowners generate`. Its example config
set `bin/codeownership` for the Ruby gem, while the gem passes
`bin/codeownership validate`, which regenerates CODEOWNERS by default.
Both the stale-CODEOWNERS message and the new hint for allowed files in
`validate <files>` print this value as the command to run to update
CODEOWNERS. The gem's own runs aren't affected, since the value it
passes replaces the config's, but a config copied from the example made
`codeowners validate` tell users to run `bin/codeownership`, which only
prints the gem's usage.

The bullet now gives the real default and says the key is the whole
command these messages tell you to run, rather than a command name, and
the example sets `bin/codeownership validate`. Only the README changes.

* Rename allow_unowned_globs to optionally_owned_globs

`allow_unowned_globs` reads as `allow_` followed by the name of the
`unowned_globs` key, as though it switched on or loosened that key. The
relationship is the reverse: the new key narrows `owned_globs`, whose
files it still reads and checks, and has nothing to do with
`unowned_globs`, which takes precedence over it. The `allow_` prefix
also reads like a boolean, as rubyatscale/code_ownership#140 proposed
with `allow_unowned_files: true`, while the other list keys are noun
phrases.

`optionally_owned_globs` sits between the two existing keys: files in
`owned_globs` must have an owner, files that also match
`optionally_owned_globs` may have one, and files in `unowned_globs` are
left out. Since the name could be read as adding files beyond
`owned_globs`, the README now says it doesn't, and that a file must
match both.

The key, the `Config` and `Project` fields, the heading that
`validate <files>` prints, the README, the fixture directory, the test
file and the test names all use the new name, and prose that called
these files "allowed" now calls them optionally owned. 0.6.0 hasn't
been released, so nothing reads the old name and no alias is kept.

* Stop at the first owner in OwnerLookup::has_owner

has_owner only needs to know whether anything names an owner, but it
called FileOwnerFinder::find, which runs every owner matcher, clones
each matching source into a map of teams and collects the owners, all
to check that the list isn't empty. With `optionally_owned_globs:
['**/*']`, every file `validate <files>` is given that has no line in
CODEOWNERS reaches it.

find returns an owner exactly when some matcher names a team: a matched
directory always survives as the most specific one. So has_owner now
asks whether any matcher names a team, and stops at the first. Fresh
Eyes reported this as an info-level performance finding.
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.

RUSTSEC-2024-0449: unsafe-libyaml is unmaintained

1 participant