feat(fetch): report a dependency pinned to an exact version rather than leaving it unknown - #120
Open
justin13888 wants to merge 7 commits into
Open
feat(fetch): report a dependency pinned to an exact version rather than leaving it unknown#120justin13888 wants to merge 7 commits into
justin13888 wants to merge 7 commits into
Conversation
A manifest usually declares a range, but some declarations name a single release outright — Cargo's `=1.2.3`, PEP 440's `==2.28.1`, NuGet's `[1.2.3]`, a bare Maven or Hex version. A caller holding only a manifest had no way to tell those apart from a range, so it had to report every dependency's version as unknown even where the manifest had already answered. `exact_pin` decides by translation rather than by a table of spellings: the constraint goes through the same `to_semver_constraint` every version check already uses, and it is a pin exactly when that translation is one `=` comparator at full precision. So no reading is invented here that an ecosystem's own translator does not already make, and a bare `1.2.3` comes back as a pin for Maven and Hex and as `None` for Cargo, npm, Python, and NuGet — which is what those translators already say. Two properties the tests pin down: - What comes back is a slice of the *declared* constraint, never the translation. `to_semver_constraint` pads and rewrites to make a string the comparison engine accepts (`1.0` → `1.0.0`, `6.4.4.Final` → `6.4.4`, `1.0.0.4` → `1.0.0`), and none of those names a published artifact. A version shown to a user has to be one the registry has. - A pin whose spelling `semver::Version` cannot parse (`1.2.3.4`, `6.4.4.Final`) is not reported at all. It is exact beyond doubt, but every consumer compares with `semver::Version`, and a version that fails to parse there is treated as no version — which surfaces as a false "up to date" rather than as an honest unknown.
A manifest-only graph reported every dependency's version as unknown, including the ones the manifest had already settled. `serde = "=1.0.200"` admits exactly one release and nothing else; so does a NuGet `[1.2.3]` and a bare Gradle `4.12.0`. Calling those unknown understated what had been read, and did it in the same graph that already reports a workspace member's declared version for exactly the same reason. The constraint was being thrown away one line before the node was built: `build_project_graph` mapped the parsed items to bare names, and `shallow_graph` passed `None` for every external package. Both now carry the pin `declared_pin` reads off the declaration. Candidacy is `Item::is_checkable()`, the existing predicate for "there is a version string here worth asking a registry about". That keeps a git or path reference, and an `Inherited` entry no root has supplied a constraint for, unknown — without a second rule that could drift from the first. An inherited entry the root *did* supply is resolved before the graph is assembled, so a centrally pinned crate reports the root's pin. Where two declarations of one name do not agree on a pin, the node reports nothing. They collapse into one node, so taking the first would make the answer depend on the order the manifest lists them in, or the order the directory walk found the members in. That is not a resolution. `a_manifest_only_graph_leaves_every_dependency_version_unknown` asserted the behaviour this changes; it is rewritten as a per-node expectation naming the exact spelling of each version rather than deleted, because the spelling is the part worth protecting: guava must report `32.1.3-jre`, and Maven Central publishes no `32.1.3` at all. What this does not do: a `*.csproj` `<PackageReference Version="1.2.3" />` still reports unknown. NuGet's translator reads a bare version as an inclusive minimum, not a pin, so admitting it here would make `tree` claim a resolution for a line `check` reports as satisfied by every later release. That reading is itself a defect and is filed as #113.
`Node::version` and the README's `tree` section both stated that a dependency in a manifest-only graph is always `null`, "because a manifest declares a constraint rather than a resolution". That is now the common case rather than the rule: a constraint admitting exactly one release has resolved it. Both say so, and both say the two things a reader needs in order to predict the output: the version reported is the one the manifest spells rather than a normalized form of it, and whether a bare version is a pin is the ecosystem's call rather than the string's shape — Cargo, npm and Python read `1.2.3` as a range, NuGet as a lower bound, Maven and Hex as exact.
This was referenced Sep 6, 2026
`exact_pin`'s last guard checked the literal after `normalize_version` padded it, and nothing downstream pads. `declared_pin` puts the literal straight into a `Node::version`, and every reader of that field — the CLI renderers, the JSON and DOT emitters, the OSV query, and the TUI's lookup — parses it exactly as written. So the guard proved a claim about a string no consumer ever constructs, and the comment above it said the opposite of what the line did. A Gradle catalog pinning `junit:junit` at `4.12` therefore reached `check_version` as a raw `4.12`, which `Version::parse` rejects; that is read as no current version at all, the comparison falls back to the newest release, and the row rendered a green `ok` for a dependency three releases and nine years behind. That is issue #96's failure verbatim, reintroduced inside the branch stacked on its fix. Nothing caught it because its two consequences suppress each other: the same raw string also misses OSV, and `ui/tree.rs` draws the vulnerability badge ahead of the status badge, so wherever OSV does match, the false `ok` is hidden. Parse the literal as written instead. It is strictly a narrowing — `4.12`, `1.0`, `[1.0]` and `==0.20` drop back to `unknown`, which is what they reported before this branch — and it matches #107's own criterion, that the declared string parse cleanly as an exact `semver::Version`. Padding is not an alternative: `4.12.0` is a different artifact from `4.12` and Maven Central publishes only one of them, so the declared string is the only one that can be reported and therefore the only one worth testing. The three witnesses in the declared-spelling test were all padding cases, so the test proving that rule proved it only on inputs the rule now rejects. They are replaced with rewrites the guard still permits: a Maven release alias, a PEP 440 local segment, and a PEP 440 pre-release respelling. The usable-version invariant now parses the pin raw as well.
Every graph-level assertion about a manifest pin used a three-segment version. The committed Gradle catalog contains no two-segment entry and the committed csproj contains no `[x.y.z]` interval, so the whole NuGet accept path had no test above `pin.rs` and the partial-precision reject path had none either — the defect in the guard could not have surfaced here. Two cases close that. A catalog holding `4.12` beside `4.12.0` asserts the first resolves nothing and the second resolves, in one file through one code path. A csproj holding `[1.2.3]`, `[1.0]` and `[1.0,2.0)` asserts NuGet's single-version interval is the one spelling in that ecosystem which pins. Also corrects the guava note: `maven_to_semver` keeps the `jre` token, so the translation is not `32.1.3`. What makes the declared spelling the right answer is that Maven Central publishes `32.1.3-jre` and `32.1.3-android` and no bare `32.1.3`.
The crate's `unknown` tests build their graphs by hand with `DependencyGraph::from_resolved`, so none of them calls `build_project_graph` and none can reach `exact_pin` at all. "The `dependable-tui` suite passing untouched" was therefore not evidence about this change. These start from a real Gradle catalog on disk, build the project the way `data::discover_projects` does, walk the row through `App::selected_key`, and then make the same `check_version` call `data::lookup` makes with the key that comes out — the whole path a version travels from a manifest to a badge. The second test is the one that would have caught the guard defect: it asserts `junit:junit` at `4.12` carries no version and spawns no lookup, and then runs the call the pipeline *would* have made, showing it answers `UpToDate` against a registry offering `4.13.2`. That false `ok` is why the version must never reach there. `tempfile` joins the crate as a dev-dependency because reaching `build_project_graph` requires a manifest on disk.
`Node::version` and the README both said a manifest-only dependency carries a version wherever its constraint named one release. That is half the rule: the spelling also has to be one this crate can parse as written, because a `Node::version` no consumer can compare with is read downstream as no version at all. Name the forms that fall out — `4.12`, `1.2.3.4`, `6.4.4.Final` — and why.
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 #107.
A manifest-only dependency graph reported every dependency's version as
unknown, including the ones the manifest had already resolved.serde = "=1.0.200"admits exactly one release; so does
<PackageReference Version="[1.2.3]" />, abare Gradle
4.12.0, and a PEP 440==2.28.1.dependable treesaidunknownfor all of them.
What changed
dependable_core::exact_pin(constraint, ecosystem) -> Option<&str>(new,crates/dependable-core/src/semver/pin.rs) — pure, IO-free. It decides bytranslation, not by a table of spellings: the constraint goes through the same
to_semver_constraintevery version check already uses, and it is a pin exactlywhen that translation is one
=comparator at full precision. No reading isinvented that an ecosystem's own translator does not already make, so
treeandcheckcannot disagree about the same line of the same file.Two properties the tests pin down:
translation.
to_semver_constraintpads and rewrites to produce a string thecomparison engine accepts (
1.0→1.0.0,6.4.4.Final→6.4.4, NuGet1.0.0.4→1.0.0) and none of those names a published artifact.semver::Versioncannot parse as written is notreported:
1.2.3.4,6.4.4.Final, and equally a two-segment4.12, a NuGet[1.0], a PEP 440==0.20. Each is exact beyond doubt, but every consumercompares with
semver::Versionon the declared string, and one that fails toparse there is treated as no version:
dependable-tui'sdata.rs::lookupfalls back to the newest compatible release and renders a green
ok. Shipping aknown false-green inside the branch whose purpose is removing false greens (fix(tui): a dependency whose version was never read renders as up to date #96)
is the outcome that branch exists to prevent. The first push of this branch
did exactly that — see "Repaired after review" below.
Wiring (
crates/dependable-fetch/src/tree.rs, private) — both manifest-onlyassemblers now carry the pin onto the node:
direct_graph(the non-Cargo path)and
shallow_graph(Cargo with noCargo.lock). Candidacy is gated on theexisting
Item::is_checkable(), so git and path references, and an unresolvedInheritedentry, stay unknown without a second rule. Where two items share aname and do not yield the same pin, the node reports
None— ambiguity is not aresolution, and first-wins would make the answer depend on source order.
Known limitation, stated plainly
This does not close the issue's own headline example. A
*.csproj<PackageReference Version="1.2.3" />still reportsunknown, becausenuget_constraint_to_semverreads a bare NuGet version as>=1.2.3— aninclusive minimum, not a pin (
crates/dependable-core/src/semver/nuget.rs,comment: "A bare version is an inclusive minimum in NuGet").
maven.rsmakesthe opposite call for the identical shape ("A bare version is read as exact, as
Hex's is").
Filed separately as #113: that
asymmetry is a real defect — because
>=makeslatest_compatibleequallatest_availableand there is no NuGet lockfile reader in the tree,checkon abare-
Version*.csprojreports UpToDate whatever NuGet publishes. Fixing itchanges
check,list,fix,report, and SARIF output for every C# user andwould make
--fixstart offering rewrites it does not offer today, which is whyit was not folded into a PR about tree nodes.
Base
Based on
fix/96-unknown-graph-version(PR #104), notmaster.Decisions taken
1. Deliverable boundary — which manifest-only assemblers gain the pin
direct_graph(the non-Cargo path) andshallow_graph(the Cargo, no-lockfile path).direct_graphalone —run_treecallsbuild_workspace_graphand NEVER reachesdirect_graph, so that option changes nothing adependable treeuser can observe; the whole delivery would be invisible from the CLI. The Cargo case is also the one needing no ecosystem judgement at all, since=1.2.3translates to a single full-precisionOp::Exact.Nonefor external packages inshallow_graphand delete its test case.2. Which declared constraints count as a pin
to_semver_constraint(constraint, ecosystem)output parses as a VersionReq with exactly ONE comparator,Op::Exact, with both minor and patch present. No new per-ecosystem judgement is invented.Version="1.2.3"count — the asymmetry was verified directly:nuget.rsmaps a bare version to>={v}with the in-code comment "A bare version is an inclusive minimum in NuGet", whilemaven.rsmaps a bare version to={v}with the comment "A bare version is read as exact, as Hex's is." Admitting NuGet bare here would make the same string mean two different things in two modules, and would maketreesay "update" for a packagecheckreports UpToDate on the same file.==2.28.1and Cargo=1.2.3, unambiguous pins by anyone's reading, still reporting unknown, and it makes the rule about FILES where the issue frames it about CONSTRAINTS.nuget_constraint_to_semverso a bare version becomes=— that is the most correct long-term fix and it repairs a real defect (issue fix(core): a bare NuGet Version reports UpToDate whatever the registry publishes #113), but it changescheck,fix,reportand SARIF output for every C# user inside a PR about tree nodes, and would make--fixstart offering rewrites it does not offer today.Ecosystem::CSharpclause to the helper admitting a bare fully-specified literal.*.csproj<PackageReference Version="1.2.3" />still reportsunknown, because NuGet's translation makes it>=1.2.3. See the section above and issue fix(core): a bare NuGet Version reports UpToDate whatever the registry publishes #113.3. A pin whose spelling the comparison engine cannot parse
semver::Versionexactly as the manifest wrote it, with no normalization.1.2.3.4,6.4.4.Final,4.12,[1.0]and==0.20therefore stay unknown;32.1.3-jre,4.12.0,13.0.1,=1.0.200and==2.28.1are reportable.normalize_version", and the code matched the words. That was wrong, and it is the HIGH finding this branch was repaired for. See "Repaired after review".crates/dependable-tui/src/data.rs::lookupcompares raw natives withcheck_version("*", &versions, Some(version)); whenVersion::parsefails,lockedis None,currentfalls back to the newest compatible release, and the row renders a GREENok. Admitting Maven pins like6.4.4.Finalputs non-semver strings in front of that for the first time. Shipping a known false-green inside the branch whose whole purpose is removing false greens is the one outcome issue fix(tui): a dependency whose version was never read renders as up to date #96 exists to prevent.data.rs::lookupto translate — the machinery it needs (to_semver_versions,in_native_versions,native_for) is private independable-fetch, so this means either duplicating a rule the repository keeps deliberately single, or a public-API decision larger than this issue. Filed as fix(tui): the package lookup compares untranslated versions on both sides, so a non-semver ecosystem is misread #148.exact_pinand not indata.rs::lookup:exact_pinis the component making the claim, so it is the component that must prove it. ANode::versionis read by the CLI renderers, the JSON and DOT emitters, the OSV query, and the TUI alike; only the helper can guarantee the string is safe for all of them. Repairing one consumer leaves every other present and future consumer of an unparseableNode::versionbroken — which is exactly what fix(tui): the package lookup compares untranslated versions on both sides, so a non-semver ecosystem is misread #148 documents, and why it is filed rather than folded in here.Checkerevaluation method the TUI calls — adds public API todependable-fetchfor an issue about a graph node and drags a third crate into the change.data.rs::lookup.4. Whether a declared pin is distinguishable from a lockfile resolution
Node::versiondoc comment and README.GraphSource— that is per-GRAPH, not per-node, so it cannot express the mixed graphwith_rootalready builds (a lockfile graph whose root node's version came from the manifest), and it reaches only the CLI, not the TUI the issue names.NodeviaLockedPackage— it spans all four crates and either changesLockedPackage::new's arity, touching every lockfile parser, or adds a second constructor a future parser can pick wrongly.VersionSource-style field toNodeandLockedPackage, fed at each construction site.5. (taken in-lane, then re-picked in repair) Witnesses for "report the declared spelling, never the translation"
32.1.3-jrerow, and prove the rule with rows where the translation genuinely differs and the literal parses raw: a Maven release alias (1.0.0-RELEASE→=1.0.0), a PEP 440 local segment (==1.2.3+local→=1.2.3), and a PEP 440 pre-release respelling (==1.2.3-rc1→=1.2.3-rc.1).1.0(Jvm),[1.0](CSharp) and==0.20(Python) — all three padding cases, i.e. inputs the rule now rejects. A test proving a rule only on inputs the rule excludes proves nothing.maven_to_semver("32.1.3-jre") == "32.1.3"is wrong in the other direction too.maven.rskeeps an unrecognized qualifier as a semver pre-release, so the translation is=32.1.3-jre. What makes the declared spelling the right answer for guava is that Maven Central publishes32.1.3-jreand32.1.3-androidand no bare32.1.3— not that the translation truncates it. Verified by a failing assertion, not by reading.6. (taken in-lane)
Node::version's doc commentNode::version's doc comment incrates/dependable-core/src/graph.rsthat now say the opposite of what the code does. Documentation only — no field, no signature, no behaviour.graph.rsunder "Do NOT touch" and, under "Signature changes", requires this exact restatement. The specific instruction was followed over the general one, on the reasoning that "Do NOT touch" is aimed at the structural change decision 4 rejected, and that leaving a public API's doc comment asserting the opposite of its behaviour is a worse outcome than a doc-only edit. Flagged here so a reviewer can reverse it in one hunk if the general rule was meant to win.Repaired after review
Three findings from review of the first push. All three are fixed in this branch;
the commits are
fix(core): prove a pin on the string its consumers actually parseand the twotest:commits after it.HIGH — the guard modelled a padded trip; its consumers make an unpadded one
exact_pin's last line was:normalize_versionpads on dot count: 0 dots →{core}.0.0, 1 dot →{core}.0,2+ unchanged. No consumer pads.
git grep normalize_versionovercrates/*/srcreturns no call site on the consumer path at all —
declared_pinputs the literalstraight into a
LockedPackageversion,DependencyGraph::from_resolvedclonesit, the TUI row clones it,
App::selected_keyreads it withrow.version.as_deref()?,model::keydoes a bareto_owned(), anddata.rs::lookuphands the raw string tocheck_version("*", &versions, Some(version)). The comment asserted the opposite of what the line did.Reproduction.
gradle/libs.versions.toml:Maven Central publishes
4.13.2,4.13.1,4.13,4.12,4.11forjunit:junit.exact_pin("4.12", Jvm)translated to=4.12.0— one comparator,Op::Exact, minor and patch present — and the guard padded4.12to4.12.0,which parses. So it returned
Some("4.12"). Then incheck_version("*", […], Some("4.12")): only4.13.2and4.13.1parse,latest_available = 4.13.2,locked = NonebecauseVersion::parse("4.12")fails,currentfalls back tolatest_compatible = 4.13.2, the armSome(cur) if *cur >= latest_availableistaken, and
ui/tree.rsrenders a greenokfor a dependency three releasesand nine years behind. Same walk for PEP 440
==0.20and NuGet[1.0].This is verbatim the failure class of #96 — "a dependency whose version was
never read renders as up to date" — reintroduced inside the branch stacked on
#96's own fix.
The false green appears whenever the registry publishes at least one strict-semver
stable release. Two conditions suppress it, neither common: if no published
version parses as strict semver the status is
Errorand no badge renders; if thenewest parseable release is a pre-release the status is
UpdateAvailable.Why nothing caught it, which is the interesting part. The two consequences of
carrying an unparseable version suppress each other.
ui/tree.rsrendersVULN nahead of the status badge, so where OSV matches on the raw string the
vulnerability badge hides the false
ok; where OSV does not match — the sameraw string missing an advisory, a silent false negative on vulnerabilities — there
is no VULN badge and the green
okis what shows. Each failure mode is onlyvisible in the state where the other is absent.
Fix. Parse the literal exactly as written, and correct the comment. Strictly a
narrowing, with no user-visible regression: every case it drops reported
unknownbefore this branch, and it is what #107's own criterion asks for — "the declared
string parses cleanly as an exact
semver::Version".What
exact_pinreturns, precisely:Some(…)None(wasSomebefore the repair)=1.2.3,= 1.2.3,=1.2.3-alpha.14.12,1.0— any two-segment bare version=1.3.0[1.0]— any single-version interval below three segments==2.28.1,==1.2.3+local,==1.2.3-rc1==0.20— any two-segment==[1.2.3],[1.2.3-beta.1]1.2.3.4,6.4.4.Final,[1.2.3.4], every range, union, wildcard, dist-tag and MSBuild property)4.12.0,1.9.24,32.1.3-jre,1.0.0-RELEASE3.10.3,== 3.10.3The rule in one line: the ecosystem's own translator must yield a single
full-precision
Op::Exactcomparator, and the declared literal must itself parseas a
semver::Version. Neither condition implies the other.MEDIUM — the partial-precision and NuGet-pin paths had no test above
pin.rsEvery graph-level assertion used a three-segment version; the committed Gradle
catalog has no two-segment entry and the committed csproj has no
[x.y.z]interval, so the entire NuGet accept path was untested outside
pin.rs. And thisPR's own claim that "the
dependable-tuisuite passing untouched is itself arequired outcome" was not supported: that suite's
unknowntests build theirgraphs by hand with
DependencyGraph::from_resolvedand never callbuild_project_graph, so they are structurally incapable of reachingexact_pin.Three tests close it:
a_two_segment_catalog_version_is_exact_and_still_resolves_nothing— a catalogholding
4.12beside4.12.0; the first resolves nothing, the second resolves.a_nuget_single_version_interval_resolves_and_a_two_segment_one_does_not— acsproj holding
[1.2.3],[1.0]and[1.0,2.0).crates/dependable-tui/tests/pinned_lookup.rs(new file) — starts from a realGradle catalog on disk, builds the project the way
data::discover_projectsdoes, walks the row through
App::selected_key, and makes the samecheck_versioncalldata::lookupmakes with the key that comes out.a_pin_the_comparison_engine_cannot_read_is_never_looked_upis the test thatwould have caught the HIGH finding: it asserts
junit:junitat4.12carriesno version and spawns no lookup, and then runs the call the pipeline would
have made, showing it answers
UpToDateagainst a registry offering4.13.2.All three were confirmed to fail against the un-fixed guard before being kept.
LOW — the Maven union defence was untested as an expectation
[1.0],[2.0]was rejected only becausepin_literal's character screen finds,/[/].maven::interval_rangeusesrfindand deliberately keeps thelast interval, so
maven_constraint_to_semver("[1.0],[2.0]")is=2.0.0—one comparator,
Op::Exact, full precision — which the comparator check wouldpass. If
pin_literalis ever relaxed, the helper would start reporting the lastinterval of a union as a resolution with the suite still green. Union rows
asserting
Noneare now in the table:[1.0],[2.0]for bothJvmandCSharp,and
(,1.0],[1.2,)forJvm.Issues filed out of this review
data.rs::lookupisecosystem-blind on both sides of the comparison: it passes an untranslated
current version and an untranslated registry version list into
check_version, where the CLI path (dependable-fetch/src/check.rs) runsto_semver_versionsandto_semver_constraintfirst and maps back throughnative_for. Pre-existing and separate from the guard defect — but it is why anunparseable
Node::versionproduced a green badge rather than an error, and itwill bite again for any future consumer. The machinery it needs is private in
dependable-fetch, so closing it is either a duplication or a public-APIdecision; the issue puts both options with their costs.
mirror image of fix(core): a bare NuGet Version reports UpToDate whatever the registry publishes #113.
Ecosystem::Dartfalls throughto_semver_constraint's_ => normalize_constraintarm, so apubspec.yamlfoo: 6.0.5parses asOp::Caretandexact_pinreturnsNone— even though pub reads a bare versionas exact (
^6.0.5being the range form). Nothing in the tree covers Dart.Go (
require x v1.6.0under MVS) is adjacent but genuinely uncertain and isexplicitly excluded from that issue.
Validation
Every command prefixed
env -u FORCE_COLOR -u COLORTERM, since this repository hastests sensitive to an ambient
FORCE_COLOR(#100).cargo test -p dependable-core semver::pincargo test -p dependable-core semvercargo test -p dependable-fetch --test project_graphcargo test -p dependable-fetch --test treecargo test -p dependable-tuimise run testmise run fmt:checkmise run lint(clippy-D warnings)convco check origin/fix/96-unknown-graph-version..HEADno errors in 7 commitsSome
pin.rstable rows changed verdict, which is the point of the repair ratherthan a regression:
("==0.20", Python)wentSome("0.20")→None, and thethree witnesses of
reports_the_declared_spelling_and_never_the_translation(
"1.0"Jvm,"[1.0]"CSharp,"==0.20"Python) were replaced rather thanloosened. No test was weakened, skipped, or narrowed to make anything pass.
The earlier claim here — that "the
dependable-tuisuite passing untouched isitself a required outcome" — was withdrawn: that suite could not reach this
change at all. It now has a test that can (
tests/pinned_lookup.rs), which iswhat actually protects decision 3.