fix(tui): report a dependency with no known version as unknown - #104
Open
justin13888 wants to merge 7 commits into
Open
fix(tui): report a dependency with no known version as unknown#104justin13888 wants to merge 7 commits into
justin13888 wants to merge 7 commits into
Conversation
A graph built from manifests alone resolves no versions, and both `LockedPackage` and `Node` recorded that state as `String::new()`. An empty string is a value, not an absence: every consumer downstream had to remember that this particular string means "we never learned the version", and one that forgot would read it as a version and act on it. Both fields become `Option<String>`. A lockfile always records a version, so every parsed entry is `Some`; `None` is carried only by the entries the builder synthesizes — a shallow Cargo workspace, a manifest-only project graph, an npm or Bun workspace link, and a project whose manifest declares no version of its own (a `pom.xml` inheriting from a `<parent>`, a `*.csproj`). BREAKING CHANGE: `dependable_core::LockedPackage::version` and `dependable_core::Node::version` are now `Option<String>`, and `LockedPackage::new` takes `Option<String>`. Both types are re-exported from `dependable_fetch::core`. Rendered output is unchanged: every existing consumer treated the empty string as "unknown" already, so each one keeps that behaviour against `None`.
`tree --format json` is a schema other tools read. A node whose version was never resolved was serialised as `"version": ""`, which a consumer can only read as a package at the empty version. It is now `null`. `null` rather than omitting the key: every node keeps the same shape, so a consumer never has to tell an absent key from an absent version, and a schema that drops a field on some rows is the harder one to parse. The ASCII and DOT renderers are unchanged — both already printed the bare name for a node with no version. BREAKING CHANGE: `tree --format json` emits `"version": null` for a node whose version was never read, where it previously emitted `""`. Every node in a shallow tree (no `Cargo.lock`) is in that state.
A row in the tree carried its version as a `String` that was empty when nothing had read one. Three separate `version.is_empty()` checks — in `selected_key`, in `status_badge`, and in the detail pane — were what kept the tool from asserting freshness about a package it knew nothing about. Delete any one of them and the row silently gains a green `ok`: `check_version` reads an empty current version as "not locked", falls back to the newest compatible release, and answers `UpToDate`. `Row::version` becomes an `Option<String>`, so the check is the type rather than three copies of a convention. `age` — the one place that never had the check — gets it as a consequence, and the detail pane's `resolved` version is now passed down to the metadata block instead of being read back off the row, so the code that formats a version cannot be reached without one. The status column now reports `unknown` for such a row rather than leaving the cell blank. A blank cell reads as "nothing to report about this package", which is the opposite of what is true; `unknown` says the tool never learned what version is declared. This is the state every Gradle, `pom.xml`, `*.csproj`, and `mix.exs` project is in, along with any Cargo workspace with no `Cargo.lock`. The detail pane's explanation drops its "no lockfile for this project" clause: that is one of several reasons a version goes unread — a `pom.xml` inheriting from a `<parent>` has one, and a `pubspec.lock` is read but records no edges — and the project row's own caveat is where the reason belongs. Closes #96
Replacing the `String::new()` sentinel with `Option<String>` did not on its own close issue #96: nothing normalized `Some("")`, so the sentinel simply moved inside the `Option`. Four parsers can still produce it — a hand-edited or generator-produced `Cargo.lock` with `version = ""`, `unquote("\"\"")` in the Mix reader, `"version": ""` or a bare `"v"` in the Composer reader, and the Bun reader — and every consumer that had an `is_empty()` guard before now has only an `Option` check. The symptom is issue #96 verbatim. `Version::parse("")` fails, so the checker's `locked` is `None`, `current` falls back to the latest compatible release under a `*` constraint, and the `>= latest_available` arm answers `UpToDate`: a green `ok` in the TUI status column and a "latest 2.0.0 / status up to date" detail pane for a package whose version nobody ever read. The renderers say `serde v` for the same node. `LockedPackage::new` is the one choke point every package entry passes through, parsed and synthesized alike, so an empty version is normalized to `None` there. That makes `Some("")` unrepresentable rather than merely discouraged, which is what issue #96 asked for — an `Option` that still admits the empty string has not removed the sentinel, only re-typed it. Adding `is_empty()` checks back at each consumer would leave the next consumer to rediscover the rule. Three tests falsify it: the parser test that a `Cargo.lock` recording `version = ""` yields `None`, the TUI test that such a node reports `unknown` and is never looked up, and the renderer test that no `ascii`, `dot`, or `json` output prints it as a version. Also corrects the `LockedPackage::version` doc, which claimed a parsed entry is always `Some` — the npm and Bun readers both parse workspace links, which record a location rather than a version and yield `None`. BREAKING CHANGE: `dependable_core::LockedPackage::new` no longer stores an empty `version`; it is normalized to `None`, so `LockedPackage::version` and `graph::Node::version` are never `Some("")`. This also declares a break that landed unmarked earlier in this branch: `dependable_tui::rows::Row::version` is `Option<String>` rather than `String`.
…file `shallow_graph` builds every Cargo workspace member from the `(name, manifest content)` pair it just parsed and passes `None` for the version, discarding the `[package] version` sitting in that content. A `Cargo.toml` declaring `version = "0.4.2"` with no `Cargo.lock` beside it renders as `demo-app (workspace)` and reports `"version": null` in `--format json`. That is a false claim, not merely a missing one. A member is resolved against nothing: what its manifest declares *is* its version, whether or not a lockfile exists, so there is no "unresolved" state to report. `None` on a member says no version was ever read for it, which is more emphatically wrong than the empty string was — the TUI detail pane reads "version not resolved — nothing read a version for this" over a crate whose version is written one line below its name. `build_project_graph` already reads `meta.literal_version()` for every non-Cargo root; the Cargo path simply never did. This reads the member's version the same way, and resolves `version.workspace = true` against the root's `[workspace.package]` table, which is already in hand here — reading only the literal would report every member of a version-inheriting workspace as unknown, which is the majority shape for a Cargo workspace. A member's *dependencies* stay unknown, which is the distinction issue #96 is about: a manifest declares a constraint, and nothing here resolved it. `falls_back_to_shallow_graph_without_lockfile` asserted the defect — every node `version.is_none()` over a fixture whose three members each declare `version = "0.1.0"`, under the message "a manifest-only graph resolves no versions". It is corrected to assert what is true of that fixture: the members report `0.1.0`, `serde` and `gitdep` report nothing, and no node carries the empty string. A new test covers the inherited case.
The `tree` section documented the shallow graph as producing `null` "for every node", which described a defect rather than the intended behaviour: a workspace member carries the version its manifest declares, and only its dependencies are unknown. It also claimed a version "is never the empty string", which was untrue until the parse boundary normalized one away. Both are now accurate, and the note covers the `ascii` and `dot` renderers as well as `--format json`, since all three read the same absence.
…e crate
`shallow_graph` resolved every collected member's `version.workspace = true`
against the outer scan root's `[workspace.package]`. But `collect_members`
walks every `Cargo.toml` under the root, and `SKIP_DIRS` covers only `target`,
`node_modules`, `.git` and `vendor` — so a `cargo fuzz`, `examples/` or `xtask`
directory holding its own independent `[workspace]` is descended into, and its
crates took a version from a root Cargo would never resolve them against.
Nobody excludes such a subtree either, because Cargo already ignores it.
For the layout below with no `Cargo.lock`, `tree` reported `a-fuzz v1.0.0`:
root/Cargo.toml [workspace.package] version = "1.0.0"
root/crates/a/Cargo.toml version.workspace = true
root/fuzz/Cargo.toml own [workspace], version = "0.0.0"
That is worse than the state it replaced, where the node simply had no version:
it turns "unknown" into a confidently wrong number, the failure class this whole
branch exists to remove.
The walk now carries whether the scan root is a crate's nearest `[workspace]`
ancestor, and an inherited field is resolved only when it is. A crate in a
nested workspace reports no version rather than a borrowed one — resolving it
against its own root would mean a second resolution scope, which is a larger
change than this. A version such a crate states outright is still its own, and
still reported.
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 #96
Phase 1 verdict: the symptom is not currently reachable. The fix is prophylactic.
I tried to reproduce the reported green
okbefore changing anything, by drivingAppover a graph shaped exactly asdirect_graph/shallow_graphsynthesize one(a
NodeKind::Registrynode withString::new()as its version). Two routes:serderow.App::selected_keyreturns
None, sospawn_lookupis never called and nothing is ever stored inapp.packagesunder an empty-version key. The row rendered with a blankVERSION, blank AGE, and a blank STATUS; the detail pane said "version not
resolved".
PackageData::Readywithstatus: UpToDateandlatest: 2.0.0directly under the(Rust, "serde", "")key, bypassingselected_keyentirely.status_badge's ownrow.version.is_empty()checkstill blanked the STATUS cell. No
ok, no latest version.age()inui/tree.rs— the one route without the check — is also inert, becauseit only reads
app.packages, and no code path ever populates that map under anempty-version key. So three independent guards, none of them load-bearing on its
own, are what currently hold. A user cannot see the reported green
okonmastertoday.What is visible on
masteris the same dishonesty one layer over, in a publicschema.
dependable tree --format jsonon a Rust project with noCargo.lockemitted
"version": ""for every node — including the root, whose version themanifest plainly declares.
The issue's Direction paragraph is the substance either way: the distinction
between "no known version" and "the empty version" was held by three copies of a
convention, and deleting any one of them silently restores the bug. That is what
this changes.
The change
Node::versionandLockedPackage::versionindependable-corebecomeOption<String>. The distinction is now the type, not a sentinel string plus aconvention that every consumer has to remember.
Noneis produced only where nothing read a version: a shallow Cargo workspace(no
Cargo.lock), a manifest-only project graph (Gradle,pom.xml,*.csproj,pubspec.yaml), an npm/Bun workspace link, and a project whose manifest declaresno version of its own. Every lockfile parser still yields
Some.Row::versionin the TUI follows, soselected_key,status_badge,age, andthe detail pane get the check from the type.
agenever had it. The detailpane's
resolvedversion is now threaded intofacts_lines/metadata_linesrather than read back off the row, so the code that formats a version cannot be
reached without one.
The TUI's STATUS column now reports
unknownfor such a row instead ofleaving the cell blank. A blank cell reads as "nothing to report about this
package"; the truth is that the tool never learned what version is declared.
Breaking changes
Library (
dependable-core, re-exported fromdependable_fetch::core):Node::versionisOption<String>(wasString).LockedPackage::versionisOption<String>(wasString), andLockedPackage::newtakesOption<String>.Both types are
#[non_exhaustive], so the field change is the breaking part.The only external consumer named in the repo is an IDE via
dependable-fetch.JSON output schema (
dependable tree --format json):"version": null, whereit previously serialised as
"version": "". Every node of a shallow tree is inthat state.
null, not an omitted key. Every node keeps the same shape,so a consumer never has to distinguish an absent key from an absent version.
Reverses by adding
#[serde(skip_serializing_if = "Option::is_none")]toNodeDto::version.The ASCII and DOT renderers are byte-for-byte unchanged — both already printed the
bare name for a node with no version, so
output/tree.rs's existing assertionsstand untouched. No test was deleted or weakened.
What now falsifies the bug
Three new tests in
crates/dependable-tui/tests/render.rs, driving the realrenderer over
TestBackend:a_dependency_with_no_known_version_says_unknown— reads the STATUS cell out ofits column (not the whole screen) and requires it to be
unknown.a_dependency_with_no_known_version_is_never_looked_up—selected_key()isNone.a_freshness_verdict_never_attaches_to_a_version_we_did_not_read— forcesReady { status: UpToDate, latest: 2.0.0 }into the store under the emptyversion and requires the cell to still read
unknownand2.0.0to be absent.I checked these are failing-first two ways. Replacing the
unknownbranch withreturn None(i.e.master's blank cell) fails the first and third. Removing theversion check from
status_badgeentirely — the actual bug the issue describes —renders
serde … okand fails the third with that exact screen in the message.Plus
json_reports_an_unread_version_as_nullinoutput/tree.rs, and two graph-level tests:
a_manifest_only_graph_leaves_every_dependency_version_unknown(over the
sample-kotlinGradle catalogs) and an assertion inside the existingfalls_back_to_shallow_graph_without_lockfile.Judgement calls
LockedPackage::versionfollowsNode::version. It did not have to: Icould have kept
Stringthere and decoded""toNoneinsideDependencyGraph::from_resolved. I did not, because that relocates the sentinelrather than removing it, and because
LockedPackagehas four synthesizingproducers of its own (
shallow_graph's members and externals,direct_graph,with_root) that all mean "unknown". Reverses by restoringStringandnormalising empty-to-
Noneat the onefrom_resolvedboundary.Declined to use
parsed.items[..].version_constraintattree.rs. Theissue asks whether a declared constraint is a better answer than "unknown". It
is not, and using it would reintroduce the reported bug: a constraint is not a
resolved version, and
check_version("*", versions, Some(constraint))parses arange like
[1.0,2.0)or^1.2as aVersion, fails, falls back tolatest_compatible, and answersUpToDate— the same wrong greenok, nowfrom data that looks plausible. A constraint would need to be carried as a
constraint and evaluated as one, which is a larger change than this issue.
root_versionbecameOption<&str>. A manifest that declares no version ofits own (a
pom.xmlinheriting from<parent>) now leaves the project root'sversion unknown instead of blank, which is the case the issue calls out as
affecting every dependency in such a file.
Detail-pane wording. "version not resolved — no lockfile for this project"
became "version not resolved — nothing read a version for this". The old clause
was often false: a
pom.xmlwith a<parent>has no lockfile at all, andpubspec.lockis read but records no edges. Why it could not be read is theproject row's caveat to explain.
VERSION column stays blank for an unknown version, rather than repeating
unknown. STATUS is where the row says it does not know; saying it twice on oneline adds nothing.
Observed but out of scope
dedupe_workspacesincrates/dependable-tui/src/data.rsfingerprints a projectby its roots'
name version. Two distinct projects of the same ecosystem whoseroots share an inferred name and both have unknown versions fingerprint
identically and one is dropped. That is pre-existing, is not what #96 reports,
and is not fixed here — the fingerprint keeps its current behaviour (unknown
renders as a fixed
?). Fixing it properly means folding the manifest path intothe fingerprint.
Validation
The 20 ignored are the network-gated tests that run under
mise run test:live.FORCE_COLOR/COLORTERMare unset because they makeowo-colorsemit ANSIinside tests and break
output::tree::tests::ascii_points_a_member_at_its_own_treeon every branch,
masterincluded.Review repairs
An adversarial review built and ran both binaries against this branch and found
that the change did not fix the defect it named as the real one, and reopened
issue #96's own symptom through a narrower door. Both are confirmed with
side-by-side output and repaired in three further commits.
1. A workspace member's declared version was discarded (HIGH)
shallow_graphbuilt every Cargo member from the(name, manifest content)pair it had just parsed and passed
Nonefor the version, throwing away the[package] versionin that content.build_project_graphalready readsmeta.literal_version()for non-Cargo roots; the Cargo path never did.Reproduced with a
Cargo.tomldeclaringname = "demo-app",version = "0.4.2", one dependency, noCargo.lock:--format json{"name":"demo-app","version":null,…}{"name":"demo-app","version":"0.4.2",…}asciidemo-app (workspace)demo-app v0.4.2 (workspace)nullthere was a worse answer than""had been, not a better one. A member isresolved against nothing — what its manifest declares is its version — so
there is no unresolved state to report, and the detail pane read "version not
resolved — nothing read a version for this" over a crate whose version sits one
line under its name. Its dependencies stay unknown, which is the distinction
the issue is actually about: a manifest declares a constraint, and nothing here
resolved it.
version.workspace = trueis resolved against the root's[workspace.package]table, which
shallow_graphalready holds — reading only the literal wouldreport every member of a version-inheriting workspace as unknown, which is the
majority shape for a Cargo workspace.
Test correction, not weakening.
falls_back_to_shallow_graph_without_lockfileasserted
nodes().iter().all(|n| n.version.is_none())under the message "amanifest-only graph resolves no versions", over a fixture whose three members
each declare
version = "0.1.0". It pinned the defect. It now asserts what istrue of that fixture:
a,b, andcreport0.1.0;serdeandgitdepreport nothing; no node carries the empty string. A new test,
a_member_inheriting_its_version_from_the_workspace_root_still_reports_one,covers the inherited case.
README.mddocumented the defect as intended and iscorrected too.
2.
Some("")was never normalized, so issue #96's symptom was reachable again (HIGH)masterguarded withrow.version.is_empty(), which covered both "no version"and "empty version". This branch replaced it with an
Optioncheck and nothingnormalized
Some("") → None. Four parsers can still emit it:cargo_lock_graphfrom a hand-edited or generator-produced
Cargo.lockwithversion = "",mix_lock_graphfromunquote("\"\""),composer_lock_graphfrom"version": ""or a bare"v"throughstrip_v, andbun_lock_graph.Reproduced on a
Registrynode whose version isSome(""):selected_key()Some((Rust, "serde", ""))— a real lookup is spawnedNoneokunknownlatest 2.0.0/status up to dateThat is issue #96 verbatim. The mechanism is in
semver/checker.rs:Version::parse("")fails,lockedbecomesNone,currentfalls back tolatest_compatibleunder the*constraint, and the>= latest_availablearmanswers
UpToDate.age()was likewise unguarded.Repaired at the parse boundary, not at the consumers.
LockedPackage::newisthe single choke point every package entry passes through, parsed and synthesized
alike, and it now normalizes an empty version to
None. Addingis_empty()checks back at each consumer would have left the next consumer to rediscover the
rule — and an
Optionthat still admitsSome("")has not achieved what theissue's Direction asked for, which is that the distinction cannot be lost by
accident.
Nodeis only ever constructed insideDependencyGraph::from_resolvedfrom a
LockedPackage, so the boundary covers the whole graph.Three tests falsify it, each verified failing without the normalization:
an_empty_version_is_recorded_as_no_version_at_all(dependable-core) — aCargo.lockrecordingversion = ""parses toNone, and so does asynthesized entry.
an_empty_version_string_is_treated_as_no_version_at_all_not_as_up_to_date(
dependable-tui) — the row carries no version,selected_key()isNone,and the STATUS cell reads
unknown.an_empty_version_in_a_lockfile_is_never_rendered_as_a_version(
dependable) — noascii,dot, orjsonoutput prints it as a version.3. The renderers printed
serde v(LOW)output/tree.rsdropped itsis_empty()checks formatch … as_deref(), sowith a
Cargo.lockcarryingversion = ""the branch rendered└── serde vand
n1 [label="serde v"]wheremasterrendered└── serdeandn1 [label="serde"]. Fix 2 closes it — confirmed by rerunning the samelockfile, which now renders
└── serdeandn1 [label="serde"]— and therenderer test above pins it. The body's claim above that the ASCII and DOT
renderers are byte-for-byte unchanged held for
Nonebut not forSome(""); itholds now.
4. The
dependable-tuibreak, declareddependable_tui::rows::Row::versionisOption<String>where it wasString.rowsis apub modanddependable-tuiis published, so this is a public APIbreak alongside the
dependable-coreand JSON-schema ones listed above. Itlanded in
fix(tui): report a dependency with no known version as unknown, whichcarries neither a
!nor a trailer; that commit is pushed and cannot bereworded, so the break is declared here and carried as a
BREAKING CHANGE:trailer on
fix(core)!: read an empty version as no version at all.5. A doc claim contradicted by code in the same PR (LOW)
LockedPackage::version's doc said "A lockfile always records one, so a parsedentry is always
Some.Noneis what a synthesized entry carries."package_lock_graphandbun_lock_graphboth produceNonefor parsedentries — an npm or Bun workspace link records a location
(
workspace:packages/lib), not a version. The doc now says so, and states thatSome("")is unrepresentable.Decisions taken
[package] versionis read, notunknown. It is reported, inherited versions included.
Some("")is unrepresentable, normalized at the parse boundary ratherthan checked for at each consumer.
tree --format jsonkeepsnullrather than omitting the key.Explicit beats absent, and the README documents it.
LockedPackage::versionstays anOptionandnew()normalizes —both, not either. The type states the intent; the constructor enforces it.
.csprojVersion="1.2.3", a Gradle catalog pin) stays reported as unknown for now.That is outside issue fix(tui): a dependency whose version was never read renders as up to date #96, and the "a constraint is not a resolution" argument
above does not cover a constraint admitting exactly one version. Filed as
feat(fetch): report a dependency pinned to an exact version rather than leaving it unknown #107.
unknownwith a blank VERSION column, andthe detail-pane wording, are kept as the body describes them.
Validation after the repairs
No test was deleted or weakened. The one test that changed is the corrected
falls_back_to_shallow_graph_without_lockfile, which asserted the defectdescribed in repair 1; it now asserts more than it did, not less.
Repair: a nested independent workspace no longer inherits the outer root's version
The commit that taught the shallow graph to resolve
version.workspace = trueresolved it for every collected member against the outer scan root's
[workspace.package]. But the member walk descends into everyCargo.tomlunder the root — the skip list covers only
target,node_modules,.gitandvendor— so acargo fuzz,examples/orxtaskdirectory holding its ownindependent
[workspace]was swept in, and its crates took a version from aroot Cargo would never resolve them against.
[workspace] excludedoes notsave it either: nobody lists a nested workspace there, because Cargo already
ignores such a subtree.
Given this layout with no
Cargo.lock:and in
--format json,a-fuzz's"version"goes from"1.0.0"tonull.This reached
ascii,dotandjsonalike, and it was worse than the state itreplaced — that node previously had no version at all, so the change turned
"unknown" into a confidently wrong number, which is the exact failure class this
branch exists to remove.
The member walk now carries whether the scan root is a crate's nearest
[workspace]ancestor, and an inherited scalar is resolved only when it is. Acrate inside a nested workspace reports no version rather than a borrowed one;
resolving it against its own root would mean introducing a second resolution
scope, which is a larger change than this. A version such a crate states
outright is unaffected and still reported. Bounded to the lockfile-less shallow
path — with a
Cargo.lockthe nested crate is not in the lock and never appears.Covered by
a_nested_independent_workspace_does_not_inherit_the_outer_roots_version,which fails on the previous commit with
left: Some("1.0.0"), right: None.Validation after this repair
857 is 856 plus the new test; nothing was deleted or weakened.
list --depth 4,list --format json,treeandtree --format jsonoverthis repository and all 16 fixtures remain byte-identical to
master. Thosetargets all resolve a lockfile, so they do not exercise the changed path; a
second sweep over lockfile-less copies of
sample-rust,sample-workspace,sample-workspace-inheritandsample-monorepoplus thefuzz/andexamples/inner/layouts above, comparing this commit against its parent,differs in exactly two places: the
a-fuzzanda-exampleversions dropping tonull. Every other output on that corpus is unchanged.