Repository navigation
Scope stale todo checks to given paths and warn about unmatched paths - #65
Conversation
`check` with paths, and `check-contents`, checked only a subset of files but compared every recorded violation against what that subset found. An entry for a file outside the subset looked as if its file had been deleted, so it was reported as stale and the run exited 1. Checking a single file, as editor integrations do on save, failed whenever another file had a recorded violation. `check_all` now tells `CheckAllBuilder` whether paths were supplied. In a run given paths, an entry whose file wasn't checked is skipped, and an entry whose file was checked is still stale if the run no longer finds it. A full run still treats a recorded file it didn't check as deleted. This can't be read off the checked set. Paths that match no included file leave it empty, and that run must still count as one given paths, not as a full run that reports every entry as stale. A run given a directory, `pks check .` included, no longer reports entries for files deleted under it. Keeping that would mean knowing which arguments were directories, which argument expansion decides and then throws away. It's left out here, since a full `check` still reports those entries. packwerk's open fix for the same bug, Shopify/packwerk#415, also skips the deleted-file check whenever files are supplied. Seven new tests cover a relative and an absolute file argument, `check-contents`, a directory, paths that match no included file, and in-scope stale entries still being reported. All seven fail on main. One more pins that a full `check` still reports an entry for a deleted file. Fixes rubyatscale#48. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dduugg
left a comment
There was a problem hiding this comment.
Thanks for this, and for sticking so closely to the direction in #48. The fix does what it says. On uses_strict_mode, check packs/bar/app/services/bar.rb and check-contents on the same file now exit 0. On contains_stale_violations, a single-file run reports only that file's stale entries: 1 for foo.rb and 2 for bar.rb, where main reported 3 and 4.
I ran the new tests against main's src/: the seven you said fail do fail, and the full-run test passes. I also tried three plausible wrong versions of the flag (always scoped, skip every stale check when scoped, derive it from absolute_paths), and each one breaks at least one test. update output is byte-identical to main, --experimental-parser scopes the same way, and clippy, fmt and CI are all clean.
One request, one thing I'd like tracked before release, and a few small items.
Deleted files in a scoped run
I said in #48 that the simpler version was fine, but after running it I'd like to revisit that. The loss of deleted-file detection isn't limited to directories. On uses_strict_mode with foo.rb deleted, all three of these exit 0 on this branch, while a plain pks check exits 1:
pks check packs/foo/app/services/foo.rb
pks check packs/foo
pks check .
The first one worries me most. A pre-commit hook that runs pks check $(git diff --cached --name-only) gets the deleted file's path from git, so deleting a file that has todo entries passes the hook and leaves the entries behind.
. also puts us out of step with packwerk. packwerk check . runs as a full check, with or without Shopify/packwerk#415. Dir.glob("./**/*") yields paths that start with ./, and File.fnmatch?("**/*.{rb,rake,erb}", "./packs/bar/app/services/bar.rb", File::FNM_EXTGLOB) is false because of the leading period. So specified_files comes back empty and files_specified? is false. That is probably an accident on packwerk's side, but it's what packwerk check . users get today.
The rule I floated in #48 covers all three cases, and it turned out to be small. check_all still has the raw files, so nothing needs argument expansion to remember which arguments were directories. Keep the supplied paths as absolute paths in place of scoped:
supplied_paths: files
.iter()
.map(|f| configuration.absolute_root.join(f))
.collect(),Then, for an entry whose file wasn't checked, call it stale when the file is at or under one of those paths and isn't on disk:
} else if !supplied_paths.is_empty() {
let path = absolute_root.join(&todo_violation_identifier.file);
supplied_paths.iter().any(|p| path.starts_with(p)) && !path.exists()
} else {Path::starts_with compares whole components, so packs/foo doesn't match packs/foobar, and join returns absolute arguments unchanged. I tried this on top of your branch. All 42 tests in check_test.rs still pass, the three commands above exit 1, and check packs/bar and check packs/bar/app/services/bar.rb still exit 0. It would need a test for each of the three commands.
If you'd rather keep the current behaviour, the CHANGELOG should say that naming the deleted file also stops it being reported. "A run given a directory" reads as though file arguments are unaffected.
Paths that match nothing now pass
You raised this in the notes, and I agree it belongs in its own issue. I'd like that issue open before this ships, though, because this PR is what makes the problem silent. An argument that matches no included file checks nothing, and it now exits 0 with "No violations detected!". That covers typos, paths containing .., absolute directories, and absolute paths through a symlink. For example, with a fixture copied under /tmp (a symlink to /private/tmp on macOS), pks check /private/tmp/.../foo.rb checks the file and reports its stale entry, while pks check /tmp/.../foo.rb checks nothing and exits 0. A warning when an argument matches no included file would probably be enough.
Smaller things
tests/check_test.rs:456: the comment says editor integrations pass the absolute path of the file being saved, and the PR description says the same of the VS Code extension. As far as I can tell, neither plugin does. packwerk-vscode strips the workspace root and runs${executable} ${relativeFileName}withcwdset to the root. packwerk-intellij passesVfsUtilCore.getRelativePath(...), tocheck-contentswhen it lints unsaved buffers. The test is still worth keeping, since absolute arguments take a different branch inuser_inputted_paths_to_absolute_filepaths. I'd just reword the comment to say that.- Two positive cases aren't covered: a directory argument where a file inside it does have a stale entry (the directory test uses a pack with no entries), and
check-contentsreporting a stale entry for the file it was given. Both go through code that's already tested, so they're cheap insurance.
Not yours to fix
-o csvnever includes stale entries. Oncontains_stale_violations,pks check -o csvprints the header and a "No violations detected!" row, then exits 1. Same onmain.- A
./in the middle of a path isn't normalized.pks check packs/foo/./app/services/foo.rboncontains_stale_violationskeeps that spelling in the violation's file, so the recorded entry doesn't match andmainreports a new privacy violation. Pre-existing, and it doesn't interact with this change beyond the stale entry for that file being skipped too.
Happy to take another look once you've decided on the deleted-file rule.
A run given paths skipped every entry for a file it didn't check, so it stopped reporting entries for deleted files too. On `uses_strict_mode` with `foo.rb` deleted, `pks check packs/foo/app/services/foo.rb`, `pks check packs/foo` and `pks check .` all exited 0, while a full `check` exited 1. A pre-commit hook running `pks check $(git diff --cached --name-only)` gets the deleted file's path, so deleting a file with todo entries passed the hook and left the entries behind. `check_all` now keeps the supplied paths, joined onto the root, in place of the `scoped` flag. An entry whose file wasn't checked is stale when the file is at or under one of those paths and isn't on disk. Joining leaves absolute arguments unchanged, and `.` or a trailing slash still covers everything under it. Entries for files outside the supplied paths are still skipped, so checking one file no longer fails on entries elsewhere in the app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both cases go through code that other tests already reach, but neither had a test of its own: a directory argument where a file inside it has a stale entry, and `check-contents` reporting a stale entry for the file it was given. Also reword the absolute-path test's comment. packwerk-vscode passes a path relative to the root; the test is there because absolute arguments take their own branch in `user_inputted_paths_to_absolute_filepaths`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An argument that matches no included file checks nothing: a typo, an excluded file, a path containing `..`, an absolute directory, or an absolute path that doesn't go through the canonical project root, such as a symlinked `/tmp` on macOS. In an app with todo entries those runs used to fail on other files' stale entries. Now they exit 0 with "No violations detected!", so the mistake goes unnoticed. `check_all` now intersects each argument with the included files on its own and prints a warning on stderr for any that matched nothing. The union of those sets is what a single call returned, and a directory is still globbed once. The warning goes to stderr and doesn't change the exit code, so `-o json` and `-o csv` stdout stays parseable. Editor plugins check one file on every open and save. packwerk-vscode shows stderr only when its `showWarnings` setting is on, which is off by default, and packwerk-intellij reads only stdout, so neither treats the warning as a failure. packwerk prints "No files found or given" on stdout, and only when no argument matched at all. pks names each unmatched argument, including in a longer list, and keeps stdout clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Calling an unchecked entry under a supplied path stale only when its file is missing from disk skips a file that still exists but is no longer included, because it was newly excluded for example. A full `check` reports that entry as stale, but `check .` and `check packs/foo` didn't. The test is now whether the file is an included file, which is what a full run checks, so a run given paths agrees with a full run for every entry under those paths. An absolute directory argument still flags nothing: it isn't expanded, so nothing under it is checked, but its files are still included. This also drops a stat per entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For a path that doesn't exist, the warning said no included file matched and suggested checking the include and exclude globs. That advice can't help, and for a deleted file it sat next to the stale report for that same file. A pre-commit hook passing `git diff --cached --name-only` hits this on every staged deletion. When the argument joined onto the root doesn't exist, the warning now says only that. An existing path that matches no included file keeps the previous message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A hook passing every staged file, or an editor opening the Gemfile, sends existing files that pks never checks: YAML, a README, the Gemfile, an excluded Ruby file. Warning about each of them adds noise and says nothing the user didn't already know. An argument that matches no included file now warns only when it's likely a mistake: - a path that doesn't exist, as before; - a directory with no included files, with the include and exclude advice, as before; - an existing file whose canonical path is an included file, which means it was spelled with `..` or through a symlink. The warning names the path to pass instead. Any other existing file is skipped quietly. That still covers the typo, `..`, absolute directory and symlink cases the warning was added for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An absolute directory isn't expanded into files, and a `..` or symlinked spelling of a directory matches nothing, so each of them got the advice to check the include and exclude globs. That can't help when the directory holds included files, and absolute directories are one of the cases the warning is for. A directory now gets the same check as a file: if any included file is under its canonical path, the warning names the root-relative path to pass instead, `.` for the project root. Only a directory with no included files keeps the include and exclude advice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
check is given paths|
Thanks for the careful review, and for trying the rule on top of the branch. Deleted files in a scoped run. Agreed, and I've taken your rule with one change. Each of your three commands now exits 1 on the deleted-file copy and has a test, and there are tests for an excluded file under Paths that match nothing. I decided to fix this here rather than leave it for a separate issue, since this PR is what makes it silent. I narrowed it so a hook passing every staged file doesn't warn on YAML, a README or the Gemfile: an existing file that just isn't included stays silent, unless its canonical path is an included file. Editor integrations. You're right, packwerk-vscode passes the path relative to the root. The test comment now says the test is there for the absolute-path branch, and I've corrected the description. The two positive cases. Added both: a directory argument with a stale entry inside it, and CSV and The changes are in seven new commits on top, so you can see just the delta. Sent with Claude Code |
dduugg
left a comment
There was a problem hiding this comment.
Both requests from my last review are handled, and I tried to break each of the seven commits before trusting it.
Deleted files in a scoped run
You went further than what I suggested, and it's the better rule. I'd proposed "stale if the file is under a supplied path and not on disk," but your version checks !included_files.contains(&path) instead, and that also catches a file that's still on disk but excluded, which mine would have missed. test_check_with_dot_reports_recorded_violations_of_excluded_files_as_stale pins exactly that case, and I confirmed it fails without the included_files check (a !path.exists() version would pass it incorrectly).
I reverted checker.rs to aa94bef (what I last reviewed) with these commits' tests kept, and nine of the sixteen new tests fail there, split cleanly between the deleted/excluded-file fix and the new warning. The other seven pass on both, which is what I'd expect from tests pinning scope restrictions and positive cases that the old code already got right.
All three commands from my last review now exit 1 on the deleted-file copy, each with its own test: the file itself, packs/foo, and ..
Paths that match nothing
This covers more than the cases I listed. I tried the symlink case again (/tmp/.../foo.rb through the /tmp to /private/tmp symlink on macOS) and it now warns and names the canonical path to pass. I also tried a wrong-case path on APFS (packs/Bar/app/services/BAR.rb) and a ..-laden relative path, neither of which you called out specifically, and both get the same "pass it as X instead" treatment, because included_spelling canonicalizes before comparing. A typo gets "does not exist," a README or package.yml stays silent, and mixing a real file with a typo in one invocation checks the real file and warns only about the typo. -o csv and -o json both still parse with a warning on stderr.
One edge case, low priority: pks check .. warns with "no included file matches .., check the include and exclude globs," which is the generic message rather than one about pointing outside the root. included_spelling can't suggest a relative spelling here, since ..'s canonical form doesn't strip against absolute_root the way a path inside the project does. Nobody has a reason to pass .., so I wouldn't hold the PR for it, just flagging it in case it's cheap to special-case later.
Everything else
cargo test, clippy --all-targets --all-features -- -Dwarnings, and fmt --all -- --check are all clean on 9f0be51, aside from the usual test_gitignore_negation_patterns failure from my own global gitignore. CI is green. The diff against current main is just checker.rs, CHANGELOG.md, and check_test.rs, so there's nothing to reconcile with #66 or #59.
This one's good to merge.
Fixes #48, following the direction in your comment.
pks check <paths>andpks check-contentsonly check the files they're given, but the stale check still compares every entry in everypackage_todo.ymlagainst that subset. An entry for a file outside it looks as if its file had been deleted, so it's reported as stale and the run exits 1. The packwerk VS Code extension checks one file at a time, by its path relative to the root, when a file is opened or saved, so in any app with a todo entry in another file, every one of those checks fails.Approach
A run given paths now only considers entries for files at or under those paths, and such an entry is stale if the run no longer finds it or its file is no longer included, which is the test a full run uses. So a deleted or newly excluded file is still caught when the run names the file, a directory containing it, or
., which covers a pre-commit hook runningpks check $(git diff --cached --name-only).A path that matches no included file checks nothing. In an app with todo entries those runs used to fail on other files' entries anyway; with the stale fix alone they would exit 0 silently. So
checkandcheck-contentsnow warn on stderr when an argument matches nothing and it's likely a mistake, naming the path to pass where there is one; an existing file that isn't included stays quiet. Stdout and the exit code don't change, so JSON and CSV output and the editor plugins are unaffected. packwerk only prints "No files found or given", on stdout, when nothing matched at all.Where to look
files_to_checkintersects each argument on its own so it can report the ones that matched nothing; the union is the set a single call used to return, and a directory is still globbed once.Sent with Claude Code