Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@

### Breaking Changes

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

references that are **not** already recorded in a `package_todo.yml`. This matches
packwerk's `unlisted_strict_mode_violations` (Shopify/packwerk#368).

**Who is affected:** any project with a strict pack whose existing violations are
recorded in todo files. Previously `pks check` failed on every one of them, so a
strict pack could only be green with an empty todo 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.

Two small corrections here.

"whose existing violations are recorded in todo files" points at the wrong pack's file. Entries live in the referencing pack's package_todo.yml, not the strict pack's, because all_violations sets referencing_pack_name: self.name for the pack that owns the file (pack.rs:198). CHECKERS.md:16 gets this right with "another package's package_todo.yml", so the two documents currently disagree.

"could only be green with an empty todo list" overstates it. Green required no strict entries, not an empty file. A strict pack with only non-strict recorded violations was already green on main.


**What changes:** pks silently produces different (smaller) results without any
configuration change. Strict packs that were red because of grandfathered
violations go green. New references into a strict pack still fail, and `pks update`
still refuses to record an unrecorded strict violation, so strict mode cannot be
silenced by running 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.

"different (smaller) results" is right for check, but the entry never mentions the update change, which is the half that touches committed files.

On main, write_violations_to_disk dropped every strict violation, and a pack left with none had its todo file removed outright by delete_package_todo_from_disk. So update used to delete recorded strict entries and now retains them. That is a change to files in the repo, it is larger rather than smaller, and it is what will actually show up in a diff or a stale-todo CI step.

It is also the strongest justification for the Breaking Changes heading this sits under, which the entry never quite states. Lines 19 to 21 describe the half of update that did not change, so as written a reader could reasonably conclude update behaves the same as before.


**Opt out:** there is no config flag, matching packwerk. To see everything the todo
files are 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.

The prose is accurate, but under an "Opt out:" heading a reader will try --ignore-recorded-violations as a drop-in CI gate to preserve the old behavior. It is not one: the flag also short-circuits build_reportable_violations (checker.rs:151-154), so it surfaces every recorded violation of every type in every pack, not just the strict ones. Anyone swapping it into CI gets a wall of unrelated failures. "No opt out" as the heading, with the existing "to see everything" sentence underneath, would land better.

Two mechanical things while you are here. The fence on line 26 is untagged, where the respect_gitignore entry this mirrors uses a tagged one; ```sh would match. And Shopify/packwerk#368 on line 11 renders as inert text rather than a link, since GitHub autolinks `owner/repo#N` in issue and PR comments but not inside Markdown files. The surrounding docs use full URLs.


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


### Breaking Changes

#### `respect_gitignore` defaults to `true`

pks now respects `.gitignore` files by default. Files and directories matched by
Expand Down
11 changes: 7 additions & 4 deletions CHECKERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ enforce_privacy: true

Setting `enforce_privacy` to `true` will make all references to private constants in your package a violation.

Setting `enforce_privacy` to `strict` will forbid all references to private constants in your package. **This includes violations that have been added to other packages' `package_todo.yml` files.**
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.


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.


### Using public folders
You may enforce privacy either way mentioned above and still expose a public API for your package by placing constants in the public folder, which by default is `app/public`. The constants in the public folder will be made available for use by the rest of the application.
Expand Down Expand Up @@ -99,8 +101,7 @@ end => Ideal solution. No exceptions from rubocop and very low risk of the magic
Sometimes it is desirable to only enforce privacy on a subset of constants in a package. You can do so by defining a `private_constants` list in your package.yml. Note that `enforce_privacy` must be set to `true` or `'strict'` for this to work.

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


```yaml
enforce_privacy: strict
Expand All @@ -110,6 +111,8 @@ strict_privacy_ignored_patterns:

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.


### Package Privacy violation
Packwerk thinks something is a privacy violation if you're referencing a constant, class, or module defined in the private implementation (i.e. not the public folder) of another package. We care about these because we want to make sure we only use parts of a package that have been exposed as public API.

Expand Down
66 changes: 55 additions & 11 deletions src/packs/checker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,20 @@ pub struct ViolationIdentifier {
pub referencing_pack_name: String,
pub defining_pack_name: String,
}

impl ViolationIdentifier {
/// `strict` describes how a violation should be treated, not which violation
/// it is, and `package_todo.yml` has nowhere to record it, so recorded
/// violations are always rebuilt with `strict: false`. Compare through this
/// so a violation in a strict pack can still match its recorded entry.
pub fn recorded_key(&self) -> Self {

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.

Two small things on this method, neither blocking.

pub can be pub(crate). To be precise about the scope of that suggestion, since I raised an FFI worry in my head and it turned out to be unfounded: this is not reachable from the serde_magnus consumers at all. checker is declared pub(crate) mod (packs.rs:8) and there are no pub use re-exports, so pub here commits the crate to nothing externally. It is cleanliness only. All five call sites are in checker.rs and package_todo.rs. The name recorded_key is good, keep it.

On the allocations I raised last round, I measured instead of guessing, and your call to leave it was right. recorded_key() is 4 heap allocations per call, and check makes 2F + S calls for F found violations and S strict ones, so cost is linear in violations rather than files and there is no nested-loop multiplication. At 20k violations the owned set in build_stale_violations measures 5.87ms against 4.21ms for a borrowed key, and 5.65 MiB against 0.28 MiB. That is noise next to parsing the tree, so I would not hold the PR for it.

When someone does pick it up, a borrowed RecordedKey<'a> over the five non-strict fields is about 20 lines and covers both this and the ..self.clone() spelling. One alternative worth ruling out explicitly so nobody tries it: a custom Hash/PartialEq that ignores strict allocates nothing, but it makes strict-mode assertions pass vacuously, because checker/common_test.rs:40-84 builds expected Violations with strict as a parameter and compares them with assert_eq!. Normalizing the recorded set once instead measures worse than what you have.

Self {
strict: false,
..self.clone()
}
}
}

/// A violation combines an identifier with display metadata.
///
/// `source_location` is intentionally separate from `ViolationIdentifier` because:
Expand Down Expand Up @@ -124,7 +138,7 @@ impl<'a> CheckAllBuilder<'a> {
.cloned()
.collect(),
strict_mode_violations: self
.build_strict_mode_violations()
.build_strict_mode_violations(recorded_violations)
.into_iter()
.collect(),
})
Expand All @@ -142,7 +156,10 @@ impl<'a> CheckAllBuilder<'a> {
self.found_violations
.violations
.iter()
.filter(|v| !recorded_violations.contains(&v.identifier))
.filter(|v| {
!recorded_violations
.contains(&v.identifier.recorded_key())
})
.collect()
};
reportable_violations
Expand All @@ -152,11 +169,11 @@ impl<'a> CheckAllBuilder<'a> {
&mut self,
recorded_violations: &'a HashSet<ViolationIdentifier>,
) -> anyhow::Result<Vec<&'a ViolationIdentifier>> {
let found_violation_identifiers: HashSet<&ViolationIdentifier> = self
let found_violation_identifiers: HashSet<ViolationIdentifier> = self
.found_violations
.violations
.par_iter()
.map(|v| &v.identifier)
.map(|v| v.identifier.recorded_key())
.collect();
let relative_files = self
.found_violations
Expand Down Expand Up @@ -196,9 +213,13 @@ impl<'a> CheckAllBuilder<'a> {
Ok(stale_violations)
}

/// `found_violation_identifiers` is keyed by [`ViolationIdentifier::recorded_key`].
/// `todo_violation_identifier` needs no such normalization: it comes from
/// `pack_set.all_violations`, which rebuilds every recorded violation with
/// `strict: false` already, so it is its own recorded key.
fn is_stale_violation(
relative_files: &HashSet<&str>,
found_violation_identifiers: &HashSet<&ViolationIdentifier>,
found_violation_identifiers: &HashSet<ViolationIdentifier>,
todo_violation_identifier: &ViolationIdentifier,
) -> bool {
let violation_path_exists =
Expand All @@ -210,11 +231,23 @@ impl<'a> CheckAllBuilder<'a> {
}
}

fn build_strict_mode_violations(&self) -> Vec<Violation> {
/// Strict mode reports violations that are not already recorded in a
/// `package_todo.yml`, matching packwerk's `unlisted_strict_mode_violations`
/// (Shopify/packwerk#368). Turning strict on therefore blocks new violations
/// without also requiring every recorded one to be fixed first.
fn build_strict_mode_violations(
&self,
recorded_violations: &HashSet<ViolationIdentifier>,
) -> Vec<Violation> {
self.found_violations
.violations
.iter()
.filter(|v| v.identifier.strict)
.filter(|v| {
self.configuration.ignore_recorded_violations
|| !recorded_violations
.contains(&v.identifier.recorded_key())
})
.cloned()
.collect()
}
Expand Down Expand Up @@ -302,22 +335,33 @@ pub(crate) fn update(configuration: &Configuration) -> anyhow::Result<()> {
&checkers,
)?;

let strict_violations = &violations
let recorded_violations = &configuration.pack_set.all_violations;

// Only *unlisted* strict violations make `check` fail, so only those are
// worth reporting here. Reporting recorded ones too claimed `check` would
// fail when it succeeds. Same filter as `build_strict_mode_violations`, and
// as packwerk's `unlisted_strict_mode_violations`.
let unlisted_strict_violations = &violations
.iter()
.filter(|v| v.identifier.strict)
.filter(|v| !recorded_violations.contains(&v.identifier.recorded_key()))
.collect::<Vec<&Violation>>();
if !strict_violations.is_empty() {
for violation in strict_violations {
if !unlisted_strict_violations.is_empty() {
for violation in unlisted_strict_violations {
let strict_message =
build_strict_violation_message(&violation.identifier);
println!("{}", strict_message);
}
println!(
"{} strict mode violation(s) detected. These violations must be fixed for `check` to succeed.",
&strict_violations.len()
&unlisted_strict_violations.len()
);
}
package_todo::write_violations_to_disk(configuration, violations);
package_todo::write_violations_to_disk(
configuration,
violations,
recorded_violations,
);
println!("Successfully updated package_todo.yml files!");
Comment on lines +365 to 370

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.

Still pre-existing and still not yours to fix. Raising it once more only because this PR edits this block and adds a comment asserting the filter matches packwerk's unlisted_strict_mode_violations, which makes it the natural moment to either fix or explicitly scope out.

packwerk treats this state as a failure and pks reports success. update_todo_command.rb returns unlisted_strict_mode_violations.empty? && offense_collection.errors.empty? as its result and cli.rb does exit(success); USAGE.md says running bin/packwerk update-todo on a strict pack "will not add new violations in the package_todo.yml file and the command will return an error"; and packwerk has a test named for the exit code. pks returns Ok(()) unconditionally, so main.rs maps it to ExitCode::SUCCESS, and line 365 prints "Successfully updated package_todo.yml files!" right after announcing violations that must be fixed.

A follow-up issue or a one-line note that it is out of scope would settle it either way.


Ok(())
Expand Down
15 changes: 14 additions & 1 deletion src/packs/package_todo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use serde::{ser::SerializeMap, Deserialize, Serialize, Serializer};
use std::collections::{BTreeMap, HashMap, HashSet};
use tracing::debug;

use super::checker::ViolationIdentifier;
use super::{pack::Pack, Configuration, Violation};

#[derive(PartialEq, Debug, Eq, Deserialize, Serialize, Default, Clone)]
Expand Down Expand Up @@ -133,6 +134,7 @@ pub fn package_todos_for_pack_name(
pub fn write_violations_to_disk(
configuration: &Configuration,
violations: HashSet<Violation>,
recorded_violations: &HashSet<ViolationIdentifier>,
) {
debug!("Starting writing violations to disk");
// First we need to group the violations by the responsible pack, which today is always the referencing pack
Expand All @@ -141,7 +143,18 @@ pub fn write_violations_to_disk(
let mut violations_by_responsible_pack: HashMap<String, Vec<Violation>> =
HashMap::new();
for violation in violations {
if violation.identifier.strict {
// 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;
Comment on lines +146 to 158

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.

}
let referencing_pack_name =
Expand Down
84 changes: 81 additions & 3 deletions tests/check_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,12 +320,34 @@ fn test_check_without_stale_violations() -> Result<(), Box<dyn Error>> {
}

#[test]
fn test_check_with_strict_mode() -> Result<(), Box<dyn Error>> {
fn test_check_with_recorded_strict_mode_violation() -> Result<(), Box<dyn Error>>
{
// The violation is already recorded in packs/foo/package_todo.yml, so strict
// mode tolerates it and only blocks new ones. Matches packwerk's
// `unlisted_strict_mode_violations` (Shopify/packwerk#368).
cargo_bin_cmd!("pks")
.arg("--project-root")
.arg("tests/fixtures/uses_strict_mode")
.arg("check")
.assert()
.code(0)
.stdout(predicate::str::contains("No violations detected!"));

common::teardown();
Ok(())
}

#[test]
fn test_check_with_recorded_strict_mode_violation_ignoring_todo(
) -> Result<(), Box<dyn Error>> {
// `--ignore-recorded-violations` is the escape hatch: it still surfaces
// everything the todo file is grandfathering.
cargo_bin_cmd!("pks")
.arg("--project-root")
.arg("tests/fixtures/uses_strict_mode")
.arg("check")
.arg("--ignore-recorded-violations")
.assert()
.code(1)
.stdout(predicate::str::contains(
"packs/foo cannot have privacy violations on packs/bar because strict mode is enabled for privacy violations in the enforcing pack's package.yml file",
Expand All @@ -338,18 +360,74 @@ fn test_check_with_strict_mode() -> Result<(), Box<dyn Error>> {
Ok(())
}

#[test]
fn test_check_with_unrecorded_strict_mode_violation(
) -> Result<(), Box<dyn Error>> {
// No package_todo.yml entry for this one, so strict mode must still fail.
cargo_bin_cmd!("pks")
.arg("--project-root")
.arg("tests/fixtures/contains_strict_violations")
.arg("check")
.assert()
.code(1)
.stdout(predicate::str::contains(
"packs/foo cannot have privacy violations on packs/bar because strict mode is enabled for privacy violations in the enforcing pack's package.yml file",
));

common::teardown();
Ok(())
}

#[test]
fn test_check_with_partially_recorded_strict_mode_violations(
) -> Result<(), Box<dyn Error>> {
// The case that makes strict mode adoptable, and the one nothing else
// covers: one recorded violation (::Bar) and one unrecorded (::Baz) in the
// same strict pack, in the same run. Only the unrecorded one is reported,
// and the run still fails because of it.
cargo_bin_cmd!("pks")
.arg("--project-root")
.arg("tests/fixtures/uses_strict_mode_partially_recorded")
.arg("check")
.assert()
.code(1)
.stdout(predicate::str::contains(
"packs/foo cannot have privacy violations on packs/baz because strict mode is enabled for privacy violations in the enforcing pack's package.yml file",
))
.stdout(predicate::str::contains(
"packs/foo cannot have dependency violations on packs/baz because strict mode is enabled for dependency violations in the enforcing pack's package.yml file",
))
.stdout(predicate::str::contains("::Baz"))
// The recorded one stays silent: no strict message, no new-violation
// report, no stale-todo line.
.stdout(predicate::str::contains("packs/bar").not())
.stdout(predicate::str::contains("::Bar").not())
.stdout(
predicate::str::contains(
"There were stale violations found, please run `packs update`",
)
.not(),
);

common::teardown();
Ok(())
}

#[test]
fn test_check_with_strict_mode_output_csv() -> Result<(), Box<dyn Error>> {
// 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.

cargo_bin_cmd!("pks")
.arg("--project-root")
.arg("tests/fixtures/uses_strict_mode")
.arg("tests/fixtures/contains_strict_violations")
.arg("check")
.arg("-o")
.arg("csv")
.assert()
.code(1)
.stdout(predicate::str::contains("Violation,Strict?,File,Constant,Referencing Pack,Defining Pack,Message"))
.stdout(predicate::str::contains("privacy,true,packs/foo/app/services/foo.rb,::Bar,packs/foo,packs/bar,packs/foo cannot have privacy violations on packs/bar because strict mode is enabled for privacy violations in the enforcing pack\'s package.yml file"))
.stdout(predicate::str::contains(
"privacy,true,packs/foo/app/services/foo.rb,::Bar,packs/foo,packs/bar,packs/foo cannot have privacy violations on packs/bar because strict mode is enabled for privacy violations in the enforcing pack\'s package.yml file",
));
Expand Down
32 changes: 32 additions & 0 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,38 @@ pub fn delete_foobar_app_with_custom_readme() {
}
}

// Restores the round-trip strict-mode fixture. Its todo file records a strict
// violation, which is the state `check` tolerance depends on, so any test that
// runs `update` against it has to put it back.
#[allow(dead_code)]
pub fn set_up_uses_strict_mode_round_trip_fixture() {
let package_todo = String::from(
"\
# This file contains a list of dependencies that are not part of the long term plan for the
# 'packs/foo' package.
# We should generally work to reduce this list over time.
#
# You can regenerate this file using the following command:
#
# bin/packwerk update-todo
---
packs/bar:
\"::Bar\":
violations:
- privacy
- dependency
Comment on lines +162 to +163

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.

files:
- packs/foo/app/services/foo.rb
",
);

fs::write(
"tests/fixtures/uses_strict_mode_round_trip/packs/foo/package_todo.yml",
package_todo,
)
.unwrap();
}

// In case we want our tests to call `update` or otherwise mutate the file system
#[allow(dead_code)]
pub fn set_up_fixtures() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
enforce_privacy: strict
enforce_dependencies: strict
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module Bar
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
enforce_privacy: strict
enforce_dependencies: strict
Loading