Skip to content

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

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

refactor(fix): decline a wildcard only where a bare version is not a caret range#106
justin13888 wants to merge 5 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.x1.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

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`.
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