refactor!: harden the workspace-root descriptor before a second ecosystem uses it - #102
Open
justin13888 wants to merge 4 commits into
Open
refactor!: harden the workspace-root descriptor before a second ecosystem uses it#102justin13888 wants to merge 4 commits into
justin13888 wants to merge 4 commits into
Conversation
`WorkspaceRoots` named a list of candidate roots and one `root_kind` for all of them, which cannot describe an ecosystem whose roots are not all one file format. A JavaScript member roots either at a `pnpm-workspace.yaml` or at the workspace root's own `package.json`; one kind over both names runs the JSON parser over YAML (the pnpm root is silently walked past) or the YAML parser over JSON (a Bun root is mis-read). Each candidate now carries its own kind, and the walk reports back the kind of the root it found, so the parser that recognised a root is the parser that reads it. Three consequences: - `nearest_workspace_root` and `workspace_root_of` return the root's kind alongside its path and text. - `workspace_declarations` takes the *root's* kind rather than the member's, which its doc already claimed and its signature contradicted; a caller holding a located root and its text can now call it. - A self-governing manifest is recognised and read as its own kind, so the two can no longer disagree. `self_governing` documents that invariant and a `debug_assert!` in `workspace_root_of` enforces it, since accepting a manifest as its own root with one parser and reading it with another yields `Err`, and an unparseable root declares nothing at all — every inherited entry then reported as unresolved rather than as wrong. BREAKING CHANGE: `WorkspaceRoots::root_names` is now `&[(&str, ManifestKind)]` and `WorkspaceRoots::root_kind` is gone; `nearest_workspace_root` and `workspace_root_of` return a three-tuple; and `workspace_declarations` takes the root's kind.
…s path `WorkspaceCache` memoized a root's declarations under its path alone, which is only correct while a path determines the parser that read it. It no longer does: a candidate name now carries its own kind, so one file can be a candidate root for two of them, and whichever member kind was checked first would populate the cache for the other — an order-dependent wrong answer, so an intermittent one. `ManifestKind` derives `Hash` to be part of the key. BREAKING CHANGE: `WorkspaceCache` is now keyed on `(PathBuf, ManifestKind)`.
`workspace_package_defaults` was the one caller left re-deriving the parser instead of using the kind `nearest_workspace_root` now returns — the shape this branch removes everywhere else, still standing inside it. It cannot fire today: the function early-returns unless the member is a `Cargo.toml`, and Cargo's descriptor names a single candidate whose kind is Cargo's own. It becomes wrong the moment that descriptor gains a second candidate of another kind, which is exactly what pairing a kind with each candidate name now permits — and the failure would not be an error, it would be `[workspace.package]` defaults read out of a file that never declared any. Reading the root as a Cargo `[workspace]` table stays this function's own business, because scalar inheritance is a Cargo-only axis. The guard is how it says so, instead of assuming it.
`workspace_package_defaults` called `nearest_workspace_root`, which excludes the asking manifest, so a Cargo root that is also a package could never see the `[workspace.package]` table sitting in its own file. A single `Cargo.toml` holding both `[workspace.package] version = "9.9.9"` and `[package] version.workspace = true` is legal, and `list` reported it as `"version": null, "version_inherited": true` — both "there is no version" and "the version came from somewhere else" at once. `workspace_root_of` exists precisely to handle the self-governing case, and it is already what the dependency inheritance a few lines up resolves through, via `workspace_source`. The scalar axis now agrees; the manifest's own text is in hand at the call site, so the self case costs no extra read. This is pre-existing, not a regression: `origin/master` behaves identically. The refactor's repair commit touched this function and left the asymmetry standing, which is why it is closed here. A knock-on: a manifest declaring `[workspace]` whose own `[workspace.package]` names no `version` now reports none, where before it walked past itself and borrowed an outer root's. Cargo resolves such a manifest against its own table and errors when the key is absent — it never walks up — so reporting nothing is the honest answer rather than an unrelated workspace's number. `tempfile` joins the crate's dev-dependencies so the test can build the layout on disk; the fixtures directory is walked wholesale by `cli_report`, so a new fixture there would not have been inert.
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 #94.
The workspace-root descriptor introduced in #90 describes Cargo, and Cargo satisfies every assumption in it by coincidence: one candidate name, and a root kind equal to the member kind. This closes the four traps that only bite the second ecosystem to use the shape, while nothing depends on it.
Trap 2 — one
root_kindcannot cover a heterogeneous name listroot_names: &[&str]plus a singleroot_kindforces one parser over every candidate name. The real case is JavaScript: a member roots either atpnpm-workspace.yamlor, for Bun, at the workspace root's ownpackage.json.PackageJsonfor both runs the JSON parser over YAML and silently walks past the pnpm root;PnpmWorkspaceYamlfor both reads a Bun root as YAML.Each candidate now carries its own kind —
root_names: &'static [(&'static str, ManifestKind)]— androot_kindis gone. The per-directory scan is extracted intoroot_in_dir, which recognizes each candidate with the kind it is paired with and returns that kind with the path and the content, so the parser that accepted a root is the parser that reads it. A candidate its own kind does not recognize is walked past rather than ending the search, so an unreadable name can no longer hide a real root later in the list.The field doc no longer calls these "file names": Gradle's candidate is
gradle/libs.versions.toml, a relative path, and thedir.join()in the walk already depends on that.Falsified by
discover::tests::an_unrecognised_candidate_does_not_hide_a_later_one— a directory holding a plainpackage.jsonand a real Cargo workspace root, searched with a heterogeneous list. Revertingroot_in_dirto one shared kind across the list fails it (verified).discover::tests::a_candidate_is_read_with_the_kind_it_is_paired_withpins the other half: identical bytes are not a root underPackageJsonand are one underCargoToml, and the kind reported back is the pair's.Trap 1 —
self_governingsilently assumedroot_kind == kindworkspace_root_ofaccepted a self-governing manifest usingkind.declares_workspace, while the walk recognized one usingroots.root_kind.declares_workspace. With the two differing, the manifest was accepted by one parser and then read by the other, which returnsErr; an unparseable root declares nothing, so every inherited entry came backsource: "inherited", constraint: null— a wrong answer wearing the shape of a missing declaration.With trap 2 closed this is mostly structural:
workspace_root_ofreportskindas the root kind in the self case, which is the kind whosedeclares_workspaceaccepted the text, so recognition and parsing can no longer disagree.WorkspaceRoots::self_governingstates the invariant it needs — the kind must appear among its ownroot_nameskinds — and adebug_assert!inworkspace_root_ofenforces it.Falsified by
manifest::tests::a_self_governing_kind_is_one_of_its_own_root_kinds, which checks the invariant against every descriptor that exists, anddiscover::tests::a_self_governing_root_is_reported_as_its_own_kind.Trap 4 —
workspace_declarationstook the member's kindIts doc said the root is parsed as its own kind; its parameter was the member's kind, with the root kind re-derived inside. A caller holding a located root and its text could not call it without also knowing which member kind produced it. It now takes the root's
ManifestKinddirectly — exactly whatworkspace_root_ofandnearest_workspace_rootnow return — and both callers (discover::workspace_source,Checker::workspace_source) pass it through.This could not be a separate commit from trap 2: the function's body read
roots.root_kind, and that field is removed. It is in the same commit for that reason.Trap 3 — the workspace cache was keyed on the root path alone
Correct only while root path → root kind is a function. Trap 2's shape lets that be violated by accident, and the resulting failure is order-dependent — whichever member kind is checked first populates the entry — hence intermittent.
WorkspaceCacheis nowCache<(PathBuf, ManifestKind), Arc<Vec<Item>>>, andManifestKindderivesHashto be part of the key.Falsified by
cache::tests::a_root_cached_under_one_kind_is_not_served_to_another; reverting the key toPathBufis a compile error at every use site.Breaking API changes
All in
publibrary surface, all in the same direction — the located root's kind travels with the root:dependable_core::WorkspaceRoots::root_namesis&'static [(&'static str, ManifestKind)].dependable_core::WorkspaceRoots::root_kindis removed; the kind now belongs to the name that matched.dependable_fetch::nearest_workspace_rootanddependable_fetch::workspace_root_ofreturnOption<(PathBuf, ManifestKind, String)>.dependable_fetch::workspace_declarationstakes the root's kind rather than the member's.dependable_fetch::WorkspaceCacheis keyed on(PathBuf, ManifestKind).workspace_sourcekeeps its signature: its callers want the declarations, not the parser that produced them, and the root kind is already resolved by then. Reversed by having it return the kind too if a caller ever needs it.Judgement calls
("package.json", ManifestKind::CargoToml).WorkspaceRootsis#[non_exhaustive], so a synthetic descriptor cannot be built outsidedependable-core, anddeclares_workspacerecognizes only Cargo content today — a test using real descriptors would prove nothing. Extractingroot_in_dir, which takes a plain&[(&str, ManifestKind)], is what makes a heterogeneous list constructible in a test at all. Reversed by replacing the staged pairing with a real second ecosystem's descriptor once one recognizes roots.self_governingis guarded by adebug_assert!plus a test over all kinds, not by construction. The descriptor is a hand-writtenmatcharm, so the invariant cannot be encoded in the type without turning the constructor into a function. Reversed by makingWorkspaceRootsconstructible only through a checked builder.ManifestKindgained aHashderive rather than the cache hashing a kind discriminant by hand. Additive, and no variant carries data.ALL_KINDSwas added independable-core's test module so a kind-wide invariant is asserted over the whole set; an exhaustivematchover it means a new variant fails to compile rather than quietly skipping the invariants. The existingonly_cargo_looks_for_a_workspace_rootnow iterates it instead of its own hand-written list.Review repairs
An independent review confirmed the change's central safety property empirically:
dependable list --depth 4over this repository's own Cargo workspace — which uses[workspace.dependencies]inheritance — produces byte-identical output onmasterand on this branch.root_in_diris logically identical to master's inlined loop (samesame_fileexclusion, sameread_to_string, samedeclares_workspace, same continuation on failure), Cargo's descriptor is the single pair("Cargo.toml", CargoToml)— master'sroot_kind— and the.gitboundary is untouched. No existing Cargo workspace resolves differently, androot_in_dircannot walk past a root master would have found.The review also found that
self_governing's guard is stronger than this PR claimed. In release builds,workspace_root_ofreturnskindfor the self case and both callers pass that returned kind toworkspace_declarations, so recognition and parsing use one and the same kind by construction — trap 1's silentsource: "inherited", constraint: nullpath is structurally closed, not assert-guarded. Thedebug_assert!guards a weaker descriptor-consistency invariant whose violation would not reproduce trap 1, and it is additionally enforced at the only place a descriptor can be written, sinceWorkspaceRootsis#[non_exhaustive]with no constructor andall_kinds_lists_every_variant_once's exhaustive match forces a new variant intoALL_KINDSor fails to compile.Three findings came back.
workspace_package_defaultsstill re-derived its parser (crates/dependable/src/runner.rs) — the trap-4 shape left standing inside the PR that removes it everywhere else. It discarded the root kindnearest_workspace_rootnow returns and hard-codedparse_workspace. Inert today, since it early-returns for a non-Cargo member and Cargo's descriptor names one candidate; wrong the moment that descriptor gains a second candidate of another kind, which is exactly what this PR permits. The failure would not be an error — it would be[workspace.package]defaults read out of a file that never declared any. Now guarded on the returned kind. Fixed in354141c.workspace_declarations' contract change is silent, not type-checked. The signature stays(ManifestKind, &str) -> Vec<Item>; only the meaning of the first argument flips from the member's kind to the root's. Master also short-circuited toVec::new()for a kind with noworkspace_roots(), and that guard is gone, so the function now parses whatever kind it is handed. A downstream caller — the IDE integrationdependable-fetchexists to serve — callingworkspace_declarations(ManifestKind::PackageJson, root_text)used to get[]and now gets that text'scatalog/catalogsentries. No compile error signals the migration. This is deliberate: the parameter now means what the doc always said it meant, and re-adding the short-circuit would reintroduce the "member's kind decides the root's parser" coupling the PR removes. But it belongs in the breaking-changes list as the one break a downstream crate will not be told about by the compiler.This body overstated one behaviour change. It claimed "a candidate its own kind does not recognize is walked past rather than ending the search, so an unreadable name can no longer hide a real root later in the list." Master's loop already continued on failure — no early return existed.
an_unrecognised_candidate_does_not_hide_a_later_onefalsifies trap 2 (one kind applied over a heterogeneous list), not a "hiding" behaviour that was never there. Documentation only; the code is right.Validation after the repair
Repair: an inherited version now resolves against the root that governs it
A fourth finding, from the same area as the first:
workspace_package_defaultslocated its root with
nearest_workspace_root, which excludes the askingmanifest, rather than
workspace_root_of, which exists precisely to handle theself-governing case. A Cargo root that is also a package could therefore never
see the
[workspace.package]table sitting in its own file.A single
Cargo.tomlholding both:reported
"version": null, "version_inherited": true— both "there is noversion" and "the version came from somewhere else" at once. In the ASCII
listing it printed
Cargo.toml — selfroot — Rustwith no version at all; it nowprints
selfroot v9.9.9.This is pre-existing, not a regression —
origin/masterbehaves identically,so it is outside this PR's blast radius. It is closed here because the repair
commit
354141cedited this exact function and left the asymmetry standing,and because the dependency inheritance three lines up already resolves the self
case through
workspace_source→workspace_root_of. The scalar axis had noreason to disagree, and the manifest's own text is already in hand at the call
site, so the self case costs no extra read.
One knock-on, in the same failure class the guard above addresses: a manifest
declaring
[workspace]whose own[workspace.package]names noversionnowreports none, where before it walked past itself and borrowed an outer root's
number. Cargo resolves such a manifest against its own table and errors when the
key is absent — it never walks up — so reporting nothing is the honest answer
rather than an unrelated workspace's version.
Covered by
a_root_that_is_also_a_package_inherits_its_version_from_itself,which fails on
354141cwithleft: Null, right: "9.9.9".tempfilejoins thecrate's dev-dependencies so the test can build the layout on disk — the fixtures
directory is walked wholesale by
cli_report, so a new fixture there would nothave been inert.
Validation after this repair
854 is 853 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, beforeand after this commit — no fixture has a root that is also a package. A targeted
differential against
354141cover five hand-built layouts (a self-governingroot inheriting, one with a literal version, an ordinary member under a root, a
self-governing root that also has members, and a self-declaring root whose own
table names no version) differs in exactly the three places described above:
the two self-governing roots gaining their version, and the table-less one
losing the borrowed one.
treeoutput is unchanged throughout — it does not gothrough this path.