Skip to content

fix(fix): decline to rewrite a wildcard constraint - #89

Merged
justin13888 merged 5 commits into
masterfrom
fix/87-wildcard-constraint-pin
Sep 1, 2026
Merged

fix(fix): decline to rewrite a wildcard constraint#89
justin13888 merged 5 commits into
masterfrom
fix/87-wildcard-constraint-pin

Conversation

@justin13888

@justin13888 justin13888 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes #87.

The gap

rewrite_constraint in crates/dependable/src/fix.rs declines the forms it cannot rewrite without changing their meaning: anything containing ,, anything with a space or | after the operator prefix, and anything starting with a letter once that prefix is stripped (the dist-tag guard, which keeps latest and next from being pinned).

A wildcard falls through all three. For 1.x the operator prefix is empty, so rest is 1.x — no comma, no space, no pipe, and it starts with a digit. The function returns the target version, and the range the author wrote becomes a pin.

Reachable by default

The issue recorded the path as read from source but not reproduced, so the first commit is a witness rather than a patch.

check_version("1.x", &["1.0.0", "1.9.0", "2.0.0"], None) is UpdateAvailable with latest_compatible == 1.9.0: the wildcard matches 1.0.0 and 1.9.0 but not 2.0.0, so the newest release sits outside the range. Default fix — no --all, no lockfile — takes latest_compatible and hands 1.x to rewrite_constraint, which rewrites "lodash": "1.x" to "lodash": "1.9.0". In npm a bare version is an exact match, so the floating constraint collapses to one release.

1.x, 1.*, and 1.X are reachable this way; * needs a lockfile behind the newest, and 1.+ needs --all. Bare x and any were caught only by accident, because they happen to start with a letter.

The change

Decline, beside the dist-tag guard: rest is *, or any dot-segment of rest is *, x, X, or +.

Declining rather than widening (1.x2.x) is deliberate. Widening needs per-ecosystem knowledge of how a wildcard is spelled, and this layer sees only the raw string with no Ecosystem. It would also be wrong for NuGet, where 1.* and a bare 2.0.0 mean different things — a bare NuGet version is an inclusive minimum. Declining leaves the author with the floating constraint they asked for, exactly as a dist-tag is left alone.

One documented behaviour is reversed

Two existing asserts required a bare * to be pinned, one of them with a comment justifying it. Both now assert the opposite, and the comment says so rather than being deleted. Issue #87 names * alongside 1.x and 1.* as "ranges, not versions", so this is the issue's intent. Nothing else depends on it — the PRD and SCOPE.md document only latest.

Tests

  • rewrite_never_narrows_a_wildcard_to_a_pin1.x, 1.*, 1.X, 1.+, ^1.x, 1.2.x, * all decline.
  • a_wildcard_dependency_is_left_untouched_by_fix — a package.json with "lodash": "1.x" yields no fix record and a byte-identical manifest.
  • wildcard_constraint_is_reported_as_upgradable — the reachability witness in the core checker.

All four failed on the first commit and pass on the second.

Folded in on review: a constraint carrying an @ is declined

rewrite_constraint split a Composer stability flag off before testing for a wildcard (let versionish = rest.split('@').next()...), which was right for the wildcard test but not for the rewrite: the function emits only format!("{prefix}{new_version}"), so nothing carries the flag forward. A flag hung off a plain version therefore passed every guard and was rewritten flag-free. Verified end to end against the real parser and check_version under dependable fix --all, newest 7.0.0:

input before harm
"@dev" "7.0.0" unbounded "any version, dev stability" becomes an exact pin
">=2.8@dev" ">=7.0.0" flag dropped
"2.8@dev" "7.0.0" flag dropped
"^1.0@beta" "^7.0.0" flag dropped

The "@dev" row is #87's own harm class — a range collapsed to a pin — reached without a wildcard. It is pre-existing: the old guard also returned Some(">=7.0.0") for ">=2.8@dev". The @ handling on this branch made it visible and cheap to close.

rewrite_constraint now declines any rest containing '@' outright, as a dist-tag is already declined: a stability flag qualifies a range this layer cannot reconstruct. That subsumes the split('@') inside is_wildcard, which is simplified away, and makes the npm-alias decline (npm:pkg@1.0.0) explicit rather than an accident of the alphabetic dist-tag guard.

No false positive: none of the concrete forms the shipped parsers emit contains an @ — Go pseudo-versions, v2.0.0+incompatible, 1.2.3+build.5, 1.0.0-alpha+exp.sha.5114f85, 1.0.0-x.7.z.92, NuGet 1.0.0.4, Python 1!2.0 and ~=1.4, Hex ~> 1.0 — and rewrite_leaves_every_concrete_version_form_rewritable asserts each by name so it stays that way.

rewrite_declines_a_stability_flag_on_a_plain_version covers "@dev", ">=2.8@dev", "2.8@dev", "^1.0@beta", and "npm:pkg@1.0.0". Reverting the guard makes it fail on the first four (the alias was already caught incidentally); "*@dev" and the rest of the wildcard suite stay green either way.

Note

output::tree::tests::ascii_points_a_member_at_its_own_tree fails in some local shells, on this branch and on master alike. It is not a regression and not related to this change: the test asserts on plain substrings, and the renderer emits ANSI colour when FORCE_COLOR is set in the environment, so the assertion fails on the escape codes. env -u FORCE_COLOR cargo test --bin dependable passes on master and on this branch. CI does not set FORCE_COLOR, which is why it stays green there.

Worth pinning colour off in that test so it cannot depend on the caller's environment, but that is unrelated to #87 and is left alone here.

Unresolved review notes

Review of this branch raised five further findings. None is repaired here; each is tracked or recorded below.

Per-ecosystem wildcard handling — Medium — crates/dependable/src/fix.rs, rewrite_constraint — tracked as #92

Claim. The decline is blanket because rewrite_constraint carries no Ecosystem in its signature — but the ecosystem is reachable one call up: apply_fixes takes manifest: &Path, and ManifestKind::detect(path).ecosystem() is two lines away. So declining everywhere is conservatism, not necessity. An earlier version of the guard comment claimed the layer could not see the ecosystem; that was wrong and has been corrected.

Scenario. Cargo serde = "1.*", locked at 1.0.100, 1.0.219 published. check reports PatchAvailable. Before this branch, fix wrote serde = "1.0.219" — which Cargo reads as ^1.0.219, still a range and the idiomatic form. Now the manifest is left alone. The npm harm this branch fixes is real, but the same substitution was correct for Rust, Go, and Dart, where a bare version is a caret or an inclusive minimum rather than a pin.

Direction. Thread Ecosystem (or a narrower bare_version_is_exact: bool) from apply_fixes into rewrite_constraint and decline only where a bare version pins — npm, Composer, Hex. NuGet stays declined on its own merits.

Why not here. It contradicts the approved plan for #87, which chose the blanket decline deliberately. The maintainer elected to ship the conservative guard and refine it separately.

npm partial versions are still narrowed — Low — crates/dependable/src/fix.rs, rewrite_constraint — tracked as #92

Claim. npm treats a partial version as an X-range: "16"16.x, "1.0"1.0.x. Neither contains a wildcard character, so is_wildcard does not see them and the rewrite proceeds.

Scenario. "react": "16" with versions up to 16.14.0 and 18.2.0 published becomes "react": "16.14.0" — an exact pin, from a constraint that floated across all of 16.x. The same class of harm as #87, reached by a different spelling. It is pre-existing rather than introduced here.

Direction. Cannot be fixed without the ecosystem, because for Cargo "1.0""1.5.0" is correct. Depends on the finding above.

"Everything is already up to date." after declined wildcards — Low — crates/dependable/src/runner.rs, run_fix — tracked as #93

Claim. run_fix reports success from an empty record list, so a declined-but-updatable item is indistinguishable from nothing to do.

Scenario. A package.json whose only dependency is "lodash": "1.x", with 1.9.0 in range: check reports an available update, fix prints "Everything is already up to date." and exits 0, and --dry-run prints nothing. Not new in kind — dist-tags and compound ranges were already silent — but this branch widens the silent set.

Direction. Have plan_fixes return the declined-but-updatable items alongside its records, and emit a note: line in the register report_inherited_skips already uses for exactly this class of contradiction.

Why not here. It lives in runner.rs and in plan_fixes' return type, both outside this branch's manifest.

An interior x segment is declined when it should not be — Low — crates/dependable/src/fix.rs, is_wildcard

Claim. is_wildcard matches x/X as a whole dot-segment, which a semver prerelease or build identifier can legitimately be: 1.0.0-alpha.x.1 splits to ["1", "0", "0-alpha", "x", "1"] and 1.0.0+build.x to ["1", "0", "0+build", "x"]. Both are declined as wildcards though neither floats.

Why not here. Pre-existing — the same whole-segment match shipped in the first commit of this branch — contrived, and unrelated to the @ change. No shipped parser has been observed emitting either shape. Recorded so the next person touching is_wildcard knows the case exists.

NuGet [1.0] is widened to a floating minimum — Low — crates/dependable/src/fix.rs, rewrite_constraint

Claim. NuGet's bracketed exact form [1.0] holds no comma, no space, no |, no letter, and no wildcard, so it is rewritten to [1.5.0 — or, depending on how the span is recorded, to a bare version, which NuGet reads as an inclusive minimum. Either way the author's exact pin is not preserved.

Why not here. Pre-existing, outside this diff, and in a different guard than the one this branch touches. [1.0,2.0) is already declined by the comma guard; only the comma-free bracketed form falls through. Recorded, not fixed.

`rewrite_constraint` has three guards -- a comma, a space or `|` after the
operator prefix, and a leading letter -- and none of them sees a wildcard.
For `1.x` the operator prefix is empty, the rest holds no space or pipe, and
it starts with a digit, so the function returns the target version and the
range the author wrote collapses to a pin.

The witness in the core checker proves the path is live rather than dead
code: `1.x` against `1.0.0, 1.9.0, 2.0.0` is `UpdateAvailable` with a
compatible target of `1.9.0`, which is exactly what default `fix` hands to
`rewrite_constraint`. The behavioural case then shows a `package.json`
pinned to `1.9.0` where `1.x` was asked for -- in npm a bare version is an
exact match.

The two existing asserts that a bare `*` is pinned are reversed here. Issue
the reversal is the issue's intent.
A wildcard is a range the author chose, not a version, so substituting a
concrete release narrows their manifest. Decline it beside the dist-tag
guard: `rest` is `*`, or any of its dot-segments is `*`, `x`, `X`, or `+`.
None of those is legal in a concrete version, so nothing rewritable is
caught, and Gradle's `1.+` has the same shape as npm's `1.x`.

Decline rather than widen. Widening `1.x` to `2.x` needs to know how the
ecosystem spells a wildcard, and this layer sees only the raw string with no
`Ecosystem` to ask. It would also be wrong for NuGet, where `1.*` and a bare
`2.0.0` mean genuinely different things -- a bare NuGet version is an
inclusive minimum, not a pin. Declining leaves the floating constraint the
author wrote, which is what they asked for.
The guard matched the whole dot-segment, so a Composer stability flag walked
straight past it: `2.8.*@dev` splits into `["2", "8", "*@dev"]`, no segment
equals a wildcard, and `--fix --all` rewrote the constraint to `7.0.0` — an
exact pin in Composer that loses both the wildcard and the flag. That is the

Cut the stability flag off before splitting, and match `*` and `+` by their
leading character, since neither can legitimately begin a version segment:
semver build metadata and Go's `+incompatible` attach their `+` to the end of a
numeric segment (`0+incompatible`), never the start. `x`/`X` stay an exact
whole-segment match, because a letter can legitimately lead an identifier
inside a prerelease or build (`1.0.0-alpha+exp.sha.5114f85`).

Every concrete form the shipped parsers emit is asserted still rewritable — Go
pseudo-versions, `+incompatible`, semver build metadata and prereleases, the
spec's own `1.0.0-x.7.z.92`, NuGet four-part versions and bracketed ranges,
Python epochs and `~=`, Hex's `~>` — so the widened guard cannot quietly stop
`fix` from working on ordinary dependencies.

The `--all` target-selection branch had no coverage at all, which is where this
lived: `results_for` never set `latest_available`, so no test could reach it.
It does now, and the wildcard case is asserted under `--all` alongside a
non-wildcard neighbour that must still be fixed.
`rewrite_constraint` split a Composer stability flag off before testing for
a wildcard, but the rewrite emits only `{prefix}{new_version}` — nothing
carries the flag forward. A flag hung off a plain version therefore passed
every guard and was rewritten flag-free: `">=2.8@dev"` became `">=7.0.0"`,
and the unbounded `"@dev"` ("any version, dev stability") became the exact
pin `"7.0.0"` — issue #87's harm reached without a wildcard.

A stability flag qualifies a range this layer cannot reconstruct, so decline
every `@` form outright, as a dist-tag is already declined. That subsumes the
`split('@')` inside `is_wildcard`, which is simplified away, and makes the
npm alias decline (`npm:pkg@1.0.0`) explicit rather than an accident of the
alphabetic dist-tag guard.

No concrete form the shipped parsers emit contains an `@`, so nothing that
rewrote before stops rewriting.
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.

fix(fix): a wildcard constraint may be rewritten to a pin

1 participant