Skip to content

fix(cli): head an unread dependency list as unread in list too - #115

Open
justin13888 wants to merge 5 commits into
refactor/98-locked-package-sourcefrom
fix/109-list-unread-dependency-list
Open

fix(cli): head an unread dependency list as unread in list too#115
justin13888 wants to merge 5 commits into
refactor/98-locked-package-sourcefrom
fix/109-list-unread-dependency-list

Conversation

@justin13888

@justin13888 justin13888 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Closes #109.

list counted the items it held, so a SwiftPM project with no readable Package.resolved was headed (0 dependencies) — an inventory reporting an empty inventory for a list it never opened. check and the HTML report have said dependency list unread here since the Swift work landed; list did not, because run_list discarded the boolean report_lockfile_notices already returns:

let _ = report_lockfile_notices(manifest);

so ProjectReport never carried the fact and neither of list's own surfaces could state it.

What changed

  • ProjectReport gains pub dependencies_unread: bool, run_list binds the boolean instead of discarding it.
  • Table headingreport.items.is_empty() && report.dependencies_unread yields the literal string dependency list unread, character for character the phrase output/table.rs and the HTML template already use. A user comparing check and list sees one phrase, not two.
  • --format json — a per-project dependencies_unread boolean, immediately before dependencies because it qualifies that array. Plain, always emitted, no skip_serializing_if: the answer is always known. Named after ManifestReport, ManifestResults and the HTML model rather than inventing a list-local name.
  • README example — the list --format json example gains "dependencies_unread": false in the same position fn json emits it. Because the field has no skip_serializing_if the tool emits it on every project, so an example without it was the one place a decoder written from the README — the artefact people copy — would reject a real document. The example is otherwise a faithful superset of the schema, and decision 4 argues from exactly that consistency.
  • #[must_use] on report_lockfile_notices — the discarded return value above is what caused this bug, the value is load-bearing in three places, and every function it delegates to already carries the attribute. It is worth being precise about what this buys: it would not have caught this bug, because let _ = f() is exactly the spelling that silences the lint and exactly what the base code had. It guards the bare-statement spelling only. Added because CLAUDE.md states the convention and the attribute is free, not because it closes the hole.

The items.is_empty() conjunct is load-bearing rather than decorative, and matches both existing consumers: a Package.resolved that parses to zero pins really does declare nothing and is still headed (0 dependencies); a partially read list is still counted rather than disclaimed. Both cases are asserted.

Before this, the only thing separating list --format json over an unread project from the same command over a genuinely empty one was lockfilenull against "Package.resolved". An earlier revision of this body claimed the two documents were identical apart from the root path; that was wrong, and the test written from it was wrong in the same way. lockfile answers a different question, since --no-lock-file produces the same null for a project whose list was read perfectly well. Set lockfile aside — as the test now does explicitly — and the two documents were byte-identical, which is the defect. The test asserts they now differ once it is set aside, and asserts why the field is needed rather than a count: summary.dependencies is 0 in both.

Decisions taken

1. Deliverable boundary — how much of the issue closes here

Taken: the heading and the per-project list --format json field. Both sentences of the issue's own Direction paragraph.

  • Rejected — the heading alone. The issue exists precisely because the JSON half was "scoped out rather than made in passing" last time; closing it on the heading alone reproduces the pattern that created it. Verified: no field on a list project object separates unread from empty today, and lockfile: null is not a substitute because --no-lock-file produces it too.
  • Rejected — heading + JSON field + a summary-level counter. The counter forces a naming choice that is wrong either way (projects_unread is inconsistent with check's manifests_unread; manifests_unread uses a word list's summary uses nowhere else), and that choice deserves its own issue rather than being taken in passing here.
  • Reverses: delete dependencies_unread from ProjectDto and its line in fn json; drop the JSON test.
  • Filed: fix(cli): the two halves of "dependency list unread" that list still cannot say #114 carries both remainders — the summary counter with its naming fork, and the --format text gap from decision 3.

2. The JSON surface and what it does to dependable.list/v1

Taken: add the field, retain dependable.list/v1, and amend the README's schema paragraph so it states the policy it actually follows — v1 permits added fields and forbids removed, renamed or retyped ones.

  • Rejected — adding the field and saying nothing. The README worded the pin as "which fields exist and what type each holds", so an added field contradicts the promise the README publishes. Leaving that is the tool disagreeing with its own documentation.
  • Rejected — minting dependable.list/v2. Every consumer pinning == "dependable.list/v1" breaks because of the bump, not because of the field. The bump causes the breakage it exists to prevent, and it sets the precedent that any added key costs a version.
  • Reverses: revert the README clause and change SCHEMA in output/list.rs to dependable.list/v2, updating the assertion in tests/cli_list.rs.

3. --format text and whether anything is printed under the heading

Taken: heading only. --format text unchanged, and no explanatory body line under the table heading.

  • Rejected — an indented explanatory line under the heading. check's own table adds no such line, so "exactly as check does" argues against it, and list's table has no manifest-level body line for any zero-dependency project today.
  • Rejected — a manifest-level record in --format text. That format's contract is one record per dependency at fixed arity, documented in-code as deliberate, and any sentinel in the name column is a string a real package could hold.
  • Reverses: add the println! under the heading in fn table, or a manifest-level record in fn text.

Known cost, disclosed rather than hidden: list --format text emits nothing at all for an unread manifest — strictly worse than the "0 dependencies" this issue objects to. That is a real gap, it is not closed here, and it is written up in #114.

4. Taken inside the manifest, during implementation

Taken: the SCHEMA doc comment in output/list.rs is amended alongside the README's, and the README documents the new dependencies_unread key beside its siblings.

The doc comment carried the same "which fields exist and what type each holds" wording the README did, and ends with "The README states the same policy for readers who never open this file" — so amending one and not the other would have left the two statements of one policy disagreeing. The key documentation follows the file's own precedent: version_inherited, inherited and lockfile are each described in that section, and leaving one sibling undocumented is an inconsistency rather than a smaller diff. Both edits are inside files already in scope.

Reverses: revert the two README hunks and the SCHEMA doc hunk; the code is unaffected.

5. Was dependable.list/v1 always meant to permit added fields?

Taken: yes — additive-within-v1 is the contract, and the previous wording was imprecise rather than a different promise. check --format json already added manifests_unread additively, with an in-tree doc comment making that exact argument, and no golden-file snapshot of list --format json exists in CI to be broken by a new key.

  • Rejected — minting dependable.list/v2. Every consumer pinning == "dependable.list/v1" breaks because of the bump rather than because of the field, so the bump causes the breakage it exists to prevent. It also sets the precedent that any added key costs a version.
  • Reverses: set SCHEMA to dependable.list/v2 and update the assertion in tests/cli_list.rs.

6. Is "unread implies empty" an invariant or a coincidence?

Taken: an invariant, enforced by adoptable_from confining a dependency source to the manifest's own directory. The items.is_empty() conjunct in the two heading consumers is defensive documentation of that invariant. The JSON field deliberately carries the raw fact without the conjunct, because a machine consumer wants the fact rather than a rendering decision.

  • Rejected — adding the conjunct to the JSON field. It would make the document assert a rendering choice, and if the invariant ever broke, the field would then hide the disagreement instead of exposing it.
  • Reverses: gate the JSON field on items.is_empty() && dependencies_unread.

7. Should the JSON document carry the cause, not just the fact?

Taken: no — stderr-only, for now. dependencies_unread: true is emitted identically for "no Package.resolved", "it exists but could not be read", and "it is malformed"; the distinguishing reason stays on stderr, where every --format sees it.

  • Rejected — a cause field. It multiplies a boolean into an enum on a versioned document for a distinction no consumer has asked for, and the reason string is not yet stable enough to publish.
  • Reverses: replace the bool with an optional reason enum on ProjectDto.

Verified behaviour

Case Result
Swift project, no Package.resolved heading (dependency list unread), JSON true, warning still on stderr, exit 0
Swift project, Package.resolved = {"pins":[],"version":2} heading (0 dependencies), JSON false
list --no-lock-file, resolved file present unchanged — the file parses, so unread is false
list --no-lock-file, no resolved file unread heading — correct and honest
Any non-Swift ecosystem dependencies_unread: false; the one visible change to non-Swift output
--features / --licenses take &mut [ProjectReport] and never construct one; unaffected

Tests

Two tests in crates/dependable/tests/fixture_swift.rs, beside the check-heading, JSON and HTML twins for this exact fact — only a SwiftPM project can set the bit, so that is where the assertions belong.

  • the_list_heading_says_the_list_went_unread_rather_than_counting_zero — also asserts the warning on stderr survives: the heading joins it, it does not replace it.

  • list_json_distinguishes_an_unread_dependency_list_from_an_empty_one — asserts the two documents now differ, the field's two values, summary.dependencies == 0 in both, and schema == "dependable.list/v1".

    The comparison strips both root (a scratch path) and lockfile before comparing, so it discriminates on dependencies_unread alone. Stripping only root — as the first revision of this test did, copied from the check --format json twin where the shape is load-bearing because that document carries no per-manifest lockfile key — left the assertion firing on the lockfile difference and passing whether or not the new field existed at all. Falsified deliberately: with dependencies_unread removed from ProjectDto and its line in fn json, the repaired assert_ne! fails on two byte-identical documents, and the field was then restored.

crates/dependable/tests/cli_list.rs is deliberately untouched and passes as-is: it already pins schema == "dependable.list/v1" and the npm (4 dependencies) heading, both of which this change must not disturb.

Validation

cargo test -p dependable --test fixture_swift (24 passed, 1 ignored), --test cli_list (13 passed, untouched), mise run test (full workspace green), mise run fmt:check, mise run lint, convco check — all clean.

An earlier revision of this body reported three dependable-fetch tests/checker.rs tests failing locally against a populated ~/.cache/dependable left by live runs. That did not reproduce on the latest run: with the cache still present (884K), tests/checker.rs passed 23/23 and mise run test exited 0 with no failures anywhere in the workspace. The note is retained only to record that the earlier observation was environmental and is not a property of this branch; nothing in this diff touches that crate, and CI is green.

Residuals

Not fixed here, recorded so it is not lost:

Base

Based on refactor/98-locked-package-source (#103), the tip of the pom.xml/Swift stack, because the dependency_list_unread notice and check's heading only exist there. Merge, do not rebase, to take base updates.

`list` counted the items it held, so a SwiftPM project with no readable
`Package.resolved` was headed "(0 dependencies)" — an inventory reporting
an empty inventory for a list it never opened. `check` and the HTML report
already say "dependency list unread" here; `list` did not, because
`run_list` discarded the bool `report_lockfile_notices` returns.

Carry that bool onto `ProjectReport` and use it in both surfaces `list`
owns: the table heading gets the same words `check` and the HTML report
use, and `--format json` gains a per-project `dependencies_unread`. The
JSON half matters on its own — nothing on a `list` project object
separated "declares nothing" from "nothing was read", and `lockfile: null`
does not answer it because `--no-lock-file` produces that too.

The `items.is_empty()` conjunct is load-bearing, and matches the two
existing consumers: a `Package.resolved` that parses to zero pins really
does declare nothing and is still counted, as is a partially read list.

An added key stays within `dependable.list/v1`: a consumer that reads the
fields it knows is unaffected by one it never looks at, and bumping the
version would break every consumer pinning `v1` in order to protect none
of them. README worded the pin as "which fields exist", which an added
field contradicts, so it now states the policy it actually follows.

`--format text` is unchanged: its contract is one record per dependency at
fixed arity, and a manifest-level record has no place in it.

Refs #109
…-unread-dependency-list

Brings `PackageSource::Locked` and `PackageSource::Unidentified`, and the
two `source_token` arms they need, onto the branch that adds
`dependencies_unread`. Git merged `output/list.rs` cleanly because the
three changes sit in different parts of the file; the properties were
checked rather than assumed, and all three hold together:

- a Swift project with no `Package.resolved` still heads its list
  "dependency list unread" and reports `dependencies_unread: true`;
- a lockfile pin still reports `"source": "locked"`;
- a POM entry whose coordinate the file never states still reports
  `"source": "unidentified"` and annotates as `(unidentified)`.

Neither new variant falls through `_ => "unknown"`.
The `assert_ne!` over the two `list --format json` documents
discriminated nothing. It stripped `root`, but the two runs also differ
in `lockfile`: the unread project has no `Package.resolved` to name, so
its `lockfile` is null, while the empty one names the file. The
assertion fired on that difference alone — it passed unchanged with
`dependencies_unread` absent from `ProjectDto` entirely — so its message
stated something untrue.

The shape came from the `check --format json` twin, where it is
load-bearing: that document carries no per-manifest `lockfile` key, so
those two really were byte-identical before `manifests_unread`. The
premise does not transfer to a document that carries one.

Strip `lockfile` too, for the same reason `root` was already stripped.
With the field removed from `ProjectDto` the two documents are now
byte-identical and the assertion fails, which is what it always claimed
to be testing.
`dependencies_unread` has no `skip_serializing_if`, so `list --format
json` emits it on every project, not only a SwiftPM one. The README
example omitted it, which made it the single field a decoder written
from that example — the artefact people copy — would reject on a
document the tool actually produces.

The example is otherwise a faithful superset of the schema: every field
without `skip_serializing_if`, plus the two optional ones. Restore that
property.
Its return value is load-bearing in three places — the `list` heading,
`list --format json`, and the `--fail-on any` exit code — its own doc
comment says the caller has to carry it, and every function it delegates
to (`lockfile_notices`, `locate_lockfile`, `lockfile_items`,
`swift_package_resolved_items`) already carries the attribute. This
wrapper was the one unmarked link in that chain, and CLAUDE.md asks for
`#[must_use]` on important return types.

Be honest about what it buys: `#[must_use]` would not have caught the
bug this branch fixes, because `let _ = f()` is exactly the spelling
that silences the lint and exactly what the base code had. It guards the
bare-statement spelling only. Add it anyway — the convention is stated
and the attribute is free.
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