Skip to content

refactor(fix): decline a wildcard only where a bare version is not a caret range - #106

Open
justin13888 wants to merge 9 commits into
fix/stabilization-passfrom
refactor/92-ecosystem-aware-wildcard
Open

justin13888 wants to merge 9 commits into
fix/stabilization-passfrom
refactor/92-ecosystem-aware-wildcard

Conversation

@justin13888

@justin13888 justin13888 commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Closes #92.

Stacked on unmerged PR #99 (fix/stabilization-pass). The base of this PR
is fix/stabilization-pass, not master, because #99 also substantially
rewrites crates/dependable/src/fix.rs. Do not merge this before #99.

rewrite_constraint declined every wildcard constraint (from #87 / PR #89). The
comment on that guard claimed the layer could not see the ecosystem. It could:
plan already takes manifest: &Path, and ManifestKind::detect(path).ecosystem()
is two lines away. The blanket decline was conservatism, not impossibility.

What a bare version means, per ecosystem

The rewrite keeps a constraint's operator prefix and substitutes the version, so
a constraint written with no operator is written back as a bare version.
Whether that is safe is therefore a question about the ecosystem's resolver, not
about the string. Ecosystem::bare_version records the answer, with
bare_version_is_exact as the shorthand for the sharpest case.

Three answers turned out to be needed, not two. bare_version_is_exact alone
cannot decide this: a bare NuGet version is not exact, and NuGet must still
decline — an inclusive minimum has no upper bound, so 1.* → 1.9.0 widens
where npm's 1.x → 1.9.0 narrows. Both are wrong; only a bare version read
as a caret preserves the range.

Ecosystem bare_version() bare_version_is_exact() Evidence Wildcard verdict
Rust Caret false The Cargo book: "Specifying only the version number is equivalent to a caret requirement" — serde = "1.0" is ^1.0. rewritten (1.* only)
Go Minimum false A require line is the lowest version the module needs; minimal version selection then builds with the highest such requirement in the graph. declined
Npm Exact true node-semver: a fully specified version is a comparator with an implicit =. A partial version is an X-range instead. declined
Python Exact true PEP 508 has no bare form at all (an operator is mandatory), so the only reading that occurs is Poetry's, whose "exact requirements" are written bare and install "this version and this version only". declined
Php Exact true Composer's exact version constraint is the bare form: "install this version and this version only". A range needs the wildcard spelled out (1.0.*). declined
Dart Exact true pub's traditional-syntax table reads 1.2.3 as "Only the given version", and the docs steer authors to ^1.2.3 precisely because the bare form is that restrictive. declined
CSharp Minimum false NuGet's range table: 1.0 is x ≥ 1.0, "minimum version, inclusive". [1.0] is how an exact match is written. declined
Elixir Exact true A Hex requirement with no operator is an equality requirement: Version.match?("2.0.1", "2.0.0") is false. Floating needs ~>. declined
Jvm Minimum false A plain Gradle version string is a required version — the minimum, "optimistically upgraded" by conflict resolution — not a pin; strictly is the pinning form. Maven's plain <version> is likewise a soft requirement mediation may override. declined

Two variants that disagree with the issue

The issue expected Go and Dart to join Cargo in the "safe to substitute"
bucket. Both were checked against the resolver's own documentation instead:

  • Dart is Exact, not a caret. pub's own version-constraint table reads a
    bare 1.2.3 as "only the given version" — that is exactly why the docs push
    ^1.2.3. Substituting would have collapsed a pubspec range to one release.
    What reverses it: pub changing the traditional-syntax reading of a bare
    version, which would be a breaking change to every existing pubspec.
  • Go is Minimum, and unreachable either way. go.mod has no wildcard
    syntax at all, so no go.mod can reach this guard. The answer is recorded for
    correctness rather than for effect.

The rule, and why it is narrower than "not exact"

A wildcard is rewritten only when all of these hold:

  1. the ecosystem reads a bare version as a caret range (Rust alone today);
  2. the constraint carries no operator prefix — otherwise what gets written
    back is not a bare version, so the caret reading that justifies the rewrite
    does not apply (^1.x, =1.*, Python's ==1.*);
  3. the wildcard sits in the minor position and is *, x, or X.

Point 3 is not decoration. Two shapes stay declined even in Cargo, and
substituting either would have been a new bug:

  • * is every version; any concrete release confines it to one major — a
    narrowing.
  • 1.2.* is >=1.2.0, <1.3.0, while a caret over any 1.2.z reaches to
    <2.0.0 — a widening past the bound the author wrote.
  • Gradle's 1.+ is a prefix range with its own resolution rules; no
    caret-reading ecosystem accepts + as a wildcard, so it is not treated as one.

What survives is exactly the issue's case: serde = "1.*" (>=1.0.0, <2.0.0)
becomes serde = "1.0.219" (>=1.0.219, <2.0.0). The floor rises, which is what
fix does to every constraint; the upper bound holds.

ManifestKind::detect(path) == None

Declined. plan passes Option<Ecosystem>, and rewrite_constraint reads
None as BareVersion::Exact — the reading that declines strictly more than
either other one. An unrecognized manifest is therefore never rewritten into
something a recognized manifest would have refused. It is not an error: the
run still proceeds, and every ecosystem-independent form (^1.0, 1.0.0) is
still rewritten. Asserted by
an_unrecognized_manifest_kind_declines_every_ecosystem_dependent_form.

The npm partial-version gap (the issue's "Related")

npm reads a partial version as an X-range: "react": "16" is 16.x, "1.0" is
1.0.x. There is no * anywhere, so is_wildcard saw nothing and fix wrote
"16.14.0" — a pin. Nothing in the string separates it from Cargo's "1.0",
where the same rewrite is correct.

Guarded now for every ecosystem whose bare version is exact, and only for
operator-free constraints — ^16 is a caret range in npm too and is still
rewritten to ^16.14.0. Cargo (1.0 = ^1.0) and NuGet (1.0 = >= 1.0) keep
the rewrite, because there it only raises a floor.

Judgement call: Composer normalizes a partial to a full version, and Hex and
pub reject one outright, so declining there gives up a rewrite that would have
been harmless. "Harmless" is the whole claim being made, though, and the trade is
one unfixed constraint that already pinned against an author silently losing
their range. What reverses it: a per-ecosystem "partial is a range" fact, if
someone wants Composer's partials fixed again.

Tests

Nothing was deleted or weakened. rewrite_constraint grew a third argument at
47 call sites; the assertions moved, none changed meaning.

Re-expressed, not removed:

  • rewrite_leaves_every_concrete_version_form_rewritable — the regression net,
    still green, all 12 shapes intact. Each is now asserted against the ecosystem
    that actually writes it (Go pseudo-versions and +incompatible against Go,
    NuGet's 1.0.0.4 and [1.0,2.0) against CSharp, Python's 1!2.0 and ~=1.4
    against Python, Hex's ~> 1.0 against Elixir), which is a stronger claim
    than the single-ecosystem original: a partial-version or wildcard guard firing
    on the wrong reading would take one of these with it. Python's ==1.* still
    stays declined, now doubly so (exact reading and an operator prefix).
  • rewrite_never_narrows_a_wildcard_to_a_pin — split into two halves. Every
    original assertion survives: all seven shapes are asserted for every
    non-caret ecosystem, and 1.+, ^1.x, =1.*, 1.2.x, * are asserted
    declined for Cargo too.
  • rewrite_preserves_operator_prefix, rewrite_skips_dist_tags,
    rewrite_declines_a_wildcard_wearing_a_stability_flag,
    rewrite_declines_a_stability_flag_on_a_plain_version,
    rewrite_skips_multi_constraint,
    rewrite_skips_space_and_pipe_compound_constraints — these guards run before
    the ecosystem is consulted (or apply to forms that are not bare versions), so
    each now loops over all nine ecosystems rather than asserting against one.
    That turns "ecosystem-independent" from an assumption into an assertion.
  • a_wildcard_dependency_is_left_untouched_by_fix (npm 1.x) and
    a_wildcard_dependency_is_left_untouched_by_fix_all (Composer 2.8.*@dev) —
    unchanged verdicts; both ecosystems still decline.

New:

  • every_ecosystem_states_how_it_reads_a_bare_version — the table above as a
    test, spelled out per variant so a new Ecosystem forces the decision.
  • the_exactness_shorthand_agrees_with_the_full_reading.
  • rewrite_updates_a_minor_wildcard_where_a_bare_version_is_a_caret — the new
    behaviour, plus the same input declined for the other eight and for None.
  • rewrite_declines_a_partial_version_where_a_bare_version_is_exact.
  • a_cargo_minor_wildcard_is_updated_by_fix — end to end: serde = "1.*" is
    rewritten while its clap = "1.2.*" neighbour is not, in the same file on the
    same run.
  • the_same_wildcard_is_still_declined_for_npm — the same manifest shape, one
    ecosystem apart, opposite verdict.
  • an_npm_partial_version_is_left_untouched_by_fix — "react": "16" untouched
    while "vue": "^3.0.0" is rewritten beside it.
  • an_unrecognized_manifest_kind_declines_every_ecosystem_dependent_form.

API

BareVersion is new and public, re-exported from dependable-core and
dependable-fetch. An enum rather than a second boolean beside
bare_version_is_exact: two booleans that must stay consistent is the shape that
invites exactly the silent-corruption bug this guard exists to prevent, and one
match with nine deliberate arms is what makes the table above reviewable.

Validation

env -u FORCE_COLOR -u COLORTERM cargo test --workspace     917 passed; 0 failed; 20 ignored
env -u FORCE_COLOR -u COLORTERM cargo clippy --workspace --all-targets -- -D warnings   clean
cargo fmt --all --check                                    clean
convco check                                               no errors in 3 commits

FORCE_COLOR and COLORTERM are unset for the first two because this shell
exports them and three tree tests are colour-sensitive; that is a separate
issue, fixed in PR #101, and is unrelated to this change.


Restacked on the repaired base

This branch was cut from fix/stabilization-pass before ten commits landed on it,
nine of them repairs for two CRITICAL and two HIGH defects. origin/fix/stabilization-pass
(34a5b92) is merged in at a66e581 — a merge, not a rebase, so the pushed
history is unchanged.

Conflicts

crates/dependable/src/fix.rs was the only conflicted file. Both hunks were
additive and both sides were kept:

Hunk Resolution
Imports The base added DependencyKind for the override guard; this branch added BareVersion, Ecosystem, ManifestKind for the ecosystem reading. Kept as two use lines carrying all of them.
Test-module preamble The base opened it with fix_all_leaves_an_override_alone, this branch with the EVERY_ECOSYSTEM constant. Both kept; the override test's plan_fixes call gains the ecosystem argument this branch added to the signature, as Some(ManifestKind::PackageJson.ecosystem()) — the reading its own package.json fixture is written in.

The override guard in plan_fixes merged without conflict and is intact: an
item of kind Override is skipped before target selection, so fix --all does
not rewrite a security pin.

Gates

cargo test --workspace     941 passed / 0 failed / 20 ignored
cargo clippy --workspace --all-targets -- -D warnings   clean
cargo fmt --all --check    clean

Both the base (925) and this branch's pre-merge count are exceeded, so no test
was lost in the resolution.

Repairs verified end to end

Run against the built binary, not inspected:

  1. fix --all on {"pnpm":{"overrides":{"foo@2>bar":"3.0.0"}}} leaves the override
    untouched. Worth noting the registry resolves bar — the last arrow segment — to
    an unrelated package at 0.1.2, which is exactly the downgrade the guard prevents.
  2. A dependency the registry 404s reports error and exits 0 under
    --fail-on vulnerable, with note: 1 dependency was not found in its registry, so it is not gated on.
  3. A Gradle catalog entry version = "[4.0,4.9" reports undetermined, not
    up to date.
  4. An npm "overrides": {"semver": "$semver"} resolves as a reference to the
    semver constraint and exits 0.

Restacked on the second repair round

fix/stabilization-pass received ten further commits after this branch was last brought forward — repairs for four defects that its own first round of repairs had introduced. This branch now contains them: Go's 410 Gone counted as not-found alongside 404; an ErrorOrigin (NotFound / Unanswered / Local) carried on CheckResult from the typed fetch error, splitting ScanIntegrity into unresolved (exempt from the gate, reported) and unevaluated (unanswerable); an override key carrying a version range no longer split on the > inside that range; a bare * in PEP 440 and Poetry translating to * rather than reading as a failed translation; a stderr note for undetermined dependencies; -q honoured on both notes; PackageSource::Unresolved gaining its own "unresolved" list token; and crates/dependable/tests/cli_gate.rs, a stub HTTP registry that can answer with a chosen status code and content type.

The merge was clean — no conflicted files.

Everything load-bearing across the stack survived, verified in the merged tree rather than assumed: the DependencyKind::Override exclusion in plan_fixes (which deliberately records no decline note, since an override is not a refused rewrite but a version the author forced), all three has_update() call sites, report_declined_fixes and the ErrorOrigin-based gate coexisting in runner.rs, Ecosystem::bare_version(), and translation-failure detection reaching Undetermined.

Validation

cargo test --workspace                                  → 0 failures
cargo test --workspace --no-default-features            → 0 failures
cargo clippy --workspace --all-targets -- -D warnings   → clean
cargo fmt --all --check                                 → clean

The behaviours the stack must not lose are each covered by a test that ran green in the merged tree, rather than by a claim:

fix::tests::fix_all_leaves_an_override_alone                          ok
a_range_in_an_override_key_is_not_a_parent_separator                  ok
a_scoped_override_key_names_the_package_after_the_last_arrow          ok
an_override_key_carrying_a_range_is_checked_as_its_own_package        ok
a_go_module_the_proxy_answers_410_for_does_not_break_the_gate         ok
a_package_the_registry_answers_404_for_does_not_break_the_gate        ok
an_unreadable_constraint_still_refuses_to_certify_the_build           ok
a_metadata_document_listing_no_versions_is_not_exempt_from_the_gate   ok
semver::python::tests::a_bare_wildcard_is_any_version                 ok
a_poetry_wildcard_resolves_instead_of_going_undetermined              ok
a_declined_wildcard_is_reported_instead_of_claimed_up_to_date         ok

Decisions taken

  1. Whether --all may cross a wildcard's major boundary
    Taken: yes, and the rationale comment now says so. With --all the target is latest_available, so serde = "1.*" becomes serde = "3.4.0" and the author's upper bound does not survive.
    Rejected: declining a wildcard rewrite under --all — --all already crosses the declared bound for ^1.0, and its help text says "Update all, including beyond the declared constraint". Carving out wildcards would make one flag mean two things.
    Reverses: add && !all to the wildcard permit condition.

  2. Whether the bare-version answer should hang off ManifestKind rather than Ecosystem
    Taken: Ecosystem stands. The finer distinction is real — within Python, requirements.txt is PEP 508 while Poetry's pyproject.toml is exact and pixi.toml's conda table is a prefix match — but every within-ecosystem divergence currently identified either resolves the same way or would decline strictly more, so the coarser answer is never less safe today.
    Rejected: moving bare_version onto ManifestKind — it multiplies a nine-row truth table into a twelve-plus-row one whose extra rows nothing yet distinguishes, and plan would stop being able to answer for a kind it could not detect.
    Reverses: move bare_version to ManifestKind and have Ecosystem::bare_version delegate for the common case.

  3. Accepting 1.x in Cargo, a spelling Cargo itself cannot parse
    Taken: keep accepting it. semver::VersionReq::parse("1.x") rejects it, so such a constraint becomes Error/Undetermined upstream and never reaches the rewriter; the arm is unreachable latitude rather than a wrong answer.
    Rejected: restricting the wildcard character to * for Rust — it would add an ecosystem-specific spelling rule to a predicate that is otherwise about shape, to remove a branch nothing can enter.
    Reverses: drop "x" | "X" from is_minor_wildcard for Cargo.

  4. Declining partial versions uniformly across every Exact ecosystem
    Taken: uniform decline. Composer normalises a partial and Hex and pub reject one outright, so the decline is a no-op or at worst a lost fix there — never a harm.
    Rejected: making the guard npm-specific — it would need a per-ecosystem "a partial is a range" fact that does not exist in the codebase, invented for three ecosystems where the answer cannot matter.
    Reverses: gate the partial-version branch on Ecosystem::Npm.

Unresolved review notes

  • Low — crates/dependable/src/fix.rs, EVERY_ECOSYSTEM — a hand-maintained [Ecosystem; 9] that would silently under-cover a tenth variant. bare_version's exhaustive match would still fail the build, so a new variant cannot pass unnoticed entirely, but the loop's coverage would quietly narrow. Not repaired: out of the scope that put this pull request in the work order.
  • Low — Item::is_pinned is spelling-based, not semantic — a Cargo =1.2.3 is protected from a plain fix; an npm "lodash": "1.2.3", which this change formally records as an exact pin, is not. Filed as fix(core): Item::is_pinned tests the spelling of a pin, not whether it is one #118.

A rewriter that replaces a constraint's version span writes the new version
back bare, so whether that is safe turns on what a bare version means to the
resolver. Cargo reads it as a caret range, npm as one exact release, NuGet as
an inclusive minimum — three different answers that no amount of looking at
the constraint string can tell apart.

`Ecosystem::bare_version` records the reading per variant, decided from each
resolver's own documentation and evidenced in the doc comment, with
`bare_version_is_exact` as the shorthand for the sharpest case.
…caret

`rewrite_constraint` declined every wildcard, because it saw only the raw
constraint string and a blanket refusal was the safe call. The ecosystem was
never actually out of reach: `plan` holds the manifest path, and
`ManifestKind::detect` names it.

With the reading threaded through, the refusal narrows to the cases that need
it. Cargo's `serde = "1.*"` becomes `serde = "1.0.219"` again — a caret range
with a raised floor, which is what the wildcard already was — while npm's
`"1.x"`, NuGet's `1.*`, and Gradle's `1.+` stay untouched, the first because a
bare version there is one release and the last two because a bare version there
has no upper bound at all.

The rewrite is allowed only for the wildcard shape a caret reproduces: no
operator prefix, and the wildcard in the minor position. `*` admits every major
and `1.2.*` stops at `1.3.0`, so both stay declined even in Cargo, and an
unrecognized manifest kind is read as the most restrictive answer rather than
as permission.
npm reads a version with fewer components than a full one as an X-range:
`"react": "16"` is `16.x` and `"1.0"` is `1.0.x`. There is no `*` anywhere in
those, so the wildcard guard saw nothing to object to and `fix` wrote
`"16.14.0"` into a dependency that had been tracking a line of releases — the
same harm the wildcard guard exists to prevent, reached without a wildcard.

Nothing in the constraint string separates it from Cargo's `"1.0"`, where the
identical rewrite is correct, so the guard is the ecosystem's reading of a bare
version: declined wherever that reading is exact, kept wherever it is a caret
or a minimum and raising the floor is all the rewrite does.

Composer normalizes a partial to a full version and Hex and pub reject one, so
declining there gives up a rewrite that would have been harmless. That is the
cheaper side of the trade: the cost is one unfixed constraint that already
pinned, against an author losing their range.
…e-wildcard

This branch was cut before ten commits landed on its base, nine of them
repairs for defects an adversarial review confirmed. Bring them forward so
the ecosystem-aware wildcard work sits on the repaired base rather than the
one that silently downgrades a security pin.

`crates/dependable/src/fix.rs` was the only conflict, and both hunks were
additive:

- The imports. The base added `DependencyKind` for the override guard; this
  branch added `BareVersion`, `Ecosystem`, and `ManifestKind` for the
  ecosystem reading. Both are kept.
- The test module preamble. The base opened it with
  `fix_all_leaves_an_override_alone`; this branch opened it with the
  `EVERY_ECOSYSTEM` constant. Both are kept, and the override test's
  `plan_fixes` call gains the `ecosystem` argument this branch added to the
  signature — `Some(ManifestKind::PackageJson.ecosystem())`, the reading its
  own fixture is written in.

The override guard itself merged without conflict and is intact: `plan_fixes`
still skips `DependencyKind::Override` before it reaches the target selection,
so `fix --all` leaves a pnpm security pin alone.
…e-wildcard

Brings the second repair round forward: the `ErrorOrigin` gate, the Go
proxy 410, the override key range split, the bare PEP 440 `*`, and the
undetermined/unresolved stderr notes.

Conflict in `crates/dependable-fetch/src/lib.rs`: both sides widened the
same `dependable_core` re-export list. Resolved as the union, keeping
`BareVersion` and `ErrorOrigin`.
`BareVersion::Minimum` is a floor: a resolver may pick at or above it and
never below. Maven's plain `<version>` is not that. It is a soft
requirement — a preference nearest-wins mediation may satisfy with an
older release — so reading it as `>=1.9.0` claims a guarantee Maven does
not make.

Inert today, because `ManifestKind::detect` maps only `*.versions.toml`
to `Jvm` and Gradle's `require` genuinely is a minimum. But this table is
the record a future `pom.xml` parser will be read against, so scope the
row to the catalogs the variant actually reaches and mark Maven's reading
unresolved rather than "likewise".

Also state on the variant itself that a floor is not a preference, so the
distinction is visible where the reading is defined and not only in the
one row that tripped over it.
Three constraint shapes reached `rewrite_constraint` and were rewritten
into a strictly smaller range than the author wrote — the harm #87 was
filed for, arriving through the door #92 opened.

A zero major. Cargo's caret is *minor*-scoped below 1.0.0, so the
guarantee the wildcard permit rests on does not hold there.
`is_minor_wildcard` accepted any all-digit major, `0` included: `0.*` is
`>=0.0.0, <1.0.0`, and the `0.y.z` written back is `^0.y.z`, which reaches
only `<0.(y+1).0`. A manifest that accepted `0.11.0` stopped accepting it.
A bare `0` reached the same harm through the partial-version branch, and
`0.0` through the same branch one component further down.

The predicate is stated once, as the thing that actually matters: a caret
keys its bound off the leftmost non-zero component, so a constraint that
wrote one keeps its bound under substitution and a constraint whose
components are all zero does not. That keeps `0.9` rewritable — `^0.9`
and `^0.9.5` both stop below `0.10.0` — which a blanket "decline major 0"
would have cost.

A partial version behind a tilde. `~>`, `~=` and `~` read their upper
bound off the number of components supplied, so a three-component version
behind a two-component operator collapses a major-wide range to a
minor-wide one. This repository's own translators pin the arities:
`semver::elixir` expands `~> a.b` to `<(a+1).0.0`, and `semver::python`
expands `~=1.4` to `<2.0.0` against `~=1.4.2`'s `<1.5.0`. So `~> 1.0`
became `~> 1.7.10` and a 1.x range became a 1.7.x one. Declined for all
three spellings in every ecosystem: the arity each is sensitive at
differs, and declining a form that happened to be safe costs a fix while
permitting one that is not costs the author their range. A full-arity
`~1.0.0` is unaffected.

The exactness question is now asked through `Ecosystem::bare_version_is_exact`,
which is what that predicate is for and which until now had no caller
outside its own tests.

Two existing assertions moved with the behaviour rather than being
weakened: `~=1.4` and `~> 1.0` sat in a test named for concrete version
forms while being partial ones, so they are asserted at full arity there
and their partial forms are asserted as declined in a test of their own.
Both new tests fail against the previous code with the exact harmful
values — `0.*` to `0.10.0`, `~> 1.0` to `~> 1.7.10`.
…bound

The wildcard permit is justified by "the author's upper bound survives",
and that guarantee belongs to the default path only. Under `--all` the
target is `latest_available` rather than `latest_compatible`, so
`serde = "1.*"` becomes `serde = "3.4.0"` and the bound moves with it.

That is intended, not an oversight: `--all` is documented as updating
beyond the declared constraint and already does exactly this to `^1.0`,
so carving wildcards out of it would make one flag mean two things. Say
so where the guarantee is claimed, since the comment as written reads as
unconditional.
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