fix(cli): let an override's forced version be advanced on purpose - #122
Open
justin13888 wants to merge 7 commits into
Open
fix(cli): let an override's forced version be advanced on purpose#122justin13888 wants to merge 7 commits into
justin13888 wants to merge 7 commits into
Conversation
`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`.
…feat/111-advance-forced-versions
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #111.
plan_fixesskipped everyDependencyKind::Overridewith an earlycontinueplaced before the
has_update()/is_pinned()filter and before targetselection. An override with a newer release waiting was therefore
indistinguishable from one with nothing to do: it never reached
declined, sonothing was reported, and a manifest whose only outdated entry was an override
got "Everything is already up to date." from
fixover the top of whatcheckhad just said.
What changed
One machinery, not two. The decision moves after target selection and is
expressed as a
Declinedcarrying a newDeclineReason::ForcedVersion. Thereporting path PR #108 built then carries it with no change at all:
report_declined_fixesprints thenote:line and the summary counts it.runner.rsgains exactly one argument at thefix::plancall site.fix --overridesopts into rewriting one. The default is still never towrite over a forced version,
--allincluded, so PR #99's guarantee stands:editsnever receives an override unless the flag is set.Ordering is load-bearing. The
Err(reason)arm sits before theforcedguard, so an override carrying a wildcard reports the wildcard — the reason
that would still stand with
--overridesturned on — rather than a flag thatwould leave it exactly where it is. Both directions are pinned by tests.
A
$nameoverride reference is unaffected: the parser records a zero-width spanfor it, so it fails
is_rewritable()before any of this and is neither rewrittennor reported.
Tests
Seven unit tests in
fix.rs(the retainedfix_all_leaves_an_override_alone,now asserting the
Declinedinstead of an empty list; nothing-available;constraint-beats-flag in both directions;
$namereference; the rewrite itself;a scoped pnpm
"foo@2>bar"key rewriting bar's span) and four end-to-endtests in
cli_fix.rsagainst the existing hermetic loopback registry — reportedinstead of silent,
--allstill declines,--overrides --allrewrites, and--dry-runwrites 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 —
--overrideswithout--all— producing a rewrite. Every earlier case thatproduced a
FixRecordpassedall = true, and the two that passedall = false, overrides = trueboth ended in a decline, so the write path forthe recommended invocation was unpinned. The fixture is a range-form override
(
"^1.0.0") withlatest_availableset to2.0.0, deliberately pastlatest_compatibleof1.9.0: it asserts the rewrite lands as^1.9.0andthat nothing reached
2.0.0, so a path that ignored the compatible targetcannot pass it. Verified to fail when the
allargument is flipped.Validation
cargo test -p dependable --bin dependable fix::cargo test -p dependable --test cli_fixmise run testtest result: ok, 0 failed (pre-existing#[ignore]s only, all network-gated)mise run fmt:checkmise run lintclippy -D warnings)convco check origin/feat/105-ecosystem-filter..HEADEach was run with
env -u FORCE_COLOR -u COLORTERM, because this repository hastests sensitive to an ambient
FORCE_COLOR.Filed, not fixed
#147 — a Gradle version catalog's
strictlyis classifiedDependencyKind::Normal, so a plaindependable fixwith no flags advances it.gradle_catalog.rsreadsstrictly/require/preferthrough one code pathand stamps all three
Normalwith a real span; theforcedguard testskind == Override, which onlypackage_json.rsever sets.strictlyisGradle'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
strictlyfromrequire/preferinside the parser, which is adependable-corechange to how a rich version is classified, not afixchange. The README now scopes its guarantee to
package.jsonand links #147rather than promising cover it does not give.
#121 —
check's default table gives an override no annotation, so the most-runcommand is the one that cannot tell a forced version from an ordinary
dependency, while
listlabels it(override)andcheck --format jsonalready emits
"kind": "override". Not folded in here:output/table.rshas noannotation concept at all, so adding one means either porting
list.rs'sannotation()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), notmaster.Decisions taken
1. Deliverable boundary — how much of the issue closes here
fix --overridesthat 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.resolutionspin thatfix --allcan never move, still unfixable by the tool; the author is told about it and handed a manual edit.check's default table —checkis the most-run command andoutput/table.rshas no annotation concept at all today, so this would mean either portinglist.rs'sannotation()(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.[fix] overrides = trueconfig key —config.rsusesdeny_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.overridesfield fromFixArgsand passfalseat thefix::plancall site; the reporting half stands alone.check-annotates-overrides half, as check: the default table gives an override no annotation #121.2. Flag shape
--overridesas an independent boolean, matching every otherfixflag (--all,--dry-run,--no-cache,--no-vuln). It composes with--allon the axis--allalready means everywhere else:--overridesadvances within the declared constraint,--overrides --allbeyond 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 onfixis--manifest-glob'sconflicts_with = "manifest", which guards a genuine contradiction; this is not one.--include <kind>value enum —DependencyKindhas eight variants and onlyOverrideis 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.requires = "all"to the clap attribute, or replace the field with aVec<Include>value enum.3. A forced version that is itself known-vulnerable
all_vulnerabilitiesis 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".warning:line for that case — it needs a new field onDeclined, a branch inreport_declined_fixes, and new OSV test infrastructure (thecli_fix.rsharness disables vulnerability scanning wholesale), for a wording difference.vulnerablefield toDeclinedand branch on it inreport_declined_fixes.4. Commit split (taken during implementation, not in the recorded plan)
feat:commit for the behaviour plus onedocs:commit, rather than the planned split of reporting and the flag into twofeat:commits.--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.git rebase -ito split thefeat:commit, accepting one of the two costs above.5. What "within their constraint" means for
--overridesaloneto_version_reqbuilds the requirement withVersionReq::parse, which reads a bare1.0.0as Cargo's^1.0.0— so for"overrides": { "lodash": "1.0.0" },latest_compatibleis1.9.0and--overridesalone moves the pin across five minor releases. MeanwhileEcosystem::bare_versionrecords npm's own reading of that same string asBareVersion::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.--overridesalone 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 wholefixcommand.--allfor any bare-version override — it forbids the safer within-model advance, and the only precedent for an inter-flag constraint onfix(--manifest-globvs--manifest) guards a genuine contradiction. This is not one.--overrideswrite path on the ecosystem's bare-version reading rather than onlatest_compatible.6. Whether the pin note should name both flags
Pinnedguard'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:--overridesalone is refused by the pin guard,--allalone passes it and is then refused by theforcedguard, which names--overridesin turn.an_override_that_is_also_a_pin_reports_the_pinasserts exactly that sequence — the old comment was contradicted by the test directly beneath it.--all --overridesbefore 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.7. A forced version that is itself known-vulnerable — the consequence, stated (extends decision 3)
CheckResult::all_vulnerabilitiesis declared and populated nowhere in the workspace, so advisories are only known for the version already declared.--overridescan therefore move a pin from one vulnerable release to another with no signal at all, and the safety paragraph says so.all_vulnerabilitiesis populated — the flag is opt-in and the default is unchanged, so withholding it protects nobody who is not already protected by the default.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:dependabletreats this way; nothing in Cargo, Go, Python, or the restdeclares one" and that
fixnever rewrites one by default,--allincluded.A Gradle
strictlyis read and classifiedNormal, so a flaglessfixadvances it (fix(core): a Gradle
strictlyversion is classified Normal, so plainfixadvances a forced version #147). The guarantee is now scoped topackage.jsonexplicitly.[patch]/[replace], Composerreplace/conflict, andpnpm-workspace.yaml'soverrides: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.
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.
DeclineReasongains a variant (
#[non_exhaustive], andexplain()is its only match site inthe workspace);
fix::planandfix::plan_fixeseach gain a trailingbool.Declined's contract widens from "a constraint refused" to "a constraintrefused, or the entry is a forced version" — the doc comments that asserted the
narrow contract, including the in-test comment that explained why
declinedwasempty 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.lockchange.