Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions .claude/rules/detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,5 @@ Non-obvious implementation details for each detection feature. These are NOT dis
- **Feature flag detection**: AST-based detection of feature flag patterns. Three categories: (1) environment variables (`process.env.FEATURE_*` with 12 built-in prefixes), (2) SDK calls (25+ built-in patterns for LaunchDarkly, Statsig, Unleash, GrowthBook, Split, ConfigCat, Flagsmith), (3) config objects (opt-in heuristic for objects named "feature", "flag", "toggle"). Reports flag locations, detection confidence (high/low), guard spans. Cross-references with dead code: flags guarding unused exports are highlighted. Configured via `flags` section in config. Default severity: `off`. CLI: `fallow flags`. Inline suppression: `// fallow-ignore-next-line feature-flag` (line-level) and `// fallow-ignore-file feature-flag` (file-level). The suppression check runs in `crates/cli/src/flags.rs` during both built-in and custom flag collection loops, using `is_suppressed()` / `is_file_suppressed()` with `IssueKind::FeatureFlag`. The file-level check is hoisted outside the per-flag loop for efficiency.
- **Unused pnpm catalog entries**: `pnpm-workspace.yaml`'s `catalog:` map (default catalog) and `catalogs.<name>:` maps (named catalogs) are parsed via `serde_yml` plus a section-aware line scanner (`crates/config/src/workspace/pnpm_catalog.rs`) that records the 1-based source line for every entry. Workspace `package.json` files are walked for dependency values matching the `catalog:` protocol: a bare `"react": "catalog:"` and an explicit `"react": "catalog:default"` both resolve to the default catalog per pnpm's spec; named references use the catalog name verbatim. The detector (`crates/core/src/analyze/unused_catalog.rs::find_unused_catalog_entries`) emits one `UnusedCatalogEntry` per catalog entry no workspace `package.json` references via `catalog:` for the matching catalog group. Each finding also carries `hardcoded_consumers: Vec<PathBuf>`, the workspace `package.json` paths that declare the same package with a non-`catalog:` version range, so consumers can decide whether to delete the catalog entry or switch the consumer to `catalog:` first (the differentiator vs knip's catalog detection). Output sort is `(path, catalog_name, entry_name)` with the default catalog sorting first via `catalog_sort_key`. Default severity `warn` (not `error`) because catalog entries ship zero bytes; matches the rest of the `unused_dev_dependencies` / `unused_optional_dependencies` family. JSON `actions[]` emits a YAML-style `# fallow-ignore-next-line unused-catalog-entry` suppression action via the new `SuppressKind::YamlComment`. Filter-to-workspaces clears catalog findings (whole-project concern, not per-workspace). **Auto-fix** (`crates/cli/src/fix/catalog.rs`, issue #335): `fallow fix` removes the entry by line-aware deletion (no YAML round-trip writer in the workspace; a full reprint via `serde_yaml_ng` would obliterate comments). Object-form entries (`react:\n specifier: ^18.2.0`) are detected by consuming subsequent lines whose indent is strictly greater than the entry's own; blank lines stop the consumption to preserve inter-entry whitespace. Entries with non-empty `hardcoded_consumers` are skipped (a record with `applied: false`, `skipped: true`, `skip_reason: "hardcoded_consumers"` is emitted so the user knows to migrate the consumer first). Multi-document YAML files (`---` separator) are rejected up front with `skip_reason: "multi_document_yaml"`. When removing the last entry of a catalog group (default `catalog:` or a named `catalogs.<name>:`) leaves the header with no children, the fix rewrites the header to `catalog: {}` / `<name>: {}` instead of leaving bare `key:`, because bare-key parses as null in YAML and pnpm rejects null-valued catalogs with `Cannot convert undefined or null to object` at install time (verified against pnpm 10.33.4). The post-edit content is reparsed with `serde_yaml_ng` as a syntactic sanity gate before persisting. The per-instance `auto_fixable` bool in the JSON `actions[]` flips to `false` for entries with hardcoded consumers (computed in `build_actions` in `crates/cli/src/report/json.rs`, mirroring the existing per-instance pattern used by `unresolved_catalog_references`); agents that filter on the bool no longer apply destructive ops to entries needing consumer-side migration first. After a successful fix that touched `pnpm-workspace.yaml`, the human stderr emits a one-line `Run \`pnpm install\` to refresh pnpm-lock.yaml` reminder. The LSP code action (`crates/lsp/src/code_actions/quick_fix.rs::build_remove_catalog_entry_actions`) mirrors the same guard and uses anchored key-prefix matching to reject substring collisions between sibling catalog entries with shared prefixes (`react` vs `react-native`). See issues #329 (detection) and #335 (auto-fix). See also: the `unresolved-catalog-reference` inverse rule (consumer references a catalog that does not declare the package).
- **Unresolved pnpm catalog references**: the inverse of `unused-catalog-entries`. The same YAML parse + consumer walk (`find_unresolved_catalog_references` in `crates/core/src/analyze/unused_catalog.rs`) iterates over every `catalog:` / `catalog:<name>` reference collected in `CatalogConsumers.referenced_with_locations` and emits an `UnresolvedCatalogReference` for any `(catalog_name, package_name)` pair the YAML does not declare. The consumer-side line number is captured by a section-aware `scan_dep_lines` pass over the raw `package.json` source that tracks brace depth so nested-object keys (`peerDependenciesMeta.<pkg>.optional`) cannot be misread as direct dep entries. Each finding carries `available_in_catalogs: Vec<String>`: other catalogs in the same workspace that DO declare the package, sorted lexicographically; agents flip the reference to one of those when non-empty, otherwise add the missing entry to the named catalog. Default severity `error` (matches `unresolved-imports`): `pnpm install` errors with `ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_CATALOG_PROTOCOL` so the static finding catches the same failure pre-install. Default-catalog rendering uses a special-case "default catalog" phrasing in human / SARIF / LSP / CodeClimate output instead of `catalog 'default'` because users who write bare `catalog:` think of it as "the catalog", not as a named one. Suppression is config-only via the new `IgnoreCatalogReferenceRule { package, catalog?, consumer? }` array on `FallowConfig` (consumer is a glob, all three fields AND together when set); `package.json` does not support comments so inline suppression is structurally impossible. JSON `actions[]` is discriminated by the `available_in_catalogs` shape: `update-catalog-reference` (primary when alternatives exist), `add-catalog-entry` (primary when none do), `remove-catalog-reference` (fallback from the generic spec), plus an `add-to-config` for `ignoreCatalogReferences` with a paste-ready value scoped to the consumer. Filter-to-workspaces retains findings under the active set because each finding is anchored at a consumer `package.json` (unlike unused-catalog-entries which lives in the root YAML). The LSP diagnostic mirrors the catalog-entries pattern with `root.join(&finding.path)` so the URI built from the project-relative path is absolute. See issue #334.
- **Unused pnpm dependency overrides**: pnpm's `overrides:` section (top-level in `pnpm-workspace.yaml` for pnpm 9+, or legacy `pnpm.overrides` in the root `package.json`) lets a workspace force a specific transitive dependency version (typically for a CVE patch). The detector (`crates/core/src/analyze/unused_overrides.rs::find_unused_dependency_overrides`) parses both source forms via `crates/config/src/workspace/pnpm_overrides.rs`, walks every workspace `package.json` to compute the union of declared package names across `dependencies`, `devDependencies`, `peerDependencies`, and `optionalDependencies`, then emits an `UnusedDependencyOverride` for every override whose target package is not in that set. Parent-chain shapes (`react>react-dom: ^17`) are evaluated against the parent-chain rule: USED when EITHER the parent OR the target is declared, covering the CVE-fix pattern where the parent is declared in the workspace and the override forces a transitive version under it. Bare-name targets carry an optional `hint` flagging the case the static algorithm cannot disambiguate (the override may target a purely-transitive package). Default severity `warn`: most unused overrides are removable cruft, but the transitive-CVE pattern means some are intentional pins that a static algorithm cannot prove are obsolete. Each finding decomposes the raw key into `target_package`, `parent_package` (Option), `version_constraint` (Option, e.g. `Some("<18")` for `@types/react@<18`), and `version_range` (the forced version). Suppression is config-only via `ignoreDependencyOverrides: [{ package, source? }]` on `FallowConfig`: `package` matches the override's `target_package` exactly, optional `source` scopes to `"pnpm-workspace.yaml"` or `"package.json"`. JSON `actions[]` emits `remove-dependency-override` as primary plus an `add-to-config` for `ignoreDependencyOverrides`. Filter-to-workspaces clears findings (whole-project concern). See also: `misconfigured-dependency-overrides` (the entry-shape variant). See issue #336.
- **Misconfigured pnpm dependency overrides**: same parser as `unused-dependency-overrides`, different gate. `find_misconfigured_dependency_overrides` emits `MisconfiguredDependencyOverride` for entries whose key cannot be parsed (`DependencyOverrideMisconfigReason::UnparsableKey`: empty key, dangling separators like `react>`, malformed selectors like `@types/react@<<18`) or whose value is missing/empty (`EmptyValue`). pnpm itself refuses to honor these at install time. The pnpm idiom values `-` (delete), `$ref` (self-reference), and `npm:alias@^1` (npm alias) are explicitly allowlisted and never flagged. Default severity `error`: a malformed override breaks `pnpm install` for every contributor. Findings carry the raw key, raw value, the reason discriminant (serialized as kebab-case `unparsable-key` / `empty-value`), source, path, and line. Suppression: same `ignoreDependencyOverrides` config-only rule as `unused-dependency-overrides`. JSON `actions[]` emits `fix-dependency-override` as primary plus the same `add-to-config` suppress. Filter-to-workspaces clears findings. See also: `unused-dependency-overrides` (the consumer-not-declared variant). See issue #336.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **`fallow fix` now auto-removes unused pnpm catalog entries from `pnpm-workspace.yaml`.** The `unused-catalog-entries` detector shipped in v2.70.0, but until now the only available action was `# fallow-ignore-next-line unused-catalog-entry`; users had to hand-edit the YAML to drop the entry. The fix is line-aware (preserves comments and stylistic choices in the file) and detects object-form entries such as `react:\n specifier: ^18.2.0\n publishConfig: {}` by consuming subsequent lines whose indent is strictly greater than the entry's own. When removing the last entry of a catalog group (default `catalog:` or a named `catalogs.<name>:`) leaves the header with no children, the fix rewrites the header to `catalog: {}` / `<name>: {}` so the file stays installable; bare `key:` in YAML parses as null which pnpm rejects with `Cannot convert undefined or null to object` at install time. Entries whose `hardcoded_consumers` is non-empty are skipped: removing the catalog entry while a workspace package still pins a hardcoded version of the same package would break the user's next `pnpm install`. The skip is surfaced in the human stderr summary and in the JSON output (`{"type": "remove_catalog_entry", "applied": false, "skipped": true, "skip_reason": "hardcoded_consumers", "consumers": [...], "description": "..."}`), and the per-instance `auto_fixable` bool on the check-command action correctly flips to `false` for findings with hardcoded consumers so agents that filter on the bool skip those automatically. After a successful run the CLI emits a one-line `Run \`pnpm install\` to refresh pnpm-lock.yaml` reminder so the workspace stays internally consistent. The fix output's top-level envelope adds a `"skipped"` count alongside the existing `"total_fixed"` so consumers can gate on partial-fix runs. The LSP `unused-catalog-entry` diagnostic now exposes a matching `Remove unused catalog entry` quick-fix code action with the same hardcoded-consumer guard, the same empty-parent rewrite, and 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. (Closes [#335](https://github.com/fallow-rs/fallow/issues/335).)

- **Detects unused and misconfigured pnpm `overrides` entries.** **Upgrade note:** the new `misconfigured-dependency-overrides` rule defaults to `error`, so a workspace with a malformed override key or empty value will flip from a green `fallow check` on v2.72 to a red one on the next minor. To absorb the change without action, set `rules.misconfigured-dependency-overrides: "warn"` in your fallow config before upgrading. Two new rules read both `pnpm-workspace.yaml`'s `overrides:` top-level (canonical, pnpm 9+) and the root `package.json`'s `pnpm.overrides` (legacy form). `unused-dependency-overrides` (default `warn`) flags entries whose target package is not declared in any workspace `package.json`; conservative static algorithm uses the parent-chain rule (`react>react-dom` is considered USED when EITHER `react` OR `react-dom` is declared, covering the CVE-fix pattern where the parent is declared and the override forces a transitive version). Findings carry the raw key, structured `target_package` / `parent_package` / `version_constraint` / `version_range` decomposition, the source file (`pnpm-workspace.yaml` or `package.json`), 1-based line number, and an optional `hint` flagging entries that may target a purely transitive dependency (CVE-fix or canary-alias pattern). `misconfigured-dependency-overrides` (default `error`) catches entries whose key cannot be parsed (empty key, dangling separators) or whose value is missing; `pnpm install` refuses to honor these. Special pnpm values (`-` removal, `$ref` self-reference, `npm:alias@^1`) are explicitly allowlisted and never flagged as misconfigured. Suppression is config-only via `ignoreDependencyOverrides: [{ package, source? }]` (inline YAML / JSON comments are not feasible since `pnpm-workspace.yaml` uses YAML and `package.json` has no comment syntax); the optional `source` field scopes a suppression to `"pnpm-workspace.yaml"` or `"package.json"`. New `IssueKind::UnusedDependencyOverride` (discriminant 23) and `IssueKind::MisconfiguredDependencyOverride` (discriminant 24); new `UnusedDependencyOverride` + `MisconfiguredDependencyOverride` structs on `AnalysisResults`. All six report formats render the findings (human two-tier, JSON with discriminated `remove-dependency-override` / `fix-dependency-override` primary actions + `ignoreDependencyOverrides` add-to-config suppress, SARIF rules `fallow/unused-dependency-override` and `fallow/misconfigured-dependency-override`, compact, markdown, CodeClimate). The GitHub Action and GitLab CI jq scripts surface both in summary tables and emit `::warning` for unused and `::error` for misconfigured annotations. The LSP emits matching diagnostics anchored on the source file line. The MCP `analyze` tool accepts `issue_types: ["unused-dependency-overrides", "misconfigured-dependency-overrides"]`, and the VS Code "Unused Code" tree shows two new categories. `fallow explain unused-dependency-override` and `fallow explain misconfigured-dependency-override` open with the CVE-pin caveat and pnpm-grammar examples respectively. (Closes [#336](https://github.com/fallow-rs/fallow/issues/336))

## [2.72.0] - 2026-05-12

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ fallow fix --dry-run # Preview automatic cleanup

## What it finds

- **Dead code**: unused files, exports, dependencies, types, cycles, boundaries, stale suppressions, unused pnpm `catalog:` entries, unresolved pnpm `catalog:` references (a `package.json` references a catalog that does not declare the package, so `pnpm install` would fail), GraphQL documents linked by `#import`, plus opt-in API hygiene checks such as private type leaks
- **Dead code**: unused files, exports, dependencies, types, cycles, boundaries, stale suppressions, unused pnpm `catalog:` entries, unresolved pnpm `catalog:` references (a `package.json` references a catalog that does not declare the package, so `pnpm install` would fail), unused or misconfigured pnpm `overrides:` entries (an override forces a version no workspace package depends on, or an override key/value is malformed), GraphQL documents linked by `#import`, plus opt-in API hygiene checks such as private type leaks
- **Duplication**: repeated blocks from exact to semantic clones
- **Complexity**: high-risk functions, file scores, hotspots, and refactor targets
- **Architecture drift**: boundary violations across layers and modules
Expand Down
6 changes: 5 additions & 1 deletion action/jq/annotations-check.jq
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,9 @@ def dependency_action(pkg):
(.unused_catalog_entries[]? |
"::warning file=\(.path | san),line=\(.line),title=Unused catalog entry::Catalog entry '\(.entry_name | san)' (catalog '\(.catalog_name | san)') is not referenced by any workspace package via the catalog: protocol.\(nl)\(nl)\(if ((.hardcoded_consumers // []) | length) > 0 then "Hardcoded consumers: " + (.hardcoded_consumers | map(san) | join(", ")) + ".\(nl)Switch them to catalog: before removing." else "Remove the entry from pnpm-workspace.yaml." end)"),
(.unresolved_catalog_references[]? |
"::error file=\(.path | san),line=\(.line),title=Unresolved catalog reference::Package '\(.entry_name | san)' is referenced via `catalog:\(if .catalog_name == "default" then "" else (.catalog_name | san) end)` but \(if .catalog_name == "default" then "the default catalog" else "catalog '" + (.catalog_name | san) + "'" end) does not declare it. `pnpm install` will fail.\(nl)\(nl)\(if ((.available_in_catalogs // []) | length) > 0 then "Available in: " + (.available_in_catalogs | map(san) | join(", ")) + ".\(nl)Switch the reference to a catalog that declares this package, or add it to the named catalog." else "Add this package to the named catalog in pnpm-workspace.yaml, or remove the reference and pin a hardcoded version." end)")
"::error file=\(.path | san),line=\(.line),title=Unresolved catalog reference::Package '\(.entry_name | san)' is referenced via `catalog:\(if .catalog_name == "default" then "" else (.catalog_name | san) end)` but \(if .catalog_name == "default" then "the default catalog" else "catalog '" + (.catalog_name | san) + "'" end) does not declare it. `pnpm install` will fail.\(nl)\(nl)\(if ((.available_in_catalogs // []) | length) > 0 then "Available in: " + (.available_in_catalogs | map(san) | join(", ")) + ".\(nl)Switch the reference to a catalog that declares this package, or add it to the named catalog." else "Add this package to the named catalog in pnpm-workspace.yaml, or remove the reference and pin a hardcoded version." end)"),
(.unused_dependency_overrides[]? |
"::warning file=\((.path // "") | san),line=\(.line // 0),title=Unused dependency override::Override `\((.raw_key // "") | san)` forces `\((.target_package // "") | san)` to `\((.version_range // "") | san)` but no workspace package depends on `\((.target_package // "") | san)`.\(nl)\(nl)\(if .hint then (.hint | san) + ".\(nl)" else "" end)Delete the entry, or scope it under a real parent (`pkg>\((.target_package // "") | san)`) if it pins a transitive."),
(.misconfigured_dependency_overrides[]? |
"::error file=\((.path // "") | san),line=\(.line // 0),title=Misconfigured dependency override::Override `\((.raw_key // "") | san)` -> `\((.raw_value // "") | san)` is malformed (\((.reason // "unparsable") | san)). `pnpm install` will reject this entry.\(nl)\(nl)Fix the key/value to match pnpm's override grammar, or remove the entry.")
] | .[]
Loading