Skip to content

feat(fix): auto-remove unused pnpm catalog entries (closes #335)#363

Merged
BartWaardenburg merged 6 commits into
mainfrom
feat/335-auto-fix-unused-catalog-entries
May 12, 2026
Merged

feat(fix): auto-remove unused pnpm catalog entries (closes #335)#363
BartWaardenburg merged 6 commits into
mainfrom
feat/335-auto-fix-unused-catalog-entries

Conversation

@BartWaardenburg
Copy link
Copy Markdown
Collaborator

Summary

Closes #335. fallow fix now auto-removes unused entries from pnpm-workspace.yaml. The unused-catalog-entries detector shipped in v2.70.0 (#329), but until now the only available action was the suppression comment; users had to hand-edit the YAML.

  • Line-aware deletion preserves comments and stylistic choices in the file (no full YAML reprint).
  • Object-form entries like react:\n specifier: ^18.2.0 are removed as one block.
  • When the last entry of a catalog group is removed, the header is rewritten to catalog: {} (or <name>: {}) so the file stays installable. Bare react17: parses as null in YAML, which pnpm rejects with Cannot convert undefined or null to object at install time (verified against pnpm 10.33.4).
  • Entries whose hardcoded_consumers is non-empty are skipped: removing the catalog entry while a workspace package still pins a hardcoded version would break the next pnpm install. The skip is surfaced in stderr and in the JSON output.
  • Per-instance auto_fixable on the check-command JSON action flips to false for entries with hardcoded consumers, so agents that filter on the bool no longer apply destructive ops to entries needing consumer-side migration first.
  • Top-level "skipped" count alongside "total_fixed" on the fix-command JSON envelope for partial-fix gating in CI.
  • After a successful run the CLI emits a one-line Run pnpm install to refresh pnpm-lock.yaml reminder.
  • New LSP Remove unused catalog entry quick-fix code action mirrors the same guards plus an anchored key-prefix sanity check so sibling entries with shared prefixes (react vs react-native, lodash vs lodash-es) cannot be deleted by mistake.

Follow-up issues filed:

Companion-repo docs updated in fallow-docs (8273cde, de563ec) and fallow-skills (4c21c52, f1c2d8e).

Test plan

  • cargo test -p fallow-cli -p fallow-lsp passes (19 catalog unit tests, 9 LSP catalog code-action tests, snapshot tests for json output)
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo fmt --all -- --check clean
  • End-to-end smoke against tests/fixtures/issue-329-pnpm-catalog: real pnpm install --no-frozen-lockfile succeeds against the post-fix file
  • Inverse smoke: bare react17: (pre-fix shape) reproduces Cannot convert undefined or null to object against the same pnpm 10.33.4
  • OLD-vs-NEW behavioral comparison on synthetic pnpm monorepo: OLD has 0 catalog fixes + no skipped field, NEW has 5 catalog records (4 would-remove + 1 hardcoded-consumer skip) + skipped: 1
  • Determinism: two dry-run invocations on the same fixture produce byte-identical JSON
  • Real-world fixtures (preact, fastify): no crash, no NaN, expected 0 catalog entries (neither uses pnpm catalogs)

…e.yaml

Adds `fallow fix` support for `unused-catalog-entries` findings shipped
in v2.70.0 (#329). The previous JSON `actions[]` array declared
`remove-catalog-entry` with `auto_fixable: false`; this PR closes the
round-trip with a line-aware YAML deleter.

Implementation choices:

- Line-aware deletion (no YAML round-trip writer in the workspace).
  Object-form entries are detected by consuming subsequent lines whose
  indent is strictly greater than the entry's own indent.
- Per-instance `auto_fixable`: catalog entries with a non-empty
  `hardcoded_consumers` list keep `auto_fixable: false`; entries safe
  to remove are marked `true`. Agents that filter on the bool no longer
  apply destructive ops to entries that need consumer-side migration
  first.
- Skip records use the existing `remove_dependency` shape
  (`{type, applied: false, skipped: true, skip_reason, description,
  consumers}`) instead of a new top-level type, keeping the `fixes[]`
  array grep-able by issue kind.
- Reparse-and-validate the post-edit content with `serde_yaml_ng`
  before persisting; reject multi-document YAML files up front.
- Top-level `"skipped": <n>` count in JSON output alongside
  `total_fixed`. Human stderr surfaces a one-line skip summary plus a
  `pnpm install` reminder when any catalog entry was actually removed.
- LSP code action mirrors the same hardcoded-consumer guard; emits a
  single TextEdit covering the full line range so undo collapses to
  one operation.

Fixes #335.
…fix output

Folds in review feedback before merge:

- LSP code action's "is the reported line still the right catalog entry?"
  sanity check was a loose `line.contains(entry_name)` substring scan.
  pnpm catalogs commonly hold sibling entries with shared prefixes
  (`react` and `react-native`, `lodash` and `lodash-es`, `core-js` and
  `core-js-bundle`); if the file had been edited since `fallow check`
  ran, the action could delete the wrong line. Replaced with anchored
  key matching that accepts unquoted, double-quoted, and single-quoted
  YAML key forms.
- LSP handler now accepts `file_lines` from the caller so the deletion
  range is computed against in-memory editor content (mirrors
  `build_remove_export_actions`). Previously read the file from disk,
  ignoring unsaved edits.
- LSP action's reconstructed diagnostic now uses the named-catalog
  message wording (`'react' in catalog 'react17' is not referenced...`)
  to match what the diagnostic emitter publishes, so "Fix all in file"
  source actions correlate cleanly.
- `consumers` field on skip records is now serialized with the same
  `to_string_lossy().replace('\\', "/")` normalization that
  `serde_path::serialize_vec` applies to `hardcoded_consumers` on the
  check side, so agents correlating check + fix output see identical
  path strings on Windows.
- Top-level `"skipped": 0` is now emitted on the zero-issues early-exit
  JSON envelope so consumers can rely on the field being present.
- Human stderr reorders to lead with the actionable next step
  (`Run \`pnpm install\`...`) and follows with residual work
  (`Skipped N catalog entry/entries...`). Fixed the awkward
  `entry(ies)` pluralization.
…rate rules

- CHANGELOG.md [Unreleased]: full user-facing entry under Added describing
  the line-aware deletion, hardcoded-consumer guard, per-instance
  auto_fixable flip, JSON shape additions (top-level skipped, skip
  records with skip_reason / consumers / description), the pnpm install
  reminder, and the LSP code action.
- .claude/rules/detection.md: extends the existing 'Unused pnpm catalog
  entries' bullet with the auto-fix details (line-aware strategy,
  hardcoded-consumer skip, multi-doc YAML guard, reparse-validate, LSP
  anchored-key matching).
- .claude/rules/cli-crate.md: updates the fix/ module description to
  list catalog.rs alongside the existing fixers.
- crates/cli/src/fix/mod.rs: includes the emit_human_summary helper
  extraction that satisfies the workspace too_many_lines lint
  (run_fix is back under the 150-line ratchet); was inadvertently
  left uncommitted in 5a1c780.
…oesn't break

A real install-breaking edge case was caught post-implementation: when
the last entry of a named catalog (`catalogs.react17:`) or the default
catalog (`catalog:`) was removed, the header was left bare (`react17:`)
which parses as a null value in YAML. pnpm 10.33.4 then errors with
`Cannot convert undefined or null to object` at install time.

Reproduced on `tests/fixtures/issue-329-pnpm-catalog`: after
`fallow fix --yes` the file's `react17:` was emptied, and a synthetic
pnpm smoke against the resulting layout failed.

This commit:

- Adds `find_parent_header_line` + `rewrite_empty_catalog_parents` in
  `crates/cli/src/fix/catalog.rs`. After draining the deletion ranges,
  for each unique parent header whose entries were deleted, check
  whether any non-comment-non-blank children survive at greater indent.
  If none survive, append \` {}\` to the header line so the catalog
  serializes as an empty mapping (verified accepted by pnpm 10.33.4).
- Mirrors the same logic in
  `crates/lsp/src/code_actions/quick_fix.rs::build_parent_rewrite_edit`.
  The LSP code action now emits a second \`TextEdit\` in the same
  \`WorkspaceEdit\` whenever the entry being removed is the last child
  of its parent, so the quick-fix and `fallow fix` produce equivalent
  results.
- Updates the existing \`leaves_empty_catalog_header_in_place\` unit
  test (which asserted the buggy state) and adds three regression
  tests covering default-catalog rewrite, named-catalog rewrite, and
  the negative case (siblings remain so no rewrite fires).
- Adds three LSP regression tests covering the new TextEdit-pair
  shape (one deletion + one parent rewrite) and the no-rewrite path
  when siblings stay.
- Updates the CHANGELOG `[Unreleased]` entry and the detection.md rule
  bullet to describe the rewrite behavior.

Companion-doc updates land in fallow-docs (de563ec) and fallow-skills
(f1c2d8e) in the same drop.

Verified end-to-end against `tests/fixtures/issue-329-pnpm-catalog`:
after fix, `pnpm-workspace.yaml`'s `catalogs.react17` is `{}` (empty
mapping), not null. Python yaml.safe_load confirms the parsed value
type is dict, not None.
…9 fixture

A parallel reviewer caught a real install-breaking bug in #335's initial
implementation because no integration test exercised \`fallow fix --yes\`
end-to-end against the canonical \`tests/fixtures/issue-329-pnpm-catalog\`
fixture and inspected the resulting YAML's parsed value types. Synthetic
helper unit tests in \`fix/catalog.rs\` covered the rewrite logic in
isolation, but the binary-vs-fixture flow had no coverage.

This commit adds \`fix_catalog_issue_335_empties_parent_to_empty_map_not_null\`,
which:

- Copies the issue-329 fixture into a tempdir (avoids mutating the
  canonical fixture).
- Runs \`fallow fix --yes --format json --quiet\` against it through
  the actual binary via the existing \`run_fallow_in_root\` harness.
- Parses the post-fix \`pnpm-workspace.yaml\` with serde_yaml_ng and
  asserts \`catalogs.react17\` is an EMPTY MAPPING (\`{}\`), not null.
  String-level assertion alone is insufficient because the bug
  reproduced (bare \`react17:\`) parses as null but visually looks like
  an empty catalog group.
- Sanity-checks that sibling \`catalogs.legacy\` and the default
  \`catalog:\` map are untouched where consumers still reference them.
- Asserts the JSON envelope's top-level \`skipped: 1\` count (the
  fixture's \`hardcoded-pkg\` entry has a hardcoded consumer in
  \`packages/app\`).

Adds \`serde_yaml_ng\` to \`fallow-cli\` dev-dependencies so the
integration test can parse the result properly.
@BartWaardenburg BartWaardenburg merged commit 899baa4 into main May 12, 2026
21 checks passed
@BartWaardenburg BartWaardenburg deleted the feat/335-auto-fix-unused-catalog-entries branch May 12, 2026 20:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fallow fix: auto-remove unused entries from pnpm-workspace.yaml

1 participant