Skip to content

fix(cli): let an override's forced version be advanced on purpose - #122

Open
justin13888 wants to merge 7 commits into
feat/105-ecosystem-filterfrom
feat/111-advance-forced-versions
Open

fix(cli): let an override's forced version be advanced on purpose#122
justin13888 wants to merge 7 commits into
feat/105-ecosystem-filterfrom
feat/111-advance-forced-versions

Conversation

@justin13888

@justin13888 justin13888 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Closes #111.

plan_fixes skipped every DependencyKind::Override with an early continue
placed before the has_update() / is_pinned() filter and before target
selection. An override with a newer release waiting was therefore
indistinguishable from one with nothing to do: it never reached declined, so
nothing was reported, and a manifest whose only outdated entry was an override
got "Everything is already up to date." from fix over the top of what check
had just said.

What changed

One machinery, not two. The decision moves after target selection and is
expressed as a Declined carrying a new DeclineReason::ForcedVersion. The
reporting path PR #108 built then carries it with no change at all:
report_declined_fixes prints the note: line and the summary counts it.
runner.rs gains exactly one argument at the fix::plan call site.

$ dependable fix .
note: left lodash = 1.0.0 alone in package.json: 1.9.0 is available, but an override forces this version onto the resolved tree; pass --overrides to advance it
Nothing to rewrite. 1 available update left alone; see the notes above.

fix --overrides opts into rewriting one. The default is still never to
write over a forced version, --all included, so PR #99's guarantee stands:
edits never receives an override unless the flag is set.

Ordering is load-bearing. The Err(reason) arm sits before the forced
guard, so an override carrying a wildcard reports the wildcard — the reason
that would still stand with --overrides turned on — rather than a flag that
would leave it exactly where it is. Both directions are pinned by tests.

A $name override reference is unaffected: the parser records a zero-width span
for it, so it fails is_rewritable() before any of this and is neither rewritten
nor reported.

Tests

Seven unit tests in fix.rs (the retained fix_all_leaves_an_override_alone,
now asserting the Declined instead of an empty list; nothing-available;
constraint-beats-flag in both directions; $name reference; the rewrite itself;
a scoped pnpm "foo@2>bar" key rewriting bar's span) and four end-to-end
tests in cli_fix.rs against the existing hermetic loopback registry — reported
instead of silent, --all still declines, --overrides --all rewrites, and
--dry-run writes nothing.

Added in the repair pass: an_override_advances_within_its_range_without_all,
which pins the flag combination this PR's own README recommends first —
--overrides without --all — producing a rewrite. Every earlier case that
produced a FixRecord passed all = true, and the two that passed
all = false, overrides = true both ended in a decline, so the write path for
the recommended invocation was unpinned. The fixture is a range-form override
("^1.0.0") with latest_available set to 2.0.0, deliberately past
latest_compatible of 1.9.0: it asserts the rewrite lands as ^1.9.0 and
that nothing reached 2.0.0, so a path that ignored the compatible target
cannot pass it. Verified to fail when the all argument is flipped.

Validation

command outcome
cargo test -p dependable --bin dependable fix:: 42 passed, 0 failed
cargo test -p dependable --test cli_fix 21 passed, 0 failed
mise run test exit 0; 40 suites, every one test result: ok, 0 failed (pre-existing #[ignore]s only, all network-gated)
mise run fmt:check exit 0
mise run lint exit 0 (clippy -D warnings)
convco check origin/feat/105-ecosystem-filter..HEAD no errors in 5 commits

Each was run with env -u FORCE_COLOR -u COLORTERM, because this repository has
tests sensitive to an ambient FORCE_COLOR.

Filed, not fixed

#147 — a Gradle version catalog's strictly is classified
DependencyKind::Normal, so a plain dependable fix with no flags advances it.
gradle_catalog.rs reads strictly / require / prefer through one code path
and stamps all three Normal with a real span; the forced guard tests
kind == Override, which only package_json.rs ever sets. strictly is
Gradle's pinning form and the usual shape of a JVM security pin, so the
protection this PR documents does not reach it. Not fixed here: it means
separating strictly from require / prefer inside the parser, which is a
dependable-core change to how a rich version is classified, not a fix
change. The README now scopes its guarantee to package.json and links #147
rather than promising cover it does not give.

#121check's default table gives an override no annotation, so the most-run
command is the one that cannot tell a forced version from an ordinary
dependency, while list labels it (override) and check --format json
already emits "kind": "override". Not folded in here: output/table.rs has no
annotation concept at all, so adding one means either porting list.rs's
annotation() wholesale — which also covers dev/build/optional/peer/declared/
indirect/local/git/jsr and so changes far more rows than the override rows — or
inventing a second, inconsistent vocabulary. That is a design decision about
check's default output, not a bug fix.

Base

Stacked on #116 (feat/105-ecosystem-filter), not master.

Decisions taken

1. Deliverable boundary — how much of the issue closes here

  • Taken: reporting and an opt-in fix --overrides that rewrites a forced version when asked. The issue's title is "let an override's forced version be advanced on purpose", and reporting alone advances nothing.
  • Rejected: reporting only — it leaves the issue's own motivating case, a stale resolutions pin that fix --all can never move, still unfixable by the tool; the author is told about it and handed a manual edit.
  • Rejected: also annotating overrides in check's default table — check is the most-run command and output/table.rs has no annotation concept at all today, so this would mean either porting list.rs's annotation() (which also covers dev/build/optional/peer/declared/indirect/local/git/jsr, changing far more rows than override rows) or inventing a second, inconsistent vocabulary. Filed instead.
  • Rejected: a [fix] overrides = true config key — config.rs uses deny_unknown_fields, so the key is a hard contract from the day it ships, there is no [fix] section to extend, and a destructive opt-in should be visible at the call site rather than sticky and invisible.
  • Reverses: delete the overrides field from FixArgs and pass false at the fix::plan call site; the reporting half stands alone.
  • Filed: the check-annotates-overrides half, as check: the default table gives an override no annotation #121.

2. Flag shape

  • Taken: --overrides as an independent boolean, matching every other fix flag (--all, --dry-run, --no-cache, --no-vuln). It composes with --all on the axis --all already means everywhere else: --overrides advances within the declared constraint, --overrides --all beyond it.
  • Rejected: declaring it requires = "all" — that forbids the SAFER operation, advancing a stale override within its own range without opening the door to a major bump. The only precedent for an inter-flag constraint on fix is --manifest-glob's conflicts_with = "manifest", which guards a genuine contradiction; this is not one.
  • Rejected: an --include <kind> value enum — DependencyKind has eight variants and only Override is declined by kind, so the enum ships with one member and an implied promise; cli.rs's existing enums are all output formats and none is a set.
  • Reverses: add requires = "all" to the clap attribute, or replace the field with a Vec<Include> value enum.

3. A forced version that is itself known-vulnerable

  • Taken: no special case. It gets the same note as any other declined override.
  • Rejected: rewriting it without the flag, on the argument that a live advisory proves the pin has outlived its reason — the inference is unsound. A live advisory against the pinned release is NOT evidence the pin was a security pin; a stale compatibility pin on an old release carries advisories too. And all_vulnerabilities is declared but populated nowhere in the workspace, so the target it would advance to is itself unchecked — it could move a pin from one vulnerable release to another. It also directly contradicts what the issue reaffirms: "the default stays never write over a forced version".
  • Rejected: a louder warning: line for that case — it needs a new field on Declined, a branch in report_declined_fixes, and new OSV test infrastructure (the cli_fix.rs harness disables vulnerability scanning wholesale), for a wording difference.
  • Reverses: add a vulnerable field to Declined and branch on it in report_declined_fixes.

4. Commit split (taken during implementation, not in the recorded plan)

  • Taken: one feat: commit for the behaviour plus one docs: commit, rather than the planned split of reporting and the flag into two feat: commits.
  • Why: the two halves are not separable into commits that are each true on their own. The user-facing note the reporting half prints names --overrides ("…pass --overrides to advance it"), so a reporting-only commit would either ship a note pointing at a flag that does not exist, or ship wording that the very next commit rewrites. Splitting the other way — flag plumbing first — puts an unused parameter in the tree.
  • Reverses: git rebase -i to split the feat: commit, accepting one of the two costs above.

5. What "within their constraint" means for --overrides alone

  • Taken: within what the tool's requirement model admits, and the README now says exactly that rather than "within their constraint". to_version_req builds the requirement with VersionReq::parse, which reads a bare 1.0.0 as Cargo's ^1.0.0 — so for "overrides": { "lodash": "1.0.0" }, latest_compatible is 1.9.0 and --overrides alone moves the pin across five minor releases. Meanwhile Ecosystem::bare_version records npm's own reading of that same string as BareVersion::Exact. The two disagree, that disagreement is fix(core): Item::is_pinned tests the spelling of a pin, not whether it is one #118, and the README names it instead of implying the tool respects npm's reading.
  • Rejected: making --overrides alone respect the ecosystem's reading — it means changing how a bare npm version is read everywhere, which moves ordinary dependencies too, not just forced ones. That is fix(core): Item::is_pinned tests the spelling of a pin, not whether it is one #118's work and its blast radius is the whole fix command.
  • Rejected: requiring --all for any bare-version override — it forbids the safer within-model advance, and the only precedent for an inter-flag constraint on fix (--manifest-glob vs --manifest) guards a genuine contradiction. This is not one.
  • Reverses: gate the --overrides write path on the ecosystem's bare-version reading rather than on latest_compatible.

6. Whether the pin note should name both flags

  • Taken: the note names the next flag that must be lifted, and the Pinned guard's comment now says that rather than claiming "every note names a flag that acts". For an override written as an explicit pin the two-step is deliberate: --overrides alone is refused by the pin guard, --all alone passes it and is then refused by the forced guard, which names --overrides in turn. an_override_that_is_also_a_pin_reports_the_pin asserts exactly that sequence — the old comment was contradicted by the test directly beneath it.
  • Rejected: naming both flags in one note — it tells a user to pass --all --overrides before they have decided they want either, which is the opposite of the incremental disclosure the decline list exists for. Each note is true at the moment it is printed.
  • Reverses: have the pin note for an override name both flags.

7. A forced version that is itself known-vulnerable — the consequence, stated (extends decision 3)

  • Taken: still no special case, and the README now states the consequence rather than leaving it implied: CheckResult::all_vulnerabilities is declared and populated nowhere in the workspace, so advisories are only known for the version already declared. --overrides can therefore move a pin from one vulnerable release to another with no signal at all, and the safety paragraph says so.
  • Rejected: blocking the flag until all_vulnerabilities is populated — the flag is opt-in and the default is unchanged, so withholding it protects nobody who is not already protected by the default.
  • Reverses: refuse to advance an override whose target carries a live advisory, once the target's advisories are actually fetched.

Documentation corrections (repair pass)

Three false statements in this PR's own README section, corrected in
docs: narrow the forced-version guarantee to the manifests it covers:

  1. The guarantee was overclaimed. It said npm-family maps are "the only maps
    dependable treats this way; nothing in Cargo, Go, Python, or the rest
    declares one" and that fix never rewrites one by default, --all included.
    A Gradle strictly is read and classified Normal, so a flagless fix
    advances it (fix(core): a Gradle strictly version is classified Normal, so plain fix advances a forced version #147). The guarantee is now scoped to package.json explicitly.
  2. It was also underclaimed in the other direction. Cargo [patch] /
    [replace], Composer replace / conflict, and pnpm-workspace.yaml's
    overrides: are safe because no parser reads them at all — an absence,
    not a guard. The README now says which it is, so a future parser addition is
    not read as already covered.
  3. The safety warning stated the inverse of the hazard. "Advancing a security
    pin past the release it was holding the tree above puts the vulnerability
    back" describes nothing reachable: the same section defines an override as
    holding a dependency above a vulnerable release, and advancing further above
    keeps it above. It is replaced by the four things the flag genuinely cannot
    work out — which release the pin was chosen for, whether the target is any
    safer (see decision 7), which direction the pin points (a pnpm override can
    pin downward), and whether the pin's upper bound was the point.

Signature changes

All private to the binary crate, so no published API moved. DeclineReason
gains a variant (#[non_exhaustive], and explain() is its only match site in
the workspace); fix::plan and fix::plan_fixes each gain a trailing bool.
Declined's contract widens from "a constraint refused" to "a constraint
refused, or the entry is a forced version" — the doc comments that asserted the
narrow contract, including the in-test comment that explained why declined was
empty for an override, are rewritten rather than left to contradict the code.

No error code, exit code, schema version, JSON field, or config key. No
Cargo.lock change.

`plan_fixes` skipped every `overrides` / `resolutions` entry with an early
`continue` placed before the has-update filter and before target selection.
A forced version with a newer release waiting was therefore indistinguishable
from one with nothing to do: it never reached `declined`, so nothing was
reported, and a manifest whose only outdated entry was an override was answered
with "Everything is already up to date." over the top of what `check` had just
said.

Move the decision after target selection and express it as a `Declined` with a
new `DeclineReason::ForcedVersion`, so the existing reporting machinery carries
it: `report_declined_fixes` prints the note and the summary counts it, unchanged.
The note names a concrete available version, which is what makes the question it
puts to the author — has this pin outlived its reason? — answerable.

`fix --overrides` is how they answer yes. The default is still never to write
over a forced version, `--all` included; the flag composes with `--all` on the
axis `--all` already means, advancing within the declared constraint on its own
and beyond it together. Constraint guards still fire first, so an override
carrying a wildcard reports the wildcard rather than a flag that would leave it
exactly where it is.

A `$name` override reference stays unwritable and unreported: the parser records
a zero-width span for it, so it fails `is_rewritable` before any of this.

Closes #111
The `overrides` / `resolutions` maps had no entry in the README at all, so
neither the default — `fix` never writes over a forced version, `--all`
included — nor the note it now prints instead was written down anywhere a user
would look. Says which maps count, that they are npm-family `package.json` only,
how `--overrides` composes with `--all` and `--dry-run`, and why to find out what
a pin is for before advancing it.
…feat/111-advance-forced-versions

The two branches both rewrote how `fix` accounts for an update it does not
write, from opposite ends: #116 made the accounting exhaustive (five new
`DeclineReason` variants, `Declined::target` an `Option<String>`, a counted
`report_inherited_skips`), and #122 gave the one kind that was still silent —
an `overrides` / `resolutions` entry — a reason of its own and a flag that
advances it.

Resolved on the ordering rather than on the lines. `ForcedVersion` is now the
last variant and the last guard in `plan_fixes`, because every reason above it
would still stand with `--overrides` turned on: an override carrying a wildcard
reports the wildcard, one already at its target reports the range, and one
written as an explicit pin reports the pin, since `--all` and not `--overrides`
is the flag that would move it. Every note names a flag that acts.

The default is unchanged: `edits` still never receives an override unless
`--overrides` was asked for by name, `--all` included.
The forced-versions section claimed npm-family `overrides` / `resolutions`
were "the only maps `dependable` treats this way; nothing in Cargo, Go,
Python, or the rest declares one", and that `fix` never rewrites one by
default. The second half is false outside `package.json`.

A Gradle version catalog's rich versions are read for their version
string, and `strictly` — Gradle's pinning form, and the usual shape of a
JVM security pin — is recorded with `DependencyKind::Normal` and a real
span. The `forced` guard tests `kind == Override`, which only the
`package.json` parser ever sets, so a plain `fix` with no flags advances
a `strictly` pin. That defect is issue #147 and is not fixed here; the
README now says so instead of promising otherwise.

Cargo's `[patch]` / `[replace]`, Composer's `replace` / `conflict`, and
`pnpm-workspace.yaml`'s `overrides:` are named separately, because they
are safe for a different reason: no parser reads them at all. Implying a
deliberate guard where there is only an absence would misdescribe what
protects them.

Two more corrections in the same section. The safety warning stated the
inverse of the hazard — an override holds a dependency *above* a
vulnerable release, so advancing it further above is not how the
vulnerability comes back. It now names the four things the flag cannot
work out: which release the pin was chosen for, whether the target is any
safer (`all_vulnerabilities` is declared and populated nowhere, so
advisories are only known for the version already declared), which
direction the pin points, and whether its upper bound was the point. And
`--overrides` is no longer described as advancing "within their
constraint": the requirement is built with `VersionReq`, which reads a
bare `1.0.0` as a caret while npm — and `Ecosystem::bare_version` —
read it as exact, so the honest bound is the range the tool reads. That
disagreement is issue #118 and belongs there, since it moves ordinary
dependencies too.
The `Pinned` guard's comment claimed that for an override written as an
explicit pin, "`--all` is the flag that would move it" and "every note
names a flag that acts". The test directly beneath it,
`an_override_that_is_also_a_pin_reports_the_pin`, asserts the opposite:
with `--all` alone the pin guard passes and the `forced` guard reports a
second note naming `--overrides`. Neither flag on its own moves that
entry.

The ordering the comment explains is right; only the claim about it was
wrong. Each note names the next flag that has to be lifted, and the two
are disclosed one at a time — which is what makes each note true at the
moment it is printed.
`--overrides` on its own is the invocation the README recommends first,
and no test exercised it producing a rewrite. Every case that produced a
`FixRecord` passed `all = true`; the two that passed
`all = false, overrides = true` both ended in a decline, so the write
path for the recommended combination was unpinned.

The fixture is a range-form override, `"^1.0.0"`, which is the honest
shape for "advances within its constraint": the range admits `1.9.0` and
refuses `2.0.0`. `latest_available` is set past `latest_compatible` on
purpose — with the two equal, a path that ignored the compatible target
would pass unchanged — and the test asserts both that the rewrite lands
as `^1.9.0` and that nothing reached `2.0.0`.
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.

1 participant