Preserve comments when auto-correcting unused dependencies - #60
corsonknowles wants to merge 3 commits into
Conversation
`check-unused-dependencies --auto-correct` rebuilt the Pack struct and handed it to write_pack_to_disk, which re-serializes the whole package.yml through serde. serde has no notion of comments, so every comment in the file was silently deleted -- including comments documenting why an entry is in ignored_dependencies, which is exactly the kind of note a reader needs. It also rewrote the top-level keys into struct field order, which the existing test encoded as expected output (enforce_dependencies moved from first to third). Remove the offending list items as text instead. Reading the file, dropping the matching `- <name>` lines from the top-level `dependencies:` block, and writing it back leaves the rest byte for byte identical: comments in every position survive, key order is untouched, and other blocks -- notably ignored_dependencies -- are never considered. Verified against a real monorepo: injecting one unused dependency into a package.yml that carries two explanatory comments above an ignored_dependencies entry, then auto-correcting, now returns the file to byte-identical with its committed version. Before this change the two comments were gone. The fixture and test expectations grow comments in three positions -- above the first key, inside the dependencies block, and between the list and a following key -- so a regression here fails loudly. Note this fixes only the auto-correct path. write_pack_to_disk still destroys comments for callers that genuinely rewrite a pack (create, add-dependency, add-constant-dependencies); a general fix needs a comment-preserving YAML representation and is left out of scope.
|
fwiw, in the past i've handled this by moving comments into a |
dduugg
left a comment
There was a problem hiding this comment.
Thanks for picking this up. Losing the note above an ignored_dependencies entry is a real problem and worth fixing. As written, though, this swaps a cosmetic bug for a correctness one, so I'm requesting changes.
Auto-correct now silently skips dependencies it can't match as text
remove_dependency_lines compares the parsed names from pack.dependencies against raw lines, and only matches an unquoted - name at column 0 with nothing after it (checker.rs:508-509). Anything else is left in place, and the command still exits 0 with no output. main removes the dependency in every one of these cases:
- a trailing comment on the item:
- packs/baz # unused - an indented list:
- packs/baz(the style packwerk's USAGE.md uses) - quoted items:
- "packs/baz",- 'packs/baz' - flow style:
dependencies: [packs/bar, packs/baz] - a comment on the key line:
dependencies: # keep sorted - a blank line inside the list, which ends the block at line 517
The first case is the reproduction from the PR description. Run verbatim against this branch, auto-correct exits 0, the file is byte-identical to the input, and a second check-unused-dependencies still reports packs/foo depends on packs/baz but does not use it. On main the dependency is removed (and the comments are lost, as you describe).
This hurts most where auto-correct runs unattended. A common setup is a pre-commit hook that runs check-unused-dependencies --auto-correct and stages the result, with CI running the check without --auto-correct. After this change the hook passes, then CI fails and tells the user to run --auto-correct, which they just did. Indented lists aren't rare: 17 of the 33 fixture package.yml files in this repo that have a dependencies list use them, and so do about 5% of the ones in a large monorepo I checked. Files written by pks or by Ruby's YAML.dump are unindented, so the gap is hand-edited files.
Suggested direction: keep the text edit, but check its result. Parse the edited text back into a Pack and confirm it equals the original minus exactly the removed dependencies. If it doesn't, because the file uses a layout the matcher doesn't handle, fall back to write_pack_to_disk with a warning. That loses comments but is correct, so nothing that works today regresses. It's also worth widening the matcher to accept leading indentation, quotes, a trailing # comment, and blank lines, so the fallback is rare. A format-preserving YAML crate (yamlpatch, yaml-edit) could replace all of this later, but that's a bigger change and doesn't need to block this one.
Tests
remove_dependency_lines is a pure function with no unit tests, and the integration fixture only covers the one layout it handles. Please add unit tests for the cases above, plus first/middle/last item, removing every dependency, and dependencies: as the last key. The old fixture had dependencies: last, and the reordered one no longer covers that. It would also help for the integration test to run check-unused-dependencies again after auto-correct and assert it's clean, rather than only comparing strings.
Smaller things
- A comment directly above a removed item stays behind and ends up describing the next item. The comment at line 515 says a comment "belongs to whatever follows it", but the code keeps it even when what follows is deleted.
- Removing every dependency leaves a bare
dependencies:(null). pks and packwerk both load that, butpks lint-package-yml-filesthen deletes the key, so the output is no longer lint-clean the way it was on main. Dropping the key when the list ends up empty would match the old behavior. - CRLF files come back as LF throughout (
lines()plusjoin("\n")), even when nothing is removed, because the write at line 488 is unconditional. main did the same, so it isn't a regression, but it contradicts "byte for byte identical".split_inclusive('\n')keeps line endings as they are, and skipping the write when nothing changed would help too. - In the Scope section, the command is
update-dependencies-for-constant, notadd-constant-dependencies.lint-package-yml-filesalso rewrites every package.yml throughwrite_pack_to_disk(packs.rs:244), so it strips comments as well. - This is a user-visible behavior change, so it probably wants an entry under Unreleased in CHANGELOG.md, as #45 and #63 have.
- Nit: the new comments at lines 477-479, 496 and 515-516 run past the 80-column width the rest of the file keeps to. rustfmt doesn't wrap comments on stable, so
fmt --checkwon't flag them. The comment at 477-480 also reads more like commit-message history than a description of the code.
Review found that the line matcher only recognised unquoted, unindented items with nothing after them, and silently left every other dependency in place while exiting 0. Widen it and check its result: - accept indented lists, quoted items, a trailing `# comment` on an item or on the `dependencies:` key, and blank lines inside the list - parse the edited text back into a Pack and require it to equal the original minus exactly the removed dependencies; if it doesn't, fall back to write_pack_to_disk with a warning on stderr, so no layout that auto-correct handled before regresses - drop a comment directly above a removed item along with it - drop the `dependencies:` key when no items remain, as serializing did - keep line endings (split_inclusive) and skip the write when unchanged The editing moves into its own module with unit tests for each layout, and the integration tests now run check-unused-dependencies again after auto-correcting to assert it is clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The branch predates main's 0.4.0 and 0.5.0 releases, so an entry under its Unreleased heading conflicts with main. The entry text is in the PR description instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dduugg
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround. This covers everything from my first review: the matcher now handles indented, quoted, inline-comment, key-comment and blank-line layouts; the re-parse check and fallback mean nothing that worked before regresses; and the unit tests cover the cases I asked about. The reproduction from the description now changes only the - packs/baz line. I also ran the new code over about 850 real package.yml files from two large monorepos, removing each dependency in turn (about 10,700 cases). Every case took the text path and produced exactly the expected result.
A few things before this merges:
Comments above a removed item are deleted, whatever they say
pending_comments.clear() (dependency_removal.rs:44-45) drops every comment directly above a removed item. That fixes the orphaned per-item note from my first review, but it also takes out comments that aren't about that item:
dependencies:
# Keep this list sorted.
- packs/baz # unused: the header is deleted with it, though packs/bar stays
- packs/bardependencies:
- packs/bar
# - packs/bop # re-add after the migration
- packs/baz # unused: the commented-out entry is deleted tooNeither prints anything. It isn't a regression, since main loses every comment, but the point of this PR is that comments are worth keeping, and the description says everything else is left alone. One option is to treat a comment as covering the run of items below it, up to the next blank line or comment, and drop it only when every item in that run is removed. The simpler one is to never delete comments and accept the occasional stale note. Either way, a commented-out # - entry should probably always be kept, and the suggested CHANGELOG text should describe whichever rule you pick.
Removing every dependency leaves the block's other lines behind
When no items remain, only the dependencies: line is removed (dependency_removal.rs:74-76). Comments and blank lines from inside the list stay, still indented, under the previous key:
enforce_dependencies: true
# Keep sorted.
enforce_privacy: trueA comment on the key line (dependencies: # keep sorted) is lost along with the key. And if a block scalar comes right before dependencies:, a leftover indented comment becomes part of the scalar. The parse check catches that and falls back, so the whole file loses its comments. Removing the whole block when it ends up empty would fix all three.
CHANGELOG
The conflict only exists because the branch is 10 commits behind main. After merging main in, the entry is a single insert under the existing Unreleased → Fixes section, next to the ones #65 and #66 added. We squash-merge, so leaving it to the merge means someone else writes it. Merging main also gets CI running on a current base: the green run on this head tested a merge with #64, before #59, #65, #66 and #67 landed. (The suite does pass locally on a merge with current main.)
Smaller things
- The fallback test (
tests/check_unused_dependencies.rs:189-194) only checkscontains. The fallback output is deterministic, so anassert_eqon the whole file would also cover theenforce_*keys and the dropped header. - The warning (
checker.rs:491-496) prints the absolute path.pack.relative_yml()in backticks would match the warnings #65 added. It could also say which dependency was removed, and that the rewrite reorders keys. Some(updated) if updated == contents => {}atchecker.rs:485can't be reached. The names always come from the parsed file, so an edit that removes nothing failsis_exact_removaland falls back.- Nit: removing an item between blank lines leaves two blank lines in a row, and
handles_blank_lines_inside_the_listasserts that.
`pks rm` is a new command, and pre-1.0 a new feature wants a minor bump. Once this merges, auto-release.yml tags and releases v0.6.0, since Cargo.toml's version has no tag yet. Retitle `## Unreleased` to `## 0.6.0`, the heading dist takes the release notes from, and add entries for the two user-visible changes in this release that didn't have one: auto-correct keeping comments in package.yml (#60), using the entry #60 suggested, and the warm-cache speedup from skipping files that haven't changed (#59).
check-unused-dependencies --auto-correctsilently deletes every comment in apackage.ymlit touches.Reproduction
Run
pks check-unused-dependencies --auto-correct. On main,- packs/bazis removed, and all three comments go with it. That includes the one explaining why an entry is inignored_dependencies, which is the note a future reader most needs. The loss is easy to miss in review unless you read the whole file.Cause
remove_reference_to_dependencyrebuilds thePackstruct and callswrite_pack_to_disk, which re-serializes the whole file through serde. serde has no notion of comments, so every round trip drops them. The same round trip also reorders the top-level keys into struct field order.Fix
Remove the unused entries as text, then check the result:
dependencies:block and leave everything else alone, including comments, key order, line endings and other blocks such asignored_dependencies:. The matcher handles unindented and indented lists, quoted items, a trailing# commenton an item or on thedependencies:key, and blank lines inside the list. A comment directly above a removed item is removed with it. If no items remain, thedependencies:key is removed too, as serializing did. The file is not written if nothing changed.Packand require it to equal the original minus exactly the removed dependencies.dependencies: [a, b], rewrite the file throughwrite_pack_to_diskas before and print a warning on stderr that comments were not preserved. Every layout auto-correct handled before is still corrected.The editing lives in a new module,
src/packs/checker/dependency_removal.rs.Tests
dependency_removal.rscover:dependencies:as the last key, with and without a final newline#that is part of a valuemetadata: dependencies:key andignored_dependencies:left untouchedcheck-unused-dependenciesagain afterwards and asserts it passes.cargo test,cargo clippy --all-targets --all-features -- -D warningsandcargo fmt --all -- --checkall pass.CHANGELOG
This branch predates the 0.4.0 and 0.5.0 releases, so an entry under its Unreleased heading would conflict with main. Suggested entry for Unreleased → Fixes:
Scope
This fixes only the auto-correct path. Other commands still rewrite a whole pack through
write_pack_to_diskand lose its comments:create,add-dependency,update-dependencies-for-constant, andlint-package-yml-files, which rewrites everypackage.yml. A general fix needs a format-preserving YAML representation, such asyamlpatchoryaml-edit. That is a much larger change, so it isn't in this PR.🤖 Generated with Claude Code