Skip to content

refactor!: harden the workspace-root descriptor before a second ecosystem uses it - #102

Open
justin13888 wants to merge 4 commits into
masterfrom
refactor/94-workspace-root-descriptor
Open

refactor!: harden the workspace-root descriptor before a second ecosystem uses it#102
justin13888 wants to merge 4 commits into
masterfrom
refactor/94-workspace-root-descriptor

Conversation

@justin13888

@justin13888 justin13888 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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_kind cannot cover a heterogeneous name list

root_names: &[&str] plus a single root_kind forces one parser over every candidate name. The real case is JavaScript: a member roots either at pnpm-workspace.yaml or, for Bun, at the workspace root's own package.json. PackageJson for both runs the JSON parser over YAML and silently walks past the pnpm root; PnpmWorkspaceYaml for both reads a Bun root as YAML.

Each candidate now carries its own kind — root_names: &'static [(&'static str, ManifestKind)] — and root_kind is gone. The per-directory scan is extracted into root_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 the dir.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 plain package.json and a real Cargo workspace root, searched with a heterogeneous list. Reverting root_in_dir to one shared kind across the list fails it (verified). discover::tests::a_candidate_is_read_with_the_kind_it_is_paired_with pins the other half: identical bytes are not a root under PackageJson and are one under CargoToml, and the kind reported back is the pair's.

Trap 1 — self_governing silently assumed root_kind == kind

workspace_root_of accepted a self-governing manifest using kind.declares_workspace, while the walk recognized one using roots.root_kind.declares_workspace. With the two differing, the manifest was accepted by one parser and then read by the other, which returns Err; an unparseable root declares nothing, so every inherited entry came back source: "inherited", constraint: null — a wrong answer wearing the shape of a missing declaration.

With trap 2 closed this is mostly structural: workspace_root_of reports kind as the root kind in the self case, which is the kind whose declares_workspace accepted the text, so recognition and parsing can no longer disagree. WorkspaceRoots::self_governing states the invariant it needs — the kind must appear among its own root_names kinds — and a debug_assert! in workspace_root_of enforces 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, and discover::tests::a_self_governing_root_is_reported_as_its_own_kind.

Trap 4 — workspace_declarations took the member's kind

Its 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 ManifestKind directly — exactly what workspace_root_of and nearest_workspace_root now 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. WorkspaceCache is now Cache<(PathBuf, ManifestKind), Arc<Vec<Item>>>, and ManifestKind derives Hash to be part of the key.

Falsified by cache::tests::a_root_cached_under_one_kind_is_not_served_to_another; reverting the key to PathBuf is a compile error at every use site.

Breaking API changes

All in pub library surface, all in the same direction — the located root's kind travels with the root:

  • dependable_core::WorkspaceRoots::root_names is &'static [(&'static str, ManifestKind)].
  • dependable_core::WorkspaceRoots::root_kind is removed; the kind now belongs to the name that matched.
  • dependable_fetch::nearest_workspace_root and dependable_fetch::workspace_root_of return Option<(PathBuf, ManifestKind, String)>.
  • dependable_fetch::workspace_declarations takes the root's kind rather than the member's.
  • dependable_fetch::WorkspaceCache is keyed on (PathBuf, ManifestKind).

workspace_source keeps 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

  • The heterogeneous test uses ("package.json", ManifestKind::CargoToml). WorkspaceRoots is #[non_exhaustive], so a synthetic descriptor cannot be built outside dependable-core, and declares_workspace recognizes only Cargo content today — a test using real descriptors would prove nothing. Extracting root_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_governing is guarded by a debug_assert! plus a test over all kinds, not by construction. The descriptor is a hand-written match arm, so the invariant cannot be encoded in the type without turning the constructor into a function. Reversed by making WorkspaceRoots constructible only through a checked builder.
  • ManifestKind gained a Hash derive rather than the cache hashing a kind discriminant by hand. Additive, and no variant carries data.
  • ALL_KINDS was added in dependable-core's test module so a kind-wide invariant is asserted over the whole set; an exhaustive match over it means a new variant fails to compile rather than quietly skipping the invariants. The existing only_cargo_looks_for_a_workspace_root now 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 4 over this repository's own Cargo workspace — which uses [workspace.dependencies] inheritance — produces byte-identical output on master and on this branch. root_in_dir is logically identical to master's inlined loop (same same_file exclusion, same read_to_string, same declares_workspace, same continuation on failure), Cargo's descriptor is the single pair ("Cargo.toml", CargoToml) — master's root_kind — and the .git boundary is untouched. No existing Cargo workspace resolves differently, and root_in_dir cannot 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_of returns kind for the self case and both callers pass that returned kind to workspace_declarations, so recognition and parsing use one and the same kind by construction — trap 1's silent source: "inherited", constraint: null path is structurally closed, not assert-guarded. The debug_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, since WorkspaceRoots is #[non_exhaustive] with no constructor and all_kinds_lists_every_variant_once's exhaustive match forces a new variant into ALL_KINDS or fails to compile.

Three findings came back.

  • workspace_package_defaults still 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 kind nearest_workspace_root now returns and hard-coded parse_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 in 354141c.

  • 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 to Vec::new() for a kind with no workspace_roots(), and that guard is gone, so the function now parses whatever kind it is handed. A downstream caller — the IDE integration dependable-fetch exists to serve — calling workspace_declarations(ManifestKind::PackageJson, root_text) used to get [] and now gets that text's catalog/catalogs entries. 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_one falsifies 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

cargo test --workspace                                  → 39 test binaries, 0 failures
cargo clippy --workspace --all-targets -- -D warnings   → clean
cargo fmt --all --check                                 → clean

Repair: an inherited version now resolves against the root that governs it

A fourth finding, from the same area as the first: workspace_package_defaults
located its root with nearest_workspace_root, which excludes the asking
manifest, rather than workspace_root_of, which exists precisely to handle the
self-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.toml holding both:

[workspace]

[workspace.package]
version = "9.9.9"

[package]
name = "selfroot"
version.workspace = true

reported "version": null, "version_inherited": true — both "there is no
version" and "the version came from somewhere else" at once. In the ASCII
listing it printed Cargo.toml — selfroot — Rust with no version at all; it now
prints selfroot v9.9.9.

This is pre-existing, not a regressionorigin/master behaves identically,
so it is outside this PR's blast radius. It is closed here because the repair
commit 354141c edited this exact function and left the asymmetry standing,
and because the dependency inheritance three lines up already resolves the self
case through workspace_sourceworkspace_root_of. The scalar axis had no
reason 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 no version now
reports 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 354141c with left: Null, right: "9.9.9". 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.

Validation after this repair

env -u FORCE_COLOR -u COLORTERM cargo test --workspace     854 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 master..HEAD                                  no errors in 4 commits

854 is 853 plus the new test; nothing was deleted or weakened.

list --depth 4, list --format json, tree and tree --format json over
this repository and all 16 fixtures remain byte-identical to master, before
and after this commit — no fixture has a root that is also a package. A targeted
differential against 354141c over five hand-built layouts (a self-governing
root 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. tree output is unchanged throughout — it does not go
through this path.

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

refactor(fetch): harden the workspace-root descriptor before a second ecosystem uses it

1 participant