Skip to content

refactor(core): split a lockfile-supplied version out of PackageSource::Inherited - #103

Open
justin13888 wants to merge 9 commits into
feat/85-swift-package-resolvedfrom
refactor/98-locked-package-source
Open

refactor(core): split a lockfile-supplied version out of PackageSource::Inherited#103
justin13888 wants to merge 9 commits into
feat/85-swift-package-resolvedfrom
refactor/98-locked-package-source

Conversation

@justin13888

@justin13888 justin13888 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes #98.

Stacked on unmerged PR #97 (feat/85-swift-package-resolved), whose head is
this PR's base. It must not merge before #97. The four Inherited producers only
coexist on that branch, so master has nothing to rebase onto yet.

PackageSource::Inherited had four producers meaning four different things. This
splits out the one that is not inheritance at all.

Producer What it means After
parsers/cargo_toml.rs workspace = true, resolved against [workspace.dependencies] Inherited
parsers/gradle_catalog.rs a version.ref alias shared by several libraries Inherited
parsers/pom_xml.rs a version this parser could not resolve Inherited
lockfiles/swift_package_resolved.rs the version came from a lockfile; nothing declares it Locked

Locked means the version came from a lockfile rather than any manifest. It keeps
Inherited's mechanics exactly — is_checkable() && !has_position(), the
"check it, never rewrite it" behaviour — and differs only in what it tells a
consumer. "inherited" invites going to the central declaration and bumping it;
a Swift pin has no such declaration, because Package.swift is a Swift program
this crate declines to read and Package.resolved is the only place the version
is written.

Why POM stays Inherited

The issue calls it a separate question and it is. An unresolvable POM version is
already carried at the result level: DependencyStatus::Undetermined, plus the
per-manifest deferred_versions notice on stderr naming the entries and the
reason. Nothing at the item level is missing, and a third variant would be a
second way to say what those two already say. What is genuinely wrong there —
that "inherited" is a poor word for "unreadable" — is a naming question about
POM's own resolution, not a data-model gap, and it does not share Locked's
distinguishing fact (a lockfile). Reversed by: evidence that a consumer needs to
tell an unresolvable POM entry from a resolved Gradle alias from the item alone,
without reading the result.

The audit

PackageSource is #[non_exhaustive] and every match on it already carries a
wildcard, so adding a variant compiles cleanly everywhere and the blast radius is
silent drift. Every site, checked by hand:

Changed — the load-bearing invariant

  • dependable-core/src/item.rs is_checkable()Locked joins Inherited's
    arm (!version_constraint.is_empty()). Without it a Swift pin falls to the
    wildcard, stops being checkable, and a Swift project reports nothing.
  • dependable-core/src/item.rs has_position() — was self.source != Inherited,
    a != against one variant. Now !matches!(source, Inherited | Locked). This is
    the sharpest one: left alone, Locked would regain a position, point every
    reporter at line 1 of a Package.swift that declares nothing, and become
    rewritable by --fix. Both functions now name the exempt variants explicitly
    and the doc says why, so a source added later starts out without a position.
  • dependable-fetch/src/check.rs unfetchable()Locked gets its own
    Undetermined arm. The wildcard is Local, which says "there is no registry
    for this"; of a published Swift package with no version recorded that is simply
    false, and it is the exact inversion DependencyStatus's own doc warns against.
  • dependable/src/output/list.rs source_token() — new "locked" token. The
    wildcard is "unknown", which would have hidden the whole ecosystem.
  • dependable-core/src/lockfiles/swift_package_resolved.rs — the producer moves.

Audited, deliberately unchanged

  • dependable/src/output/list.rs, the inherited: bool DTO field — reads
    source == Inherited, so a locked entry is false. Correct and intended: the
    field exists to send a consumer to the file where the version can be bumped.
    The field doc now says so, and the two fields still cannot contradict each other.
  • dependable/src/output/list.rs annotation() — no arm added on purpose. A
    locked entry states a version so it is not (unresolved), and it falls through
    to its kind, which is Indirect — the honest annotation, and the one it
    already rendered. Hardcoding (indirect) on the source would be wrong for any
    future lockfile reader whose entries are not indirect.
  • dependable/src/output/json.rs inherited_from — stays Inherited-only.
    Naming the workspace root as the source of a locked constraint would point at a
    declaration that is not there.
  • dependable/src/runner.rs report_inherited_skips — stays Inherited-only.
    It is gated on report.workspace_root and exists to name the file to edit
    instead; a locked entry has no such file. Swift already gets its own per-project
    "nothing here can be checked" notice.
  • dependable-core/src/parsers/cargo_workspace.rs — the != Inherited guard now
    also excludes Locked, which is right: a lockfile already supplied the version
    and no root above it promised one.
  • dependable-fetch/src/check.rs undeclared_inheritance, detached_inheritance,
    deferred_versions — all three keep source == Inherited && constraint.is_empty().
    Each accuses a specific file (a workspace root, a missing root, a <parent>);
    a locked entry has no such file to accuse. detached_inheritance and
    deferred_versions are additionally kind-gated to Cargo and POM.
  • dependable-fetch/src/tree.rs — the source match reaches only Cargo manifests,
    so Locked cannot arrive; the _ => registry+ wildcard would be right for it
    regardless.
  • dependable-report/src/sarif.rs and dependable/src/output/github.rs — both
    gate line emission on has_position(), so both are correct by construction once
    has_position is. This is why that function is the invariant worth testing.
  • dependable-tui — holds no PackageSource logic at all.

JSON schema change

list --format json gains a sixth source token, "locked". A locked entry
reports "inherited": false — stated in the DTO doc and asserted in a test.

It stays dependable.list/v1. The document's shape (which fields exist, what
type each holds) is unchanged, and the token sets inside those fields were already
open: source_token has always fallen back to "unknown" for a variant it does
not name, so a consumer matching exhaustively on it was never safe. The SCHEMA
constant's doc now records that reading. Reversed by: treating any token-set
addition as breaking, which would mean the constant should have bumped for
"jsr" too. README's source paragraph is updated.

What falsifies a regression

Two tests, one per layer:

  • item::tests::a_locked_item_is_checkable_and_has_no_position — holds
    is_checkable() && !has_position() && !is_rewritable() for Locked. This is
    the pair that stops the variant silently drifting back through a wildcard.
  • fixture_swift::a_swift_pin_is_reported_as_locked_and_never_as_inherited — runs
    the binary and asserts "source": "locked" with "inherited": false on
    swift-nio, and that no Swift entry claims to inherit. Fails on the previous
    behaviour. The two assertions are one fact stated twice on purpose: source and
    inherited are computed by different functions, and either can drift alone.

a_swift_dependency_has_no_position_and_is_never_rewritable additionally now
asserts every checkable Swift pin is Locked.

Judgement calls

  • is_checkable guards Locked on a non-empty constraint rather than
    returning true unconditionally. Today the Swift reader only produces Locked
    with a version (a branch pin becomes Git), so the two are indistinguishable in
    practice; the guard keeps Locked and Inherited reading identically and means
    a future lockfile reader that records no version does not fetch on a blank.
    Reversed by: a lockfile reader for which "no version recorded" should still be
    queried by name.
  • Locked sits beside Inherited on the enum rather than replacing it.
    Adding is additive for library consumers; PackageSource is #[non_exhaustive]
    so no downstream match breaks.

Review repairs

An independent re-derivation of the PackageSource audit found no site this PR
missed and confirmed each "deliberately unchanged" decision against a concrete
wrong-output scenario. Three findings came back, all low; each was reproduced
before it was fixed.

Base merge

feat/85-swift-package-resolved had moved on by four repair commits
(0ab4e6ae060ef4), several of them in
crates/dependable-core/src/lockfiles/swift_package_resolved.rs, which this
branch also changes. origin/feat/85-swift-package-resolved is merged in at
128549c. The merge was clean — no conflicts, in that file or anywhere — and
all three gates were re-run on the merge commit before any repair started. What
arrived from the base and matters here: read_lockfile applying !read_lockfiles
only to a lockfile that is not a dependency source, split_authority deciding
port-vs-path from the presence of a URL scheme, DEP003 moving its fact out of
properties.status into dependencyListUnread, and
Summary::manifests_unread / ManifestView::dependencies_unread.

1. A versionless locked pin lost its explanation (c59d66c)

pin_item chose Locked on any Some(version), with no emptiness filter —
unlike the name path two blocks above, which filters one. A Package.resolved
carrying "state": { "version": "" } therefore produced Locked with an empty
version_constraint.

Before, dependable list on such a project:

Package.swift — Swift (1 dependencies)
  github.com/apple/swift-nio — (indirect)

A bare with nothing to explain it. Locked has no annotation arm precisely
because a lockfile pin always states a version, and locked_note stays silent
with no locked version to name. That is the exact output the Inherited
(unresolved) arm exists to prevent, for the reason its own comment gives: a
bare dash reads like a parse failure.

After:

warning: Package.resolved: was read but records no versions
Package.swift — Swift (1 dependencies)
  github.com/apple/swift-nio 635b25 (git)

Fixed at the producer rather than by adding a renderer arm, so Locked with an
empty constraint is unrepresentable instead of merely unrendered: the degenerate
pin falls through to the branch/revision state and the Git arm pin_item
already had. SwiftPM writes null, never "", for a versionless pin, so this
needs a hand-edited or third-party-generated file to reach.

Falsified by two new tests in the Swift reader. With the filter removed:

an_empty_version_is_not_a_locked_pin ... FAILED
  assertion `left != right` failed: an empty string is not a resolved version
a_locked_pin_always_states_a_version ... FAILED
  github.com/acme/b is Locked with no version

The second walks four pin shapes — a real version, an empty one, an empty one
beside a branch, and a local package — and asserts the invariant over all of
them, so it holds for shapes the first test does not name.

2. A test narrowing dropped the Git and Local assertions (3217776)

a_swift_dependency_has_no_position_and_is_never_rewritable had gained a
continue for non-checkable pins, added only so the source == Locked
assertion inside the loop would hold. It also silently dropped !has_position(),
!is_rewritable(), version_line == 0 and the zero-width-span assertion for
the branch pin and the local package — which the test's own doc comment still
claimed to cover.

That coverage is the point. Both pins carry version_line: 0, so a
has_position that stopped consulting is_checkable would hand them line 1 of
Package.swift in SARIF and in GitHub annotations. Removing the
is_checkable() && conjunct from Item::has_position — a plausible
simplification once the exclusion list reads Inherited | Locked — left the
narrowed suite fully green (20 passed; 0 failed), including the surviving
a_branch_pin_and_a_local_package_report_what_they_always_did, which asserts
only !is_checkable().

The Locked assertion is now a guarded assertion inside the loop body rather
than a continue, so every pin is asserted on. With the conjunct removed the
restored test fails:

a_swift_dependency_has_no_position_and_is_never_rewritable ... FAILED
  sample-helpers: no span in Package.swift means nothing may point at one

3. Comment detached from its subject, and the decisions (3055745)

In build_workspace_graph a blank line separated the extended comment from the
match item.source it explains; it is closed.

  • The README no longer contradicts the schema doc. The dependable.list/v1
    doc comment declares that the schema pins the document's shape and that the
    token sets inside those fields are open. README.md gave source as a closed
    enumeration and omitted the unknown fallback that argument rests on. It now
    states the open-set policy, names unknown, and tells consumers to match the
    tokens they care about and default the rest.
  • The Registry/Locked boundary is the narrower reading, stated on the
    variant.
    The test is whether a manifest declares the dependency, not where
    the version string came from: a Cargo.toml entry whose exact version came out
    of Cargo.lock stays Registry, because it was declared and --fix rewrites
    the declaration. Issue refactor(core): PackageSource::Inherited now means four different things #98's phrasing was ambiguous between the two readings,
    and a lockfile-first reader added later — a Gemfile.lock, a poetry.lock
    needs the settled one; the doc now says so and names that case.
  • Locked gets its own arm in build_workspace_graph. The correctness
    argument for the wildcard was carried entirely by a comment, and a comment is
    the one thing a #[non_exhaustive] enum will not check. No broader
    compile-time scheme: that is a larger design question and out of scope.

Gates

cargo test --workspace     41 test-result lines | 952 passed | 0 failed | 21 ignored
cargo clippy --workspace --all-targets -- -D warnings    Finished, no warnings
cargo fmt --all --check    no diff

Base was 948 passed; the merge brought it to 950 and the two new reader tests to
952. No test was deleted or weakened.


Carried forward over the base's second repair round

feat/85-swift-package-resolved gained two fixes after this branch last merged it: a numeric guard on the port split in swift_package_resolved.rs, and an "unread dependency list" heading in the HTML report and the check table. Merged in (never rebased); nothing on this branch changed.

Both branches touch swift_package_resolved.rs, but in different regions — the base's guard is in split_authority, this branch's change is pin_item's pin.version.clone().filter(|v| !v.is_empty()) — so the merge was clean. Both survive:

  • split_authority carries .filter(|i| authority[i + 1..].bytes().all(|b| b.is_ascii_digit()))
  • pin_item carries the empty-version filter, and grep -rn "PackageSource::Locked" still shows exactly one producer — swift_package_resolved.rs, behind that filter. Every other hit is a consumer (check.rs, tree.rs, list.rs, item.rs) or a test.

list --format json over the sample-swift fixture still reports "source": "locked" with "inherited": false for all four version-pinned entries.

Fixture sweep

check --no-vuln --format json, fix --dry-run, list --depth 4, tree and tree --format json were run over all 18 fixtures on both branches — 180 stdout/stderr captures each, byte-identical throughout. list --format json differs on sample-swift alone, and only in source/inherited (inherited/true -> locked/false) for the four version-pinned entries in each of its two manifests. Nothing else moved.

Gates

cargo test --workspace     954 passed | 0 failed | 21 ignored
cargo clippy --workspace --all-targets -- -D warnings    Finished, no warnings
cargo fmt --all --check    no diff

952 before; the base's two new tests take it to 954. No test was lost in the merge.

`Inherited` had grown to mean four different things, one of which — a version
read out of a lockfile with no manifest declaration behind it anywhere — is not
inheritance at all. `Locked` names that case.

It shares `Inherited`'s mechanics exactly: checkable, no position, never
rewritable. Both `is_checkable` and `has_position` name it explicitly, because
`PackageSource` is `#[non_exhaustive]` and every match on it carries a wildcard
arm — a variant left out of `has_position` silently gains line 1 of a file that
never declared it and becomes rewritable. The new test pins both halves.
A `Package.resolved` pin is not inherited from anything: `Package.swift` is a
program this crate declines to read, so no manifest declares the package at all.
Calling it `Inherited` sent a consumer looking for a central declaration to bump
that does not exist.

Behaviour is unchanged — `Locked` is checkable, has no position, and is never
rewritable, exactly as before.

`unfetchable` gains the matching arm, since `Locked` would otherwise fall to the
wildcard and report `Local` — "there is no registry for this" — of a package
that is published and merely has no version recorded. The three
inheritance-warning predicates and `resolve_workspace_inheritance` keep reading
`Inherited` by name and so no longer speak for Swift, which is right: none of
them has a root to name for a locked entry.
`list --format json` gains a sixth `source` token. `inherited` stays `false`
beside it: the flag exists to send a consumer to the file where the version can
be bumped, and a lockfile pin has no such file.

Stays within `dependable.list/v1`. The document's *shape* is unchanged, and the
token sets inside it were already open — `source` has always fallen back to
`"unknown"` for a variant the CLI does not name, so no consumer could safely
match them exhaustively. The schema comment now says so.

`check --format json` keeps `inherited_from` off a locked entry for the same
reason, and the workspace note on stderr keeps reading `Inherited` by name
rather than "skipped by fix", because it has no root to point a Swift reader at.
`annotation` deliberately gains no arm: a locked entry states a version, so it
is not unresolved, and its `kind` already says the useful thing.
`a_swift_pin_is_reported_as_locked_and_never_as_inherited` fails on the previous
behaviour: it asserts `"source": "locked"` with `"inherited": false` on a
`Package.resolved` pin, and that no Swift entry claims to inherit. The two
halves are computed by different functions from the same field, so either can
drift back alone.

The Maven fixture keeps asserting `"source": "inherited"` with
`"inherited": true`, because POM entries are unchanged; its comment now states
the implication runs one way only, and that a locked entry is the deliberate
non-inheritor.
`pin_item` chose `Locked` on any `Some(version)`, with no emptiness filter —
unlike the name path two blocks above, which filters one. A `Package.resolved`
carrying `"state": { "version": "" }` therefore produced `Locked` with an empty
`version_constraint`, and `dependable list` printed

      github.com/apple/swift-nio — (indirect)

a bare `—` with nothing to explain it. `Locked` has no annotation arm precisely
because a lockfile pin always states a version, and `locked_note` stays silent
when there is no locked version to name, so the line said nothing at all. That
is the exact output the `Inherited` `(unresolved)` arm exists to prevent, for
the reason its own comment gives: a bare dash reads like a parse failure.

Filter the emptiness at the producer rather than adding a renderer arm, so
`Locked` with an empty constraint is unrepresentable instead of merely
unrendered. The degenerate pin now falls through to the branch/revision state
and the `Git` arm it already had, printing `635b25 (git)`.

SwiftPM writes `null`, never `""`, for a versionless pin, so this needs a
hand-edited or third-party-generated file to reach.
`a_swift_dependency_has_no_position_and_is_never_rewritable` had gained a
`continue` for non-checkable pins, added only so the `source == Locked`
assertion inside the loop would hold. It also dropped the `!has_position()`,
`!is_rewritable()`, `version_line == 0` and zero-width-span assertions for the
branch pin and the local package — which the doc comment still claimed to
cover.

That coverage is the point: those two pins carry `version_line: 0` like every
other, so a `has_position` that stopped consulting `is_checkable` would hand
them line 1 of `Package.swift` in SARIF and in GitHub annotations. Dropping the
`is_checkable() &&` conjunct leaves the narrowed test green and the surviving
`a_branch_pin_and_a_local_package_report_what_they_always_did`, which asserts
only `!is_checkable()`, green too.

Make the `Locked` assertion conditional inside the loop body instead, so every
pin is asserted on. With the conjunct removed the test now fails on
`sample-helpers`.
The `dependable.list/v1` doc comment now says the schema pins the document's
shape and that the token sets inside those fields are open. `README.md` still
gave `source` as a closed enumeration and omitted the `unknown` fallback that
argument rests on, so the two contradicted each other. State the open-set
policy in the README and name `unknown`.

Record the narrower reading of the `Registry`/`Locked` boundary on the variant
itself: the test is whether a manifest declares the dependency, not where the
version string came from. A `Cargo.toml` entry whose exact version came out of
`Cargo.lock` stays `Registry`, because it was declared and `--fix` rewrites the
declaration. Issue #98 was ambiguous between the two readings; a lockfile-first
reader added later needs the settled one.

In `build_workspace_graph`, give `Locked` its own arm. The correctness argument
for the wildcard was carried entirely by a comment, and a comment is the one
thing a `#[non_exhaustive]` enum will not check. Also close the blank line that
had separated that comment from the `match` it explains.
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.

1 participant