Skip to content

Tolerate recorded violations in strict mode (Part B of #43) - #45

Open
iMacTia wants to merge 7 commits into
rubyatscale:mainfrom
iMacTia:strict-mode-tolerates-recorded-violations
Open

Tolerate recorded violations in strict mode (Part B of #43)#45
iMacTia wants to merge 7 commits into
rubyatscale:mainfrom
iMacTia:strict-mode-tolerates-recorded-violations

Conversation

@iMacTia

@iMacTia iMacTia commented Aug 17, 2026

Copy link
Copy Markdown

Fixes #41. Follow-up to #43, which needs to land first: every comparison here relies on that PR's recorded_key().

This is Part B of the split @dduugg suggested. build_strict_mode_violations now skips violations already recorded in a package_todo.yml, matching packwerk's unlisted_strict_mode_violations (Shopify/packwerk#368). Turning strict mode on blocks new violations without requiring the existing list to be emptied first. --ignore-recorded-violations still surfaces everything the todo files are grandfathering.

The blocking bug you found, and why it had to come with this

write_violations_to_disk dropped every strict violation when regenerating todo files. That line was pre-existing, but this tolerance is what starts depending on those entries, so a routine pks update deleted the records that made the build green. It reproduced exactly as you described on uses_strict_mode, with no source change in between:

$ pks check     # exit 0, No violations detected!
$ pks update    # exit 0, deletes packs/foo/package_todo.yml
$ pks check     # exit 1, 2 violations

It now drops only the unlisted strict violations. update still cannot be used to silence strict mode, but it no longer un-grandfathers what check tolerates. Same reasoning as packwerk re-adding already-listed offenses in OffenseCollection#add_offense.

The rest of your list

  • update's summary message. It filtered on .identifier.strict with no recorded filter, so it claimed N violations "must be fixed for check to succeed" while check reported none. It uses the same filter as the checker now.
  • CHECKERS.md. Both sentences you quoted are rewritten. The strict_privacy_ignored_patterns section now says when to reach for a path exemption instead: a todo entry grandfathers one (constant, file) pair, so a new reference from the same file still fails, whereas a pattern exempts the path outright.
  • CHANGELOG. New entry in the respect_gitignore who's-affected format. You were right that ## Unreleased was stale. I checked, and 2fe98b7 is an ancestor of v0.4.0, so that section is now ## 0.4.0 and this sits under a fresh ## Unreleased. Agreed on 0.5.0 rather than 0.4.x for the release itself. I have not touched the version in Cargo.toml, since the gitignore PR did not bump it either and that felt like a release step rather than mine to take.
  • Not gating this behind a config option, per your reasoning. packwerk made it the default with no opt-out, and --ignore-recorded-violations is already the escape hatch.

Tests

  • test_check_with_recorded_strict_mode_violation: the recorded case is clean.
  • test_check_with_recorded_strict_mode_violation_ignoring_todo: the escape hatch still reports it.
  • test_check_with_unrecorded_strict_mode_violation: an unrecorded strict violation still fails.
  • test_check_with_partially_recorded_strict_mode_violations: the mixed case you flagged as uncovered. One recorded (::Bar) and one unrecorded (::Baz) in the same strict pack in one run, and only the unrecorded one is reported.
  • test_update_preserves_recorded_strict_violations: the recorded entry survives update, and the false summary line is gone.
  • test_check_update_check_round_trip_with_strict_mode: check, update, check, still clean.

test_check_with_strict_mode_output_csv moves to contains_strict_violations, which ships no todo file, so it still has output to assert against. The duplicate assertion it carried was byte-identical to the line below it, so dropping it costs no coverage.

Two new fixtures rather than edits to uses_strict_mode, so the mutating tests cannot race the read-only ones.

Still deferred, as you said was fine: moving strict off ViolationIdentifier onto Violation, and the borrowed-key optimisation in build_stale_violations.

cargo test, cargo clippy --all-targets --all-features -- -Dwarnings and cargo fmt --all -- --check all pass.

Fixes rubyatscale#41.

`ViolationIdentifier` carries `strict`, but violations rebuilt from
`package_todo.yml` always get `strict: false`, so in a strict pack a found
violation could never equal its recorded entry.

Both comparisons against the recorded set now normalize the found side through
`recorded_key()`, which zeroes the flag. That fixes both symptoms: a recorded
violation in a strict pack was reported as new, and its todo entry was reported
as stale.

The todo side needs no normalization. `is_stale_violation` takes its argument
from `pack_set.all_violations`, which already rebuilds every recorded violation
with `strict: false`, so it is its own recorded key. There is a doc comment
saying so, because the asymmetry with the found side reads like an oversight
otherwise.

`test_check_with_strict_mode` pins the corrected output on `uses_strict_mode`:
still exit 1, because strict mode itself is unchanged here, but neither a
new-violation report nor a stale-todo line. It fails without the fix.
Builds on rubyatscale#43, which has to land first: every comparison here needs that PR's
`recorded_key()`.

`build_strict_mode_violations` now skips violations already recorded in a
`package_todo.yml`, matching packwerk's `unlisted_strict_mode_violations`
(Shopify/packwerk#368). Turning strict mode on therefore blocks new violations
without also requiring the existing list to be emptied first.
`--ignore-recorded-violations` still surfaces everything the todo files are
grandfathering.

Three things had to come with it, because the tolerance reads state that nothing
was previously protecting.

`write_violations_to_disk` preserved nothing for strict packs: it dropped every
strict violation when regenerating todo files, so a routine `pks update` deleted
the entries `check` had just started depending on. On `uses_strict_mode` that was
check clean, update, check red, with no source change in between. It now drops
only the *unlisted* strict violations, so `update` still cannot be used to
silence strict mode, but it stops un-grandfathering what strict mode is now
tolerating. packwerk keeps the entry for the same reason, in
`OffenseCollection#add_offense`.

`update`'s summary message filtered on `.identifier.strict` with no recorded
filter, so it announced that N violations "must be fixed for `check` to succeed"
while `check` reported none. It uses the same filter as the checker now.

`CHECKERS.md` asserted the opposite of this behaviour in two places: that strict
mode includes violations recorded in other packages' todo files, and that you
must clear existing violations before enabling it. Both are rewritten, and the
`strict_privacy_ignored_patterns` section now says when to reach for a path
exemption rather than a recorded entry, since the recorded case is covered by
default.

Tests:

- `test_check_with_recorded_strict_mode_violation` — the recorded case is clean
- `test_check_with_recorded_strict_mode_violation_ignoring_todo` — the escape
  hatch still reports it
- `test_check_with_unrecorded_strict_mode_violation` — an unrecorded strict
  violation still fails
- `test_check_with_partially_recorded_strict_mode_violations` — one recorded and
  one unrecorded in the same strict pack, in one run. Only the unrecorded one is
  reported. This is the case that makes strict mode adoptable and nothing covered
  it, so a regression here would have been silent
- `test_update_preserves_recorded_strict_violations` — the recorded entry
  survives `update`, and the misleading summary line is gone
- `test_check_update_check_round_trip_with_strict_mode` — check, update, check,
  still clean. This is the round trip that was broken

`test_check_with_strict_mode_output_csv` moves to `contains_strict_violations`,
which ships no todo file, so it still has output to assert against. The duplicate
assertion it carried was byte-identical to the one below it, so dropping it costs
no coverage.

Two new fixtures rather than edits to `uses_strict_mode`, so the mutating tests
cannot race the read-only ones: `uses_strict_mode_partially_recorded` and
`uses_strict_mode_round_trip`.

The CHANGELOG entry follows the `respect_gitignore` who's-affected format. Its
`## Unreleased` heading was stale — `2fe98b7` is an ancestor of v0.4.0, so
everything under it had already shipped — so that section is now `## 0.4.0` and
this change sits under a fresh `## Unreleased`. Pre-1.0, a breaking change like
this wants 0.5.0 rather than 0.4.x.

@dduugg dduugg left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for turning this around fast, and for carrying the write_violations_to_disk fix rather than deferring it. The round trip is stable now: on uses_strict_mode_partially_recorded, check then update then check keeps ::Bar, never writes ::Baz, and reports only ::Baz. The update summary line is accurate, and a deleted recorded reference still gets flagged stale and pruned.

Three things I probed that came back clean, so they don't get re-litigated later: a todo entry moved under a nonexistent defining pack does not grant tolerance, since both pack names are in the comparison key; recording only privacy for a constant still reports its dependency violation; and running update twice yields a byte-identical file.

clippy and fmt are clean. cargo test --no-fail-fast is 261 passed, 1 failed, the failure being test_gitignore_negation_patterns, which reproduces on main from a local global gitignore rule. Note that bare cargo test aborts at gitignore_test and never reaches update_test.

The code is right. Almost everything I left inline is about what the new prose tells people to do. One blocking item, on CHECKERS.md:18: the new adoption note describes the one ordering that does not work, and the ordering that does work is currently written down nowhere.

Two things I noticed that are not yours to fix

pks check <file> and check-contents flag every recorded violation outside the checked subset as stale, and update cannot clear it. On uses_strict_mode a full check is clean, while pks check packs/bar/app/services/bar.rb and the equivalent check-contents both exit 1 with "There were stale violations found, please run packs update". Byte-identical on main, so out of scope here, but it means the editor and LSP path exits 1 in every repo that has a todo file. Probably worth its own issue.

src/packs/checker/privacy.rs:64 has a live dbg!(constant_is_private, constant_is_in_private_namespace); firing on every privacy check for packs using private_constants. Pre-existing and untouched by this PR, but it writes to stderr in released builds.

Nothing except the adoption-order note needs to block, and that one is prose. Happy to re-review quickly once the docs get another pass.

Comment thread CHECKERS.md Outdated
Setting `enforce_privacy` to `strict` will forbid *new* references to private constants in your package. **Violations already recorded in another package's `package_todo.yml` are tolerated**, so strict mode stops the list growing rather than requiring it to be empty.

Note: You will need to remove all existing privacy violations before setting `enforce_privacy` to `strict`.
Note: you do not need to remove existing privacy violations before setting `enforce_privacy` to `strict`. Turn it on, and any reference that is not already recorded will fail the check. To see everything the todo files are currently grandfathering, run `pks check --ignore-recorded-violations`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: this describes the one adoption order that does not work.

"Turn it on, and any reference that is not already recorded will fail the check" is true, and the preceding sentence is true, but together they point at a dead end. Tolerance only matches entries that are already in a todo file, and once a pack is strict update will never create them. On contains_strict_violations, which is strict and ships no todo file:

$ pks check     # exit 1
$ pks update    # "1 strict mode violation(s) detected", then
                # "Successfully updated package_todo.yml files!"
                # no package_todo.yml is written
$ pks check     # exit 1, unchanged

A user who follows this note lands there, and their only exits are fixing the reference, hand-writing the todo entry, or reverting to true. The supported order is the reverse of what the note implies: run update while the pack is still true, commit the todo files, then flip to strict.

That sentence is currently in neither CHECKERS.md nor the CHANGELOG, and it is the one this section most needs, given the PR exists to make strict mode adoptable. Not a code bug, and pks matches packwerk here.

Comment thread CHECKERS.md Outdated
Note: You will need to remove all existing privacy violations before setting `enforce_privacy` to `strict`.
Note: you do not need to remove existing privacy violations before setting `enforce_privacy` to `strict`. Turn it on, and any reference that is not already recorded will fail the check. To see everything the todo files are currently grandfathering, run `pks check --ignore-recorded-violations`.

Running `pks update` will not silence strict mode either: an unrecorded strict violation is never written to a `package_todo.yml`, so it keeps failing until the reference itself is dealt with.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This promises a bit more than holds. It is true of update, but a hand-added todo entry does silence strict mode, and update then preserves the edit rather than dropping it:

$ printf -- '---\npacks/bar:\n  "::Bar":\n    violations:\n    - dependency\n    - privacy\n    files:\n    - packs/foo/app/services/foo.rb\n' > packs/foo/package_todo.yml
$ pks check    # No violations detected!
$ pks update   # rewrites the file, keeps the hand-added `- privacy`
$ pks check    # No violations detected!

That matches packwerk, so it is not a bug. But "it keeps failing until the reference itself is dealt with" reads as a guarantee, when in practice the boundary is only as strong as review of package_todo.yml diffs. Worth a clause.

Comment thread CHECKERS.md Outdated
### Ignore strict mode for violation coming from specific path patterns
If you want to activate `'strict'` mode on your package but have a few privacy violations you know you will deal with later,
you can set a list of patterns to exclude.
You do not need this to adopt `'strict'` mode on a package that already has violations you will deal with later: violations recorded in a `package_todo.yml` are tolerated by default. Reach for these patterns when you want to exempt a **path** instead of a recorded list.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This sentence and the one at line 114 now actively steer people toward strict_privacy_ignored_patterns, which pks does not implement.

grep -rn strict_privacy_ignored_patterns over the repo hits exactly one line: the yaml block just below, at CHECKERS.md:108. There is no field for it on Pack, and #[serde(flatten)] pub client_keys (pack.rs:125) swallows it with no error and no effect. So a user who takes this advice adds a key that does nothing and ships an unguarded strict pack.

The pks equivalent is enforcement_globs_ignore with enforcements: [privacy] (pack.rs:363), already documented at CHECKERS.md:190.

The dead yaml block predates this PR, but the PR is what turns it into a recommendation, so it would be good to either repoint these two sentences at enforcement_globs_ignore or drop the comparison and delete the stale section.

Comment thread CHECKERS.md Outdated

In this example, violations on constants of your engine referenced in those files `engines/another_engine/test/**/*` will not fail Packwerk checks.

The difference matters. A `package_todo.yml` entry grandfathers one `(constant, file)` pair, so a *new* reference from the same file still fails. A pattern here exempts the path outright, so anything those files reference later is ignored too. Prefer the todo file unless you genuinely want the whole path exempt.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"so a new reference from the same file still fails" is false as written. I added a second Bar reference to the already-recorded foo.rb in uses_strict_mode and check stayed at No violations detected!, exit 0.

Your own doc comment at checker.rs:60 says why: "Multiple references to the same constant in the same file are considered one violation, even if they occur at different lines." The second reference is the same violation, so it matches the recorded entry.

The stated unit is also narrower than the real one. Pack::all_violations builds the cross product of violation types and files, so what gets grandfathered is (defining pack, constant, violation type, referencing file), not (constant, file).

One word fixes the conclusion:

A package_todo.yml entry grandfathers one constant referenced from one file, so a reference to a different constant from the same file still fails.

That is also exactly what uses_strict_mode_partially_recorded tests, with ::Bar recorded and Baz not, both referenced from the same file. packwerk behaves the same way, since PackageTodo#listed? does files.include?(reference.relative_path), so this is wording only.

Comment thread CHANGELOG.md Outdated

#### Strict mode tolerates violations already recorded in `package_todo.yml`

`enforce_privacy: strict` and `enforce_dependencies: strict` now fail only on

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This scopes the change to 2 of the 5 checkers, but the filter is checker-agnostic.

checker.rs:245 is filter(|v| v.identifier.strict), and all five arms of rules_checker_setting() (pack_checker.rs:85-101) can return CheckerSetting::Strict. So enforce_layers: strict, enforce_visibility: strict, and enforce_folder_privacy() are all affected too.

Confirmed on the layer case, using layer_violations with enforce_layers: strict on packs/feature_flags and the layer violation recorded in its todo file:

main:  1 violation(s) detected: Layer violation: `::Payments` ...
       There were stale violations found, please run `packs update`
       packs/feature_flags cannot have layer violations on packs/payments ...
       exit=1

this branch:  No violations detected!
              exit=0

Anyone using enforce_layers: strict to hold a layer boundary hard gets the same silent relaxation, and the entry does not warn them. One line of prose.

Comment thread src/packs/package_todo.rs
Comment on lines +146 to 158
// An *unlisted* strict violation is never recorded, so `update` cannot
// be used to silence strict mode. An already-recorded one has to be
// re-written, because `check` now tolerates recorded violations in
// strict packs and `PackageTodo` is dumped wholesale from these
// entries — dropping it here would delete the record that made the
// build green and fail the next `check` with no source change in
// between. packwerk keeps the entry for the same reason, in
// `OffenseCollection#add_offense`.
if violation.identifier.strict
&& !recorded_violations
.contains(&violation.identifier.recorded_key())
{
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is right, and the comment explaining why is welcome. The gap is on the other side of it: nothing pins that update still prunes a recorded strict violation once its reference is gone.

Behavior is correct today, I checked. Deleting the recorded reference gets "There were stale violations found" from check, and update removes the entry and the file.

But the comment says an already-recorded strict violation "has to be re-written", and the natural over-correction to that is to union recorded_violations into the write set rather than intersecting it with found violations. That change would make strict todo entries immortal: never prunable, with check permanently green for a reference that no longer exists in the source. test_update_preserves_recorded_strict_violations only asserts the entry is present, so it would still pass, and so would the round-trip test.

That is the highest-value test this PR is missing. A Drop-in variant of the round-trip fixture with the reference removed, asserting the todo file is gone after update, would cover it.

Comment thread tests/check_test.rs Outdated
Comment on lines +418 to +421
// Uses `contains_strict_violations` rather than `uses_strict_mode`: the
// latter's violation is recorded, so there is nothing left to assert against
// in the CSV. The duplicate assertion this used to carry was byte-identical
// to the one below it, so nothing is lost by dropping it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The second half of this comment is right, the duplicate assertion really was byte-identical. The first half claims more than the test now delivers.

After the fixture swap this test no longer exercises strict tolerance at all. It passes on main, on this branch, and under a mutation that disables the recorded filter entirely, because contains_strict_violations ships no package_todo.yml, so the violation is an ordinary unrecorded one and the strict message is built from identifier.strict regardless of which bucket it lands in.

That is fine, a CSV format test is worth having. Just worth toning the comment down, or adding a recorded entry to the fixture so the CSV test actually distinguishes the recorded and unrecorded buckets.

Comment thread tests/update_test.rs Outdated
contents
);

common::set_up_uses_strict_mode_round_trip_fixture();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This restore, and the matching one at line 269, are trailing statements, so a panic skips them.

Concretely: run test_update_preserves_recorded_strict_violations against unpatched src/ and it leaves tests/fixtures/uses_strict_mode_round_trip/packs/foo/package_todo.yml deleted in the working tree. Test correctness is unaffected, since both tests also restore on entry, but the first run that catches a regression is the run that dirties the tree, and given the pre-commit hook that is a plausible way for a deleted fixture to get committed by accident.

A Drop guard around the fixture would make the restore unconditional and let you drop the paired call at the top of each test.

Comment thread tests/common/mod.rs
Comment on lines +75 to +77
violations:
- privacy
- dependency

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This helper hand-writes violations: as privacy then dependency, but update writes them sorted, so dependency first. The pre-existing set_up_fixtures() further down this file uses the sorted order, so this is also inconsistent with local convention.

Harmless at runtime, since the reader collects into a set. The cost is that the round-trip test never compares the file against what update actually produced, only that "::Bar" appears somewhere in it. Writing the sorted order would let test_check_update_check_round_trip_with_strict_mode assert byte equality against the fixture, which would close the pruning gap I mentioned on write_violations_to_disk more or less for free.

Separately, and not about this helper: no fixture anywhere records a single violation type in a strict pack. Both uses_strict_mode and uses_strict_mode_partially_recorded record privacy and dependency together for ::Bar. I verified the single-type case works correctly today, since recorded_key() keeps violation_type. But if a future refactor ever normalized violation_type the way strict is normalized, recording one type would silence both and the whole suite would stay green.

Comment thread CHANGELOG.md
pks check --ignore-recorded-violations
```

## 0.4.0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This retitling is correct, and the evidence is stronger than the PR description claims. git log main --oneline -- CHANGELOG.md returns exactly one commit, 2fe98b7, so the entire old ## Unreleased section originated in a single pre-0.4.0 commit. Nothing shipped is left under Unreleased and nothing unshipped is being labelled as released. Tags do exist, v0.4.0 resolves to "Bump version to 0.4.0 for gitignore release (#38)", and Cargo.toml on main agrees at 0.4.0.

Agreed on leaving the version bump to a release PR, which matches how #25 and #38 were done.

One thing to flag for whoever cuts 0.5.0, since it makes these headings functional rather than bookkeeping: release.yml:281 builds the GitHub Release body from announcement_github_body, which cargo-dist derives by matching a CHANGELOG.md heading against the version being tagged. So ## Unreleased has to be retitled to ## 0.5.0 in the bump PR, or this entry silently will not reach the release notes.

The blocking item first. CHECKERS.md described the one order that does not work:
it said you could turn `strict` on and let tolerance cover the existing
references. Tolerance only ever matches entries already in a `package_todo.yml`,
and `update` will not create them once the pack is strict, so following that note
left you with `check` red and no supported way out. Reproduced on
`contains_strict_violations`: check exit 1, update prints "Successfully updated"
and writes no todo file, check exit 1 unchanged.

The section now leads with the order that works: run `update` while the pack is
still `true`, commit the todo files, then flip to `strict`. It also says what
happens if you flip first, since that is the state a reader arrives in.

Other CHECKERS.md corrections:

- "keeps failing until the reference is dealt with" was a guarantee about
  `update`, not about the file. A hand-added entry does silence strict mode and
  `update` preserves it, so the boundary is only as strong as review of
  `package_todo.yml` diffs.
- "a *new* reference from the same file still fails" was wrong. Multiple
  references to one constant in one file are a single violation, so the second
  matches the recorded entry. Verified: a second `Bar` reference in the recorded
  `foo.rb` leaves check at exit 0. It is a *different constant* from the same
  file that still fails. Also corrected the grandfathering unit, which is
  (defining pack, constant, violation type, referencing file).
- `strict_privacy_ignored_patterns` is packwerk's key and pks does not implement
  it. It appears nowhere outside that doc block, and `Pack`'s `#[serde(flatten)]`
  swallows it silently, so a reader following the old text shipped an unguarded
  strict pack. Repointed at `enforcement_globs_ignore` with
  `enforcements: [privacy]`, with a note that the packwerk key has no effect.

CHANGELOG corrections:

- Not limited to privacy and dependencies. The filter is checker-agnostic and all
  five checker types can be `strict`, so layers, visibility and folder privacy
  relax identically.
- Entries live in the *referencing* pack's todo file, not the strict pack's
  (`all_violations` sets `referencing_pack_name: self.name`). Now agrees with
  CHECKERS.md.
- "could only be green with an empty todo list" overstated it; green required no
  *strict* entries.
- Added the `update` half, which touches committed files and is the strongest
  reason this is breaking: `update` used to erase recorded strict entries and now
  retains them, so expect it in a diff or a stale-todo CI step.
- "Opt out:" is now "No opt out:", since `--ignore-recorded-violations` also
  disables recorded-violation filtering everywhere else and surfaces every
  recorded violation of every type. Tagged the fence, and used a full URL for
  packwerk#368, which does not autolink inside a Markdown file.

Tests:

- New `test_update_prunes_recorded_strict_violation_once_reference_is_gone`.
  Preserving recorded strict violations must not make them immortal. Mutating
  `write_violations_to_disk` so entries are never pruned is caught by this test
  and by the pre-existing `test_update_with_stale_violations`, but that fixture
  is non-strict, so the strict path had no coverage.
- Fixture restoration moved into a `RoundTripFixture` Drop guard. Trailing
  restores were skipped on panic; the mutation run above demonstrated it by
  leaving `contains_stale_violations` dirty.
- `tests/common/mod.rs` writes violation types in sorted order, matching what
  `update` emits and `set_up_fixtures()`. The round-trip and preserve tests now
  assert byte equality against that constant instead of grepping for "::Bar".
- Toned down the CSV test comment: after the fixture swap it exercises CSV
  formatting only, not strict tolerance.

`recorded_key` is `pub(crate)`. Left the allocation shape alone, per your
measurements. Added a note scoping out the `update` exit code, which returns
success while announcing violations that must be fixed.

`cargo test --no-fail-fast` 263 passed 0 failed, clippy and fmt clean.
@iMacTia

iMacTia commented Aug 18, 2026

Copy link
Copy Markdown
Author

Thanks, this was a genuinely useful pass. The blocking one was a real defect in the prose and I had it backwards: I reproduced your contains_strict_violations sequence exactly, check exit 1, update prints "Successfully updated" and writes nothing, check exit 1 unchanged. Pushed in 22bc710.

CHECKERS.md now leads with the order that works, as three steps: run update while the pack is still true, commit the todo files, then flip to strict. It also spells out what happens if you flip first, since that is the state a reader who ignores the ordering will arrive in.

The rest of your inline notes, all taken:

  • Same-file references. You are right and I verified it before rewording: a second Bar reference added to the already-recorded foo.rb leaves check at exit 0. Reworded to different constant, and corrected the grandfathering unit to (defining pack, constant, violation type, referencing file).
  • strict_privacy_ignored_patterns. Worse than a stale section: grep confirms it exists nowhere but that doc block, so my edit was recommending a key that #[serde(flatten)] silently eats. Repointed at enforcement_globs_ignore with enforcements: [privacy], and left a note saying the packwerk key has no effect here rather than deleting the section outright, since people will arrive looking for the packwerk name.
  • Checker-agnostic. Confirmed structurally too, all five arms of rules_checker_setting route through checker_setting_for. The entry now says layers, visibility and folder privacy relax identically.
  • Referencing pack, not the strict pack. Fixed, and the two documents now agree.
  • The update half. You are right that this is the strongest justification for the heading and I had omitted it. The entry now says update used to erase recorded strict entries and now retains them, and that this shows up in a diff or a stale-todo step.
  • "No opt out" as the heading, with the reasoning you gave about build_reportable_violations. Tagged the fence, and switched packwerk#368 to a full URL.

On the pruning gap, which was the most valuable thing you found: added test_update_prunes_recorded_strict_violation_once_reference_is_gone. I mutation-tested it rather than assuming it bites. Making entries unprunable is caught by that test and by the pre-existing test_update_with_stale_violations, so pruning was not entirely unguarded, but that fixture is non-strict, so the strict path specifically had nothing on it. The new test sits next to the comment that invites the over-correction.

Also took the Drop guard, and your reasoning was demonstrated live while I was working: the mutation run failed test_update_with_stale_violations, which skipped its trailing set_up_fixtures() and left that fixture dirty in my tree. tests/common/mod.rs now writes sorted order, and both round-trip tests assert byte equality against that constant instead of grepping for "::Bar".

recorded_key is pub(crate). Left the allocation shape alone given your measurements, and added a one-line note scoping out the update exit code rather than changing it here.

Two smaller things you flagged: I toned down the CSV test comment, since after the fixture swap it exercises formatting only. And on the single-violation-type fixture gap, I have left it, but you are right that it is latent: nothing would catch a future refactor that normalized violation_type the way strict is normalized.

One measurement you may want, since you hit a gitignore failure too. gitignore_test is flaky here at about 1 run in 12, and it is not this branch: 12 clean runs on this branch gave 1 failure, 12 on main gave 1 failure. The failing test varies between test_check_ignores_violations_in_gitignored_files and test_respect_gitignore_can_be_disabled, and the symptom is Failed to create cache file ... Invalid argument (os error 22), which looks like common::teardown()'s glob deleting tests/fixtures/*/tmp/cache/packwerk while a sibling test is writing into it. Different mechanism from your global-gitignore one. It also means the pre-commit hook rejects intermittently for reasons unrelated to the commit.

Happy to file issues for the two out-of-scope items, the check <file> and check-contents stale-violation exit and the live dbg! at privacy.rs:64, if you would rather have them tracked than buried in this thread. Say the word and I will write them up with the reproductions.

Self-review before handing this back, three things:

`RoundTripFixture::drop` no longer unwraps. It runs while a failing test is
unwinding, so a filesystem error there would have been a panic during a panic,
which aborts the test binary and replaces the real failure with an abort. It now
reports to stderr and carries on, matching `teardown()`. `set_up` still unwraps,
where panicking is informative because the test has not started.

The guard is `#[must_use]` with a doc comment saying to bind it to a named
variable. `let _ = RoundTripFixture::set_up()` drops it immediately and silently
removes the protection, and `#[must_use]` cannot catch that spelling, so the
warning has to be written down.

`CHECKERS.md` demotes the new adoption-order heading from `####` to `###`. It sat
between an `##` and its `###` siblings, so it skipped a level and rendered as
subordinate to nothing.

Verified the guard still does its job rather than assuming: forced a panic inside
a guarded test, confirmed the run reports FAILED rather than aborting, and
confirmed the fixture came back clean during the unwind.

`cargo test --no-fail-fast` 263 passed 0 failed, clippy and fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@iMacTia

iMacTia commented Aug 18, 2026

Copy link
Copy Markdown
Author

Pushed bfec99d, and filed the two out-of-scope items you found as #48 and #49 with reproductions, credited to you.

I ran another pass over my own diff before handing it back, on the theory that you would find these on the next round otherwise. Three things, all mine:

RoundTripFixture::drop was unwrapping. That runs while a failing test unwinds, so a filesystem error there would have been a panic during a panic, which aborts the test binary and replaces the real failure with an abort. It reports to stderr and continues now, matching teardown(). set_up still unwraps, where panicking is informative because the test has not started. I verified the guard still does its job rather than assuming: forced a panic inside a guarded test, confirmed the run reports FAILED rather than aborting, and confirmed the fixture came back clean during the unwind.

The guard was easy to misbind. let _ = RoundTripFixture::set_up() drops it immediately and silently removes the protection, and #[must_use] does not catch that spelling. It is now #[must_use] with a doc comment saying to bind it to a named variable, since the let _ case can only be documented.

CHECKERS.md skipped a heading level. The new adoption-order section was #### sitting between an ## and its ### siblings. Demoted to ###.

One thing that is not a code change but affects how you read this PR: the issue reference was on the wrong one. #43 said "Fixes #41", which would have auto-closed that issue on merge while the policy half was still open here. Issue #41 lists three broken comparisons and calls the third a policy question, so #43 fixes the reported-as-new and reported-as-stale halves and this PR fixes the third, which is the one that makes package_todo.yml actually take effect. #43 now says "Part of #41" and this PR carries the closing reference.

And a state thing worth knowing, since it changes what "green" means here. No CI has ever run on either PR. Every workflow run on both branches is action_required, which is the first-time-contributor approval gate:

$ gh run list --repo rubyatscale/pks
2026-08-18T09:22  CI              pull_request  completed/action_required  strict-mode-tolerates-recorded-violations
2026-08-18T09:22  CodeQL          pull_request  completed/action_required  strict-mode-tolerates-recorded-violations
2026-08-18T09:22  Security audit  pull_request  completed/action_required  strict-mode-tolerates-recorded-violations

So the only test evidence on either PR is yours locally and mine locally. If you or a maintainer can approve the workflow runs, that would be worth more than another round of us both running cargo test on our own machines. Worth noting ci.yml also has paths-ignore: ['**.md'], so a docs-only revision would skip CI entirely even once approved.

cargo test --no-fail-fast is 263 passed 0 failed here, clippy and fmt clean.

Sent with Claude Code

An independent pass over this branch found more than my own did. Six things.

**The `enforcement_globs_ignore` example did not work.** Replacing the inert
packwerk key with a live recommendation is worthless if the recommendation is also
inert, which is the same argument that motivated replacing it. Measured on a
scratch app: `engines/another_engine/test/**/*` gives exit 1, byte-identical to no
exemption at all, because `**` requires an intervening directory and so misses
`test/a_test.rb`. `engines/another_engine/test/**` gives exit 0. Corrected, and
the doc now says why, since a pattern matching nothing looks exactly like no
exemption.

**The CHANGELOG sent readers to the wrong file.** It said entries live in the
referencing package's `package_todo.yml`, "not the strict package's", stated
globally. That holds for the incoming checkers only. For `enforce_dependencies`
and `enforce_layers` the enforcing package *is* the referencing package, so the
entry is in the strict package's own file. The counterexample is the fixture this
work is built on: `uses_strict_mode/packs/foo` is `enforce_dependencies: strict`
and owns the todo file holding that entry. Split by checker direction.

**The CHANGELOG predicted something that cannot happen.** It said to expect
`update` to *re-add* strict entries. It never adds one: an unrecorded strict
violation is still not written, so the write set for strict entries is always a
subset of what is committed. Verified by removing `- privacy` from a committed
todo, running `update`, and getting no diff. The real change is that it stops
*deleting* them. Reworded to say that in the direction it actually goes.

**A comment claimed to be the only test catching a mutation.** Two tests catch it,
which `bfec99d`'s message said correctly while the comment in the file did not.
Corrected, and it now says what is actually unique: the other test uses a
non-strict fixture, so this is the only coverage of the strict path.

**Added the single-recorded-violation-type case**, which was the one review item
left half done. `::Qux` is recorded for `privacy` only in a pack strict on both,
and its dependency violation still fails. Every other strict fixture records both
types together, so nothing pinned `violation_type` inside the comparison key.
Verified the fixture is now the only strict one with a single-type entry.

**Smaller:** the two new fixtures disagreed on violation ordering, now both sorted
as `update` emits; the deferred `update` exit-code note is a one-line `// TODO:`
matching the 13 others in `src/` rather than a five-line block; and the fixture
shared between `check_test` and `update_test` now carries a comment explaining why
that coupling is safe and what would break it, since `serial_test` has no
`file_locks` here and cannot serialise across binaries.

`cargo test --no-fail-fast` 264 passed 0 failed, clippy and fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@iMacTia

iMacTia commented Aug 18, 2026

Copy link
Copy Markdown
Author

Pushed 03ce358. A second review pass over my own branch, with fresh eyes and none of my earlier conclusions, found more than my first one did. Six things, and one of them was worse than the problem it replaced.

The enforcement_globs_ignore example I put in did not work. Repointing away from strict_privacy_ignored_patterns is pointless if the replacement is also inert, which is your own argument applied to my fix. Measured on a scratch app:

glob=<none>                              exit=1
glob=engines/another_engine/test/**/*    exit=1   <- what I documented
glob=engines/another_engine/test/**      exit=0

** needs an intervening directory, so test/**/* misses test/a_test.rb. The string came from the old dead block, where it never mattered. Corrected to test/**, and the doc now says why, because a pattern that matches nothing is indistinguishable from no exemption.

The CHANGELOG sent readers to the wrong file. I generalised your CHECKERS.md:15 note past where it holds. Entries do live in the referencing package's todo file, but that is only a different package for the incoming checkers. For enforce_dependencies and enforce_layers the enforcing package is the referencing package, so the entries sit in the strict package's own file. The counterexample is the fixture this work is built on: uses_strict_mode/packs/foo is enforce_dependencies: strict and owns the todo file holding that entry. Now split by direction.

The CHANGELOG predicted something that cannot happen. I wrote that you should expect update to re-add strict entries. It never adds one. An unrecorded strict violation is still not written, so the strict entries update writes are always a subset of what is committed. Checked by removing - privacy from a committed todo, running update, and getting no diff back. The real change is that it stops deleting them, which is the opposite direction from what I wrote.

A comment claimed to be the only test catching the mutation. Two do, which bfec99d's commit message said correctly while the comment in the file did not. It now says what is actually unique: test_update_with_stale_violations uses a non-strict fixture, so the new test is the only coverage of the strict path.

Your single-violation-type gap is now closed. That was the one item I had left half done. uses_strict_mode_partially_recorded gains packs/qux, strict on both, with ::Qux recorded for privacy only, and test_check_with_single_recorded_violation_type_in_strict_pack asserts the dependency violation still fails while the privacy one stays silent. Verified it is now the only strict fixture with a single-type entry, so the gap was real.

Smaller ones. The two new fixtures disagreed on violation ordering, both sorted now. The deferred update exit-code note is a one-line // TODO: matching the 13 others in src/ rather than a five-line block. And the fixture check_test now shares with update_test carries a comment explaining why that coupling is safe and what would break it, since serial_test is declared without file_locks and cannot serialise across test binaries.

Two things I looked at and did not change. The recorded_key() allocation is real and I measured it independently at about +3% wall and +10% peak RSS on a 40k-violation synthetic app, which agrees with your conclusion that it is not worth holding the PR for. And the #166 point: write_violations_to_disk does partially reverse bullet 1 of that issue, deliberately, and I would rather you tell me if that needs its own framing than assume it does not.

cargo test --no-fail-fast 264 passed 0 failed, clippy and fmt clean. CI still has not run on this PR; every workflow is action_required.

Sent with Claude Code

iMacTia and others added 2 commits August 18, 2026 14:15
`origin/main` moved while this branch was in review, and the CHANGELOG
restructure silently captured someone else's entry.

`832309c` appended `### Internal` / `Replaced serde_yaml with yaml_serde` to the
same `## Unreleased` section this branch retitles to `## 0.4.0`. The branch edits
the heading at the top, main appended at the bottom, so there is no textual
overlap and `git merge-tree` reports a clean merge. The merged file then files a
genuinely unreleased entry under a tag that was cut before it existed. Confirmed
it is unreleased: `git merge-base --is-ancestor 832309c v0.4.0` is false.

Worse, the release-notes comment added in 03ce358 compounds it. Whoever retitles
`## Unreleased` to `## 0.5.0` would carry the strict-mode entry into the notes and
leave yaml_serde out of every release. That comment was written to prevent exactly
this and could not see it, because the change arrived through the base rather than
through the diff.

Merged `0ccf146` and moved the `### Internal` block back under `## Unreleased`.
Merge rather than rebase deliberately: rebasing rewrites all five commits and
would outdate the fifteen inline review comments on this PR mid-review. Happy to
rebase to a linear history before merge if that is preferred.

Three corrections that are mine:

**The glob note derived pks behaviour from gitignore, and they disagree.** The
correction in 03ce358 was right about the behaviour and wrong about the cause.
`git check-ignore` treats `test/**` and `test/**/*` identically, both matching
`test/a_test.rb`; pks matches only the nested path with `**/*`. Measured both. The
mechanism is `fnmatch_regex2::glob_to_regex` (`ignored.rs:19`), not gitignore, so a
reader applying the stated rule predicts the opposite of what pks does. Names the
real mechanism now and warns against reasoning from gitignore.

**The new test's comment repeated the over-claim the same commit had just fixed
elsewhere.** It said nothing else would catch a `violation_type` normalization.
Untrue: collapsing it inside `recorded_key` fails eight tests, three of them
strict-path. What is actually unique is narrower and measured: collapsing
`violation_type` in the strict filter *specifically* fails this test and nothing
else in the suite. The comment now says that, with the coarse-mutation caveat, so
the next reader is not misled into thinking the coarse result proves anything.

**A sibling comment had gone stale.** `test_check_with_partially_recorded_strict_mode_violations`
still described a two-constant fixture after `::Qux` was added to it.

Also recorded that cargo runs test binaries sequentially, so the shared-fixture
coupling is latent, and that `cargo-nextest` would make it live without anyone
touching the test.

`cargo test --no-fail-fast` 264 passed 0 failed on the merged tree, clippy and fmt
clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@iMacTia

iMacTia commented Aug 18, 2026

Copy link
Copy Markdown
Author

Pushed 7ba0136. One of these is about your entry, not mine, so leading with it.

Your yaml_serde entry was about to be filed as shipped

#46 appended ### Internal / Replaced serde_yaml with yaml_serde to the same ## Unreleased section this branch retitles to ## 0.4.0. This branch edits the heading at the top, #46 appended at the bottom, so there is no textual overlap and git merge-tree --write-tree origin/main HEAD reports a clean merge with no conflict. The merged file then reads:

  9:## Unreleased
 13:#### Strict mode tolerates violations already recorded in `package_todo.yml`
 70:## 0.4.0
 94:### Internal
 96:#### Replaced `serde_yaml` with `yaml_serde`

git merge-base --is-ancestor 832309c v0.4.0 is false, so it is genuinely unreleased, and after this merge it would have claimed it shipped in a tag cut before it existed. The release-notes comment I added in 03ce358 then makes it worse rather than better: whoever retitles ## Unreleased to ## 0.5.0 carries my entry into the notes and leaves yours out of every release.

Merged 0ccf146 and moved your ### Internal block back under ## Unreleased. Worth knowing for any other PR that restructures this file while yours is open, since git will not flag it.

I merged rather than rebased on purpose: rebasing rewrites all five commits and would outdate your fifteen inline comments mid-review. Say the word if you would rather have a linear history and I will rebase once you are done reviewing.

Three corrections that are mine

The glob fix was right about the behaviour and wrong about the cause, which is the same shape of error one level up. I wrote that these are "gitignore-style globs, so **/* requires an intervening directory". Measured both:

git check-ignore   test/**    -> test/a_test.rb IGNORED   test/models/b_test.rb IGNORED
git check-ignore   test/**/*  -> test/a_test.rb IGNORED   test/models/b_test.rb IGNORED
pks                test/**    -> exit 0 (both matched)
pks                test/**/*  -> exit 1 (direct child NOT matched)

Git treats the two patterns identically and pks does not, so the note attributed pks behaviour to git semantics that predict the opposite. The mechanism is fnmatch_regex2::glob_to_regex at ignored.rs:19. The note now names that and warns against reasoning from gitignore.

The new test's comment repeated the over-claim I had just fixed elsewhere in the same commit. It said nothing else would catch a violation_type normalization. Not true: collapsing it inside recorded_key fails eight tests, three of them strict-path. What is actually unique is narrower, and I measured it: collapsing violation_type in the strict filter specifically fails that test and nothing else in the suite. The comment says that now, with the caveat that the coarser mutation proves nothing about it.

A sibling comment had gone stale because I added ::Qux to its fixture and never updated the description.

Also recorded, on your serial_test point: cargo runs test binaries sequentially, which I confirmed with two probe binaries stamping start and end around a sleep (428 ms gap, wall time for the pair 5240 ms rather than 2000). So the coupling is latent. But that is cargo's behaviour and not a property of the design, and cargo-nextest runs different binaries concurrently, so the comment now names nextest adoption as the other trigger that would make it live.

cargo test --no-fail-fast 264 passed 0 failed on the merged tree, clippy and fmt clean. CI still has not run; every workflow on this branch is action_required.

Sent with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Triage

Development

Successfully merging this pull request may close these issues.

Recorded violations never match in strict packs, so package_todo.yml has no effect

2 participants